Merge branch 'features/new-integrations' into dev

This commit is contained in:
Matt Aitken
2023-02-21 10:10:15 +00:00
198 changed files with 44613 additions and 411 deletions
+1
View File
@@ -0,0 +1 @@
{"workspaceId":"63e5e42daf9a537ba8d9503c"}
+19 -1
View File
@@ -5,18 +5,36 @@
"version": "0.2.0",
"configurations": [
{
"command": "pnpm run dev --filter webapp",
"command": "pnpm run idev --filter webapp",
"name": "Run webapp",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}"
},
{
"command": "pnpm run dev --filter integrations",
"name": "Run integrations",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}"
},
{
"type": "chrome",
"request": "launch",
"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"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"folders": [
{
"name": "trigger.dev",
"path": "../"
},
{
"name": "Webapp",
"path": "../apps/webapp"
},
{
"name": "Integrations",
"path": "../apps/integrations"
}
],
"settings": {
"vitest.commandLine": "pnpm exec vitest"
}
}
+10 -1
View File
@@ -31,8 +31,17 @@ windows:
panes:
- cwd: /Users/eric/code/triggerdotdev/trigger.dev
commands:
- exec: pnpm run dev --filter webapp
- exec: pnpm run build --filter webapp
- exec: pnpm run idev --filter webapp
- cwd: /Users/eric/code/triggerdotdev/trigger.dev/apps/webapp
- title: integrations
layout:
split_direction: vertical
panes:
- cwd: /Users/eric/code/triggerdotdev/trigger.dev
commands:
- exec: pnpm run dev --filter integrations
- cwd: /Users/eric/code/triggerdotdev/trigger.dev/apps/integrations
- title: ngrok
layout:
split_direction: horizontal
+21
View File
@@ -0,0 +1,21 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"env": {
"node": true,
"commonjs": true
},
"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"
},
"ignorePatterns": ["**/dist/*.js"]
}
+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
+1
View File
@@ -0,0 +1 @@
{ "workspaceId": "63ea6cff29733e58db1e6b80" }
+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
+61
View File
@@ -0,0 +1,61 @@
{
"private": true,
"name": "integrations",
"version": "1.0.0",
"description": "API integrations in a format that can be used to generate clients",
"main": "./dist/index.js",
"scripts": {
"build": "tsup",
"start": "node dist/index.js",
"dev": "concurrently \"tsup --watch\" \"nodemon -q dist/index.js\"",
"test": "vitest",
"lint": "eslint --no-error-on-unmatched-pattern src/**",
"lint-fix": "eslint --no-error-on-unmatched-pattern --fix src/**",
"generate-integrations": "tsx src/trigger/sdk/generate.ts"
},
"dependencies": {
"@cfworker/json-schema": "^1.12.5",
"@prisma/client": "^4.3.0",
"@trigger.dev/sdk": "^0.2.13",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
"json-pointer": "^0.6.2",
"json-schema-deref-sync": "^0.14.0",
"json-schema-to-typescript": "^11.0.3",
"loglevel": "^1.8.1",
"morgan": "^1.10.0",
"node-fetch": "^3.3.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@types/eslint": "^8.4.6",
"@types/express": "^4.17.13",
"@types/json-pointer": "^1.0.31",
"@types/morgan": "^1.9.3",
"@types/node": "^18.13.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"concurrently": "^7.6.0",
"dotenv": "^16.0.3",
"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",
"nock": "^13.3.0",
"nodemon": "^2.0.19",
"prisma": "^4.3.0",
"rimraf": "^4.1.2",
"tiny-invariant": "^1.2.0",
"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"
},
"engines": {
"node": ">=16.0.0"
}
}
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Cache" (
"id" TEXT NOT NULL,
"namespace" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Cache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Cache_key_key" ON "Cache"("key");
-- CreateIndex
CREATE INDEX "Cache_namespace_key_idx" ON "Cache"("namespace", "key");
-- CreateIndex
CREATE UNIQUE INDEX "Cache_namespace_key_key" ON "Cache"("namespace", "key");
@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "Cache_key_key";
-- DropIndex
DROP INDEX "Cache_namespace_key_idx";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Cache" ALTER COLUMN "expiresAt" DROP NOT NULL;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+22
View File
@@ -0,0 +1,22 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
binaryTargets = ["native", "debian-openssl-1.1.x"]
previewFeatures = ["orderByNulls"]
}
model Cache {
id String @id @default(cuid())
namespace String
key String
value String
expiresAt DateTime?
createdAt DateTime @default(now())
@@unique([namespace, key])
}
@@ -0,0 +1,35 @@
import { InputSpec } from "core/action/types";
export function createParametersBody(
inputSpec: InputSpec,
params?: Record<string | number, any> | undefined
) {
//separate the parameters and body by looking at the spec and pulling properties out
let parameters: Record<string, any> | undefined = undefined;
let body: any = undefined;
if (params) {
inputSpec.parameters?.forEach((p) => {
if (!parameters) {
parameters = {};
}
parameters[p.name] = params[p.name];
});
const bodyProperties = inputSpec.body?.properties;
if (bodyProperties) {
body = {};
Object.keys(bodyProperties).forEach((name) => {
const value = params?.[name];
if (value !== undefined) {
body[name] = value;
}
});
}
}
return {
parameters,
body,
};
}
@@ -0,0 +1,50 @@
import { DisplayProperties } from "core/action/types";
import { Request, Response } from "express";
import { catalog } from "integrations/catalog";
import { z } from "zod";
import { createParametersBody } from "./createParametersBody";
import { getServiceAction } from "./validation";
const requestBodySchema = z.object({
params: z.record(z.string().or(z.number()), z.any()).optional(),
});
export async function handleActionDisplay(req: Request, res: Response) {
const serviceActionResult = getServiceAction(req.params);
if (!serviceActionResult.success) {
res
.status(404)
.send(
JSON.stringify({ success: false, error: serviceActionResult.error })
);
return;
}
const { service, action } = serviceActionResult;
const parsedRequestBody = requestBodySchema.safeParse(req.body);
if (!parsedRequestBody.success) {
res.status(400).send(
JSON.stringify({
success: false,
error: {
type: "invalid_body",
message: "Action not found",
service,
action,
issues: parsedRequestBody.error.issues,
},
})
);
return;
}
const requestData = createParametersBody(
action.spec.input,
parsedRequestBody.data.params
);
const displayProperties = await action.displayProperties(requestData);
res.send(JSON.stringify({ success: true, properties: displayProperties }));
}
@@ -0,0 +1,168 @@
import { PostgresCacheService } from "cache/postgresCache";
import { AuthCredentials } from "core/authentication/types";
import { Service } from "core/service/types";
import { validateInputs } from "core/validation/inputs";
import { Request, Response } from "express";
import { catalog } from "integrations/catalog";
import { z } from "zod";
import { createParametersBody } from "./createParametersBody";
import { getServiceAction } from "./validation";
const requestBodySchema = z.object({
credentials: z.object({ accessToken: z.string() }).optional(),
params: z.record(z.string().or(z.number()), z.any()).optional(),
metadata: z.object({
requestId: z.string(),
workflowId: z.string(),
connectionId: z.string(),
}),
});
type ReturnResponse = {
response: NormalizedResponse;
isRetryable: boolean;
ok: boolean;
};
type NormalizedResponse = {
output: NonNullable<any>;
context: any;
};
export async function handleAction(req: Request, res: Response) {
const serviceActionResult = getServiceAction(req.params);
if (!serviceActionResult.success) {
res
.status(404)
.send(JSON.stringify(error(404, false, serviceActionResult.error)));
return;
}
const { service, action } = serviceActionResult;
const parsedRequestBody = requestBodySchema.safeParse(req.body);
if (!parsedRequestBody.success) {
res.status(400).send(
JSON.stringify(
error(400, false, {
type: "invalid_body",
message: "Action not found",
service,
action,
issues: parsedRequestBody.error.issues,
})
)
);
return;
}
//for v1 of this API we're building the credentials from the action
//this is fine for now but we'll want to use the connection in future to cover complex cases
let credentials: AuthCredentials | undefined = undefined;
if (parsedRequestBody.data.credentials && action.spec.input.security) {
const firstSecurityMethod = Object.entries(action.spec.input.security)[0];
if (firstSecurityMethod) {
const [name, scopes] = firstSecurityMethod;
//get the full info from the service
const securityMethod = service.authentication[name];
switch (securityMethod.type) {
case "oauth2":
credentials = {
type: "oauth2",
name,
accessToken: parsedRequestBody.data.credentials.accessToken,
scopes,
};
break;
default:
throw new Error(`Not implemented ${securityMethod.type}`);
}
}
}
const { parameters, body } = createParametersBody(
action.spec.input,
parsedRequestBody.data.params
);
const inputValidationResult = await validateInputs(action.spec.input, {
parameters,
body,
credentials,
});
if (!inputValidationResult.success) {
res
.status(400)
.send(JSON.stringify(error(400, false, inputValidationResult.error)));
return;
}
const { metadata } = parsedRequestBody.data;
const cache = new PostgresCacheService(`${metadata.connectionId}-${service}`);
try {
const data = await action.action(
{ credentials, parameters, body },
cache,
metadata
);
//convert into the format for the webapp
const response: ReturnResponse = {
ok: true,
isRetryable: isRetryable(service, data.status),
response: {
output: data.body ?? {},
context: {
statusCode: data.status,
headers: data.headers,
},
},
};
res.send(JSON.stringify(response));
} catch (e: any) {
console.error(e);
if (e instanceof Error) {
res
.status(500)
.send(JSON.stringify(error(500, false, { error: JSON.stringify(e) })));
return;
}
if ("error" in e) {
res.status(500).send(JSON.stringify(error(500, false, e.error)));
return;
}
res
.status(500)
.send(JSON.stringify(error(500, false, { error: JSON.stringify(e) })));
}
}
function isRetryable(service: Service, status: number): boolean {
return service.retryableStatusCodes.includes(status);
}
function error(
status: number,
isRetryable: boolean,
error: Record<string, any>
): ReturnResponse {
const response: ReturnResponse = {
ok: false,
isRetryable,
response: {
output: error,
context: {
statusCode: status,
headers: {},
},
},
};
return response;
}
@@ -0,0 +1,50 @@
import { Action } from "core/action/types";
import { Service } from "core/service/types";
import { Request } from "express";
import { catalog } from "integrations/catalog";
type ServiceActionResult =
| {
success: true;
service: Service;
action: Action;
}
| {
success: false;
error: Record<string, any>;
};
export function getServiceAction(
params: Request["params"]
): ServiceActionResult {
const { service, action } = params;
const matchingService = Object.values(catalog.services).find(
(s) => s.service === service
);
if (!matchingService) {
return {
success: false,
error: { type: "missing_service", message: "Service not found", service },
};
}
const matchingAction = Object.values(matchingService.actions).find(
(a) => a.name === action
);
if (!matchingAction) {
return {
success: false,
error: {
type: "missing_action",
message: "Action not found",
service,
action,
},
};
}
return { success: true, service: matchingService, action: matchingAction };
}
+36
View File
@@ -0,0 +1,36 @@
import { IntegrationAuthentication } from "core/authentication/types";
import { Service } from "core/service/types";
import { Request, Response } from "express";
import { catalog } from "integrations/catalog";
type ServiceMetadata = {
name: string;
service: string;
version: string;
icon: string;
live: boolean;
authentication: IntegrationAuthentication;
};
export async function handleServices(req: Request, res: Response) {
const servicesMetadata: Record<string, ServiceMetadata> = {};
Object.entries(catalog.services).forEach(([key, service]) => {
if (service.live === false) return;
const metadata = omitExtraInfo(service);
servicesMetadata[key] = {
...metadata,
icon: `/integrations/${metadata.service}.png`,
};
});
res.send(
JSON.stringify({
services: servicesMetadata,
})
);
}
function omitExtraInfo(service: Service): ServiceMetadata {
const { actions, retryableStatusCodes, ...rest } = service;
return rest;
}
+58
View File
@@ -0,0 +1,58 @@
import { PrismaClient, Prisma } from ".prisma/client";
import invariant from "tiny-invariant";
export { Prisma };
let prisma: PrismaClient;
declare global {
// eslint-disable-next-line no-var
var __db__: PrismaClient;
}
// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
// in production we'll have a single connection to the DB.
if (process.env.NODE_ENV === "production") {
prisma = getClient();
} else {
if (!global.__db__) {
global.__db__ = getClient();
}
prisma = global.__db__;
}
function getClient() {
const { DATABASE_URL } = process.env;
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
// Remove the username:password in the url and print that to the console
const urlWithoutCredentials = new URL(DATABASE_URL);
urlWithoutCredentials.password = "";
console.log(
`1. 🔌 setting up prisma client to ${urlWithoutCredentials.toString()}`
);
const client = new PrismaClient({
datasources: {
db: {
url: DATABASE_URL,
},
},
log: ["warn", "error"],
});
console.log(`2.0 🔌 prisma client connecting`);
// connect eagerly
client.$connect();
console.log(`3.0 🔌 prisma client connected`);
return client;
}
export { prisma };
export type { PrismaClient } from ".prisma/client";
+53
View File
@@ -0,0 +1,53 @@
import type { Prisma, Cache } from ".prisma/client";
import { prisma } from "./db.server";
import { CacheService } from "core/cache/types";
export class PostgresCacheService implements CacheService {
constructor(private readonly namespace: string) {}
async get(key: string) {
const cachedRow = await prisma.cache.findFirst({
where: {
namespace: this.namespace,
key,
OR: [
{ expiresAt: null },
{
expiresAt: {
gt: new Date(),
},
},
],
},
});
if (cachedRow) {
return cachedRow.value;
}
return null;
}
async set(key: string, value: string, ttl?: number): Promise<void> {
const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null;
await prisma.cache.upsert({
where: {
namespace_key: {
namespace: this.namespace,
key,
},
},
update: {
value,
expiresAt,
},
create: {
namespace: this.namespace,
key,
value,
expiresAt,
},
});
}
}
@@ -0,0 +1,27 @@
import { EndpointSpecMetadata } from "core/endpoint/types";
import { RequestData } from "core/request/types";
export async function getDisplayProperties(
data: RequestData,
displayProperties: EndpointSpecMetadata["displayProperties"]
) {
return {
title: interpolateString(displayProperties.title, {
parameters: data.parameters,
body: data.body,
}),
};
}
function interpolateString(
template: string,
data: Record<string | number, any>
) {
return template.replace(/\${([^}]+)}/g, (_, key) => {
return key
.split(".")
.reduce((obj: Record<string | number, any>, prop: string | number) => {
return obj[prop];
}, data);
});
}
@@ -0,0 +1,74 @@
import { CacheService } from "core/cache/types";
import { Endpoint } from "core/endpoint/types";
import { RequestData, RequestResponse } from "core/request/types";
import { getDisplayProperties } from "./getDisplayProperties";
import { Action, Metadata } from "./types";
import { makeInputSpec, makeOutputSpec } from "./utilities";
/** Create an action where you specify the spec and action */
export const makeAdvancedAction = ({
endpoint,
spec,
action,
}: {
endpoint: Endpoint;
spec: Action["spec"];
action: (
data: RequestData,
cache?: CacheService,
metadata?: Metadata
) => Promise<RequestResponse>;
}) => {
const displayProperties = async (data: RequestData) => {
return getDisplayProperties(
data,
endpoint.spec.endpointSpec.metadata.displayProperties
);
};
return {
name: endpoint.spec.endpointSpec.metadata.name,
description: endpoint.spec.endpointSpec.metadata.description,
path: endpoint.spec.endpointSpec.path,
method: endpoint.spec.endpointSpec.method,
spec,
action,
displayProperties,
};
};
/** Creates an action that calls an endpoint */
export const makeSimpleAction = (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 makeAdvancedAction({
endpoint,
spec: {
input: makeInputSpec(endpoint),
output: makeOutputSpec(endpoint),
},
action,
});
};
export const makeSimpleActions = <
TEndpoints extends Record<string, Endpoint>,
K extends keyof TEndpoints
>(
endpoints: TEndpoints
): Record<K, Action> => {
const actions: any = {};
Object.entries(endpoints).forEach(([name, endpoint]) => {
actions[name as K] = makeSimpleAction(endpoint);
});
return actions;
};
@@ -0,0 +1,42 @@
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, any>;
export type DisplayProperties = {
title: 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>;
displayProperties: (
/** The data to be sent to the endpoint */
data: RequestData
) => Promise<DisplayProperties>;
};
@@ -0,0 +1,49 @@
import { Endpoint } from "core/endpoint/types";
import { InputSpec, OutputSpec } from "./types";
export function makeInputSpec(endpoint: Endpoint): InputSpec {
return {
security: endpoint.spec.endpointSpec.security,
parameters: endpoint.spec.endpointSpec.parameters,
body: endpoint.spec.endpointSpec.request.body?.schema,
};
}
export function makeOutputSpec(endpoint: Endpoint): OutputSpec {
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,107 @@
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;
}
return addCredentialsToConfig(fetch, { authentication, credentials });
}
export function addCredentialsToConfig(
fetch: FetchConfig,
{
authentication,
credentials,
}: {
authentication: IntegrationAuthentication;
credentials: AuthCredentials;
}
) {
// 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") {
let value = "";
switch (authConfig.placement.type) {
case "basic":
value = `Basic ${credentials.api_key}`;
break;
case "bearer":
value = `Bearer ${credentials.api_key}`;
break;
}
fetch.headers[authConfig.placement.key] = value;
}
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,61 @@
import { z } from "zod";
export type IntegrationAuthentication = Record<
string,
AuthenticationDefinition
>;
type AuthenticationDefinition = OAuth2Authentication | APIKeyAuthentication;
type OAuth2Authentication = {
type: "oauth2";
placement: AuthenticationPlacement;
authorizationUrl: string;
tokenUrl: string;
flow: "accessCode" | "implicit" | "password" | "application";
scopes: Record<string, string>;
};
type APIKeyAuthentication = {
type: "api_key";
placement: AuthenticationPlacement;
documentation: string;
scopes: Record<string, string>;
additionalFields?: {
key: string;
fieldType: "text";
name: string;
placeholder?: string;
description: string;
}[];
};
type AuthenticationPlacement = HeaderAuthentication;
interface HeaderAuthentication {
in: "header";
type: "basic" | "bearer";
key: string;
}
const OAuth2CredentialsSchema = z.object({
type: z.literal("oauth2"),
name: z.string(),
accessToken: z.string(),
scopes: z.array(z.string()),
});
const APIKeyCredentialsSchema = z.object({
type: z.literal("api_key"),
name: z.string(),
api_key: z.string(),
additionalFields: z.record(z.string(), z.string()).optional(),
scopes: z.array(z.string()),
});
export const AuthCredentialsSchema = z.discriminatedUnion("type", [
OAuth2CredentialsSchema,
APIKeyCredentialsSchema,
]);
export type AuthCredentials = z.infer<typeof AuthCredentialsSchema>;
+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,67 @@
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[]
>;
}
export 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;
}
export interface EndpointSpecMetadata {
name: string;
description: string;
displayProperties: {
title: string;
};
externalDocs?: ExternalDocs;
tags: string[];
}
interface ExternalDocs {
description: string;
url: string;
}
+71
View File
@@ -0,0 +1,71 @@
import { addCredentialsToConfig } from "core/authentication/credentials";
import {
AuthCredentials,
IntegrationAuthentication,
} from "core/authentication/types";
import { HTTPMethod } from "core/endpoint/types";
import { getFetch, safeGetJson } from "core/request/requestEndpoint";
import { FetchConfig } from "core/request/types";
import { type Response } from "node-fetch";
export type FetchOptions = {
url: string;
method: HTTPMethod;
headers?: Record<string, string>;
body?: any;
authentication: IntegrationAuthentication;
credentials?: AuthCredentials;
};
export async function serviceFetch({
url,
method = "GET",
headers,
body,
credentials,
authentication,
}: FetchOptions) {
let fetchConfig: FetchConfig = {
url,
method,
headers: {
...headers,
},
body: JSON.stringify(body),
};
if (credentials == null) {
throw {
type: "missing_credentials",
};
}
fetchConfig = addCredentialsToConfig(fetchConfig, {
authentication,
credentials,
});
try {
const fetch = await getFetch();
const response = await fetch(url, {
method,
headers,
body,
});
const json = await safeGetJson(response);
return {
success: response.ok,
status: response.status,
headers: response.headers,
body: json,
};
} catch (error) {
return {
success: false,
status: 400,
headers: {},
body: error,
};
}
}
@@ -0,0 +1,66 @@
import { JSONSchemaError } from "core/schemas/types";
export type RequestError =
| RequestBodyInvalid
| ParameterMissing
| ParametersInvalid
| ExtraParametersError
| InsufficientScopesError
| MissingResponseSpec
| ResponseBodyInvalid
| BodyMissing
| MissingCredentialsError;
export interface RequestBodyInvalid {
type: "request_body_invalid";
errors: any[];
}
export interface BodyMissing {
type: "missing_body";
}
export interface ParameterMissing {
type: "missing_parameter";
parameter: {
name: string;
};
}
export interface ParametersInvalid {
type: "parameter_invalid";
parameter: {
name: string;
value: any;
};
errors: JSONSchemaError[];
}
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: JSONSchemaError[] }>;
status: number;
body?: any;
}
@@ -0,0 +1,222 @@
import { applyCredentials } from "core/authentication/credentials";
import { EndpointSpec, EndpointSpecResponse } from "core/endpoint/types";
import { JSONSchemaError } from "core/schemas/types";
import { validate } from "core/schemas/validate";
import { type Response } from "node-fetch";
import {
FetchConfig,
RequestData,
RequestResponse,
RequestSpec,
} 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
if (body == null && request.body?.schema != null) {
throw {
type: "missing_body",
};
}
const requestValid = await 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 = await 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": {
if (parameter.schema?.type === "array") {
const array = element as Array<string>;
for (const item of array) {
path = `${path}${path.includes("?") ? "&" : "?"}${name}=${item}`;
}
break;
}
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 fetch = await getFetch();
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: JSONSchemaError[] }> = [];
for (const spec of responseSpecs) {
const responseValid = await 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,
};
}
export async function getFetch() {
return (await import("node-fetch")).default;
}
export 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,141 @@
import { JSONSchema } from "./types";
export function makeStringSchema(
title: string,
description?: string,
options?: {
defaultValue?: string;
enum?: string[];
}
): JSONSchema {
const schema: JSONSchema = {
type: "string",
title,
};
if (description) {
schema.description = description;
}
if (options?.defaultValue) {
schema.default = options.defaultValue;
}
if (options?.enum) {
schema.enum = options.enum;
}
return schema;
}
export function makeNumberSchema(
title: string,
description?: string,
options?: {
defaultValue: number;
}
): JSONSchema {
const schema: JSONSchema = {
type: "number",
title,
};
if (description) {
schema.description = description;
}
if (options?.defaultValue) {
schema.default = options.defaultValue;
}
return schema;
}
export function makeBooleanSchema(
title: string,
description?: string,
options?: {
defaultValue?: boolean;
enum?: boolean;
}
): JSONSchema {
const schema: JSONSchema = {
title,
type: "boolean",
};
if (description) {
schema.description = description;
}
if (options?.defaultValue) {
schema.default = options.defaultValue;
}
if (options?.enum) {
schema.enum = [options.enum];
}
return schema;
}
export function makeArraySchema(title: string, items: JSONSchema): JSONSchema {
return {
type: "array",
title,
items,
};
}
export function makeObjectSchema(
title: string,
options: {
optionalProperties?: Record<string, JSONSchema>;
requiredProperties?: Record<string, JSONSchema>;
additionalProperties?: boolean | JSONSchema;
}
): JSONSchema {
let properties: Record<string, JSONSchema> | undefined = undefined;
if (options.optionalProperties || options.requiredProperties) {
properties = {};
}
if (options.optionalProperties) {
properties = {
...properties,
...options.optionalProperties,
};
}
if (options.requiredProperties) {
properties = {
...properties,
...options.requiredProperties,
};
}
return {
type: "object",
title,
properties,
required: options.requiredProperties
? Object.keys(options.requiredProperties)
: undefined,
additionalProperties: options.additionalProperties,
};
}
export function makeOneOf(title: string, schemas: JSONSchema[]): JSONSchema {
return {
title,
oneOf: schemas,
};
}
export function makeAnyOf(title: string, schemas: JSONSchema[]): JSONSchema {
return {
title,
anyOf: schemas,
};
}
@@ -0,0 +1,13 @@
import { expect, test } from "vitest";
import spec from "./test-openapi-spec-v2.json";
import { dereferenceSpec, schemaFromOpenApiSpecV2 } from "./openApi";
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,4 @@
import { Schema, OutputUnit } from "@cfworker/json-schema";
export type JSONSchema = Schema;
export type JSONSchemaError = OutputUnit;
@@ -0,0 +1,57 @@
import { JSONSchema } from "./types";
export async function validate(data: any, schema?: JSONSchema) {
try {
if (schema === undefined) {
return {
success: true as const,
};
}
if (data === undefined) {
return {
success: false as const,
errors: [
{
keyword: "undefined",
keywordLocation: "undefined",
instanceLocation: "undefined",
error: "data is undefined",
},
],
};
}
const Validator = await getValidator();
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,
};
} catch (e: any) {
console.error(e);
return {
success: false as const,
errors: [
{
keyword: "undefined",
keywordLocation: "undefined",
instanceLocation: "undefined",
error: e.toString(),
},
],
};
}
}
async function getValidator() {
const tool = await import("@cfworker/json-schema");
return tool.Validator;
}
@@ -0,0 +1,12 @@
import { Action } from "core/action/types";
import { IntegrationAuthentication } from "core/authentication/types";
export type Service = {
name: string;
service: string;
version: string;
live: boolean;
authentication: IntegrationAuthentication;
actions: Record<string, Action>;
retryableStatusCodes: number[];
};
@@ -0,0 +1,109 @@
import { InputSpec } from "core/action/types";
import { checkRequiredScopes } from "core/authentication/credentials";
import { RequestError } from "core/request/errors";
import { RequestData, RequestSpec } from "core/request/types";
import { validate } from "core/schemas/validate";
type ValidationResult =
| {
success: true;
}
| {
success: false;
error: RequestError;
};
export async function validateInputs(
inputSpec: InputSpec,
{ parameters, body, credentials }: RequestData
): Promise<ValidationResult> {
if (inputSpec.security) {
if (credentials === undefined) {
return {
success: false,
error: {
type: "missing_credentials",
},
};
}
const requiredScopes = inputSpec.security[credentials.name] ?? [];
const result = checkRequiredScopes(requiredScopes, credentials);
if (!result.success) {
return {
success: false,
error: {
type: "insufficient_scopes",
missingScopes: result.missingScopes,
},
};
}
}
// validate the request body exists if it should
if (body == null && inputSpec.body != null) {
return {
success: false,
error: {
type: "missing_body",
},
};
}
//validate the request body against the schema
const requestValid = await validate(body, inputSpec.body);
if (!requestValid.success) {
return {
success: false,
error: {
type: "request_body_invalid",
errors: requestValid.errors,
},
};
}
//validate the parameters
if (inputSpec.parameters != null) {
for (const parameter of inputSpec.parameters) {
const { name, required } = parameter;
// if the parameter is missing
if (parameters === undefined || parameters[name] == null) {
if (required) {
return {
success: false,
error: {
type: "missing_parameter",
parameter: {
name,
},
},
};
} else {
continue;
}
}
const element = parameters[name];
//validate the parameter against the schema
const valid = await validate(element, parameter.schema);
if (!valid.success) {
return {
success: false,
error: {
type: "parameter_invalid",
parameter: {
name,
value: element,
},
errors: valid.errors,
},
};
}
}
}
return {
success: true,
};
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
[
{
"scope": "https://slack.com:443",
"method": "GET",
"path": "/api/conversations.list?limit=3",
"body": "",
"status": 200,
"response": [
"13a10900e45a2e5f5f662b462e26151762c481e859dbffffde6956a4097eb37f02aa77df7d8db575e3fea335752c0a039e145027e3d40b2d00eba4129d035425d78756f60ba204af170b1f86483c07dee72fe4317870e818d6cdc59b7d081878c0287860ab6e19cd3bf225f61be1bc2382ac0003f2e24cce767358bdc5ff27e6efe91e15d4cecfa8a6aaeaa6eeeaf667d1c49ef8fb383a0c655532f02ae7e5e72c8ac21662b52b9fbf2ed41ddc25fcc145a24ddfb1d411d84be8b4ff04dbb1b7b63ebf31fc3bfed0b427f133131f2a9ed4f65f9d87833bea7962df1dcbb40c575534c5b0150ce8e822c38f0726b8c1a31775894ab417e0d9318cb3c3335335424a29fa12934fe2d87ceefa561a8129f271cb7784f29485b4fd55e0491abc5f00ce6df0e046706922da1285233d68fe538d002f132a1be061b31db24c20e0de211ded031dfdc7403381cc3efb633986e2d0cae55b25863e82922a674915308c922049729e62948938a552fde834122d2af230a4ad5404eb6368268695184d5a6b371a5b02166f21265a49a688495cc46831de9149e52d1de762e84a357473940f6c7da403cff8c744e37194d39b870b86ef4f71a40ab47210eafedec73fa66bb99aa36aaa6acc30179aa4d4548102cec5c0a3e46de9484720aa1fd1458a057d8fa70eb30d722b62ad1bfdaf51732b0c120ad5261e0936adc6e03e980bf5b38efe852022fc10052d45ff3cb4e9a73bcca22c88540219b11b9ff60572714c9e4bede4f2e2fa97ef83a8cfa3a06aeb94601a99baa6d96693b436663171fddf9a64af46731d453115ddd0d4d9b7d5af35e352b56742300a313884c29056bed452699f53febf3154c19949cfb0d0f7301142aadafa91c1303d001ec4a72f59f8541fdd1e3d3cbc3c7f0437caf536fcff03"
],
"rawHeaders": [
"date",
"Sat, 18 Feb 2023 16:06:41 GMT",
"server",
"Apache",
"x-powered-by",
"HHVM/4.153.1",
"access-control-allow-origin",
"*",
"referrer-policy",
"no-referrer",
"x-slack-backend",
"r",
"x-slack-unique-id",
"Y_D3kQScXBLeo8CcHpwldgAAECY",
"strict-transport-security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-headers",
"slack-route, x-slack-version-ts, x-b3-traceid, x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags",
"access-control-expose-headers",
"x-slack-req-id, retry-after",
"x-oauth-scopes",
"channels:read,channels:join,channels:manage,chat:write,groups:write,im:write,mpim:write,chat:write.customize,reactions:write",
"x-accepted-oauth-scopes",
"channels:read,groups:read,mpim:read,im:read,read",
"expires",
"Mon, 26 Jul 1997 05:00:00 GMT",
"cache-control",
"private, no-cache, no-store, must-revalidate",
"pragma",
"no-cache",
"x-xss-protection",
"0",
"x-content-type-options",
"nosniff",
"x-slack-req-id",
"10d2571cfa8e50b57c91d537221aff81",
"vary",
"Accept-Encoding",
"content-encoding",
"br",
"content-length",
"654",
"content-type",
"application/json; charset=utf-8",
"x-envoy-upstream-service-time",
"118",
"x-backend",
"main_normal main_canary_with_overflow main_control_with_overflow",
"x-server",
"slack-www-hhvm-main-iad-cxtr",
"x-slack-shared-secret-outcome",
"no-match",
"via",
"envoy-www-iad-scsk, envoy-edge-lhr-csok",
"x-edge-backend",
"envoy-www",
"x-slack-edge-shared-secret-outcome",
"no-match",
"connection",
"close"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,53 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "POST",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m",
"body": {
"fields": {
"Employee": "Employee single create"
}
},
"status": 200,
"response": {
"id": "recY9ZLK96wNBblmo",
"createdTime": "2023-02-18T17:05:53.000Z",
"fields": {
"Employee": "Employee single create"
}
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 17:05:53 GMT",
"Content-Type",
"120",
"Connection",
"close",
"Set-Cookie",
"AWSALB=uxQV2kzjELpFGUscnluykIVz7XOSrD1HxCYqoKdrnnPDt2Djy+EBfTKTDx4p0fAh1qD5c3/jRM2bCy9rhptozXyMsWOCDL7v2H3E6VRso6pGZm+kmcdzFjjoIO36; Expires=Sat, 25 Feb 2023 17:05:53 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=uxQV2kzjELpFGUscnluykIVz7XOSrD1HxCYqoKdrnnPDt2Djy+EBfTKTDx4p0fAh1qD5c3/jRM2bCy9rhptozXyMsWOCDL7v2H3E6VRso6pGZm+kmcdzFjjoIO36; Expires=Sat, 25 Feb 2023 17:05:53 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwL5hqTq5iBqCg9Q; path=/; expires=Sun, 18 Feb 2024 17:05:53 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"114"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,73 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "POST",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m",
"body": {
"records": [
{
"fields": {
"Employee": "Employee #1"
}
},
{
"fields": {
"Employee": "Employee #2"
}
}
]
},
"status": 200,
"response": {
"records": [
{
"id": "recGy1FsSzQnQ1Q5G",
"createdTime": "2023-02-18T17:05:09.000Z",
"fields": {
"Employee": "Employee #1"
}
},
{
"id": "recfrk4NonMC37k3q",
"createdTime": "2023-02-18T17:05:09.000Z",
"fields": {
"Employee": "Employee #2"
}
}
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 17:05:09 GMT",
"Content-Type",
"145",
"Connection",
"close",
"Set-Cookie",
"AWSALB=S3w+xLzGuJ4YZa7uQpSGEZYmk2IXTVlsJ9yVl+E2aUbEW5BtGoPl4lNa9pwXEpNvI29HEMTQgpuOXAnvfTFKzm+ot6y2DAQJwqa1nDNgQVRZ2z6lNn602nx0ebhI; Expires=Sat, 25 Feb 2023 17:05:08 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=S3w+xLzGuJ4YZa7uQpSGEZYmk2IXTVlsJ9yVl+E2aUbEW5BtGoPl4lNa9pwXEpNvI29HEMTQgpuOXAnvfTFKzm+ot6y2DAQJwqa1nDNgQVRZ2z6lNn602nx0ebhI; Expires=Sat, 25 Feb 2023 17:05:08 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brw7GK0C3hS9Fnih9; path=/; expires=Sun, 18 Feb 2024 17:05:08 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"221"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,97 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "POST",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m",
"body": {
"fields": {
"Employee": "Delete now #3"
}
},
"status": 200,
"response": {
"id": "recyubpGhZWle9GOS",
"createdTime": "2023-02-18T17:25:39.000Z",
"fields": {
"Employee": "Delete now #3"
}
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 17:25:38 GMT",
"Content-Type",
"121",
"Connection",
"close",
"Set-Cookie",
"AWSALB=jt7yHA1t0HaG6Wp2xE4aJWZdQ59coYaZrRwqp8gwZmizt1A1N1H+xEqqycwyHEJtuK/6Rvc5FGcWdsJZQD2kbg5MSS0aropskjuOcOWICRp4+IHNWdYH5Osmcfre; Expires=Sat, 25 Feb 2023 17:25:38 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=jt7yHA1t0HaG6Wp2xE4aJWZdQ59coYaZrRwqp8gwZmizt1A1N1H+xEqqycwyHEJtuK/6Rvc5FGcWdsJZQD2kbg5MSS0aropskjuOcOWICRp4+IHNWdYH5Osmcfre; Expires=Sat, 25 Feb 2023 17:25:38 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwoFcsjVo3nFDHPT; path=/; expires=Sun, 18 Feb 2024 17:25:38 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"105"
],
"responseIsBinary": false
},
{
"scope": "https://api.airtable.com:443",
"method": "DELETE",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m/recyubpGhZWle9GOS",
"body": "",
"status": 200,
"response": {
"deleted": true,
"id": "recyubpGhZWle9GOS"
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 17:25:39 GMT",
"Content-Type",
"59",
"Connection",
"close",
"Set-Cookie",
"AWSALB=SjG+vAIOeCRwgx9Zhi7bVWiFG0acnh1G6+u2nL+ZUfo+4ON1RPjGdhX41SiaekKoYBmW2oCbSynCYAXKGnQjJYaOmPFAeIkml0VZuXSCv1Jm8P2PsXR+DoKIvZTk; Expires=Sat, 25 Feb 2023 17:25:39 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=SjG+vAIOeCRwgx9Zhi7bVWiFG0acnh1G6+u2nL+ZUfo+4ON1RPjGdhX41SiaekKoYBmW2oCbSynCYAXKGnQjJYaOmPFAeIkml0VZuXSCv1Jm8P2PsXR+DoKIvZTk; Expires=Sat, 25 Feb 2023 17:25:39 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwUynCRjs24wfp6Z; path=/; expires=Sun, 18 Feb 2024 17:25:39 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"41"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,125 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "POST",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m",
"body": {
"records": [
{
"fields": {
"Employee": "Delete now #1"
}
},
{
"fields": {
"Employee": "Delete now #2"
}
}
]
},
"status": 200,
"response": {
"records": [
{
"id": "recTR6YLOshwwyi1O",
"createdTime": "2023-02-18T17:22:12.000Z",
"fields": {
"Employee": "Delete now #1"
}
},
{
"id": "recuQVL2gpUjN64py",
"createdTime": "2023-02-18T17:22:12.000Z",
"fields": {
"Employee": "Delete now #2"
}
}
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 17:22:12 GMT",
"Content-Type",
"154",
"Connection",
"close",
"Set-Cookie",
"AWSALB=IAMzoMptPLC7k7rWXTocxJiu8ciJvbHqD17c0EPaM38k1FkzA/zW7S+tRKK6LOzrVFyLL8GUgFtdfDGFELrQ8uO1bADYnEiB4vfOAXjRwSBA2Dc61N3O+MIWMdnM; Expires=Sat, 25 Feb 2023 17:22:12 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=IAMzoMptPLC7k7rWXTocxJiu8ciJvbHqD17c0EPaM38k1FkzA/zW7S+tRKK6LOzrVFyLL8GUgFtdfDGFELrQ8uO1bADYnEiB4vfOAXjRwSBA2Dc61N3O+MIWMdnM; Expires=Sat, 25 Feb 2023 17:22:12 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwFID6JiyKkz2OUb; path=/; expires=Sun, 18 Feb 2024 17:22:12 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"225"
],
"responseIsBinary": false
},
{
"scope": "https://api.airtable.com:443",
"method": "DELETE",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m?records=recTR6YLOshwwyi1O&records=recuQVL2gpUjN64py",
"body": "",
"status": 200,
"response": {
"records": [
{
"deleted": true,
"id": "recTR6YLOshwwyi1O"
},
{
"deleted": true,
"id": "recuQVL2gpUjN64py"
}
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 17:22:13 GMT",
"Content-Type",
"91",
"Connection",
"close",
"Set-Cookie",
"AWSALB=psOMBihmNThSHe+ZEkpP1CKmbhnepN53OlaE2Eyi0zBUDOzM7dHGR7ptEm8ebKfpyqDolVl93quNNO44wwIfZqZHuSPVGyZsJWJoM2FsHY4G4HaFCJZoPEkqRFFs; Expires=Sat, 25 Feb 2023 17:22:12 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=psOMBihmNThSHe+ZEkpP1CKmbhnepN53OlaE2Eyi0zBUDOzM7dHGR7ptEm8ebKfpyqDolVl93quNNO44wwIfZqZHuSPVGyZsJWJoM2FsHY4G4HaFCJZoPEkqRFFs; Expires=Sat, 25 Feb 2023 17:22:12 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwq5X9BQFhfFj5lr; path=/; expires=Sun, 18 Feb 2024 17:22:12 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"97"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,79 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "GET",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m/recHcnB1MbBr9Rd2P",
"body": "",
"status": 200,
"response": {
"id": "recHcnB1MbBr9Rd2P",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Faith Butterworth",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "att3ibwyOXULYL15O",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/0Hy62Ommo3C1BR3r_TsIMw/xGdMUGBUycsvzqjCPHlIuKyvUCM_Y6InDa7Bz15pCaGtWXyue5BlHYak_kFJYmgVXbgvWAXkaigRk7xdcUHiZU66F33CrAUP36ihAFT3-eU/-_khPf6Rh8P9uLt3Oc55HupkyS000nORrpvZpY1bKwI",
"filename": "headshot-purple-2 (1).png",
"size": 191272,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/MlmaPKrIg1yOj_qipSwKhg/fVQ5W7QtoxoAszvbIHT2dksddzWQi2ETkCewMXRSY2NIiX6wD1LWuC6d4VICHwRCz_6sKW4vhwZ1vkScFYpcgQ/Iy8lz5HcTKpTrG8gBq4fQJRyPAdnfYrwn3u05_lovmc",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/BBIfL5Oqc8BAHkugmNUkXA/JygSzSF9lLJi1U1dP1xCpH1QTSJwxb2FRvJssEdxZubfzVIa9iofsOn5HKsIFLuPl2K3yYCvaxAD3ZsHa7WCig/Rpbg9vWTPa6-oxoPUO4gqkOBFHzadVBHEZhjJoIu6Y8",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/xlrKts_ACw09qLqsAW16EQ/qrDMlQpI1cVdPCe0uw6ZJw9FeZCcwenmFzZ317qbsO0q8ZY2DY5k-bpPFYBaS4KkqztczIOqdnCBIYiuJ3_wqA/4LU_0QW7hp8Rw828Z_ov3GNcZa86pywziLdQy364XXE",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "Contractor"
}
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 16:02:10 GMT",
"Content-Type",
"872",
"Connection",
"close",
"Set-Cookie",
"AWSALB=PhNLtNqLT//gbRBjRTywLiyg0lY3ZGo9Q2LLwOqLnAwsXcrRIaaELYV7A+s2lJlrAsid5UKIKld15Jq1nLyroXxAcFPd3vcbq6ku7tK7LE8HnOQiaMOV9gOoxqoa; Expires=Sat, 25 Feb 2023 16:02:10 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=PhNLtNqLT//gbRBjRTywLiyg0lY3ZGo9Q2LLwOqLnAwsXcrRIaaELYV7A+s2lJlrAsid5UKIKld15Jq1nLyroXxAcFPd3vcbq6ku7tK7LE8HnOQiaMOV9gOoxqoa; Expires=Sat, 25 Feb 2023 16:02:10 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwuEVkvPW1vATlmC; path=/; expires=Sun, 18 Feb 2024 16:02:10 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"1320"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,112 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "POST",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m/listRecords",
"body": {
"timeZone": "America/Los_Angeles",
"userLocale": "en",
"sort": [
{
"field": "Employee",
"direction": "asc"
}
],
"fields": [
"Employee"
]
},
"status": 200,
"response": {
"records": [
{
"id": "recWhRejYoDn5CNsB",
"createdTime": "2023-01-23T07:46:46.000Z",
"fields": {}
},
{
"id": "recFeH8y7qLNR6CxB",
"createdTime": "2023-01-22T23:15:09.000Z",
"fields": {
"Employee": "Employee 7"
}
},
{
"id": "recRo4MuSqRcb33pp",
"createdTime": "2023-01-23T00:48:36.000Z",
"fields": {
"Employee": "Employee 9"
}
},
{
"id": "recHcnB1MbBr9Rd2P",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Faith Butterworth"
}
},
{
"id": "recbKWDa9URtGWIGG",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Harley Holbrook"
}
},
{
"id": "rec3cPi3z4s6oe9SD",
"createdTime": "2023-01-22T23:51:21.000Z",
"fields": {
"Employee": "Jimmy dean"
}
},
{
"id": "recGPicQa5yIo9dBY",
"createdTime": "2023-01-22T19:22:05.000Z",
"fields": {
"Employee": "Matt Aitken"
}
},
{
"id": "recElL0yiCgIEVcyc",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Quinn Nguyen"
}
}
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 16:24:53 GMT",
"Content-Type",
"338",
"Connection",
"close",
"Set-Cookie",
"AWSALB=4+PtBWlCPjGrIo2V0YEi8J0ogkebmIOEMfx8TOaOhZvHsaXUZ1TQV+5DlPbCxYxrXoXbbP/F0iRXc3qLGC8LVtm5VIkxt0NDhBENoLsdbBAWF1bbmlOs2DM3N+k3; Expires=Sat, 25 Feb 2023 16:24:53 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=4+PtBWlCPjGrIo2V0YEi8J0ogkebmIOEMfx8TOaOhZvHsaXUZ1TQV+5DlPbCxYxrXoXbbP/F0iRXc3qLGC8LVtm5VIkxt0NDhBENoLsdbBAWF1bbmlOs2DM3N+k3; Expires=Sat, 25 Feb 2023 16:24:53 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brw4B3gkaAL97bpKd; path=/; expires=Sun, 18 Feb 2024 16:24:53 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"829"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,218 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "POST",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m/listRecords",
"body": {},
"status": 200,
"response": {
"records": [
{
"id": "rec3cPi3z4s6oe9SD",
"createdTime": "2023-01-22T23:51:21.000Z",
"fields": {
"Employee": "Jimmy dean",
"Phone": "7778",
"Employee type": "Employee",
"Manager": {
"id": "usrpWV6yqSVvzVO0j",
"email": "matt@trigger.dev",
"name": "Matt Aitken"
}
}
},
{
"id": "recElL0yiCgIEVcyc",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Quinn Nguyen",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "attx14Pa1lP0DMgRw",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/5gbGs-cRWoOC46FxBWQZKw/ph_QI1srGvvZArxOz207bnKOs-xlIXF8ZqMlk5ssMr-jbiMuFfXOjmjadjOACGFRyQ9nE59Dpy7540UU_6bpgsQ_tMKtCFaaf2a4ieIPEEU/ptI5mq4oczxmmrz-RRWb3MSegywNfAwWf--9mL5IT34",
"filename": "headshot-pink-1.png",
"size": 215411,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/3bEGb1n_oJxadHpHMcDg_w/1Mga_f3JNzLtWq_an_E7I98CxejzOpiALmDJHRdI0XcBbVwIv-vzj96mY-XeAL0W32Q_WCQPu4TqWzisnlqLYg/7pJLZzaspSp1339lIgp1o1Nb1durOAoIx-IflDRI8gY",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/7Xr67It2dJ296uYyumST1g/HH8QTjlaP0frLwscoJy3vJLvOdKwz1zHL_ll8zMtfFkm1uFB9-nRkA09YeSojhemScYjpvIB7OoxJTh3lqU-jQ/8jtF2js5F47hndbxts2_ZIFZaxkuq3G0BxbomG1wRDA",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/O1xGv0NoeaGTVYYDxpZnuQ/IBXER_7M4O9QqAmv_9pF2evdf5_9YzeVmdzk3Rsi2gFaK6ueREni-Kaw6Xg8VORqFpHNtUQ7JrV_5Ya4zhSuXg/VAGl2IqW98-D0E1RgBretLYJCmNCDqledVmdTrUOEIU",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "Employee"
}
},
{
"id": "recFeH8y7qLNR6CxB",
"createdTime": "2023-01-22T23:15:09.000Z",
"fields": {
"Employee": "Employee 7",
"Phone": "342567",
"Employee type": "Contractor",
"Manager": {
"id": "usrpWV6yqSVvzVO0j",
"email": "matt@trigger.dev",
"name": "Matt Aitken"
}
}
},
{
"id": "recGPicQa5yIo9dBY",
"createdTime": "2023-01-22T19:22:05.000Z",
"fields": {
"Employee": "Matt Aitken",
"Phone": "12345678",
"Employee type": "Employee",
"Manager": {
"id": "usrpWV6yqSVvzVO0j",
"email": "matt@trigger.dev",
"name": "Matt Aitken"
}
}
},
{
"id": "recHcnB1MbBr9Rd2P",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Faith Butterworth",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "att3ibwyOXULYL15O",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/xV5UysTMWXnI9p8E8KMTlA/Ov21BA--8-n8JC_sH7-sx_Iub_aXA1PGiVAohUAhE5SuEtqSMGhN39pRHI3tmu3LqEh8iDz8pTZ_TdWHVV8riCTBPEm8NvvD3o6BtJTRXcE/LJjYfS0Wh_KMcmkgL-82HOLXyhhDHnMsFvrqL1OCtXQ",
"filename": "headshot-purple-2 (1).png",
"size": 191272,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/L9WFjSk92HlOH9RDdrP7MA/7AgbUc3M9KuDz5plvUFL5dsRwt55PiqcnPvoDIawWfk0w_N4GDZPTPDiiqxFOsUMNOk4CB1RuBZFFLNEfu0bRA/RYcp3x_oGgLtvOi7CtUplGM_rmU-EdbBQ5zdcr7L-Yo",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/Zl-Thn_JCnnb_-j9CpJ0lg/9hIAzhles7CH7n0EgRzrg-_qVCz1G6oHXX8M7A70KVUBy3Ttnc1NwoSKLMj7JxmYkqc8hYCRU4qRA_S22sS1cw/BhQLDmDmMAUd4sUaxVfTbHnxPGJD9q0_NPB3l7rYz4o",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/4E18KjffN_36eSJ65hCEFg/1_ts6X0NwupOAEyHnehE_hy5ROdSOt1jODcXv5Cny3Wn4O5r3Ewl3vgsg3Lebkwv40iJo7tbtblZEdtZMecpJA/GBgtTGmld0ZH7_4FygTqipHkEpQVn4dt3CmGBnOo6Wc",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "Contractor"
}
},
{
"id": "recRo4MuSqRcb33pp",
"createdTime": "2023-01-23T00:48:36.000Z",
"fields": {
"Employee": "Employee 9",
"Phone": "12345678",
"Employee type": "Employee",
"Manager": {
"id": "usrpWV6yqSVvzVO0j",
"email": "matt@trigger.dev",
"name": "Matt Aitken"
}
}
},
{
"id": "recWhRejYoDn5CNsB",
"createdTime": "2023-01-23T07:46:46.000Z",
"fields": {}
},
{
"id": "recbKWDa9URtGWIGG",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "Harley Holbrook",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "attIMHgGEnN0YutyM",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/Fz00EH3JBLCN7V_swur5cg/p3tx8jWcy5175n6bvK9aCabwdIfMur6wDrly_X6A8cL6YnO5JBKK0YJW1EjrEzjCtkj0uJ0PAS4lbwD2hMsKlKUZjopUmDegHLnms_ucOxE/6HP7Qw3GHTGiAmHUM21jHC5fhLcyfTrlD9qrs35a9-I",
"filename": "headshot-yellow-2.png",
"size": 349926,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/Cyg5kaW38cKzs03TbM-X3g/8omtIQVimV1MrD9PPtj2SaAlOA3CxuCdqHWoVPxi6UELMV2w7EhFv9Ko3WexIF5bcQyd9-N4RLFGvd5__VgP6A/suCIwJkTLliZrHLwioXIUqnrkJ7gIOxtNrdQtkDrBjk",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/g3EV5PxrbBETRQ9Qzn5WYg/rloz8wROtoNA6Ab9HEykJEmHtnmqUzxnnksoNJU7Y1NjeoV6dQQQZxLTjQBYJukBQfil4EXI8b_ejtARnkANhw/RwNwembhBCNpNJPThTWE_Fae3KtNvBQf-nDboeTP06c",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676743200000/hFdCcDELa9Y1qNU8ynA-gA/rY9pKGsa3U-i95eAYUT9qPkXhakhXNpWaWBOKsKSSvmc0MFERzMvBTQ9X6hfBBOWdJNPm2ewSI5qaMk1OqdDYg/IVswNEnsz2QWGQtpfHLGHHaAZEWVam08gd7vSXPVMOI",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "On leave"
}
}
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 15:58:57 GMT",
"Content-Type",
"2247",
"Connection",
"close",
"Set-Cookie",
"AWSALB=eKX9sqNvujzmAJZfcbDUrm7NlI8Xe9niXH7mrVhWbrGQZxHi9a8M9cKLI067MCp0mv5UfHVCEeUvkQbnHW8lB/LQwpch1qdox/a0c+J5kcVIGXWLH8zOB6pv2t42; Expires=Sat, 25 Feb 2023 15:58:57 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=eKX9sqNvujzmAJZfcbDUrm7NlI8Xe9niXH7mrVhWbrGQZxHi9a8M9cKLI067MCp0mv5UfHVCEeUvkQbnHW8lB/LQwpch1qdox/a0c+J5kcVIGXWLH8zOB6pv2t42; Expires=Sat, 25 Feb 2023 15:58:57 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwJiIPgBOUtNPjri; path=/; expires=Sun, 18 Feb 2024 15:58:57 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"4968"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,83 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "PATCH",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m/recHcnB1MbBr9Rd2P",
"body": {
"fields": {
"Employee": "John Doe II"
}
},
"status": 200,
"response": {
"id": "recHcnB1MbBr9Rd2P",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "John Doe II",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "att3ibwyOXULYL15O",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/0Hy62Ommo3C1BR3r_TsIMw/xGdMUGBUycsvzqjCPHlIuKyvUCM_Y6InDa7Bz15pCaGtWXyue5BlHYak_kFJYmgVXbgvWAXkaigRk7xdcUHiZU66F33CrAUP36ihAFT3-eU/-_khPf6Rh8P9uLt3Oc55HupkyS000nORrpvZpY1bKwI",
"filename": "headshot-purple-2 (1).png",
"size": 191272,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/MlmaPKrIg1yOj_qipSwKhg/fVQ5W7QtoxoAszvbIHT2dksddzWQi2ETkCewMXRSY2NIiX6wD1LWuC6d4VICHwRCz_6sKW4vhwZ1vkScFYpcgQ/Iy8lz5HcTKpTrG8gBq4fQJRyPAdnfYrwn3u05_lovmc",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/BBIfL5Oqc8BAHkugmNUkXA/JygSzSF9lLJi1U1dP1xCpH1QTSJwxb2FRvJssEdxZubfzVIa9iofsOn5HKsIFLuPl2K3yYCvaxAD3ZsHa7WCig/Rpbg9vWTPa6-oxoPUO4gqkOBFHzadVBHEZhjJoIu6Y8",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/xlrKts_ACw09qLqsAW16EQ/qrDMlQpI1cVdPCe0uw6ZJw9FeZCcwenmFzZ317qbsO0q8ZY2DY5k-bpPFYBaS4KkqztczIOqdnCBIYiuJ3_wqA/4LU_0QW7hp8Rw828Z_ov3GNcZa86pywziLdQy364XXE",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "Contractor"
}
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 16:56:09 GMT",
"Content-Type",
"868",
"Connection",
"close",
"Set-Cookie",
"AWSALB=2CpHsb1CL3MMw3hBwb9rVw+mR1QJ7rNbVnCS1fkDNEEi3FM0b4jSEGQ0FhjkIkdkQfLhOMrCXK95XbgEImuhWJuv4FKNdOIk6hNaJawWjFq6kh/mOMlFi7HU8r8h; Expires=Sat, 25 Feb 2023 16:56:09 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=2CpHsb1CL3MMw3hBwb9rVw+mR1QJ7rNbVnCS1fkDNEEi3FM0b4jSEGQ0FhjkIkdkQfLhOMrCXK95XbgEImuhWJuv4FKNdOIk6hNaJawWjFq6kh/mOMlFi7HU8r8h; Expires=Sat, 25 Feb 2023 16:56:09 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwP6XoEdwHyGdxxZ; path=/; expires=Sun, 18 Feb 2024 16:56:09 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"1314"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,92 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "PATCH",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m",
"body": {
"records": [
{
"id": "recHcnB1MbBr9Rd2P",
"fields": {
"Employee": "John Doe"
}
}
]
},
"status": 200,
"response": {
"records": [
{
"id": "recHcnB1MbBr9Rd2P",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "John Doe",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "att3ibwyOXULYL15O",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/0Hy62Ommo3C1BR3r_TsIMw/xGdMUGBUycsvzqjCPHlIuKyvUCM_Y6InDa7Bz15pCaGtWXyue5BlHYak_kFJYmgVXbgvWAXkaigRk7xdcUHiZU66F33CrAUP36ihAFT3-eU/-_khPf6Rh8P9uLt3Oc55HupkyS000nORrpvZpY1bKwI",
"filename": "headshot-purple-2 (1).png",
"size": 191272,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/MlmaPKrIg1yOj_qipSwKhg/fVQ5W7QtoxoAszvbIHT2dksddzWQi2ETkCewMXRSY2NIiX6wD1LWuC6d4VICHwRCz_6sKW4vhwZ1vkScFYpcgQ/Iy8lz5HcTKpTrG8gBq4fQJRyPAdnfYrwn3u05_lovmc",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/BBIfL5Oqc8BAHkugmNUkXA/JygSzSF9lLJi1U1dP1xCpH1QTSJwxb2FRvJssEdxZubfzVIa9iofsOn5HKsIFLuPl2K3yYCvaxAD3ZsHa7WCig/Rpbg9vWTPa6-oxoPUO4gqkOBFHzadVBHEZhjJoIu6Y8",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/xlrKts_ACw09qLqsAW16EQ/qrDMlQpI1cVdPCe0uw6ZJw9FeZCcwenmFzZ317qbsO0q8ZY2DY5k-bpPFYBaS4KkqztczIOqdnCBIYiuJ3_wqA/4LU_0QW7hp8Rw828Z_ov3GNcZa86pywziLdQy364XXE",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "Contractor"
}
}
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 16:46:03 GMT",
"Content-Type",
"870",
"Connection",
"close",
"Set-Cookie",
"AWSALB=aRijGYsG1c7HW7/3PfA+iqX9yfDtsVgoPBVm4dkHcigHjt0gpNbzI/GN4nVRaRPorF1fOAE0jRrIREfxlnx9YcqQP097XI6IpF109sT5W5dRnY6rzb2gyGS93Vhn; Expires=Sat, 25 Feb 2023 16:46:03 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=aRijGYsG1c7HW7/3PfA+iqX9yfDtsVgoPBVm4dkHcigHjt0gpNbzI/GN4nVRaRPorF1fOAE0jRrIREfxlnx9YcqQP097XI6IpF109sT5W5dRnY6rzb2gyGS93Vhn; Expires=Sat, 25 Feb 2023 16:46:03 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwkAO755Iu5BkpXR; path=/; expires=Sun, 18 Feb 2024 16:46:03 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"1325"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,114 @@
[
{
"scope": "https://api.airtable.com:443",
"method": "PATCH",
"path": "/v0/appBlf3KsalIQeMUo/tblvXn2TOeVPC9c6m",
"body": {
"performUpsert": {
"fieldsToMergeOn": [
"Employee"
]
},
"records": [
{
"fields": {
"Employee": "John Doe"
}
},
{
"fields": {
"Employee": "Jane Doe"
}
}
]
},
"status": 200,
"response": {
"records": [
{
"id": "recHcnB1MbBr9Rd2P",
"createdTime": "2023-01-03T12:05:53.000Z",
"fields": {
"Employee": "John Doe",
"Phone": "(123) 456-7890",
"Headshot": [
{
"id": "att3ibwyOXULYL15O",
"width": 950,
"height": 700,
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/0Hy62Ommo3C1BR3r_TsIMw/xGdMUGBUycsvzqjCPHlIuKyvUCM_Y6InDa7Bz15pCaGtWXyue5BlHYak_kFJYmgVXbgvWAXkaigRk7xdcUHiZU66F33CrAUP36ihAFT3-eU/-_khPf6Rh8P9uLt3Oc55HupkyS000nORrpvZpY1bKwI",
"filename": "headshot-purple-2 (1).png",
"size": 191272,
"type": "image/png",
"thumbnails": {
"small": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/MlmaPKrIg1yOj_qipSwKhg/fVQ5W7QtoxoAszvbIHT2dksddzWQi2ETkCewMXRSY2NIiX6wD1LWuC6d4VICHwRCz_6sKW4vhwZ1vkScFYpcgQ/Iy8lz5HcTKpTrG8gBq4fQJRyPAdnfYrwn3u05_lovmc",
"width": 49,
"height": 36
},
"large": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/BBIfL5Oqc8BAHkugmNUkXA/JygSzSF9lLJi1U1dP1xCpH1QTSJwxb2FRvJssEdxZubfzVIa9iofsOn5HKsIFLuPl2K3yYCvaxAD3ZsHa7WCig/Rpbg9vWTPa6-oxoPUO4gqkOBFHzadVBHEZhjJoIu6Y8",
"width": 695,
"height": 512
},
"full": {
"url": "https://v5.airtableusercontent.com/v1/15/15/1676750400000/xlrKts_ACw09qLqsAW16EQ/qrDMlQpI1cVdPCe0uw6ZJw9FeZCcwenmFzZ317qbsO0q8ZY2DY5k-bpPFYBaS4KkqztczIOqdnCBIYiuJ3_wqA/4LU_0QW7hp8Rw828Z_ov3GNcZa86pywziLdQy364XXE",
"width": 3000,
"height": 3000
}
}
}
],
"Employee type": "Contractor"
}
},
{
"id": "recpyQHEcTiiwzAAe",
"createdTime": "2023-02-18T16:49:12.000Z",
"fields": {
"Employee": "Jane Doe"
}
}
],
"updatedRecords": [
"recHcnB1MbBr9Rd2P"
],
"createdRecords": [
"recpyQHEcTiiwzAAe"
]
},
"rawHeaders": [
"Date",
"Sat, 18 Feb 2023 16:49:12 GMT",
"Content-Type",
"931",
"Connection",
"close",
"Set-Cookie",
"AWSALB=/B2FI/4JzHLz69XGh0fnXEzIHT58xav2z9CRaJPk/mxxK+IPKjzX/Kd13DU/uJsnD5QcWRgFZjepxA7s8HoojMSUw3+Vkav3Db4mPcly8wQXp88r5BEUjDvFso9o; Expires=Sat, 25 Feb 2023 16:49:12 GMT; Path=/",
"Set-Cookie",
"AWSALBCORS=/B2FI/4JzHLz69XGh0fnXEzIHT58xav2z9CRaJPk/mxxK+IPKjzX/Kd13DU/uJsnD5QcWRgFZjepxA7s8HoojMSUw3+Vkav3Db4mPcly8wQXp88r5BEUjDvFso9o; Expires=Sat, 25 Feb 2023 16:49:12 GMT; Path=/; SameSite=None; Secure",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwsqrG8CnBxgniFg; path=/; expires=Sun, 18 Feb 2024 16:49:12 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-origin",
"*",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"X-Frame-Options",
"DENY",
"X-Content-Type-Options",
"nosniff",
"Vary",
"Accept-Encoding",
"airtable-uncompressed-content-length",
"1504"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,78 @@
[
{
"scope": "https://slack.com:443",
"method": "POST",
"path": "/api/chat.postMessage",
"body": {
"channel": "C00AAAAAAAA",
"text": "This channel doesn't exist, so message won't send"
},
"status": 200,
"response": [
"13270000045439f20ace64f1259fdc9ec3444e00b2e6817cef2beb4e3f1ab2011262c998a76bb93c"
],
"rawHeaders": [
"date",
"Sat, 18 Feb 2023 16:06:42 GMT",
"server",
"Apache",
"x-powered-by",
"HHVM/4.153.1",
"access-control-allow-origin",
"*",
"referrer-policy",
"no-referrer",
"x-slack-backend",
"r",
"x-slack-unique-id",
"Y_D3kqn7oMaK4u0tyzhR0AAAEDY",
"strict-transport-security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-headers",
"slack-route, x-slack-version-ts, x-b3-traceid, x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags",
"access-control-expose-headers",
"x-slack-req-id, retry-after",
"x-oauth-scopes",
"channels:read,channels:join,channels:manage,chat:write,groups:write,im:write,mpim:write,chat:write.customize,reactions:write",
"x-accepted-oauth-scopes",
"chat:write",
"expires",
"Mon, 26 Jul 1997 05:00:00 GMT",
"cache-control",
"private, no-cache, no-store, must-revalidate",
"pragma",
"no-cache",
"x-xss-protection",
"0",
"x-content-type-options",
"nosniff",
"x-slack-req-id",
"ebbefac5397dbab5385db0aae08cf6b3",
"vary",
"Accept-Encoding",
"content-encoding",
"br",
"content-length",
"40",
"content-type",
"application/json; charset=utf-8",
"x-envoy-upstream-service-time",
"93",
"x-backend",
"main_normal main_canary_with_overflow main_control_with_overflow",
"x-server",
"slack-www-hhvm-main-iad-dihx",
"x-slack-shared-secret-outcome",
"no-match",
"via",
"envoy-www-iad-uqca, envoy-edge-lhr-ozbu",
"x-edge-backend",
"envoy-www",
"x-slack-edge-shared-secret-outcome",
"no-match",
"connection",
"close"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,78 @@
[
{
"scope": "https://slack.com:443",
"method": "POST",
"path": "/api/chat.postMessage",
"body": {
"channel": "C04GWUTDC3W",
"text": "This the Trigger.dev integrations test"
},
"status": 200,
"response": [
"132a0300c4f7dfcd9f56ce9d9f9342c8d6f68b484575e2661a76992518653ed6029993da6eeb1ffccd5ee7345e3bb70b6a2dc047cc6a474c8790110b4b6112e38710141d5d7f7f7e5c14e36f28ac9610f8244dd23821e74d1688288302d2d790231ad355b5df82a0e0e8fa972e9eeeae73eef97ed686181b0ad73ce882605eae7aede863d1ef76db0bd36a6fa27fe9e317b9002e2360a92b8c47fc31049f8e6edce7e7ebcbcd1ba9a03e9b3101f2b01b4fd93d33e1da644bc8ff7161faf2553ea405e1dab9e6672888b8ed2456c3b4c796c43df1a8dab9fcc53ca1fa78c294ffe6b7c20bc170baaa060a937a4be63de7f627f531276273af355d3dd88e517b0185cce397a058db09ab71d2bca74b29d992ad9b657572babef0e99649c9b2cb3197da8159b25dbd5ab2f5d9ac64496a7192be18348e881e3192189d34d057f0f9d534a00667852a365b685402cac16b4132aa117b171387829075f67ae7f319"
],
"rawHeaders": [
"date",
"Sat, 18 Feb 2023 16:06:41 GMT",
"server",
"Apache",
"x-powered-by",
"HHVM/4.153.1",
"access-control-allow-origin",
"*",
"referrer-policy",
"no-referrer",
"x-slack-backend",
"r",
"x-slack-unique-id",
"Y_D3kaZTOTsosPP4CQqdxwAAAD8",
"strict-transport-security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-headers",
"slack-route, x-slack-version-ts, x-b3-traceid, x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags",
"access-control-expose-headers",
"x-slack-req-id, retry-after",
"x-oauth-scopes",
"channels:read,channels:join,channels:manage,chat:write,groups:write,im:write,mpim:write,chat:write.customize,reactions:write",
"x-accepted-oauth-scopes",
"chat:write",
"expires",
"Mon, 26 Jul 1997 05:00:00 GMT",
"cache-control",
"private, no-cache, no-store, must-revalidate",
"pragma",
"no-cache",
"x-xss-protection",
"0",
"x-content-type-options",
"nosniff",
"x-slack-req-id",
"bd4f8b61920e9df75d67e4cfeb504ab9",
"vary",
"Accept-Encoding",
"content-encoding",
"br",
"content-length",
"351",
"content-type",
"application/json; charset=utf-8",
"x-envoy-upstream-service-time",
"141",
"x-backend",
"main_normal main_canary_with_overflow main_control_with_overflow",
"x-server",
"slack-www-hhvm-main-iad-mhht",
"x-slack-shared-secret-outcome",
"no-match",
"via",
"envoy-www-iad-qbeb, envoy-edge-lhr-vvct",
"x-edge-backend",
"envoy-www",
"x-slack-edge-shared-secret-outcome",
"no-match",
"connection",
"close"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,75 @@
[
{
"scope": "https://slack.com:443",
"method": "GET",
"path": "/api/conversations.list?limit=1",
"body": "",
"status": 200,
"response": [
"13b70300c4c8b67c7d337bad87225e710b16c4026732712e9d6da4139c5a54d3ce1324f223350ae881931e68f7fe6f2e24241b5ae4fce77333b3308328b0bdc4c2a6b0c47fd8fe99218bb08d13c7b0eeaf3fed1350d47ec5b10d59b12bca7aac133d4690f51821096e8017ee6455da533537753e31a7cf1f8a4134bc504d55d54dddd5edb6948833517f6a3b0cb0cc0dea32ab8b76a2c2b0c8a4d6b369799577425dd5010f896e52233616b8cbf8b8df824e74d5d5cf1fc5e9f8fd719fd94f603a045e61471fcef3f123a9795cbe1f966919aeaa688a612b14de3162df8f279060601b5ea44a20d17d8337c7302e4f2e4d558826a6c8229a3e6ec7f23bfa639a72cc9ae6e29f85125e05bc3b2a819318db3338ce776ce35ed4d248740b126c7a503e5423e15e3c991b886c598e5622e0ce7dc37abc6fe8684101b30acd3947bb1c13d3a154d39724844e2171995571e95794c47e1c677542492aa2844b4dee39115d926741c0bb52eec747642206ab11f9b5d631d92d819adefc4a7429f12192908486c57ab7442a76f9301383deac46a939aa07554cf5b06d2cfe028d6b812d9122d694e12ac0b13c8f7d9c6302b6115dbca7c16b73fa70fa64dc9d450f6f85d8c36201"
],
"rawHeaders": [
"date",
"Sat, 18 Feb 2023 16:06:42 GMT",
"server",
"Apache",
"x-powered-by",
"HHVM/4.153.1",
"access-control-allow-origin",
"*",
"referrer-policy",
"no-referrer",
"x-slack-backend",
"r",
"x-slack-unique-id",
"Y_D3khlLQ9vqek922QY0uQAAECM",
"strict-transport-security",
"max-age=31536000; includeSubDomains; preload",
"access-control-allow-headers",
"slack-route, x-slack-version-ts, x-b3-traceid, x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags",
"access-control-expose-headers",
"x-slack-req-id, retry-after",
"x-oauth-scopes",
"channels:read,channels:join,channels:manage,chat:write,groups:write,im:write,mpim:write,chat:write.customize,reactions:write",
"x-accepted-oauth-scopes",
"channels:read,groups:read,mpim:read,im:read,read",
"expires",
"Mon, 26 Jul 1997 05:00:00 GMT",
"cache-control",
"private, no-cache, no-store, must-revalidate",
"pragma",
"no-cache",
"x-xss-protection",
"0",
"x-content-type-options",
"nosniff",
"x-slack-req-id",
"c9ac7f0c1a73910e91a87a6cd3925462",
"vary",
"Accept-Encoding",
"content-encoding",
"br",
"content-length",
"452",
"content-type",
"application/json; charset=utf-8",
"x-envoy-upstream-service-time",
"115",
"x-backend",
"main_normal main_canary_with_overflow main_control_with_overflow",
"x-server",
"slack-www-hhvm-main-iad-bmkm",
"x-slack-shared-secret-outcome",
"no-match",
"via",
"envoy-www-iad-vpph, envoy-edge-lhr-tspo",
"x-edge-backend",
"envoy-www",
"x-slack-edge-shared-secret-outcome",
"no-match",
"connection",
"close"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,56 @@
[
{
"scope": "https://api.sendgrid.com:443",
"method": "POST",
"path": "/v3/mail/send",
"body": {
"from": {
"email": "matt@email.trigger.dev"
},
"subject": "Hello, World!",
"content": [
{
"type": "text/plain",
"value": "Email body here"
}
],
"personalizations": [
{
"to": [
{
"email": "matt@trigger.dev"
}
],
"subject": "Hello, World!"
}
]
},
"status": 202,
"response": "",
"rawHeaders": [
"Server",
"nginx",
"Date",
"Sun, 19 Feb 2023 19:16:30 GMT",
"Content-Length",
"0",
"Connection",
"close",
"X-Message-Id",
"D9FX-x6ETvKlMlzEi0Qlpg",
"Access-Control-Allow-Origin",
"https://sendgrid.api-docs.io",
"Access-Control-Allow-Methods",
"POST",
"Access-Control-Allow-Headers",
"Authorization, Content-Type, On-behalf-of, x-sg-elas-acl",
"Access-Control-Max-Age",
"600",
"X-No-CORS-Reason",
"https://sendgrid.com/docs/Classroom/Basics/API/cors.html",
"Strict-Transport-Security",
"max-age=600; includeSubDomains"
],
"responseIsBinary": false
}
]
@@ -0,0 +1,59 @@
[
{
"scope": "https://api.sendgrid.com:443",
"method": "PUT",
"path": "/v3/marketing/contacts",
"body": {
"contacts": [
{
"email": "matt+1@mattaitken.com",
"first_name": "Matt",
"last_name": "Aitken"
}
]
},
"status": 202,
"response": {
"job_id": "1befbf98-bfdc-4565-ba5a-c7e8b91f2678"
},
"rawHeaders": [
"Server",
"nginx",
"Date",
"Mon, 20 Feb 2023 15:21:43 GMT",
"Content-Type",
"application/json",
"Content-Length",
"50",
"Connection",
"close",
"x-amzn-requestid",
"6fcf9591-e37d-4fb9-b175-59d434ba45c6",
"access-control-allow-origin",
"*",
"access-control-allow-headers",
"AUTHORIZATION, Content-Type, On-behalf-of, x-sg-elas-acl, X-Recaptcha, X-Request-Source",
"x-amz-apigw-id",
"ApNxLHMAvHcFjkA=",
"access-control-allow-methods",
"PUT,OPTIONS,DELETE,OPTIONS",
"access-control-expose-headers",
"Link, Location",
"x-amzn-trace-id",
"Root=1-63f39007-0392a34047f268f366e7d4ce;Sampled=0",
"x-envoy-upstream-service-time",
"231",
"referrer-policy",
"strict-origin-when-cross-origin",
"x-content-type-options",
"nosniff",
"x-ratelimit-limit",
"200",
"x-ratelimit-remaining",
"197",
"x-ratelimit-reset",
"17"
],
"responseIsBinary": false
}
]
@@ -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,79 @@
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 | undefined;
} {
const inputSchema = createInputSchema(spec.input);
if (inputSchema) inputSchema.title = `${name}Input`;
const outputSchema = createSuccessfulOutputSchema(spec.output);
if (outputSchema) 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 | undefined {
if (spec === undefined) return undefined;
//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,10 @@
import { compile } from "json-schema-to-typescript";
export async function getTypesFromSchema(schema: any, name: string) {
const ts = await compile(schema, name, {
additionalProperties: false,
bannerComment: "",
format: false,
});
return ts;
}
+53
View File
@@ -0,0 +1,53 @@
import { handleAction } from "api/v2/action";
import { handleActionDisplay } from "api/v2/action/display";
import { handleServices } from "api/v2/services";
import dotenv from "dotenv";
import express, { Express, NextFunction, Request, Response } from "express";
import morgan from "morgan";
dotenv.config();
const app: Express = express();
const port = process.env.PORT ?? 3006;
app.use(express.json());
app.use(morgan("combined"));
const checkAuthentication = function (
req: Request,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).send("Unauthorized");
return;
}
const auth = authHeader.split(" ");
if (auth.length !== 2 || auth[0] !== "Bearer") {
res.status(401).send("Unauthorized");
return;
}
const token = auth[1];
if (token !== process.env.API_TOKEN) {
res.status(401).send("Unauthorized");
return;
}
next();
};
app.use(checkAuthentication);
app.get("/", (req: Request, res: Response) => {
res.send("Trigger.dev integrations service");
});
app.get("/api/v2/services", handleServices);
app.post("/api/v2/:service/action/:action/display", handleActionDisplay);
app.post("/api/v2/:service/action/:action", handleAction);
app.listen(port, () => {
console.log(`⚡️[server]: Server is running at http://localhost:${port}`);
});
@@ -0,0 +1,5 @@
import { makeSimpleActions } from "core/action/makeAction";
import endpoints from "../endpoints/endpoints";
const actions = makeSimpleActions(endpoints);
export default actions;
@@ -0,0 +1,24 @@
import { IntegrationAuthentication } from "core/authentication/types";
export const authentication: IntegrationAuthentication = {
oauth: {
type: "oauth2",
placement: {
in: "header",
type: "bearer",
key: "Authorization",
},
authorizationUrl: "https://airtable.com/oauth2/v1/authorize",
tokenUrl: "https://airtable.com/oauth2/v1/token",
flow: "accessCode",
scopes: {
"data.records:read": "data.records:read",
"data.records:write": "data.records:write",
"data.recordComments:read": "data.recordComments:read",
"data.recordComments:write": "data.recordComments:write",
"schema.bases:read": "schema.bases:read",
"schema.bases:write": "schema.bases:write",
"webhook:manage": "webhook:manage",
},
},
};
@@ -0,0 +1,532 @@
import { EndpointSpecParameter } from "core/endpoint/types";
import {
makeArraySchema,
makeBooleanSchema,
makeNumberSchema,
makeObjectSchema,
makeOneOf,
makeStringSchema,
} from "core/schemas/makeSchema";
import { JSONSchema } from "core/schemas/types";
export const CollaboratorSchema = makeObjectSchema("A Collaborator", {
requiredProperties: {
id: makeStringSchema("Collaborator ID"),
email: makeStringSchema("Collaborator Email"),
name: makeStringSchema("Collaborator Name"),
},
additionalProperties: true,
});
export const ThumbnailSchema = makeObjectSchema("A Thumbnail", {
requiredProperties: {
url: makeStringSchema("Thumbnail URL"),
width: makeNumberSchema("Thumbnail Width"),
height: makeNumberSchema("Thumbnail Height"),
},
additionalProperties: true,
});
export const AttachmentSchema = makeObjectSchema("An Attachment", {
requiredProperties: {
id: makeStringSchema("Attachment ID"),
url: makeStringSchema("Attachment URL"),
filename: makeStringSchema("Attachment Filename"),
size: makeNumberSchema("Attachment Size"),
type: makeStringSchema("Attachment Type"),
},
optionalProperties: {
height: makeNumberSchema("Attachment Height"),
width: makeNumberSchema("Attachment Width"),
thumbnails: makeObjectSchema("Thumbnails", {
requiredProperties: {
small: ThumbnailSchema,
large: ThumbnailSchema,
full: ThumbnailSchema,
},
additionalProperties: true,
}),
},
additionalProperties: true,
});
export const FieldSchema = makeOneOf("FieldValue", [
makeStringSchema("StringValue"),
makeNumberSchema("NumberValue"),
makeBooleanSchema("BooleanValue"),
CollaboratorSchema,
makeArraySchema("Collaborators", CollaboratorSchema),
makeArraySchema("StringValues", makeStringSchema("StringValue")),
makeArraySchema("Attachments", AttachmentSchema),
]);
export const BaseIdParam: EndpointSpecParameter = {
name: "baseId",
in: "path",
description: "The ID of the base",
schema: {
type: "string",
},
required: true,
};
export const TableIdOrNameParam: EndpointSpecParameter = {
name: "tableIdOrName",
in: "path",
description: "The name or id of the table",
schema: {
type: "string",
},
required: true,
};
export const RecordIdParam: EndpointSpecParameter = {
name: "recordId",
in: "path",
description: "The ID of the record",
schema: {
type: "string",
},
required: true,
};
export const TimeZoneSchema: JSONSchema = {
title: "Timezone",
description:
"The time zone that should be used to format dates when using string as the cellFormat. This parameter is required when using string as the cellFormat",
type: "string",
enum: [
"utc",
"client",
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Istanbul",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kathmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Nicosia",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zaporozhye",
"Europe/Zurich",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kanton",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Wake",
"Pacific/Wallis",
],
};
@@ -0,0 +1,8 @@
import { makeEndpoints } from "core/endpoint/endpoint";
import { authentication } from "../authentication";
import * as specs from "./specs";
const baseUrl = "https://api.airtable.com/v0";
const endpoints = makeEndpoints(baseUrl, authentication, specs);
export default endpoints;
@@ -0,0 +1,606 @@
import { EndpointSpec, EndpointSpecResponse } from "core/endpoint/types";
import {
makeAnyOf,
makeArraySchema,
makeBooleanSchema,
makeNumberSchema,
makeObjectSchema,
makeStringSchema,
} from "core/schemas/makeSchema";
import {
BaseIdParam,
FieldSchema,
RecordIdParam,
TableIdOrNameParam,
TimeZoneSchema,
} from "../common/schemas";
const errorResponse: EndpointSpecResponse = {
success: false,
name: "Error",
description: "Error response",
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
oneOf: [
{
type: "object",
properties: {
error: {
type: "string",
},
},
required: ["error"],
},
{
type: "object",
properties: {
error: {
type: "object",
properties: {
type: {
type: "string",
},
},
required: ["type"],
},
},
required: ["error"],
},
],
},
};
export const listRecords: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}/listRecords",
method: "POST",
metadata: {
name: "listRecords",
description: `List records in a table. Note that table names and table ids can be used interchangeably. We recommend using table IDs so you don't need to modify your API request when your table name changes.\n
The server returns one page of records at a time. Each page will contain pageSize records, which is 100 by default. If there are more records, the response will contain an offset. To fetch the next page of records, include offset in the next request's parameters. Pagination will stop when you've reached the end of your table. If the maxRecords parameter is passed, pagination will stop once you've reached this maximum.\n
Returned records do not include any fields with "empty" values, e.g. "", [], or false.`,
displayProperties: {
title: "List records from table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/list-records",
},
tags: ["records"],
},
security: {
oauth: ["data.records:read"],
},
parameters: [BaseIdParam, TableIdOrNameParam],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
schema: makeObjectSchema("List records body", {
optionalProperties: {
timeZone: TimeZoneSchema,
userLocal: makeStringSchema(
"userLocal",
"The user locale that should be used to format dates when using string as the cellFormat. This parameter is required when using string as the cellFormat."
),
pageSize: makeNumberSchema(
"pageSize",
"The number of records returned in each request. Must be less than or equal to 100. Default is 100."
),
maxRecords: makeNumberSchema(
"maxRecords",
"The maximum total number of records that will be returned in your requests. If this value is larger than pageSize (which is 100 by default), you may have to load multiple pages to reach this total."
),
offset: makeStringSchema(
"offset",
"To fetch the next page of records, include offset from the previous request in the next request's parameters."
),
view: makeStringSchema(
"view",
"The name or ID of a view in the table. If set, only the records in that view will be returned. The records will be sorted according to the order of the view unless the sort parameter is included, which overrides that order. Fields hidden in this view will be returned in the results. To only return a subset of fields, use the fields parameter."
),
sort: makeArraySchema(
"Sort",
makeObjectSchema("Sort field", {
requiredProperties: {
field: makeStringSchema("Field name"),
},
optionalProperties: {
direction: makeStringSchema("Direction", "Direction", {
enum: ["asc", "desc"],
}),
},
})
),
filterByFormula: makeStringSchema(
"filterByFormula",
`A formula used to filter records. The formula will be evaluated for each record, and if the result is not 0, false, "", NaN, [], or #Error! the record will be included in the response. If combined with the view parameter, only records in that view which satisfy the formula will be returned. For example, to only include records where the column named "Category" equals "Programming", pass in: filterByFormula={Category}="Programming"`
),
fields: makeArraySchema(
"Only data for fields whose names or IDs are in this list will be included in the result. If you don't need every field, you can use this parameter to reduce the amount of data transferred.",
makeStringSchema("Field name")
),
},
}),
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeObjectSchema("List records success body", {
optionalProperties: {
offset: makeStringSchema(
"offset",
"To fetch the next page of records, include offset from the previous request in the next request's parameters."
),
},
requiredProperties: {
records: makeArraySchema(
"Records",
makeObjectSchema("Record", {
requiredProperties: {
id: makeStringSchema("Record ID"),
createdTime: makeStringSchema(
"createdTime",
`A date timestamp in the ISO format, eg:"2018-01-01T00:00:00.000Z"`
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
})
),
},
}),
},
],
default: [errorResponse],
},
};
export const getRecord: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}/{recordId}",
method: "GET",
metadata: {
name: "getRecord",
description:
'Retrieve a single record. Any "empty" fields (e.g. "", [], or false) in the record will not be returned.',
displayProperties: {
title:
"Get record ${parameters.recordId} from table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/get-record",
},
tags: ["records"],
},
security: {
oauth: ["data.records:read"],
},
parameters: [BaseIdParam, TableIdOrNameParam, RecordIdParam],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeObjectSchema("Successful response", {
requiredProperties: {
createdTime: makeStringSchema(
"createdTime",
"When the record was created"
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
id: makeStringSchema("id", "The record id"),
},
}),
},
],
default: [errorResponse],
},
};
export const updateRecords: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}",
method: "PATCH",
metadata: {
name: "updateRecords",
description: `Updates up to 10 records, or upserts them when performUpsert is set.`,
displayProperties: {
title: "Update records for table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/update-multiple-records",
},
tags: ["records"],
},
security: {
oauth: ["data.records:write"],
},
parameters: [BaseIdParam, TableIdOrNameParam],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
schema: makeObjectSchema("Update records body", {
optionalProperties: {
performUpsert: makeObjectSchema("Perform upsert", {
requiredProperties: {
fieldsToMergeOn: makeArraySchema(
"Fields to merge on",
makeStringSchema("Field name")
),
},
}),
typecast: makeBooleanSchema(
"typecast",
"If set to true, Airtable will try to convert string values into the appropriate cell value. This conversion is only performed on a best-effort basis. To ensure your data's integrity, this should only be used when necessary. Defaults to false when unset."
),
},
requiredProperties: {
records: makeArraySchema(
"Records to update/upsert",
makeObjectSchema("Record", {
requiredProperties: {
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
optionalProperties: {
id: makeStringSchema(
"id",
"Record ID. Required when performUpsert is not set."
),
},
})
),
},
}),
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeAnyOf("Update/upsert records success body", [
makeObjectSchema("Update response", {
requiredProperties: {
records: makeArraySchema(
"Records",
makeObjectSchema("Record", {
requiredProperties: {
id: makeStringSchema("Record ID"),
createdTime: makeStringSchema(
"createdTime",
`A date timestamp in the ISO format, eg:"2018-01-01T00:00:00.000Z"`
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
})
),
},
}),
makeObjectSchema("Upsert response", {
requiredProperties: {
createdRecords: makeArraySchema(
"Created records",
makeStringSchema("Record ID")
),
updatedRecords: makeArraySchema(
"Updated records",
makeStringSchema("Record ID")
),
records: makeArraySchema(
"Records",
makeObjectSchema("Record", {
requiredProperties: {
id: makeStringSchema("Record ID"),
createdTime: makeStringSchema(
"createdTime",
`A date timestamp in the ISO format, eg:"2018-01-01T00:00:00.000Z"`
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
})
),
},
}),
]),
},
],
default: [errorResponse],
},
};
export const updateRecord: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}/{recordId}",
method: "PATCH",
metadata: {
name: "updateRecord",
description: `Updates a single record.`,
displayProperties: {
title:
"Update record ${parameters.recordId} for table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/update-record",
},
tags: ["records"],
},
security: {
oauth: ["data.records:write"],
},
parameters: [BaseIdParam, TableIdOrNameParam, RecordIdParam],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
schema: makeObjectSchema("Update record body", {
optionalProperties: {
typecast: makeBooleanSchema(
"typecast",
"If set to true, Airtable will try to convert string values into the appropriate cell value. This conversion is only performed on a best-effort basis. To ensure your data's integrity, this should only be used when necessary. Defaults to false when unset."
),
},
requiredProperties: {
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
}),
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeObjectSchema("Update record success body", {
requiredProperties: {
id: makeStringSchema("Record ID"),
createdTime: makeStringSchema(
"createdTime",
`A date timestamp in the ISO format, eg:"2018-01-01T00:00:00.000Z"`
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
}),
},
],
default: [errorResponse],
},
};
export const createRecords: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}",
method: "POST",
metadata: {
name: "createRecords",
description: `Create up to 10 records`,
displayProperties: {
title: "Create records for table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/create-records",
},
tags: ["records"],
},
security: {
oauth: ["data.records:write"],
},
parameters: [BaseIdParam, TableIdOrNameParam],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
schema: makeObjectSchema("Create records body", {
optionalProperties: {
typecast: makeBooleanSchema(
"typecast",
"If set to true, Airtable will try to convert string values into the appropriate cell value. This conversion is only performed on a best-effort basis. To ensure your data's integrity, this should only be used when necessary. Defaults to false when unset."
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
records: makeArraySchema(
"Records to update/upsert",
makeObjectSchema("Record", {
requiredProperties: {
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
})
),
},
}),
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeAnyOf("Create records success body", [
makeObjectSchema("Multiple records created response", {
requiredProperties: {
records: makeArraySchema(
"Records",
makeObjectSchema("Record", {
requiredProperties: {
id: makeStringSchema("Record ID"),
createdTime: makeStringSchema(
"createdTime",
`A date timestamp in the ISO format, eg:"2018-01-01T00:00:00.000Z"`
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
})
),
},
}),
makeObjectSchema("Single record created response", {
requiredProperties: {
id: makeStringSchema("Record ID"),
createdTime: makeStringSchema(
"createdTime",
`A date timestamp in the ISO format, eg:"2018-01-01T00:00:00.000Z"`
),
fields: makeObjectSchema("Fields", {
additionalProperties: FieldSchema,
}),
},
}),
]),
},
],
default: [errorResponse],
},
};
export const deleteRecords: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}",
method: "DELETE",
metadata: {
name: "deleteRecords",
description: `Delete more than one records with the given record IDs. Note you can't delete a single record with this.`,
displayProperties: {
title: "Delete records for table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/delete-multiple-records",
},
tags: ["records"],
},
security: {
oauth: ["data.records:write"],
},
parameters: [
BaseIdParam,
TableIdOrNameParam,
{
name: "records",
in: "query",
description: "An array of record IDs to delete",
schema: makeArraySchema(
"Records to delete",
makeStringSchema("Record ID")
),
required: true,
},
],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeObjectSchema("Delete records response", {
requiredProperties: {
records: makeArraySchema(
"Records",
makeObjectSchema("Deleted record", {
requiredProperties: {
id: makeStringSchema("Record ID"),
deleted: makeBooleanSchema(
"deleted",
"Whether the record was deleted",
{
enum: true,
}
),
},
})
),
},
}),
},
],
default: [errorResponse],
},
};
export const deleteRecord: EndpointSpec = {
path: "/{baseId}/{tableIdOrName}/{recordId}",
method: "DELETE",
metadata: {
name: "deleteRecord",
description: `Delete a single record.`,
displayProperties: {
title:
"Delete record ${parameters.recordId} for table ${parameters.tableIdOrName}",
},
externalDocs: {
description: "API method documentation",
url: "https://airtable.com/developers/web/api/delete-record",
},
tags: ["records"],
},
security: {
oauth: ["data.records:write"],
},
parameters: [BaseIdParam, TableIdOrNameParam, RecordIdParam],
request: {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
},
responses: {
200: [
{
success: true,
name: "Success",
description: "Typical success response",
schema: makeObjectSchema("Delete records response", {
requiredProperties: {
id: makeStringSchema("Record ID"),
deleted: makeBooleanSchema(
"deleted",
"Whether the record was deleted",
{
enum: true,
}
),
},
}),
},
],
default: [errorResponse],
},
};
@@ -0,0 +1,13 @@
import { Service } from "core/service/types";
import { authentication } from "./authentication";
import actions from "./actions/actions";
export const airtable: Service = {
name: "Airtable",
service: "airtable",
version: "2.0.0",
live: true,
authentication,
actions,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
};
@@ -0,0 +1,356 @@
import { startNock, stopNock } from "testing/nock";
import { describe, expect, test } from "vitest";
import endpoints from "../endpoints/endpoints";
const authToken = () => process.env.AIRTABLE_TOKEN ?? "";
describe("airtable.endpoints", async () => {
test("listRecords simple", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.listRecords.simple");
const data = await endpoints.listRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:read"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("listRecords advanced", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.listRecords.advanced");
const data = await endpoints.listRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
timeZone: "America/Los_Angeles",
userLocale: "en",
sort: [
{
field: "Employee",
direction: "asc",
},
],
fields: ["Employee"],
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:read"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("getRecord", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.getRecord");
const data = await endpoints.getRecord.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
recordId: "recHcnB1MbBr9Rd2P",
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:read"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("updateRecords", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.updateRecords");
const data = await endpoints.updateRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
records: [
{
id: "recHcnB1MbBr9Rd2P",
fields: {
Employee: "John Doe",
},
},
],
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("upsertRecords", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.upsertRecords");
const data = await endpoints.updateRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
performUpsert: {
fieldsToMergeOn: ["Employee"],
},
records: [
{
fields: {
Employee: "John Doe",
},
},
{
fields: {
Employee: "Jane Doe",
},
},
],
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("updateRecord", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.updateRecord");
const data = await endpoints.updateRecord.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
recordId: "recHcnB1MbBr9Rd2P",
},
body: {
fields: {
Employee: "John Doe II",
},
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("createRecords", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.createRecords");
const data = await endpoints.createRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
records: [
{
fields: {
Employee: "Employee #1",
},
},
{
fields: {
Employee: "Employee #2",
},
},
],
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("createRecord", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.createRecord");
const data = await endpoints.createRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
fields: {
Employee: "Employee single create",
},
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("deleteRecords", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.deleteRecords");
const data = await endpoints.createRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
records: [
{
fields: {
Employee: "Delete now #1",
},
},
{
fields: {
Employee: "Delete now #2",
},
},
],
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
const deletedData = await endpoints.deleteRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
records: data.body.records.map((record: any) => record.id),
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(deletedData.status).toEqual(200);
expect(deletedData.success).toEqual(true);
expect(deletedData.body).not.toBeNull();
stopNock(nockDone);
});
test("deleteRecord", async () => {
const accessToken = authToken();
const nockDone = await startNock("airtable.deleteRecord");
const data = await endpoints.createRecords.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
},
body: {
fields: {
Employee: "Delete now #3",
},
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(data.status).toEqual(200);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
const deletedData = await endpoints.deleteRecord.request({
parameters: {
baseId: "appBlf3KsalIQeMUo",
tableIdOrName: "tblvXn2TOeVPC9c6m",
recordId: data.body.id,
},
credentials: {
type: "oauth2",
name: "oauth",
accessToken,
scopes: ["data.records:write"],
},
});
expect(deletedData.status).toEqual(200);
expect(deletedData.success).toEqual(true);
expect(deletedData.body).not.toBeNull();
stopNock(nockDone);
});
});
@@ -0,0 +1,10 @@
import { Catalog } from "core/catalog";
import { airtable } from "./airtable";
import { sendgrid } from "./sendgrid";
export const catalog: Catalog = {
services: {
airtable,
sendgrid,
},
};
@@ -0,0 +1,5 @@
import { makeSimpleActions } from "core/action/makeAction";
import endpoints from "../endpoints/endpoints";
const actions = makeSimpleActions(endpoints);
export default actions;
@@ -0,0 +1,215 @@
import { IntegrationAuthentication } from "core/authentication/types";
export const authentication: IntegrationAuthentication = {
api_key: {
type: "api_key",
placement: {
in: "header",
type: "bearer",
key: "Authorization",
},
documentation:
"1. [Create an API key](https://app.sendgrid.com/settings/api_keys)\n2. Copy the API key\n3. Paste the API key into the field below",
scopes: {
"alerts.create": "alerts.create",
"alerts.delete": "alerts.delete",
"alerts.read": "alerts.read",
"alerts.update": "alerts.update",
"api_keys.create": "api_keys.create",
"api_keys.delete": "api_keys.delete",
"api_keys.read": "api_keys.read",
"api_keys.update": "api_keys.update",
"asm.groups.create": "asm.groups.create",
"asm.groups.delete": "asm.groups.delete",
"asm.groups.read": "asm.groups.read",
"asm.groups.update": "asm.groups.update",
"categories.create": "categories.create",
"categories.delete": "categories.delete",
"categories.read": "categories.read",
"categories.update": "categories.update",
"categories.stats.read": "categories.stats.read",
"categories.stats.sums.read": "categories.stats.sums.read",
"email_activity.read": "email_activity.read",
"stats.read": "stats.read",
"stats.global.read": "stats.global.read",
"browsers.stats.read": "browsers.stats.read",
"devices.stats.read": "devices.stats.read",
"geo.stats.read": "geo.stats.read",
"mailbox_providers.stats.read": "mailbox_providers.stats.read",
"clients.desktop.stats.read": "clients.desktop.stats.read",
"clients.phone.stats.read": "clients.phone.stats.read",
"clients.stats.read": "clients.stats.read",
"clients.tablet.stats.read": "clients.tablet.stats.read",
"clients.webmail.stats.read": "clients.webmail.stats.read",
"ips.assigned.read": "ips.assigned.read",
"ips.read": "ips.read",
"ips.pools.create": "ips.pools.create",
"ips.pools.delete": "ips.pools.delete",
"ips.pools.read": "ips.pools.read",
"ips.pools.update": "ips.pools.update",
"ips.pools.ips.create": "ips.pools.ips.create",
"ips.pools.ips.delete": "ips.pools.ips.delete",
"ips.pools.ips.read": "ips.pools.ips.read",
"ips.pools.ips.update": "ips.pools.ips.update",
"ips.warmup.create": "ips.warmup.create",
"ips.warmup.delete": "ips.warmup.delete",
"ips.warmup.read": "ips.warmup.read",
"ips.warmup.update": "ips.warmup.update",
"mail_settings.address_whitelist.read":
"mail_settings.address_whitelist.read",
"mail_settings.address_whitelist.update":
"mail_settings.address_whitelist.update",
"mail_settings.bounce_purge.read": "mail_settings.bounce_purge.read",
"mail_settings.bounce_purge.update": "mail_settings.bounce_purge.update",
"mail_settings.footer.read": "mail_settings.footer.read",
"mail_settings.footer.update": "mail_settings.footer.update",
"mail_settings.forward_bounce.read": "mail_settings.forward_bounce.read",
"mail_settings.forward_bounce.update":
"mail_settings.forward_bounce.update",
"mail_settings.forward_spam.read": "mail_settings.forward_spam.read",
"mail_settings.forward_spam.update": "mail_settings.forward_spam.update",
"mail_settings.template.read": "mail_settings.template.read",
"mail_settings.template.update": "mail_settings.template.update",
"mail.batch.create": "mail.batch.create",
"mail.batch.delete": "mail.batch.delete",
"mail.batch.read": "mail.batch.read",
"mail.batch.update": "mail.batch.update",
"mail.send": "mail.send",
"marketing_campaigns.create": "marketing_campaigns.create",
"marketing_campaigns.delete": "marketing_campaigns.delete",
"marketing_campaigns.read": "marketing_campaigns.read",
"marketing_campaigns.update": "marketing_campaigns.update",
"partner_settings.new_relic.read": "partner_settings.new_relic.read",
"partner_settings.new_relic.update": "partner_settings.new_relic.update",
"partner_settings.read": "partner_settings.read",
"user.scheduled_sends.create": "user.scheduled_sends.create",
"user.scheduled_sends.delete": "user.scheduled_sends.delete",
"user.scheduled_sends.read": "user.scheduled_sends.read",
"user.scheduled_sends.update": "user.scheduled_sends.update",
"subusers.create": "subusers.create",
"subusers.delete": "subusers.delete",
"subusers.read": "subusers.read",
"subusers.update": "subusers.update",
"subusers.credits.create": "subusers.credits.create",
"subusers.credits.delete": "subusers.credits.delete",
"subusers.credits.read": "subusers.credits.read",
"subusers.credits.update": "subusers.credits.update",
"subusers.credits.remaining.create": "subusers.credits.remaining.create",
"subusers.credits.remaining.delete": "subusers.credits.remaining.delete",
"subusers.credits.remaining.read": "subusers.credits.remaining.read",
"subusers.credits.remaining.update": "subusers.credits.remaining.update",
"subusers.monitor.create": "subusers.monitor.create",
"subusers.monitor.delete": "subusers.monitor.delete",
"subusers.monitor.read": "subusers.monitor.read",
"subusers.monitor.update": "subusers.monitor.update",
"subusers.reputations.read": "subusers.reputations.read",
"subusers.stats.read": "subusers.stats.read",
"subusers.stats.monthly.read": "subusers.stats.monthly.read",
"subusers.stats.sums.read": "subusers.stats.sums.read",
"subusers.summary.read": "subusers.summary.read",
"suppression.create": "suppression.create",
"suppression.delete": "suppression.delete",
"suppression.read": "suppression.read",
"suppression.update": "suppression.update",
"suppression.bounces.create": "suppression.bounces.create",
"suppression.bounces.read": "suppression.bounces.read",
"suppression.bounces.update": "suppression.bounces.update",
"suppression.bounces.delete": "suppression.bounces.delete",
"suppression.blocks.create": "suppression.blocks.create",
"suppression.blocks.read": "suppression.blocks.read",
"suppression.blocks.update": "suppression.blocks.update",
"suppression.blocks.delete": "suppression.blocks.delete",
"suppression.invalid_emails.create": "suppression.invalid_emails.create",
"suppression.invalid_emails.read": "suppression.invalid_emails.read",
"suppression.invalid_emails.update": "suppression.invalid_emails.update",
"suppression.invalid_emails.delete": "suppression.invalid_emails.delete",
"suppression.spam_reports.create": "suppression.spam_reports.create",
"suppression.spam_reports.read": "suppression.spam_reports.read",
"suppression.spam_reports.update": "suppression.spam_reports.update",
"suppression.spam_reports.delete": "suppression.spam_reports.delete",
"suppression.unsubscribes.create": "suppression.unsubscribes.create",
"suppression.unsubscribes.read": "suppression.unsubscribes.read",
"suppression.unsubscribes.update": "suppression.unsubscribes.update",
"suppression.unsubscribes.delete": "suppression.unsubscribes.delete",
"teammates.create": "teammates.create",
"teammates.read": "teammates.read",
"teammates.update": "teammates.update",
"teammates.delete": "teammates.delete",
"templates.create": "templates.create",
"templates.delete": "templates.delete",
"templates.read": "templates.read",
"templates.update": "templates.update",
"templates.versions.activate.create":
"templates.versions.activate.create",
"templates.versions.activate.delete":
"templates.versions.activate.delete",
"templates.versions.activate.read": "templates.versions.activate.read",
"templates.versions.activate.update":
"templates.versions.activate.update",
"templates.versions.create": "templates.versions.create",
"templates.versions.delete": "templates.versions.delete",
"templates.versions.read": "templates.versions.read",
"templates.versions.update": "templates.versions.update",
"tracking_settings.click.read": "tracking_settings.click.read",
"tracking_settings.click.update": "tracking_settings.click.update",
"tracking_settings.google_analytics.read":
"tracking_settings.google_analytics.read",
"tracking_settings.google_analytics.update":
"tracking_settings.google_analytics.update",
"tracking_settings.open.read": "tracking_settings.open.read",
"tracking_settings.open.update": "tracking_settings.open.update",
"tracking_settings.read": "tracking_settings.read",
"tracking_settings.subscription.read":
"tracking_settings.subscription.read",
"tracking_settings.subscription.update":
"tracking_settings.subscription.update",
"user.account.read": "user.account.read",
"user.credits.read": "user.credits.read",
"user.email.create": "user.email.create",
"user.email.delete": "user.email.delete",
"user.email.read": "user.email.read",
"user.email.update": "user.email.update",
"user.multifactor_authentication.create":
"user.multifactor_authentication.create",
"user.multifactor_authentication.delete":
"user.multifactor_authentication.delete",
"user.multifactor_authentication.read":
"user.multifactor_authentication.read",
"user.multifactor_authentication.update":
"user.multifactor_authentication.update",
"user.password.read": "user.password.read",
"user.password.update": "user.password.update",
"user.profile.read": "user.profile.read",
"user.profile.update": "user.profile.update",
"user.settings.enforced_tls.read": "user.settings.enforced_tls.read",
"user.settings.enforced_tls.update": "user.settings.enforced_tls.update",
"user.timezone.read": "user.timezone.read",
"user.timezone.update": "user.timezone.update",
"user.username.read": "user.username.read",
"user.username.update": "user.username.update",
"user.webhooks.event.settings.read": "user.webhooks.event.settings.read",
"user.webhooks.event.settings.update":
"user.webhooks.event.settings.update",
"user.webhooks.event.test.create": "user.webhooks.event.test.create",
"user.webhooks.event.test.read": "user.webhooks.event.test.read",
"user.webhooks.event.test.update": "user.webhooks.event.test.update",
"user.webhooks.parse.settings.create":
"user.webhooks.parse.settings.create",
"user.webhooks.parse.settings.delete":
"user.webhooks.parse.settings.delete",
"user.webhooks.parse.settings.read": "user.webhooks.parse.settings.read",
"user.webhooks.parse.settings.update":
"user.webhooks.parse.settings.update",
"user.webhooks.parse.stats.read": "user.webhooks.parse.stats.read",
"whitelabel.create": "whitelabel.create",
"whitelabel.delete": "whitelabel.delete",
"whitelabel.read": "whitelabel.read",
"whitelabel.update": "whitelabel.update",
"access_settings.activity.read": "access_settings.activity.read",
"access_settings.whitelist.create": "access_settings.whitelist.create",
"access_settings.whitelist.delete": "access_settings.whitelist.delete",
"access_settings.whitelist.read": "access_settings.whitelist.read",
"access_settings.whitelist.update": "access_settings.whitelist.update",
},
},
};
@@ -0,0 +1,191 @@
import {
makeArraySchema,
makeObjectSchema,
makeStringSchema,
} from "core/schemas/makeSchema";
import { JSONSchema } from "core/schemas/types";
export const ErrorSchema = makeObjectSchema("Error", {
requiredProperties: {
errors: makeArraySchema(
"Errors",
makeObjectSchema("Error", {
requiredProperties: {
message: makeStringSchema("Message"),
},
optionalProperties: {
field: makeStringSchema("Field"),
help: makeStringSchema("Help"),
error_id: makeStringSchema("Error ID"),
parameter: makeStringSchema("Parameter"),
},
})
),
},
});
export const fromEmailObjectSchema: JSONSchema = {
title: "From Email Object",
type: "object",
properties: {
email: {
type: "string",
format: "email",
description:
"The 'From' email address used to deliver the message. This address should be a verified sender in your Twilio SendGrid account.",
},
name: {
type: "string",
description: "A name or title associated with the sending email address.",
},
},
required: ["email"],
example: {
email: "jane_doe@example.com",
name: "Jane Doe",
},
};
export const ToEmailArraySchema: JSONSchema = {
title: "To Email Array",
type: "array",
items: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "The intended recipient's email address.",
},
name: {
type: "string",
description: "The intended recipient's name.",
},
},
required: ["email"],
},
example: [
{
email: "john_doe@example.com",
name: "John Doe",
},
],
};
export const CCBCCEmailObjectSchema: JSONSchema = {
title: "CC BCC Email Object",
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "The intended recipient's email address.",
},
name: {
type: "string",
description: "The intended recipient's name.",
},
},
required: ["email"],
example: {
email: "jane_doe@example.com",
name: "Jane Doe",
},
};
export const ReplyToEmailObjectSchema: JSONSchema = {
title: "Reply_to Email Object",
type: "object",
properties: {
email: {
type: "string",
format: "email",
description:
"The email address where any replies or bounces will be returned.",
},
name: {
type: "string",
description:
"A name or title associated with the `reply_to` email address.",
},
},
required: ["email"],
example: {
email: "jane_doe@example.com",
name: "Jane Doe",
},
};
export const ContactRequestSchema: JSONSchema = {
title: "contact-request",
type: "object",
properties: {
address_line_1: {
type: "string",
description: "The first line of the address.",
maxLength: 100,
},
address_line_2: {
type: "string",
description: "An optional second line for the address.",
maxLength: 100,
},
alternate_emails: {
type: "array",
description: "Additional emails associated with the contact.",
minItems: 0,
maxItems: 5,
items: {
type: "string",
maxLength: 254,
},
},
city: {
type: "string",
description: "The contact's city.",
maxLength: 60,
},
country: {
type: "string",
description:
"The contact's country. Can be a full name or an abbreviation.",
maxLength: 50,
},
email: {
type: "string",
description:
"The contact's primary email. This is required to be a valid email.",
maxLength: 254,
},
first_name: {
type: "string",
description: "The contact's personal name.",
maxLength: 50,
},
last_name: {
type: "string",
description: "The contact's family name.",
maxLength: 50,
},
postal_code: {
type: "string",
description: "The contact's ZIP code or other postal code.",
},
state_province_region: {
type: "string",
description: "The contact's state, province, or region.",
maxLength: 50,
},
custom_fields: {
title: "custom-fields-by-id",
type: "object",
additionalProperties: true,
example: {
w1: "2002-10-02T15:00:00Z",
w33: 9.5,
e2: "Coffee is a beverage that puts one to sleep when not drank.",
},
},
},
required: ["email"],
};
@@ -0,0 +1,8 @@
import { makeEndpoints } from "core/endpoint/endpoint";
import { authentication } from "../authentication";
import * as specs from "./specs";
const baseUrl = "https://api.sendgrid.com/v3";
const endpoints = makeEndpoints(baseUrl, authentication, specs);
export default endpoints;
@@ -0,0 +1,540 @@
import { EndpointSpec } from "core/endpoint/types";
import {
CCBCCEmailObjectSchema,
ContactRequestSchema,
ErrorSchema,
fromEmailObjectSchema,
ReplyToEmailObjectSchema,
ToEmailArraySchema,
} from "../common/schemas";
const defaultResponses = [
{
success: false,
name: "Error",
description: "error response",
schema: ErrorSchema,
},
{
success: false,
name: "Error",
description: "No body error response",
schema: {},
},
];
export const mailSend: EndpointSpec = {
path: "/mail/send",
method: "POST",
metadata: {
name: "mailSend",
description: "Send email to one or more recipients with personalization",
displayProperties: {
title: "Send mail",
},
externalDocs: {
description: "API method documentation",
url: "https://docs.sendgrid.com/api-reference/mail-send/mail-send",
},
tags: ["send"],
},
security: {
api_key: ["mail.send"],
},
request: {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: {
schema: {
type: "object",
properties: {
personalizations: {
type: "array",
description:
"An array of messages and their metadata. Each object within personalizations can be thought of as an envelope - it defines who should receive an individual message and how that message should be handled. See our [Personalizations documentation](https://sendgrid.com/docs/for-developers/sending-email/personalizations/) for examples.",
uniqueItems: false,
maxItems: 1000,
items: {
type: "object",
properties: {
from: fromEmailObjectSchema,
to: ToEmailArraySchema,
cc: {
type: "array",
description:
"An array of recipients who will receive a copy of your email. Each object in this array must contain the recipient's email address. Each object in the array may optionally contain the recipient's name.",
maxItems: 1000,
items: CCBCCEmailObjectSchema,
},
bcc: {
type: "array",
description:
"An array of recipients who will receive a blind carbon copy of your email. Each object in this array must contain the recipient's email address. Each object in the array may optionally contain the recipient's name.",
maxItems: 1000,
items: CCBCCEmailObjectSchema,
},
subject: {
type: "string",
description:
"The subject of your email. See character length requirements according to [RFC 2822](http://stackoverflow.com/questions/1592291/what-is-the-email-subject-length-limit#answer-1592310).",
minLength: 1,
},
headers: {
type: "object",
description:
"A collection of JSON key/value pairs allowing you to specify handling instructions for your email. You may not overwrite the following headers: `x-sg-id`, `x-sg-eid`, `received`, `dkim-signature`, `Content-Type`, `Content-Transfer-Encoding`, `To`, `From`, `Subject`, `Reply-To`, `CC`, `BCC`",
},
substitutions: {
type: "object",
description:
'Substitutions allow you to insert data without using Dynamic Transactional Templates. This field should **not** be used in combination with a Dynamic Transactional Template, which can be identified by a `template_id` starting with `d-`. This field is a collection of key/value pairs following the pattern "substitution_tag":"value to substitute". The key/value pairs must be strings. These substitutions will apply to the text and html content of the body of your email, in addition to the `subject` and `reply-to` parameters. The total collective size of your substitutions may not exceed 10,000 bytes per personalization object.',
maxProperties: 10000,
},
dynamic_template_data: {
type: "object",
description:
'Dynamic template data is available using Handlebars syntax in Dynamic Transactional Templates. This field should be used in combination with a Dynamic Transactional Template, which can be identified by a `template_id` starting with `d-`. This field is a collection of key/value pairs following the pattern "variable_name":"value to insert".',
},
custom_args: {
type: "object",
description:
"Values that are specific to this personalization that will be carried along with the email and its activity data. Substitutions will not be made on custom arguments, so any string that is entered into this parameter will be assumed to be the custom argument that you would like to be used. This field may not exceed 10,000 bytes.",
maxProperties: 10000,
},
send_at: {
type: "integer",
description:
"A unix timestamp allowing you to specify when your email should be delivered. Scheduling delivery more than 72 hours in advance is forbidden.",
},
},
required: ["to"],
},
},
from: fromEmailObjectSchema,
reply_to: ReplyToEmailObjectSchema,
reply_to_list: {
type: "array",
description:
"An array of recipients who will receive replies and/or bounces. Each object in this array must contain the recipient's email address. Each object in the array may optionally contain the recipient's name. You can either choose to use “reply_to” field or “reply_to_list” but not both.",
uniqueItems: true,
maxItems: 1000,
items: {
type: "object",
properties: {
email: {
type: "string",
description:
"The email address where any replies or bounces will be returned.",
format: "email",
},
name: {
type: "string",
description:
"A name or title associated with the `reply_to_list` email address.",
},
},
required: ["email"],
},
},
subject: {
type: "string",
description:
"The global or 'message level' subject of your email. This may be overridden by subject lines set in personalizations.",
minLength: 1,
},
content: {
type: "array",
description:
"An array where you can specify the content of your email. You can include multiple [MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) of content, but you must specify at least one MIME type. To include more than one MIME type, add another object to the array containing the `type` and `value` parameters.",
items: {
type: "object",
properties: {
type: {
type: "string",
description:
"The MIME type of the content you are including in your email (e.g., `“text/plain”` or `“text/html”`).",
minLength: 1,
},
value: {
type: "string",
description:
"The actual content of the specified MIME type that you are including in your email.",
minLength: 1,
},
},
required: ["type", "value"],
},
},
attachments: {
type: "array",
description:
"An array of objects where you can specify any attachments you want to include.",
items: {
type: "object",
properties: {
content: {
type: "string",
description: "The Base64 encoded content of the attachment.",
minLength: 1,
},
type: {
type: "string",
description:
"The MIME type of the content you are attaching (e.g., `“text/plain”` or `“text/html”`).",
minLength: 1,
},
filename: {
type: "string",
description: "The attachment's filename.",
},
disposition: {
type: "string",
default: "attachment",
description:
"The attachment's content-disposition, specifying how you would like the attachment to be displayed. For example, `“inline”` results in the attached file are displayed automatically within the message while `“attachment”` results in the attached file require some action to be taken before it is displayed, such as opening or downloading the file.",
enum: ["inline", "attachment"],
},
content_id: {
type: "string",
description:
"The attachment's content ID. This is used when the disposition is set to `“inline”` and the attachment is an image, allowing the file to be displayed within the body of your email.",
},
},
required: ["content", "filename"],
},
},
template_id: {
type: "string",
description:
"An email template ID. A template that contains a subject and content — either text or html — will override any subject and content values specified at the personalizations or message level.",
},
headers: {
description:
"An object containing key/value pairs of header names and the value to substitute for them. The key/value pairs must be strings. You must ensure these are properly encoded if they contain unicode characters. These headers cannot be one of the reserved headers.",
type: "object",
},
categories: {
type: "array",
description:
"An array of category names for this message. Each category name may not exceed 255 characters. ",
uniqueItems: true,
maxItems: 10,
items: {
type: "string",
maxLength: 255,
},
},
custom_args: {
description:
"Values that are specific to the entire send that will be carried along with the email and its activity data. Key/value pairs must be strings. Substitutions will not be made on custom arguments, so any string that is entered into this parameter will be assumed to be the custom argument that you would like to be used. This parameter is overridden by `custom_args` set at the personalizations level. Total `custom_args` size may not exceed 10,000 bytes.",
type: "string",
},
send_at: {
type: "integer",
description:
"A unix timestamp allowing you to specify when you want your email to be delivered. This may be overridden by the `send_at` parameter set at the personalizations level. Delivery cannot be scheduled more than 72 hours in advance. If you have the flexibility, it's better to schedule mail for off-peak times. Most emails are scheduled and sent at the top of the hour or half hour. Scheduling email to avoid peak times — for example, scheduling at 10:53 — can result in lower deferral rates due to the reduced traffic during off-peak times.",
},
batch_id: {
type: "string",
description:
"An ID representing a batch of emails to be sent at the same time. Including a `batch_id` in your request allows you include this email in that batch. It also enables you to cancel or pause the delivery of that batch. For more information, see the [Cancel Scheduled Sends API](https://sendgrid.com/docs/api-reference/).",
},
asm: {
type: "object",
description:
"An object allowing you to specify how to handle unsubscribes.",
properties: {
group_id: {
type: "integer",
description:
"The unsubscribe group to associate with this email.",
},
groups_to_display: {
type: "array",
description:
"An array containing the unsubscribe groups that you would like to be displayed on the unsubscribe preferences page.",
maxItems: 25,
items: {
type: "integer",
},
},
},
required: ["group_id"],
},
ip_pool_name: {
type: "string",
description:
"The IP Pool that you would like to send this email from.",
minLength: 2,
maxLength: 64,
},
mail_settings: {
type: "object",
description:
"A collection of different mail settings that you can use to specify how you would like this email to be handled.",
properties: {
bypass_list_management: {
type: "object",
description:
"Allows you to bypass all unsubscribe groups and suppressions to ensure that the email is delivered to every single recipient. This should only be used in emergencies when it is absolutely necessary that every recipient receives your email. This filter cannot be combined with any other bypass filters. See our [documentation](https://sendgrid.com/docs/ui/sending-email/index-suppressions/#bypass-suppressions) for more about bypass filters.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
},
},
bypass_spam_management: {
type: "object",
description:
"Allows you to bypass the spam report list to ensure that the email is delivered to recipients. Bounce and unsubscribe lists will still be checked; addresses on these other lists will not receive the message. This filter cannot be combined with the `bypass_list_management` filter. See our [documentation](https://sendgrid.com/docs/ui/sending-email/index-suppressions/#bypass-suppressions) for more about bypass filters.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
},
},
bypass_bounce_management: {
type: "object",
description:
"Allows you to bypass the bounce list to ensure that the email is delivered to recipients. Spam report and unsubscribe lists will still be checked; addresses on these other lists will not receive the message. This filter cannot be combined with the `bypass_list_management` filter. See our [documentation](https://sendgrid.com/docs/ui/sending-email/index-suppressions/#bypass-suppressions) for more about bypass filters.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
},
},
bypass_unsubscribe_management: {
type: "object",
description:
"Allows you to bypass the global unsubscribe list to ensure that the email is delivered to recipients. Bounce and spam report lists will still be checked; addresses on these other lists will not receive the message. This filter applies only to global unsubscribes and will not bypass group unsubscribes. This filter cannot be combined with the `bypass_list_management` filter. See our [documentation](https://sendgrid.com/docs/ui/sending-email/index-suppressions/#bypass-suppressions) for more about bypass filters.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
},
},
footer: {
type: "object",
description:
"The default footer that you would like included on every email.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
text: {
type: "string",
description: "The plain text content of your footer.",
},
html: {
type: "string",
description: "The HTML content of your footer.",
},
},
},
sandbox_mode: {
type: "object",
description:
"Sandbox Mode allows you to send a test email to ensure that your request body is valid and formatted correctly.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
},
},
},
},
tracking_settings: {
type: "object",
description:
"Settings to determine how you would like to track the metrics of how your recipients interact with your email.",
properties: {
click_tracking: {
type: "object",
description:
"Allows you to track if a recipient clicked a link in your email.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
enable_text: {
type: "boolean",
description:
"Indicates if this setting should be included in the `text/plain` portion of your email.",
},
},
},
open_tracking: {
type: "object",
description:
"Allows you to track if the email was opened by including a single pixel image in the body of the content. When the pixel is loaded, Twilio SendGrid can log that the email was opened.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
substitution_tag: {
type: "string",
description:
"Allows you to specify a substitution tag that you can insert in the body of your email at a location that you desire. This tag will be replaced by the open tracking pixel.",
},
},
},
subscription_tracking: {
type: "object",
description:
"Allows you to insert a subscription management link at the bottom of the text and HTML bodies of your email. If you would like to specify the location of the link within your email, you may use the `substitution_tag`.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
text: {
type: "string",
description:
"Text to be appended to the email with the subscription tracking link. You may control where the link is by using the tag <% %>",
},
html: {
type: "string",
description:
"HTML to be appended to the email with the subscription tracking link. You may control where the link is by using the tag <% %>",
},
substitution_tag: {
type: "string",
description:
"A tag that will be replaced with the unsubscribe URL. for example: `[unsubscribe_url]`. If this parameter is used, it will override both the `text` and `html` parameters. The URL of the link will be placed at the substitution tags location with no additional formatting.",
},
},
},
ganalytics: {
type: "object",
description:
"Allows you to enable tracking provided by Google Analytics.",
properties: {
enable: {
type: "boolean",
description: "Indicates if this setting is enabled.",
},
utm_source: {
type: "string",
description:
"Name of the referrer source. (e.g. Google, SomeDomain.com, or Marketing Email)",
},
utm_medium: {
type: "string",
description: "Name of the marketing medium. (e.g. Email)",
},
utm_term: {
type: "string",
description: "Used to identify any paid keywords.",
},
utm_content: {
type: "string",
description:
"Used to differentiate your campaign from advertisements.",
},
utm_campaign: {
type: "string",
description: "The name of the campaign.",
},
},
},
},
},
},
required: ["personalizations", "from", "subject", "content"],
},
},
},
responses: {
"20x": [
{
success: true,
name: "Success",
description: "Successful response",
schema: undefined,
},
],
default: defaultResponses,
},
};
export const marketingContacts: EndpointSpec = {
path: "/marketing/contacts",
method: "PUT",
metadata: {
name: "marketingContacts",
description:
"Add or update (up to 30k) contacts. Contacts are queued and aren't created immediately.",
displayProperties: {
title: "Add/update contacts",
},
externalDocs: {
description: "API method documentation",
url: "https://docs.sendgrid.com/api-reference/contacts/add-or-update-a-contact",
},
tags: ["contacts"],
},
security: {
api_key: [],
},
request: {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: {
schema: {
type: "object",
properties: {
list_ids: {
type: "array",
description:
"An array of List ID strings that this contact will be added to.",
items: {
type: "string",
format: "uuid",
},
},
contacts: {
type: "array",
description:
"One or more contacts objects that you intend to upsert. The available fields for a contact, including the required `email` field are described below.",
minItems: 1,
maxItems: 30000,
items: ContactRequestSchema,
},
},
required: ["contacts"],
},
},
},
responses: {
"20x": [
{
success: true,
name: "Success",
description: "Successful response",
schema: {
type: "object",
properties: {
job_id: {
type: "string",
description:
'Indicates that the contacts are queued for processing. Check the job status with the "Import Contacts Status" endpoint.',
},
},
},
},
],
default: defaultResponses,
},
};
@@ -0,0 +1,13 @@
import { Service } from "core/service/types";
import { authentication } from "./authentication";
import actions from "./actions/actions";
export const sendgrid: Service = {
name: "SendGrid",
service: "sendgrid",
version: "2.0.0",
live: true,
authentication,
actions,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
};
@@ -0,0 +1,77 @@
import { startNock, stopNock } from "testing/nock";
import { describe, expect, test } from "vitest";
import endpoints from "../endpoints/endpoints";
const authToken = () => process.env.SENDGRID_API_KEY ?? "";
describe("sendgrid.endpoints", async () => {
test("mailSend simple", async () => {
const api_key = authToken();
const nockDone = await startNock("sendgrid.mailSend");
const data = await endpoints.mailSend.request({
body: {
from: {
email: "matt@email.trigger.dev",
},
subject: "Hello, World!",
content: [
{
type: "text/plain",
value: "Email body here",
},
],
personalizations: [
{
to: [
{
email: "matt@trigger.dev",
},
],
subject: "Hello, World!",
},
],
},
credentials: {
type: "api_key",
name: "api_key",
api_key,
scopes: ["mail.send"],
},
});
expect(data.status).toBeGreaterThanOrEqual(200);
expect(data.status).toBeLessThan(300);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
test("marketingContacts", async () => {
const api_key = authToken();
const nockDone = await startNock("sendgrid.marketingContacts");
const data = await endpoints.marketingContacts.request({
body: {
contacts: [
{
email: "matt+1@mattaitken.com",
first_name: "Matt",
last_name: "Aitken",
},
],
},
credentials: {
type: "api_key",
name: "api_key",
api_key,
scopes: ["mail.send"],
},
});
expect(data.status).toBeGreaterThanOrEqual(200);
expect(data.status).toBeLessThan(300);
expect(data.success).toEqual(true);
expect(data.body).not.toBeNull();
stopNock(nockDone);
});
});
@@ -0,0 +1,165 @@
import { makeAdvancedAction, makeSimpleAction } from "core/action/makeAction";
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 = makeSimpleAction(
endpoints.conversationsList
);
export const chatPostMessage: Action = makeAdvancedAction({
endpoint: endpoints.chatPostMessage,
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",
},
};
}
//we add __trigger metadata so we can associate messages with workflow runs
let bodyMetadata: {
event_payload?: any;
event_type: string;
} = {
event_type: "post_message",
};
if (metadata) {
bodyMetadata = {
...bodyMetadata,
event_payload: {
...(data.body?.metadata ?? {}),
__trigger: metadata,
},
};
} else {
bodyMetadata = {
...bodyMetadata,
event_payload: {
...(data.body?.metadata ?? {}),
},
};
}
const postMessageBody = {
...data.body,
channel: channelId,
metadata: metadata ? bodyMetadata : undefined,
};
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/openApi";
import rawSpec from "./slack_web_openapi_v2.json";
export const spec = dereferenceSpec(rawSpec);
@@ -0,0 +1,359 @@
import { EndpointSpec, EndpointSpecResponse } from "core/endpoint/types";
import { schemaFromOpenApiSpecV2 } from "core/schemas/openApi";
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",
displayProperties: {
title: "Post message to ${body.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.",
displayProperties: {
title: "List channels",
},
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.",
displayProperties: {
title: "Join ${body.channel}",
},
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,13 @@
import { Service } from "core/service/types";
import { authentication } from "./authentication";
import * as actions from "./actions/actions";
export const slackv2: Service = {
name: "Slack",
service: "slackv2",
version: "2.0.0",
live: false,
authentication,
actions,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
};
@@ -0,0 +1,97 @@
import { startNock, stopNock } from "testing/nock";
import { describe, expect, test } from "vitest";
import { chatPostMessage, conversationsList } from "../actions/actions";
const authToken = () => process.env.SLACK_TOKEN ?? "";
describe("slack-example.actions", async () => {
test("/conversations.list success", async () => {
const accessToken = authToken();
const nockDone = await startNock("action.conversations.list");
const data = await conversationsList.action({
parameters: {
limit: 3,
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken,
scopes: ["conversations:read"],
},
});
expect(data.success).toEqual(true);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(true);
stopNock(nockDone);
});
test("/chat.postMessage success with name", async () => {
const accessToken = authToken();
const nockDone = await startNock("action.chat.postMessage.name");
const data = await chatPostMessage.action({
body: {
channel: "test-integrations",
text: "Using the channel name",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken,
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");
stopNock(nockDone);
});
test("/chat.postMessage failed with bad name", async () => {
const accessToken = authToken();
const nockDone = await startNock("action.chat.postMessage.badname");
const data = await chatPostMessage.action({
body: {
channel: "this-channel-does-not-exist",
text: "Using the channel name",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken,
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");
stopNock(nockDone);
});
test("Get display properties", async () => {
const displayProperties = await chatPostMessage.displayProperties({
body: {
channel: "my-channel",
text: "Using the channel name",
},
});
expect(displayProperties.title).toEqual("Post message to my-channel");
});
});
@@ -0,0 +1,92 @@
import { startNock, stopNock } from "testing/nock";
import { describe, expect, test } from "vitest";
import endpoints from "../endpoints/endpoints";
const authToken = () => process.env.SLACK_TOKEN ?? "";
describe("slack-example.endpoints", async () => {
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 () => {
const accessToken = authToken();
const nockDone = await startNock("chat.postMessage");
const data = await endpoints.chatPostMessage.request({
body: {
channel: "C04GWUTDC3W",
text: "This the Trigger.dev integrations test",
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken,
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"
);
stopNock(nockDone);
});
test("/chat.postMessage bad channel", async () => {
const accessToken = authToken();
const nockDone = await startNock("chat.postMessage.badchannel");
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,
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");
stopNock(nockDone);
});
test("/conversations.list success", async () => {
const accessToken = authToken();
const nockDone = await startNock("conversations.list");
const data = await endpoints.conversationsList.request({
parameters: {
limit: 1,
},
credentials: {
type: "oauth2",
name: "slackAuth",
accessToken,
scopes: ["conversations:read"],
},
});
expect(data.success).toEqual(true);
expect(data.status).toEqual(200);
expect(data.body.ok).toEqual(true);
stopNock(nockDone);
});
});
+53
View File
@@ -0,0 +1,53 @@
import nock from "nock";
import path from "path";
import zlib from "zlib";
nock.back.fixtures = path.join(__dirname, "..", "fixtures");
nock.back.setMode("record");
const makeCompressedResponsesReadable = (scope: any) => {
if (scope.rawHeaders.indexOf("gzip") > -1) {
const gzipIndex = scope.rawHeaders.indexOf("gzip");
scope.rawHeaders.splice(gzipIndex - 1, 2);
const contentLengthIndex = scope.rawHeaders.indexOf("Content-Length");
scope.rawHeaders.splice(contentLengthIndex - 1, 2);
const fullResponseBody =
scope.response &&
scope.response.reduce &&
scope.response.reduce(
(previous: any, current: any) => previous + current
);
try {
// eslint-disable-next-line no-param-reassign
scope.response = JSON.parse(
zlib.gunzipSync(Buffer.from(fullResponseBody, "hex")).toString("utf8")
);
} catch (e) {
// eslint-disable-next-line no-param-reassign
scope.response = "";
}
}
return scope;
};
const defaultOptions = {
afterRecord: (outputs: any) => outputs.map(makeCompressedResponsesReadable),
};
export async function startNock(name: string, update = false) {
if (update) {
nock.back.setMode("update");
} else {
nock.back.setMode("record");
}
const { nockDone } = await nock.back(`${name}.json`, defaultOptions);
return nockDone;
}
export async function stopNock(nockDone: () => void) {
nockDone();
nock.back.setMode("wild");
}
@@ -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,292 @@
import { JSONSchema } from "core/schemas/types";
import { Service } from "core/service/types";
import { Project } from "ts-morph";
import { FunctionData } from "./types";
import {
fileNameFromTitleCase,
TitleCaseWithSpaces,
toFriendlyTypeName,
} from "./utilities";
type DocsSchemaObject = {
path: string;
required: boolean;
types: Set<string>;
default?: string;
description?: string;
children?: DocsSchemaObject[];
childrenCollectionName?: string;
childrenExpanded?: boolean;
};
export async function generateDocs(
project: Project,
basePath: string,
service: Service,
functionsData: Record<string, FunctionData>
) {
const promises = Object.values(functionsData).map(async (f) => {
//for debugging you can generate save the FunctionData JSON
// project.createSourceFile(
// `${basePath}/docs/${fileNameFromTitleCase(f.friendlyName)}.fdata.json`,
// JSON.stringify(f, null, 2),
// {
// overwrite: true,
// }
// );
//metadata and intro
let markdown = generatePageMetadata(f.title, f.description);
//Base params
markdown += `
## Params
<ParamField path="key" type="string" required={true}>
A unique string. Please see the [Keys and Resumability](/guides/resumability)
doc for more info.
</ParamField>`;
//Input schema
if (f.input) {
const inputDocsObject = generateDocSchema("params", true, f.input, true);
if (inputDocsObject) {
//for debugging you can save the DocObject JSON
// project.createSourceFile(
// `${basePath}/docs/${fileNameFromTitleCase(
// f.friendlyName
// )}.docobj.json`,
// JSON.stringify(inputDocsObject, null, 2),
// {
// overwrite: true,
// }
// );
const inputMarkdown = generateMarkdownFromDocSchema(
"ParamField",
inputDocsObject
);
markdown += inputMarkdown;
}
}
if (f.output) {
const outputDocsObject = generateDocSchema("response", true, f.output);
if (outputDocsObject) {
const outputMarkdown = generateMarkdownFromDocSchema(
"ResponseField",
outputDocsObject
);
markdown += "\n\n## Response\n\n";
markdown += outputMarkdown;
}
}
project.createSourceFile(
`${basePath}/docs/${fileNameFromTitleCase(f.friendlyName)}.mdx`,
markdown,
{
overwrite: true,
}
);
return Promise.resolve();
});
await Promise.all(promises);
return;
}
function generatePageMetadata(title: string, description: string) {
return `---
title: ${title}
sidebarTitle: ${title}
description: ${sanitizeText(description)}
---`;
}
function generateDocSchema(
key: string,
required: boolean,
schema: JSONSchema | boolean,
expanded = false
): DocsSchemaObject | undefined {
if (typeof schema === "boolean") return;
let description = createDescription(schema);
if (schema.oneOf) {
const oneOfTypes = schema.oneOf
.map((v) => {
if (typeof v === "boolean") return "";
return v.type?.toString() ?? "";
})
.filter(Boolean);
const children: DocsSchemaObject[] = [];
children.push(
...schema.oneOf.flatMap((v) => {
const doc = generateDocSchema(
toFriendlyTypeName(v.title ?? "Value"),
false,
v
);
return doc ? [doc] : [];
})
);
description +=
"\n\n*Please note that this object is one of the following possible types*";
return {
path: key,
required,
types: new Set(oneOfTypes),
description,
children,
childrenCollectionName: `possible types for ${key}`,
childrenExpanded: expanded,
};
}
if (schema.anyOf) {
const anyOfTypes = schema.anyOf
.map((v) => {
if (typeof v === "boolean") return "";
return v.type?.toString() ?? "";
})
.filter(Boolean);
const children: DocsSchemaObject[] = [];
children.push(
...schema.anyOf.flatMap((v) => {
const doc = generateDocSchema(
toFriendlyTypeName(v.title ?? "Value"),
false,
v
);
return doc ? [doc] : [];
})
);
description +=
"\n\n*Please note that this object is one of the following possible types*";
return {
path: key,
required,
types: new Set(anyOfTypes),
description,
children,
childrenCollectionName: `possible types for ${key}`,
childrenExpanded: expanded,
};
}
if (
schema.type === "object" &&
(schema.properties || schema.additionalProperties)
) {
const children: DocsSchemaObject[] = [];
if (schema.properties) {
children.push(
...Object.entries(schema.properties).flatMap(([k, v]) => {
const doc = generateDocSchema(
k,
schema.required?.find((r) => r === k) != undefined ?? false,
v
);
return doc ? [doc] : [];
})
);
}
if (
schema.additionalProperties &&
typeof schema.additionalProperties !== "boolean"
) {
if (typeof schema.additionalProperties !== "boolean") {
const doc = generateDocSchema(
`${key}[key]`,
false,
schema.additionalProperties
);
if (doc) children.push(doc);
}
}
return {
path: key,
required,
types: new Set(["object"]),
description,
children: children.sort(
(a, b) => Number(b.required) - Number(a.required)
),
childrenCollectionName: "properties",
childrenExpanded: expanded,
};
}
if (schema.type === "array" && schema.items) {
const doc = generateDocSchema(`${key}[n]`, required, schema.items);
if (doc) {
return {
path: key,
required,
types: new Set(["array"]),
description,
children: [doc],
childrenCollectionName: "items",
childrenExpanded: expanded,
};
}
}
return {
path: key,
required,
types: new Set([`${schema.type}` ?? ""]),
description,
};
}
function createDescription(schema: JSONSchema): string | undefined {
const description = schema.description
? schema.description
: TitleCaseWithSpaces(schema.title);
if (!description) return;
return sanitizeText(description);
}
//encode angle brackets for displaying in HTML
function sanitizeText(original: string): string {
return original.replace(/</gm, "&lt;").replace(/>/gm, "&gt;");
}
function generateMarkdownFromDocSchema(
fieldType: "ParamField" | "ResponseField",
docSchema: DocsSchemaObject
): string {
let markdown = `<${fieldType} path="${docSchema.path}" type="${Array.from(
docSchema.types
).join(" | ")}" required={${docSchema.required}}>\n`;
if (docSchema.description) {
markdown += ` ${docSchema.description}\n`;
}
if (docSchema.children) {
markdown += `<Expandable title="${
docSchema.childrenCollectionName ?? "properties"
}" defaultOpen={${docSchema.childrenExpanded ?? false}}>`;
docSchema.children.forEach((child) => {
markdown += generateMarkdownFromDocSchema(fieldType, child);
});
markdown += `</Expandable>`;
}
markdown += `</${fieldType}>`;
return markdown;
}
@@ -0,0 +1,207 @@
import { IndentationText, NewLineKind, Project, QuoteKind } from "ts-morph";
import { Service } from "core/service/types";
import fs from "fs/promises";
import path from "path";
import { generateInputOutputSchemas } from "generators/combineSchemas";
import { getTypesFromSchema } from "generators/generateTypes";
import rimraf from "rimraf";
import { makeAnyOf } from "core/schemas/makeSchema";
import { JSONSchema } from "core/schemas/types";
import { FunctionData } from "./types";
import { generateDocs } from "./generateDocs";
import { TitleCaseWithSpaces, toFriendlyTypeName } from "./utilities";
const appDir = process.cwd();
export async function generateService(service: Service) {
const basePath = `generated-integrations/${service.service}`;
//remove folder
const absolutePath = path.join(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(absolutePath);
await generateTemplatedFiles(project, absolutePath, service);
const functionsData = await generateFunctionData(service);
await createFunctionsAndTypesFiles(
project,
absolutePath,
service,
functionsData
);
await generateDocs(project, absolutePath, service, functionsData);
await project.save();
} catch (e) {
console.error(e);
}
}
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}.template`,
{ 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 generateFunctionData(service: Service) {
const { actions } = service;
const functions: Record<string, FunctionData> = {};
//loop through actions
for (const key in actions) {
const action = actions[key];
//generate schemas for input and output
const title = TitleCaseWithSpaces(action.name);
const name = action.name;
const friendlyName = toFriendlyTypeName(name);
const schemas = generateInputOutputSchemas(action.spec, friendlyName);
const functionCode = `
${action.description ? `/** ${action.description} */` : ""}
export async function ${action.name}(
/** This key should be unique inside your workflow */
key: string,
${
schemas.input
? `/** The params for this call */
params: ${schemas.input.title}`
: ""
}
): Promise<${schemas.output?.title ?? "void"}> {
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.name}",
params,
});
return output;
}
`;
const functionData: FunctionData = {
title,
name,
friendlyName,
description: action.description,
input: schemas.input,
output: schemas.output,
functionCode,
};
functions[name] = functionData;
}
return functions;
}
async function createFunctionsAndTypesFiles(
project: Project,
basePath: string,
service: Service,
functionsData: Record<string, FunctionData>
) {
const typeSchemas = Object.values(functionsData)
.flatMap((f) => [f.input, f.output])
.filter(Boolean) as JSONSchema[];
const combinedSchema: JSONSchema = makeAnyOf(
`${toFriendlyTypeName(service.service)}Types}`,
typeSchemas
);
const allTypes = await getTypesFromSchema(
combinedSchema,
`${service.service}Types`
);
const typesFile = project.createSourceFile(
`${basePath}/src/types.ts`,
allTypes,
{
overwrite: true,
}
);
typesFile.formatText();
const functionsFile = project.createSourceFile(
`${basePath}/src/index.ts`,
`import { getTriggerRun } from "@trigger.dev/sdk";
import { ${typeSchemas
.map((t) => t && t.title)
.join(", ")} } from "./types";
${Object.values(functionsData)
.map((f) => f.functionCode)
.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"]
}

Some files were not shown because too many files have changed in this diff Show More