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 }; }>;