From 53d383b04c81269e0a6f184ef274af9798fe2473 Mon Sep 17 00:00:00 2001
From: Samuel Bodin <1637651+bodinsamuel@users.noreply.github.com>
Date: Fri, 20 Sep 2024 18:12:17 +0200
Subject: [PATCH] feat(sdk): add new endpoints (#2747)
## Describe your changes
Fixes
https://linear.app/nango/issue/NAN-1753/document-new-endpoints-integrations-and-providers
- Add new endpoints to the SDK
There is only one method that was different enough, queries and output
that even with signature overload it wasn't possible to make it
non-breaking. Honestly, I didn't find anything compelling to deprecate
without either being ugly or breaking customers' scripts, so very open
to suggestions.
- Document new SDK method
- Add support for `include=webhook` in `GET /integrations/:key`
The SDK needs it, and some customers use it. It's a secret but not
credentials, so I didn't put a too vague name. If we need credentials at
some point, we can differentiate.
---
docs-v2/reference/api/integration/get.mdx | 4 +-
docs-v2/reference/api/integration/list.mdx | 14 +-
docs-v2/reference/sdks/node.mdx | 171 ++++++++++++------
docs-v2/spec.yaml | 8 +
.../model.service.unit.test.ts.snap | 2 +-
packages/node-client/bin/get-integration.js | 2 +-
packages/node-client/bin/get-provider.js | 11 ++
packages/node-client/bin/get-providers.js | 11 ++
packages/node-client/lib/index.ts | 76 ++++++--
packages/node-client/lib/utils.ts | 16 ++
.../getIntegration.integration.test.ts | 19 +-
.../integrations/uniqueKey/getIntegration.ts | 38 +++-
packages/server/lib/formatters/integration.ts | 5 +-
packages/server/lib/utils/tests.ts | 14 +-
packages/shared/lib/sdk/sync.ts | 11 +-
packages/shared/lib/sdk/sync.unit.test.ts | 2 +-
packages/types/lib/integration/api.ts | 9 +-
17 files changed, 317 insertions(+), 96 deletions(-)
create mode 100644 packages/node-client/bin/get-provider.js
create mode 100644 packages/node-client/bin/get-providers.js
diff --git a/docs-v2/reference/api/integration/get.mdx b/docs-v2/reference/api/integration/get.mdx
index 28f950373b7..c0272b4753f 100644
--- a/docs-v2/reference/api/integration/get.mdx
+++ b/docs-v2/reference/api/integration/get.mdx
@@ -4,7 +4,7 @@ openapi: 'GET /integrations/{uniqueKey}'
---
- ```json Example Response
+```json Example Response
{
"data": {
"unique_key": "slack-nango-community",
@@ -14,5 +14,5 @@ openapi: 'GET /integrations/{uniqueKey}'
"updated_at": "2023-10-16T08:45:26.241Z",
}
}
- ```
+```
diff --git a/docs-v2/reference/api/integration/list.mdx b/docs-v2/reference/api/integration/list.mdx
index a9de46585fd..2b69c5103ce 100644
--- a/docs-v2/reference/api/integration/list.mdx
+++ b/docs-v2/reference/api/integration/list.mdx
@@ -3,8 +3,18 @@ title: 'List all integrations'
openapi: 'GET /integrations'
---
+
+
+```ts Node Client
+const nango = new Nango({ secretKey });
+
+const response = await nango.listIntegrations();
+```
+
+
+
- ```json Example Response
+```json Example Response
{
"data": [
{
@@ -23,5 +33,5 @@ openapi: 'GET /integrations'
},
]
}
- ```
+```
diff --git a/docs-v2/reference/sdks/node.mdx b/docs-v2/reference/sdks/node.mdx
index b93eb9ee5fc..6a00c95ea6b 100644
--- a/docs-v2/reference/sdks/node.mdx
+++ b/docs-v2/reference/sdks/node.mdx
@@ -32,7 +32,7 @@ const nango = new Nango({ secretKey: '' });
- Omitting the host points to Nango Cloud. For local development, use `http://localhost:3003`. Use your instance URL if self-hosting.
+ Omitting the host points to Nango Cloud. For local development, use `http://localhost:3003`. Use your instance URL if self-hosting.
@@ -61,6 +61,63 @@ try {
}
```
+# Providers
+
+### List all providers
+
+Returns a list of providers.
+
+```js
+await nango.listProviders()
+```
+
+**Example Response**
+
+
+```json
+{
+ "data": [
+ {
+ "name": "posthog",
+ "categories": ["dev-tools"],
+ "auth_mode": "API_KEY",
+ "proxy": {
+ "base_url": "https://api.posthog.com",
+ },
+ "docs": "https://docs.nango.dev/integrations/all/posthog"
+ }
+ ]
+}
+```
+
+
+
+### Get a provider
+
+Returns a specific provider.
+
+```js
+await nango.getProvider({ provider: })
+```
+
+**Example Response**
+
+
+```json
+{
+ "data": {
+ "name": "posthog",
+ "categories": ["dev-tools"],
+ "auth_mode": "API_KEY",
+ "proxy": {
+ "base_url": "https://api.posthog.com",
+ },
+ "docs": "https://docs.nango.dev/integrations/all/posthog"
+ }
+}
+```
+
+
# Integrations
### List all integrations
@@ -79,11 +136,17 @@ await nango.listIntegrations()
"configs": [
{
"unique_key": "slack-nango-community",
- "provider": "slack"
+ "provider": "slack",
+ "logo": "http://localhost:3003/images/template-logos/slack.svg",
+ "created_at": "2023-10-16T08:45:26.241Z",
+ "updated_at": "2023-10-16T08:45:26.241Z",
},
{
"unique_key": "github-prod",
- "provider": "github"
+ "provider": "github",
+ "logo": "http://localhost:3003/images/template-logos/github.svg",
+ "created_at": "2023-10-16T08:45:26.241Z",
+ "updated_at": "2023-10-16T08:45:26.241Z",
},
]
}
@@ -95,19 +158,29 @@ await nango.listIntegrations()
Returns a specific integration.
```js
+await nango.getIntegration({ unique_key: });
+
+// Deprecated
await nango.getIntegration();
```
**Parameters**
-
+
+ The integration ID (`unique_key`)
+
+
+ Include sensitive data. Allowed values: `webhook`
+
+
+
The integration ID.
-
- Defaults to `false`.
-
+
+ Defaults to `false`.
+
**Example Response**
@@ -115,24 +188,12 @@ await nango.getIntegration();
```json
{
- "config": {
+ "data": {
"unique_key": "slack-nango-community",
"provider": "slack",
- "syncs": [
- {
- "name": "slack-messages",
- "created_at": "2023-10-16T08:45:26.241Z",
- "updated_at": "2023-10-16T08:45:26.241Z",
- "description": "Continuously fetch the latest Slack messages. Details: full refresh. Required scopes(s): channels:read, groups:read, mpim:read, im:read"
- }
- ],
- "actions": [
- {
- "name": "github-list-repos-action",
- "created_at": "2023-10-17T17:28:03.839Z",
- "updated_at": "2023-10-17T17:28:03.839Z"
- }
- ]
+ "logo": "http://localhost:3003/images/template-logos/slack.svg",
+ "created_at": "2023-10-16T08:45:26.241Z",
+ "updated_at": "2023-10-16T08:45:26.241Z",
}
}
```
@@ -159,7 +220,7 @@ await nango.createIntegration(, );
The credentials to include depend on the specific integration that you want to create.
-
+
@@ -212,7 +273,7 @@ await nango.updateIntegration(, );
The credentials to include depend on the specific integration that you want to create.
-
+
@@ -244,7 +305,7 @@ await nango.updateIntegration(, );
```
-### Delete an integration
+### Delete an integration
Deletes a specific integration.
@@ -329,7 +390,7 @@ await nango.getConnection(, );
```
-The response content depends on the API authentication type (OAuth 2, OAuth 1, API key, Basic auth, etc.).
+The response content depends on the API authentication type (OAuth 2, OAuth 1, API key, Basic auth, etc.).
If you do not want to deal with collecting & injecting credentials in requests for multiple authentication types, use the Proxy ([step-by-step guide](/integrate/guides/proxy-requests-to-an-api)).
@@ -362,16 +423,16 @@ We recommend not caching tokens for longer than 5 minutes to ensure they are fre
```json
{
- "id": 18393,
- "created_at": "2023-03-08T09:43:03.725Z",
- "updated_at": "2023-03-08T09:43:03.725Z",
- "provider_config_key": "github",
- "connection_id": "1",
+ "id": 18393,
+ "created_at": "2023-03-08T09:43:03.725Z",
+ "updated_at": "2023-03-08T09:43:03.725Z",
+ "provider_config_key": "github",
+ "connection_id": "1",
"credentials": {
- "type": "OAUTH2",
- "access_token": "gho_tsXLG73f....",
- "refresh_token": "gho_fjofu84u9....",
- "expires_at": "2024-03-08T09:43:03.725Z",
+ "type": "OAUTH2",
+ "access_token": "gho_tsXLG73f....",
+ "refresh_token": "gho_fjofu84u9....",
+ "expires_at": "2024-03-08T09:43:03.725Z",
"raw": { // Raw token response from the OAuth provider: Contents vary!
"access_token": "gho_tsXLG73f....",
"refresh_token": "gho_fjofu84u9....",
@@ -379,17 +440,17 @@ We recommend not caching tokens for longer than 5 minutes to ensure they are fre
"scope": "public_repo,user"
}
},
- "connection_config": {
+ "connection_config": {
"subdomain": "myshop",
"realmId": "XXXXX",
"instance_id": "YYYYYYY"
- },
- "account_id": 0,
- "metadata": {
+ },
+ "account_id": 0,
+ "metadata": {
"myProperty": "yes",
"filter": "closed=true"
}
-}
+}
```
@@ -729,8 +790,8 @@ Returns the synced data.
import type { ModelName } from '/models'
const records = await nango.listRecords({
- providerConfigKey: '',
- connectionId: '',
+ providerConfigKey: '',
+ connectionId: '',
model: ''
});
```
@@ -767,13 +828,13 @@ const records = await nango.listRecords({
- The maximum number of records to return. Defaults to 100.
+ The maximum number of records to return. Defaults to 100.
- Filter to only show results that have been added or updated or deleted.
+ Filter to only show results that have been added or updated or deleted.
- Available options: added, updated, deleted
+ Available options: added, updated, deleted
@@ -831,7 +892,7 @@ const records = await nango.triggerSync('', ['SYNC_NAME1', 'SYNC
The integration ID.
- The name of the syncs to trigger. If the array is empty, all syncs are triggered.
+ The name of the syncs to trigger. If the array is empty, all syncs are triggered.
The connection ID. If omitted, the sync will trigger for all relevant connections.
@@ -857,7 +918,7 @@ await nango.startSync('', ['SYNC_NAME1', 'SYNC_NAME2'], '
- The name of the syncs that should be triggered.
+ The name of the syncs that should be triggered.
The connection ID. If ommitted, the sync will trigger for all relevant connections.
@@ -883,7 +944,7 @@ await nango.startSync('', ['SYNC_NAME1', 'SYNC_NAME2'], '
- The name of the syncs that should be paused.
+ The name of the syncs that should be paused.
The connection ID. If ommitted, the sync will pause for all relevant connections.
@@ -955,7 +1016,7 @@ await nango.updateSyncConnectionFrequency('', 'SYNC_NAME', '
- The connection ID.
+ The connection ID.
The frequency you want to set (ex: 'every hour'). Set to `null` to revert to the default frequency. Uses the https://github.com/vercel/ms notations. Min frequency: 5 minutes.
@@ -1014,7 +1075,7 @@ await nango.triggerAction('', '', ''
The integration ID.
- The connection ID.
+ The connection ID.
The name of the action to trigger.
@@ -1039,7 +1100,7 @@ await nango.triggerAction('', '', ''
Makes an HTTP request using the [proxy](/understand/concepts/proxy):
```js
-const config = {
+const config = {
endpoint: '/some-endpoint',
providerConfigKey: '',
connectionId: ''
@@ -1064,7 +1125,7 @@ await nango.delete(config); // DELETE request
The integration ID (for credential injection).
- The connection ID (for credential injection).
+ The connection ID (for credential injection).
The headers of the request.
@@ -1088,7 +1149,7 @@ await nango.delete(config); // DELETE request
Override the decompress option when making requests. Optional, defaults to false
- The type of the response.
+ The type of the response.
@@ -1106,4 +1167,4 @@ The response from the external API is passed back to you exactly as Nango gets i
**Questions, problems, feedback?** Please reach out in the [Slack community](https://nango.dev/slack).
-
\ No newline at end of file
+
diff --git a/docs-v2/spec.yaml b/docs-v2/spec.yaml
index 62bc9dfd750..71c472cacf3 100644
--- a/docs-v2/spec.yaml
+++ b/docs-v2/spec.yaml
@@ -362,6 +362,14 @@ paths:
schema:
type: string
description: The integration ID (unique_key) that you created in Nango.
+ - name: include
+ in: query
+ schema:
+ type: array
+ items:
+ type: string
+ enum: [webhook]
+ description: The integration ID (unique_key) that you created in Nango.
responses:
'200':
description: Successfully returned an integration
diff --git a/packages/cli/lib/services/__snapshots__/model.service.unit.test.ts.snap b/packages/cli/lib/services/__snapshots__/model.service.unit.test.ts.snap
index fe0f637c849..07d35b52908 100644
--- a/packages/cli/lib/services/__snapshots__/model.service.unit.test.ts.snap
+++ b/packages/cli/lib/services/__snapshots__/model.service.unit.test.ts.snap
@@ -331,7 +331,7 @@ export declare class NangoAction {
*/
setFieldMapping(fieldMapping: Record): Promise>;
getMetadata(): Promise;
- getWebhookURL(): Promise;
+ getWebhookURL(): Promise;
/**
* @deprecated please use getMetadata instead.
*/
diff --git a/packages/node-client/bin/get-integration.js b/packages/node-client/bin/get-integration.js
index dff15579a23..b23b51f242b 100644
--- a/packages/node-client/bin/get-integration.js
+++ b/packages/node-client/bin/get-integration.js
@@ -4,7 +4,7 @@ const args = process.argv.slice(2);
const nango = new Nango({ host: 'http://localhost:3003', secretKey: args[0] });
nango
- .getIntegration(args[1], args[2])
+ .getIntegration({ uniqueKey: args[1] })
.then((response) => {
console.log(response);
})
diff --git a/packages/node-client/bin/get-provider.js b/packages/node-client/bin/get-provider.js
new file mode 100644
index 00000000000..4c6c52724a4
--- /dev/null
+++ b/packages/node-client/bin/get-provider.js
@@ -0,0 +1,11 @@
+import { Nango } from '../dist/index.js';
+const args = process.argv.slice(2);
+
+const nango = new Nango({ host: 'http://localhost:3003', secretKey: args[0] });
+
+try {
+ const res = await nango.getProvider({ provider: args[1] })
+ console.log(res);
+} catch (err) {
+ console.log(err?.response?.data || err.message);
+}
diff --git a/packages/node-client/bin/get-providers.js b/packages/node-client/bin/get-providers.js
new file mode 100644
index 00000000000..08201a8cfe2
--- /dev/null
+++ b/packages/node-client/bin/get-providers.js
@@ -0,0 +1,11 @@
+import { Nango } from '../dist/index.js';
+const args = process.argv.slice(2);
+
+const nango = new Nango({ host: 'http://localhost:3003', secretKey: args[0] });
+
+try {
+ const res = await nango.listProviders()
+ console.log(res);
+} catch (err) {
+ console.log(err?.response?.data || err.message);
+}
diff --git a/packages/node-client/lib/index.ts b/packages/node-client/lib/index.ts
index 179de5966fd..baca9aef470 100644
--- a/packages/node-client/lib/index.ts
+++ b/packages/node-client/lib/index.ts
@@ -13,7 +13,12 @@ import type {
OAuth2ClientCredentials,
TbaCredentials,
TableauCredentials,
- UnauthCredentials
+ UnauthCredentials,
+ GetPublicProviders,
+ GetPublicProvider,
+ GetPublicListIntegrations,
+ GetPublicListIntegrationsLegacy,
+ GetPublicIntegration
} from '@nangohq/types';
import type {
Connection,
@@ -33,7 +38,7 @@ import type {
SyncStatusResponse,
UpdateSyncFrequencyResponse
} from './types.js';
-import { getUserAgent, validateProxyConfiguration, validateSyncRecordConfiguration } from './utils.js';
+import { addQueryParams, getUserAgent, validateProxyConfiguration, validateSyncRecordConfiguration } from './utils.js';
export const stagingHost = 'https://api-staging.nango.dev';
export const prodHost = 'https://api.nango.dev';
@@ -104,6 +109,30 @@ export class Nango {
});
}
+ /****************
+ * Providers
+ *****************/
+ /**
+ * Returns a list of all available providers
+ * @returns A promise that resolves with an object containing an array of providers
+ */
+ public async listProviders(queries: GetPublicProviders['Querystring']): Promise {
+ const url = new URL(`${this.serverUrl}/providers`);
+ addQueryParams(url, queries);
+
+ const response = await this.http.get(url.href, { headers: this.enrichHeaders({}) });
+ return response.data;
+ }
+
+ /**
+ * Returns a specific provider
+ * @returns A promise that resolves with an object containing a provider
+ */
+ public async getProvider(params: GetPublicProvider['Params']): Promise {
+ const response = await this.http.get(`${this.serverUrl}/providers/${params.provider}`, { headers: this.enrichHeaders({}) });
+ return response.data;
+ }
+
/**
* =======
* INTEGRATIONS
@@ -117,28 +146,51 @@ export class Nango {
/**
* Returns a list of integrations
- * @returns A promise that resolves with an object containing an array of integration configurations
+ * @returns A promise that resolves with an object containing an array of integrations
*/
- public async listIntegrations(): Promise<{ configs: Pick[] }> {
- const url = `${this.serverUrl}/config`;
+ public async listIntegrations(): Promise {
+ const url = `${this.serverUrl}/integrations`;
const response = await this.http.get(url, { headers: this.enrichHeaders({}) });
- return response.data;
+ const tmp: GetPublicListIntegrations['Success'] = response.data;
+ // To avoid deprecating this method we emulate legacy format
+ return { configs: tmp.data };
}
+ /**
+ * Returns a specific integration
+ * @param uniqueKey - The key identifying the provider configuration on Nango
+ * @returns A promise that resolves with an object containing an integration
+ */
+ public async getIntegration(
+ params: GetPublicIntegration['Params'],
+ queries?: GetPublicIntegration['Querystring']
+ ): Promise;
+
/**
* Returns a specific integration
* @param providerConfigKey - The key identifying the provider configuration on Nango
* @param includeIntegrationCredentials - An optional flag indicating whether to include integration credentials in the response. Default is false
* @returns A promise that resolves with an object containing an integration configuration
+ * @deprecated Use `getIntegration({ unique_key })`
*/
+ public async getIntegration(providerConfigKey: string, includeIntegrationCredentials?: boolean): Promise<{ config: Integration | IntegrationWithCreds }>;
+
public async getIntegration(
- providerConfigKey: string,
- includeIntegrationCredentials: boolean = false
- ): Promise<{ config: Integration | IntegrationWithCreds }> {
- const url = `${this.serverUrl}/config/${providerConfigKey}`;
- const response = await this.http.get(url, { headers: this.enrichHeaders({}), params: { include_creds: includeIntegrationCredentials } });
- return response.data;
+ params: string | GetPublicIntegration['Params'],
+ queries?: boolean | GetPublicIntegration['Querystring']
+ ): Promise<{ config: Integration | IntegrationWithCreds } | GetPublicIntegration['Success']> {
+ if (typeof params === 'string') {
+ const url = `${this.serverUrl}/config/${params}`;
+ const response = await this.http.get(url, { headers: this.enrichHeaders({}), params: { include_creds: queries } });
+ return response.data;
+ } else {
+ const url = new URL(`${this.serverUrl}/integrations/${params.uniqueKey}`);
+ addQueryParams(url, queries as GetPublicIntegration['Querystring']);
+
+ const response = await this.http.get(url.href, { headers: this.enrichHeaders({}) });
+ return response.data;
+ }
}
/**
diff --git a/packages/node-client/lib/utils.ts b/packages/node-client/lib/utils.ts
index 01637eaf3fd..3f169e8c797 100644
--- a/packages/node-client/lib/utils.ts
+++ b/packages/node-client/lib/utils.ts
@@ -40,3 +40,19 @@ export function getUserAgent(userAgent?: string): string {
const osVersion = os.release().replace(' ', '_');
return `nango-node-client/${NANGO_VERSION} (${osName}/${osVersion}; node.js/${nodeVersion})${userAgent ? `; ${userAgent}` : ''}`;
}
+
+export function addQueryParams(url: URL, queries?: Record | undefined) {
+ if (!queries) {
+ return;
+ }
+
+ Object.entries(queries).forEach(([name, value]) => {
+ if (Array.isArray(value)) {
+ for (const el of value) {
+ url.searchParams.set(name, el);
+ }
+ } else if (value !== null && value !== undefined) {
+ url.searchParams.set(name, value);
+ }
+ });
+}
diff --git a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts
index 9a55f720f6b..9e6f4aec190 100644
--- a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts
+++ b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts
@@ -18,7 +18,7 @@ describe(`GET ${endpoint}`, () => {
});
it('should be protected', async () => {
- const res = await api.fetch(endpoint, { method: 'GET', params: { uniqueKey: 'github' } });
+ const res = await api.fetch(endpoint, { method: 'GET', params: { uniqueKey: 'github' }, query: {} });
shouldBeProtected(res);
});
@@ -45,7 +45,7 @@ describe(`GET ${endpoint}`, () => {
it('should list empty', async () => {
const env = await seeders.createEnvironmentSeed();
- const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' } });
+ const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' }, query: {} });
isError(res.json);
expect(res.res.status).toBe(404);
@@ -58,7 +58,7 @@ describe(`GET ${endpoint}`, () => {
const env = await seeders.createEnvironmentSeed();
await seeders.createConfigSeed(env, 'github', 'github');
- const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' } });
+ const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' }, query: {} });
isSuccess(res.json);
expect(res.res.status).toBe(200);
@@ -73,12 +73,23 @@ describe(`GET ${endpoint}`, () => {
});
});
+ it('should get webhook', async () => {
+ const env = await seeders.createEnvironmentSeed();
+ await seeders.createConfigSeed(env, 'github', 'github');
+
+ const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' }, query: { include: ['webhook'] } });
+
+ isSuccess(res.json);
+ expect(res.res.status).toBe(200);
+ expect(res.json.data.webhook_url).toStrictEqual(null);
+ });
+
it('should not list other env', async () => {
const env = await seeders.createEnvironmentSeed();
const env2 = await seeders.createEnvironmentSeed();
await seeders.createConfigSeed(env2, 'github', 'github');
- const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' } });
+ const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' }, query: {} });
isError(res.json);
expect(res.res.status).toBe(404);
diff --git a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts
index 2a088253549..bbf1e39acc6 100644
--- a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts
+++ b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts
@@ -1,7 +1,7 @@
import { asyncWrapper } from '../../../utils/asyncWrapper.js';
-import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
-import type { GetPublicIntegration } from '@nangohq/types';
-import { configService } from '@nangohq/shared';
+import { zodErrorToHTTP } from '@nangohq/utils';
+import type { ApiPublicIntegrationInclude, GetPublicIntegration } from '@nangohq/types';
+import { configService, getGlobalWebhookReceiveUrl, getProvider } from '@nangohq/shared';
import { z } from 'zod';
import { providerConfigKeySchema } from '../../../helpers/validation.js';
import { integrationToPublicApi } from '../../../formatters/integration.js';
@@ -12,10 +12,20 @@ export const validationParams = z
})
.strict();
+const valInclude = z.enum(['webhook']);
+const validationQuery = z
+ .object({
+ include: z
+ .union([valInclude, z.array(valInclude)])
+ .transform((val) => (Array.isArray(val) ? val : val ? [val] : []))
+ .optional()
+ })
+ .strict();
+
export const getPublicIntegration = asyncWrapper(async (req, res) => {
- const emptyQuery = requireEmptyQuery(req);
- if (emptyQuery) {
- res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
+ const valQuery = validationQuery.safeParse(req.query);
+ if (!valQuery.success) {
+ res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(valQuery.error) } });
return;
}
@@ -27,6 +37,9 @@ export const getPublicIntegration = asyncWrapper(async (re
const { environment } = res.locals;
const params: GetPublicIntegration['Params'] = valParams.data;
+ const query: GetPublicIntegration['Querystring'] = valQuery.data;
+
+ const queryInclude = new Set(query.include || []);
const integration = await configService.getProviderConfig(params.uniqueKey, environment.id);
if (!integration) {
@@ -34,7 +47,18 @@ export const getPublicIntegration = asyncWrapper(async (re
return;
}
+ const provider = getProvider(integration.provider);
+ if (!provider) {
+ res.status(404).send({ error: { code: 'not_found', message: `Unknown provider ${integration.provider}` } });
+ return;
+ }
+
+ const include: ApiPublicIntegrationInclude = {};
+ if (queryInclude.has('webhook')) {
+ include.webhook_url = provider.webhook_routing_script ? `${getGlobalWebhookReceiveUrl()}/${environment.uuid}/${integration.provider}` : null;
+ }
+
res.status(200).send({
- data: integrationToPublicApi(integration)
+ data: integrationToPublicApi(integration, include)
});
});
diff --git a/packages/server/lib/formatters/integration.ts b/packages/server/lib/formatters/integration.ts
index f397579de8e..8eb685173ed 100644
--- a/packages/server/lib/formatters/integration.ts
+++ b/packages/server/lib/formatters/integration.ts
@@ -1,4 +1,4 @@
-import type { ApiIntegration, ApiPublicIntegration, IntegrationConfig } from '@nangohq/types';
+import type { ApiIntegration, ApiPublicIntegration, ApiPublicIntegrationInclude, IntegrationConfig } from '@nangohq/types';
import { basePublicUrl } from '@nangohq/utils';
export function integrationToApi(data: IntegrationConfig): ApiIntegration {
@@ -9,11 +9,12 @@ export function integrationToApi(data: IntegrationConfig): ApiIntegration {
};
}
-export function integrationToPublicApi(data: IntegrationConfig): ApiPublicIntegration {
+export function integrationToPublicApi(data: IntegrationConfig, include?: ApiPublicIntegrationInclude): ApiPublicIntegration {
return {
unique_key: data.unique_key,
provider: data.provider,
logo: `${basePublicUrl}/images/template-logos/${data.provider}.svg`,
+ ...include,
created_at: data.created_at.toISOString(),
updated_at: data.updated_at.toISOString()
};
diff --git a/packages/server/lib/utils/tests.ts b/packages/server/lib/utils/tests.ts
index 86afe521e89..89731fdeaa7 100644
--- a/packages/server/lib/utils/tests.ts
+++ b/packages/server/lib/utils/tests.ts
@@ -37,8 +37,18 @@ export function apiFetch(baseUrl: string) {
(TEndpoint['Body'] extends never ? { body?: never } : { body: TEndpoint['Body'] }) &
(TEndpoint['Params'] extends never ? { params?: never } : { params: TEndpoint['Params'] })
): Promise<{ res: Response; json: APIEndpointsPicker['Reply'] }> {
- const search = new URLSearchParams(query as Record);
- const url = new URL(`${baseUrl}${path}?${search.toString()}`);
+ const url = new URL(`${baseUrl}${path}`);
+ if (query) {
+ Object.entries(query).forEach(([name, value]) => {
+ if (Array.isArray(value)) {
+ for (const el of value) {
+ url.searchParams.set(name, el);
+ }
+ } else {
+ url.searchParams.set(name, (value as any) || '');
+ }
+ });
+ }
const headers = new Headers();
if (token) {
diff --git a/packages/shared/lib/sdk/sync.ts b/packages/shared/lib/sdk/sync.ts
index b9c7e423952..67b151e3555 100644
--- a/packages/shared/lib/sdk/sync.ts
+++ b/packages/shared/lib/sdk/sync.ts
@@ -6,7 +6,6 @@ import proxyService from '../services/proxy.service.js';
import type { AxiosInstance } from 'axios';
import axios, { AxiosError } from 'axios';
import { getPersistAPIUrl } from '../utils/utils.js';
-import type { IntegrationWithCreds } from '@nangohq/node';
import type { UserProvidedProxyConfiguration } from '../models/Proxy.js';
import {
getLogger,
@@ -22,7 +21,7 @@ import type { SyncConfig } from '../models/Sync.js';
import type { RunnerFlags } from '../services/sync/run.utils.js';
import { validateData } from './dataValidation.js';
import { NangoError } from '../utils/error.js';
-import type { DBTeam, MessageRowInsert } from '@nangohq/types';
+import type { DBTeam, GetPublicIntegration, MessageRowInsert } from '@nangohq/types';
import { getProvider } from '../services/providers.js';
const logger = getLogger('SDK');
@@ -395,7 +394,7 @@ export class NangoAction {
string,
{ connection: Connection; timestamp: number }
>();
- private memoizedIntegration: IntegrationWithCreds | undefined;
+ private memoizedIntegration: GetPublicIntegration['Success']['data'] | undefined;
constructor(config: NangoProps, { persistApi }: { persistApi: AxiosInstance } = { persistApi: defaultPersistApi }) {
this.connectionId = config.connectionId;
@@ -660,17 +659,17 @@ export class NangoAction {
return (await this.getConnection(this.providerConfigKey, this.connectionId)).metadata as T;
}
- public async getWebhookURL(): Promise {
+ public async getWebhookURL(): Promise {
this.exitSyncIfAborted();
if (this.memoizedIntegration) {
return this.memoizedIntegration.webhook_url;
}
- const { config: integration } = await this.nango.getIntegration(this.providerConfigKey, true);
+ const { data: integration } = await this.nango.getIntegration({ uniqueKey: this.providerConfigKey }, { include: ['webhook'] });
if (!integration || !integration.provider) {
throw Error(`There was no provider found for the provider config key: ${this.providerConfigKey}`);
}
- this.memoizedIntegration = integration as IntegrationWithCreds;
+ this.memoizedIntegration = integration;
return this.memoizedIntegration.webhook_url;
}
diff --git a/packages/shared/lib/sdk/sync.unit.test.ts b/packages/shared/lib/sdk/sync.unit.test.ts
index 1d3e846825d..d4c94a7783f 100644
--- a/packages/shared/lib/sdk/sync.unit.test.ts
+++ b/packages/shared/lib/sdk/sync.unit.test.ts
@@ -49,7 +49,7 @@ describe('cache', () => {
const nodeClient = (await import('@nangohq/node')).Nango;
nodeClient.prototype.getConnection = vi.fn().mockReturnValue({ credentials: {} });
nodeClient.prototype.setMetadata = vi.fn().mockReturnValue({});
- nodeClient.prototype.getIntegration = vi.fn().mockReturnValue({ config: { provider: 'github' } });
+ nodeClient.prototype.getIntegration = vi.fn().mockReturnValue({ data: { provider: 'github' } });
vi.spyOn(proxyService, 'route').mockImplementation(() => Promise.resolve({ response: {} as AxiosResponse, logs: [] }));
});
afterEach(() => {
diff --git a/packages/types/lib/integration/api.ts b/packages/types/lib/integration/api.ts
index 54128bfcc96..8ad257e1cf8 100644
--- a/packages/types/lib/integration/api.ts
+++ b/packages/types/lib/integration/api.ts
@@ -8,7 +8,12 @@ import type { LegacySyncModelSchema, NangoConfigMetadata } from '../deploy/incom
import type { JSONSchema7 } from 'json-schema';
import type { SyncType } from '../scripts/syncs/api';
-export type ApiPublicIntegration = Merge, ApiTimestamps> & { logo: string };
+export type ApiPublicIntegration = Merge, ApiTimestamps> & {
+ logo: string;
+} & ApiPublicIntegrationInclude;
+export interface ApiPublicIntegrationInclude {
+ webhook_url?: string | null;
+}
export type GetPublicListIntegrationsLegacy = Endpoint<{
Method: 'GET';
@@ -17,6 +22,7 @@ export type GetPublicListIntegrationsLegacy = Endpoint<{
configs: ApiPublicIntegration[];
};
}>;
+
export type GetPublicListIntegrations = Endpoint<{
Method: 'GET';
Path: '/integrations';
@@ -29,6 +35,7 @@ export type GetPublicIntegration = Endpoint<{
Method: 'GET';
Path: '/integrations/:uniqueKey';
Params: { uniqueKey: string };
+ Querystring: { include?: 'webhook'[] | undefined };
Success: { data: ApiPublicIntegration };
}>;