Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-papayas-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@flatfile/plugin-export-external-api': minor
---

Initial Release
66 changes: 66 additions & 0 deletions export/external-api/README.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!-- START_INFOCARD -->

# @flatfile/plugin-export-external-api

This plugin for Flatfile enables exporting data to an external API with advanced features such as batching, error handling, retry logic, and user notifications. It processes records from Flatfile, maps them to the desired format, and exports them in batches to a specified external API endpoint.

**Event Type:** `job:ready`

<!-- END_INFOCARD -->

## Features

- Batch processing for efficient exports
- Configurable batch size
- Retry logic with configurable max retries and delay
- Detailed progress tracking and job status updates
- Error handling and logging
- User notifications for export completion or failure
- Support for custom record validation

## Parameters

#### `job` - `string` - (required)
The type of job to create.

#### `apiEndpoint` - `string` - (required)
The URL of the external API endpoint to send data to.

#### `secretName` - `string` - (required)
The name of the Flatfile Secret that contains the authentication token for the external API.

#### `batchSize` - `number` - (required)
The number of records to process in each batch.

#### `maxRetries` - `number` - (required)
The maximum number of retry attempts for failed API calls.

#### `retryDelay` - `number` - (required)
The delay (in milliseconds) between retry attempts.
Comment on lines +23 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix heading hierarchy in parameters section.

The parameter headings should be h3 (###) instead of h4 (####) to maintain proper heading hierarchy.

Apply this change to all parameter headings:

-#### `job` - `string` - (required)
+### `job` - `string` - (required)

-#### `apiEndpoint` - `string` - (required)
+### `apiEndpoint` - `string` - (required)

-#### `secretName` - `string` - (required)
+### `secretName` - `string` - (required)

-#### `batchSize` - `number` - (required)
+### `batchSize` - `number` - (required)

-#### `maxRetries` - `number` - (required)
+### `maxRetries` - `number` - (required)

-#### `retryDelay` - `number` - (required)
+### `retryDelay` - `number` - (required)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#### `job` - `string` - (required)
The type of job to create.
#### `apiEndpoint` - `string` - (required)
The URL of the external API endpoint to send data to.
#### `secretName` - `string` - (required)
The name of the Flatfile Secret that contains the authentication token for the external API.
#### `batchSize` - `number` - (required)
The number of records to process in each batch.
#### `maxRetries` - `number` - (required)
The maximum number of retry attempts for failed API calls.
#### `retryDelay` - `number` - (required)
The delay (in milliseconds) between retry attempts.
### `job` - `string` - (required)
The type of job to create.
### `apiEndpoint` - `string` - (required)
The URL of the external API endpoint to send data to.
### `secretName` - `string` - (required)
The name of the Flatfile Secret that contains the authentication token for the external API.
### `batchSize` - `number` - (required)
The number of records to process in each batch.
### `maxRetries` - `number` - (required)
The maximum number of retry attempts for failed API calls.
### `retryDelay` - `number` - (required)
The delay (in milliseconds) between retry attempts.
🧰 Tools
🪛 Markdownlint

23-23: Expected: h3; Actual: h4
Heading levels should only increment by one level at a time

(MD001, heading-increment)



## Installation

To install the plugin, use npm:

```bash
npm install @flatfile/plugin-export-external-api
```

## Example Usage

```javascript
import { exportToExternalAPIPlugin } from '@flatfile/plugin-export-external-api';

export default function (listener) {
listener.use(
exportToExternalAPIPlugin({
apiEndpoint: 'https://api.example.com/import',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing job

secretName: 'YOUR_SECRET_NAME',
batchSize: 100,
maxRetries: 3,
retryDelay: 1000
})
Comment on lines +57 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add missing required job parameter in example configuration.

The example configuration is missing the required job parameter that was documented in the parameters section.

Add the missing parameter to the configuration:

 exportToExternalAPIPlugin({
+      job: 'export-data',
       apiEndpoint: 'https://api.example.com/import',
       secretName: 'YOUR_SECRET_NAME',
       batchSize: 100,
       maxRetries: 3,
       retryDelay: 1000
     })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportToExternalAPIPlugin({
apiEndpoint: 'https://api.example.com/import',
secretName: 'YOUR_SECRET_NAME',
batchSize: 100,
maxRetries: 3,
retryDelay: 1000
})
exportToExternalAPIPlugin({
job: 'export-data',
apiEndpoint: 'https://api.example.com/import',
secretName: 'YOUR_SECRET_NAME',
batchSize: 100,
maxRetries: 3,
retryDelay: 1000
})

);
}
```
16 changes: 16 additions & 0 deletions export/external-api/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = {
testEnvironment: 'node',

transform: {
'^.+\\.tsx?$': 'ts-jest',
},
setupFiles: ['../../test/dotenv-config.js'],
setupFilesAfterEnv: [
'../../test/betterConsoleLog.js',
'../../test/unit.cleanup.js',
],
testTimeout: 60_000,
globalSetup: '../../test/setup-global.js',
forceExit: true,
passWithNoTests: true,
}
72 changes: 72 additions & 0 deletions export/external-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"name": "@flatfile/plugin-export-external-api",
"version": "0.0.0",
"url": "https://github.com/FlatFilers/flatfile-plugins/tree/main/export/external-api",
"description": "A Flatfile plugin for exporting data to an external API with batching, error handling, and retry logic",
"registryMetadata": {
"category": "export"
},
"engines": {
"node": ">= 16"
},
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead"
],
"browser": {
"./dist/index.js": "./dist/index.browser.js",
"./dist/index.mjs": "./dist/index.browser.mjs"
},
"exports": {
"types": "./dist/index.d.ts",
"node": {
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"browser": {
"require": "./dist/index.browser.js",
"import": "./dist/index.browser.mjs"
},
"default": "./dist/index.mjs"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"source": "./src/index.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "rollup -c",
"build:watch": "rollup -c --watch",
"build:prod": "NODE_ENV=production rollup -c",
"check": "tsc ./**/*.ts --noEmit --esModuleInterop",
"test": "jest src/*.spec.ts --detectOpenHandles",
"test:unit": "jest src/*.spec.ts --testPathIgnorePatterns=.*\\.e2e\\.spec\\.ts$ --detectOpenHandles",
"test:e2e": "jest src/*.e2e.spec.ts --detectOpenHandles"
},
"keywords": [
"flatfile-plugins",
"category-export"
],
"author": "Flatfile, Inc.",
"repository": {
"type": "git",
"url": "https://github.com/FlatFilers/flatfile-plugins.git",
"directory": "export/external-api"
},
"license": "ISC",
"dependencies": {
"@flatfile/plugin-job-handler": "^0.6.1",
"cross-fetch": "^4.0.0"
},
"peerDependencies": {
"@flatfile/util-common": "^1.4.1"
},
"devDependencies": {
"@flatfile/api": "^1.9.15",
"@flatfile/listener": "^1.0.5",
"@flatfile/rollup-config": "^0.1.1"
}
}
5 changes: 5 additions & 0 deletions export/external-api/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { buildConfig } from '@flatfile/rollup-config'

const config = buildConfig({})

export default config
81 changes: 81 additions & 0 deletions export/external-api/src/external.api.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Flatfile } from '@flatfile/api'
import type { FlatfileEvent, FlatfileListener } from '@flatfile/listener'
import { jobHandler } from '@flatfile/plugin-job-handler'
import { getSheetLength, Simplified } from '@flatfile/util-common'
import { exportToExternalAPI, retryOperation } from './external.api.utils'

export interface PluginConfig {
job: string
apiEndpoint: string
secretName: string
batchSize: number
maxRetries: number
retryDelay: number
}

export const exportToExternalAPIPlugin = (config: PluginConfig) => {
return (listener: FlatfileListener) => {
listener.use(
jobHandler(
`sheet:${config.job}`,
async (
event: FlatfileEvent,
tick: (
progress: number,
message?: string
) => Promise<Flatfile.JobResponse>
) => {
const { sheetId } = event.context

const authToken = await event.secrets(config.secretName)

try {
let totalExported = 0
let failedRecords = 0
let successfulBatches = 0

const sheetLength = await getSheetLength(sheetId)
const batchCount = Math.ceil(sheetLength / config.batchSize)

let pageNumber = 1
while (pageNumber <= batchCount) {
const records = await Simplified.getAllRecords(sheetId, {
pageSize: config.batchSize,
pageNumber,
})

try {
await retryOperation(
() =>
exportToExternalAPI(records, config.apiEndpoint, authToken),
config.maxRetries,
config.retryDelay
)
totalExported += records.length
successfulBatches++
} catch (error) {
console.error('Failed to export batch after retries:', error)
failedRecords += records.length
}

const progress = ((pageNumber - 1) / batchCount) * 100
await tick(
progress,
`Exported ${totalExported} records. Failed to export ${failedRecords} records.`
)
pageNumber++
}

return {
info: `Exported ${totalExported} records. Failed to export ${failedRecords} records.`,
}
} catch (error) {
console.error('Error during export:', (error as Error).message)

throw new Error('An error occurred during export.')
}
Comment on lines +72 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve error handling and propagation

The generic error message hides potentially useful information from the user.

Consider propagating the error details:

-            console.error('Error during export:', (error as Error).message)
-            throw new Error('An error occurred during export.')
+            const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+            console.error('Error during export:', errorMessage)
+            throw new Error(`Export failed: ${errorMessage}`)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error('Error during export:', (error as Error).message)
throw new Error('An error occurred during export.')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error during export:', errorMessage)
throw new Error(`Export failed: ${errorMessage}`)
}

}
)
)
}
}
37 changes: 37 additions & 0 deletions export/external-api/src/external.api.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import fetch from 'cross-fetch'

export async function exportToExternalAPI(
data: Record<string, unknown>[],
apiEndpoint: string,
authToken: string
): Promise<void> {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
Comment on lines +8 to +15

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nullify Code Language: TypeScript 🟡 HIGH Severity CWE-918

Rules lgpl javascript ssrf rule node ssrf

This application allows user-controlled URLs to be passed directly to HTTP client libraries. This can result in Server-Side Request Forgery (SSRF). SSRF refers to an attack where the attacker can abuse functionality on the server to force it to make requests to other internal systems within your infrastructure that are not directly exposed to the internet. This allows the attacker to access internal resources they do not have direct access to.
Some risks of SSRF are:

  • Access and manipulation of internal databases, APIs, or administrative panels - Ability to scan internal network architecture and services - Can be used to pivot attacks into the internal network - Circumvent network segregation and firewall rules
    To avoid this, try using hardcoded HTTP request calls or a whitelisting object to check whether the user input is trying to access allowed resources or not.
    Here is an example: var whitelist = [ "https://example.com", "https://example.com/sample" ] app.get('/ssrf/node-ssrf/axios/safe/3', function (req, res) { if(whitelist.includes(req.query.url)){ axios.get(url, {}) .then(function (response) { console.log(response); }) .catch(function (response) { console.log(response); }) } }); For more information on SSRF see OWASP: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html

Here's how you might fix this potential vulnerability

In the initial code, the 'apiEndpoint' parameter is passed directly to the 'fetch' function without any validation, which could lead to Server Side Request Forgery (SSRF) if an attacker can control the value of 'apiEndpoint'. The modified code mitigates this vulnerability by validating the 'apiEndpoint' parameter using the 'URL' class before passing it to the 'fetch' function. It checks the protocol and hostname of the URL against a whitelist of allowed values, and throws an error if the URL is not allowed.

autoFixesExperimental

Add URL validation before making the fetch request

Suggested change
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
const url = new URL(apiEndpoint);
if (url.protocol !== 'https:' || !['api.example.com', 'api2.example.com'].includes(url.hostname)) {
throw new Error('Invalid API endpoint');
}
const response = await fetch(apiEndpoint, {

poweredByNullify

Reply with /nullify to interact with me like another developer
(you will need to refresh the page for updates)


if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`)
}
}

export async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number,
delay: number
): Promise<T> {
let lastError: Error | null = null
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation()
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastError || new Error('Operation failed after max retries')
}
1 change: 1 addition & 0 deletions export/external-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './external.api.plugin'
37 changes: 19 additions & 18 deletions flatfilers/sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { FlatfileListener } from '@flatfile/listener'
import { pivotTablePlugin } from '@flatfile/plugin-export-pivot-table'
import { exportToExternalAPIPlugin } from '@flatfile/plugin-export-external-api'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Security and configuration concerns in plugin setup

  1. The authentication token should not be hardcoded in the source code. Consider using environment variables or a secure configuration management system.
  2. The API endpoint is set to a local development URL (http://localhost:5678), which won't work in production.
  3. Configuration values like batchSize, maxRetries, and retryDelay should be externalized for easier maintenance.

Consider refactoring to:

 import { exportToExternalAPIPlugin } from '@flatfile/plugin-export-external-api'
+import { config } from './config'  // Create a separate config file

 listener.use(
   exportToExternalAPIPlugin({
     job: 'export-external-api',
-    apiEndpoint: 'http://localhost:5678/api/import',
-    authToken: 'your-api-token',
-    batchSize: 100,
-    maxRetries: 3,
-    retryDelay: 1000,
+    apiEndpoint: config.apiEndpoint,
+    authToken: config.authToken,
+    batchSize: config.batchSize,
+    maxRetries: config.maxRetries,
+    retryDelay: config.retryDelay,
   })
 )

Also applies to: 6-12

import { configureSpace } from '@flatfile/plugin-space-configure'

export default async function (listener: FlatfileListener) {
listener.use(
pivotTablePlugin({
pivotColumn: 'product',
aggregateColumn: 'salesAmount',
aggregationMethod: 'sum',
groupByColumn: 'region',
exportToExternalAPIPlugin({
job: 'export-external-api',
apiEndpoint: 'http://localhost:5678/api/import',
secretName: 'EXTERNAL_API_AUTH_SECRET',
batchSize: 100,
maxRetries: 3,
retryDelay: 1000,
})
)
listener.use(
Expand Down Expand Up @@ -47,17 +48,17 @@ export default async function (listener: FlatfileListener) {
label: 'Sales Amount',
},
],
},
],
actions: [
{
operation: 'generatePivotTable',
label: 'Generate Pivot Table',
description:
'This custom action code generates a pivot table from the records in the People sheet.',
primary: false,
mode: 'foreground',
type: 'string',
actions: [
{
operation: 'export-external-api',
label: 'Export to External API',
description:
'This custom action code exports the records in the Sales sheet to an external API.',
primary: false,
mode: 'foreground',
type: 'string',
},
],
Comment on lines +51 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Review and enhance action configuration

  1. The type: 'string' seems incorrect for an export action. This property typically defines the return type of the action.
  2. Consider adding a confirmation dialog for this operation since it's exporting data to an external system.
  3. The description could be more informative about error handling and retry behavior.

Consider applying these improvements:

 actions: [
   {
     operation: 'export-external-api',
     label: 'Export to External API',
     description:
-      'This custom action code exports the records in the Sales sheet to an external API.',
+      'Exports records from the Sales sheet to an external API. Failed requests will be retried up to 3 times. Data is sent in batches of 100 records.',
     primary: false,
     mode: 'foreground',
-    type: 'string',
+    confirm: {
+      title: 'Confirm Export',
+      description: 'Are you sure you want to export all records to the external API?'
+    }
   },
 ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
actions: [
{
operation: 'export-external-api',
label: 'Export to External API',
description:
'This custom action code exports the records in the Sales sheet to an external API.',
primary: false,
mode: 'foreground',
type: 'string',
},
],
actions: [
{
operation: 'export-external-api',
label: 'Export to External API',
description:
'Exports records from the Sales sheet to an external API. Failed requests will be retried up to 3 times. Data is sent in batches of 100 records.',
primary: false,
mode: 'foreground',
confirm: {
title: 'Confirm Export',
description: 'Are you sure you want to export all records to the external API?'
}
},
],

},
],
},
Expand Down
Loading