diff --git a/docs/api-catalog.txt b/docs/api-catalog.txt index d98dc751cf..3c354c9f6f 100644 --- a/docs/api-catalog.txt +++ b/docs/api-catalog.txt @@ -2,7 +2,7 @@ > Compact provider slug catalog for agents. Use the slug to reconstruct provider-specific docs URLs only when needed. -Provider count: 884 +Provider count: 885 URL patterns: - Main provider page: https://nango.dev/docs/integrations/all/{slug}.md or https://nango.dev/docs/api-integrations/{slug}.md @@ -667,6 +667,7 @@ URL patterns: | `sage-intacct` | Sage Intacct | TWO_STEP | [docs](https://nango.dev/docs/integrations/all/sage-intacct.md) | [connect](https://nango.dev/docs/integrations/all/sage-intacct/connect.md) | | accounting, erp | | `sage-intacct-cc` | Sage Intacct (Client Credentials) | TWO_STEP | [docs](https://nango.dev/docs/api-integrations/sage-intacct-cc.md) | [connect](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | [setup](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | accounting, erp | | `sage-intacct-oauth` | Sage Intacct (OAuth) | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-intacct-oauth.md) | | | accounting, erp, popular | +| `sage-member` | SAGE Member | API_KEY | [docs](https://nango.dev/docs/api-integrations/sage-member.md) | [connect](https://nango.dev/docs/api-integrations/sage-member/connect.md) | | marketing, e-commerce | | `sage-people` | Sage People | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-people.md) | [connect](https://nango.dev/docs/integrations/all/sage-people/connect.md) | | hr | | `salesforce` | Salesforce | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/salesforce.md) | [connect](https://nango.dev/docs/api-integrations/salesforce/connect.md) | [setup](https://nango.dev/docs/api-integrations/salesforce/salesforce-api-oauth-app-setup.md) | crm, popular | | `salesforce-cc` | Salesforce (Client Credentials) | OAUTH2_CC | [docs](https://nango.dev/docs/api-integrations/salesforce-cc.md) | [connect](https://nango.dev/docs/api-integrations/salesforce-cc/connect.md) | | crm | diff --git a/docs/api-integrations/sage-member.mdx b/docs/api-integrations/sage-member.mdx new file mode 100644 index 0000000000..6eee37d795 --- /dev/null +++ b/docs/api-integrations/sage-member.mdx @@ -0,0 +1,87 @@ +--- +title: 'SAGE Member' +sidebarTitle: 'SAGE Member' +description: 'Integrate your application with the SAGE Member API' +--- + +## Quickstart + +Connect to SAGE Member with Nango and see data flow in 2 minutes. + + + + In Nango ([free signup](https://app.nango.dev)), go to [Integrations](https://app.nango.dev/dev/integrations) -> _Configure New Integration_ -> _SAGE Member_. + + + Go to [Connections](https://app.nango.dev/dev/connections) -> _Add Test Connection_ -> _Authorize_, then enter your Account Number, Login ID, and Authentication Key. Later, you'll let your users do the same directly from your app. + + + Let's make your first request to the SAGE Member API. Replace the placeholders below with your [secret key](https://app.nango.dev/dev/environment-settings), [integration ID](https://app.nango.dev/dev/integrations), and [connection ID](https://app.nango.dev/dev/connections): + + + + ```bash + curl -X POST "https://api.nango.dev/proxy/ConnectAPI" \ + -H "Authorization: Bearer " \ + -H "Provider-Config-Key: " \ + -H "Connection-Id: " \ + -H "Content-Type: application/json" \ + -d '{"serviceId": 101, "apiVer": 130}' + ``` + + + + + + Install Nango's backend SDK with `npm i @nangohq/node`. Then run: + + ```typescript + import { Nango } from '@nangohq/node'; + + const nango = new Nango({ secretKey: '' }); + + const res = await nango.post({ + endpoint: '/ConnectAPI', + providerConfigKey: '', + connectionId: '', + data: { + serviceId: 101, + apiVer: 130 + } + }); + + console.log(res.data); + ``` + + + + Nango automatically injects your Account Number, Login ID, and Authentication Key into the `auth` object of every proxied request body — you only need to supply the `serviceId` and other fields for the specific SAGE Member module you're calling. + + Or fetch credentials with the [Node SDK](/reference/sdks/node#get-a-connection-with-credentials) or [API](/reference/api/connection/get). + + You're connected! Check the [Logs](https://app.nango.dev/dev/logs) tab in Nango to inspect requests. + + + + Follow our [Auth implementation guide](/guides/primitives/auth) to integrate Nango in your app. + + To obtain your own production credentials, follow the setup guide linked below. + + + +## SAGE Member Integration guides + +Nango-maintained guides for common use cases. + +- [How do I link my account?](/api-integrations/sage-member/connect) +Connect your SAGE Member account using the Connect UI + +## Pre-built syncs & actions for SAGE Member + +Enable them in your dashboard. Extend and customize to fit your needs. + +import PreBuiltUseCases from "/snippets/generated/sage-member/PreBuiltUseCases.mdx" + + + +--- diff --git a/docs/api-integrations/sage-member/connect.mdx b/docs/api-integrations/sage-member/connect.mdx new file mode 100644 index 0000000000..cf9cdc7d48 --- /dev/null +++ b/docs/api-integrations/sage-member/connect.mdx @@ -0,0 +1,40 @@ +--- +title: SAGE Member - How do I link my account? +sidebarTitle: SAGE Member +--- + +# Overview + +To authenticate with SAGE Member, you need: +1. **Account Number** — Your SAGE Member account number. +2. **Login ID** — The login ID of the SAGE Member user account making the requests. +3. **Authentication Key** — The key associated with that user's login, used to authenticate every request. + +### Prerequisites + +- An active SAGE Workplace subscription or Supplier Advantage membership. +- A SAGE Member module enabled on your account (enable modules on the Services tab). + +### Step 1: Finding your credentials + +SAGE Member authenticates on a per-user basis, so each set of credentials is tied to one user on your account. + +1. Log in to your SAGE account and go to **Developer APIs** > **SAGE Connect**. +2. Go to the **Users** tab. +3. Click your profile in the top right to find your **Account Number**. +4. Select the user that will be making API requests and note their **Login ID** and **Authentication Key**. + + + + +### Step 2: Enter credentials in the Connect UI + +Once you have your credentials: +1. Open the form where you need to authenticate with SAGE Member. +2. Enter your **Account Number**, **Login ID**, and **Authentication Key** in their designated fields. +3. Submit the form, and you should be successfully authenticated. + + + + +You are now connected to SAGE Member. diff --git a/docs/api-integrations/sage-member/form.png b/docs/api-integrations/sage-member/form.png new file mode 100644 index 0000000000..24c9f27885 Binary files /dev/null and b/docs/api-integrations/sage-member/form.png differ diff --git a/docs/api-integrations/sage-member/users_tab.png b/docs/api-integrations/sage-member/users_tab.png new file mode 100644 index 0000000000..5640444b63 Binary files /dev/null and b/docs/api-integrations/sage-member/users_tab.png differ diff --git a/docs/docs.json b/docs/docs.json index ae10abf001..71b1bb1d3b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1568,6 +1568,7 @@ "integrations/all/sage-intacct", "integrations/all/sage-intacct-oauth", "api-integrations/sage-intacct-cc", + "api-integrations/sage-member", "integrations/all/sage-people", "api-integrations/sap-ariba", "api-integrations/salesforce", diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 6a2e3f6f5c..bda8357fc0 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -14960,6 +14960,7 @@ Use these slugs to construct provider-specific docs URLs only when needed. | `sage-intacct` | Sage Intacct | TWO_STEP | [docs](https://nango.dev/docs/integrations/all/sage-intacct.md) | [connect](https://nango.dev/docs/integrations/all/sage-intacct/connect.md) | | accounting, erp | | `sage-intacct-cc` | Sage Intacct (Client Credentials) | TWO_STEP | [docs](https://nango.dev/docs/api-integrations/sage-intacct-cc.md) | [connect](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | [setup](https://nango.dev/docs/api-integrations/sage-intacct-cc/connect.md) | accounting, erp | | `sage-intacct-oauth` | Sage Intacct (OAuth) | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-intacct-oauth.md) | | | accounting, erp, popular | +| `sage-member` | SAGE Member | API_KEY | [docs](https://nango.dev/docs/api-integrations/sage-member.md) | [connect](https://nango.dev/docs/api-integrations/sage-member/connect.md) | | marketing, e-commerce | | `sage-people` | Sage People | OAUTH2 | [docs](https://nango.dev/docs/integrations/all/sage-people.md) | [connect](https://nango.dev/docs/integrations/all/sage-people/connect.md) | | hr | | `salesforce` | Salesforce | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/salesforce.md) | [connect](https://nango.dev/docs/api-integrations/salesforce/connect.md) | [setup](https://nango.dev/docs/api-integrations/salesforce/salesforce-api-oauth-app-setup.md) | crm, popular | | `salesforce-cc` | Salesforce (Client Credentials) | OAUTH2_CC | [docs](https://nango.dev/docs/api-integrations/salesforce-cc.md) | [connect](https://nango.dev/docs/api-integrations/salesforce-cc/connect.md) | | crm | diff --git a/docs/llms.txt b/docs/llms.txt index 963b87e283..ad3d85d5a1 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -139,7 +139,7 @@ Use provider URLs by replacing `{slug}` with a slug from the API catalog: ## APIs and integrations -- [API catalog](https://nango.dev/docs/api-catalog.txt): 884 provider slugs with canonical docs routes, auth modes, setup guides, and connect guides. +- [API catalog](https://nango.dev/docs/api-catalog.txt): 885 provider slugs with canonical docs routes, auth modes, setup guides, and connect guides. - Provider-specific pages are intentionally not expanded here so core Nango guides remain easy for agents to find. ## Resources diff --git a/docs/snippets/generated/sage-member/PreBuiltUseCases.mdx b/docs/snippets/generated/sage-member/PreBuiltUseCases.mdx new file mode 100644 index 0000000000..4c3a8de9e0 --- /dev/null +++ b/docs/snippets/generated/sage-member/PreBuiltUseCases.mdx @@ -0,0 +1,3 @@ +_No pre-built syncs or actions available yet._ + +Not seeing the integration you need? [Build your own](/guides/functions/functions-guide) independently. diff --git a/packages/providers/providers.yaml b/packages/providers/providers.yaml index 9bc98e3296..3ff4b7e6e5 100644 --- a/packages/providers/providers.yaml +++ b/packages/providers/providers.yaml @@ -17887,6 +17887,46 @@ sage-intacct: description: Your Sage Intacct User Password secret: true +sage-member: + display_name: SAGE Member + categories: + - marketing + - e-commerce + auth_mode: API_KEY + proxy: + base_url: https://www.promoplace.com/ws/ws.dll + body: + auth: + acctId: ${connectionConfig.acctId} + loginId: ${connectionConfig.loginId} + key: ${apiKey} + docs: https://nango.dev/docs/api-integrations/sage-member + docs_connect: https://nango.dev/docs/api-integrations/sage-member/connect + credentials: + apiKey: + type: string + title: Authentication Key + example: 56edbbe3********************141a + pattern: '^[a-f0-9]{32}$' + description: The authentication key for your SAGE Member user. + secret: true + doc_section: '#step-1-finding-your-credentials' + connection_config: + acctId: + type: string + title: Account Number + description: Your SAGE Member account number. + pattern: '^[0-9]+$' + example: '4353478' + order: 1 + doc_section: '#step-1-finding-your-credentials' + loginId: + type: string + title: Login ID + description: The login ID for your SAGE Member user. + order: 2 + doc_section: '#step-1-finding-your-credentials' + sage-people: display_name: Sage People categories: diff --git a/packages/shared/lib/services/proxy/utils.ts b/packages/shared/lib/services/proxy/utils.ts index e470754148..975385dbef 100644 --- a/packages/shared/lib/services/proxy/utils.ts +++ b/packages/shared/lib/services/proxy/utils.ts @@ -26,6 +26,7 @@ import type { InternalProxyConfiguration, OAuth2ClientCredentials, ProviderOAuth1, + ProxyBodyValue, UserProvidedProxyConfiguration } from '@nangohq/types'; import type { Result } from '@nangohq/utils'; @@ -509,25 +510,44 @@ export function buildProxyBody({ }: { config: ApplicationConstructedProxyConfiguration; connection: ConnectionForProxy; -}): Record | null { +}): Record | null { if (!config.provider?.proxy?.body) { return null; } - const body: Record = {}; const replacers = { connectionConfig: connection.connection_config, credentials: connection.credentials, ...(connection.credentials as unknown as Record) }; - for (const [key, value] of Object.entries(config.provider.proxy.body)) { - if (typeof value !== 'string') { - continue; + // Recurses through arbitrarily nested proxy.body maps, interpolating string leaves and dropping + // (at any depth) any leaf whose placeholder didn't resolve, and any object left empty as a result. + const buildValue = (value: unknown): ProxyBodyValue | null => { + if (typeof value === 'string') { + const interpolated = interpolateIfNeeded(value, replacers); + return interpolated.includes('${') ? null : interpolated; + } + + if (!isPlainObject(value)) { + return null; } - const interpolated = interpolateIfNeeded(value, replacers); - if (!interpolated.includes('${')) { - body[key] = interpolated; + + const nested: Record = {}; + for (const [nestedKey, nestedValue] of Object.entries(value)) { + const built = buildValue(nestedValue); + if (built !== null) { + nested[nestedKey] = built; + } + } + return Object.keys(nested).length > 0 ? nested : null; + }; + + const body: Record = {}; + for (const [key, value] of Object.entries(config.provider.proxy.body)) { + const built = buildValue(value); + if (built !== null) { + body[key] = built; } } @@ -545,15 +565,29 @@ function isPlainObject(value: unknown): value is Record { ); } -function mergeInjectedBody(existing: unknown, injected: Record): unknown { +function appendInjectedEntry(append: (key: string, value: string) => void, key: string, value: ProxyBodyValue): void { + if (typeof value === 'string') { + append(key, value); + return; + } + for (const [nestedKey, nestedValue] of Object.entries(value)) { + appendInjectedEntry(append, `${key}[${nestedKey}]`, nestedValue); + } +} + +function mergeInjectedBody(existing: unknown, injected: Record): unknown { if (!existing) return injected; if (isPlainObject(existing)) return { ...existing, ...injected }; if (existing instanceof FormData) { - for (const [key, value] of Object.entries(injected)) existing.append(key, value); + for (const [key, value] of Object.entries(injected)) { + appendInjectedEntry((k, v) => existing.append(k, v), key, value); + } return existing; } if (existing instanceof URLSearchParams) { - for (const [key, value] of Object.entries(injected)) existing.append(key, value); + for (const [key, value] of Object.entries(injected)) { + appendInjectedEntry((k, v) => existing.append(k, v), key, value); + } return existing; } return existing; diff --git a/packages/shared/lib/services/proxy/utils.unit.test.ts b/packages/shared/lib/services/proxy/utils.unit.test.ts index 1933fd66eb..05d8f2d52a 100644 --- a/packages/shared/lib/services/proxy/utils.unit.test.ts +++ b/packages/shared/lib/services/proxy/utils.unit.test.ts @@ -3,6 +3,8 @@ import * as crypto from 'node:crypto'; import FormData from 'form-data'; import { describe, expect, it } from 'vitest'; +import { getProvider } from '@nangohq/providers'; + import { getTestConnection } from '../../seeders/connection.seeder.js'; import { absoluteUrlFromRedirectRequestOptions, @@ -2183,4 +2185,415 @@ describe('buildProxyBody', () => { const result = buildProxyBody({ config, connection: getTestConnection() }); expect(result).toEqual({ label: 'static' }); }); + + it('builds a nested object from a nested proxy.body map', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { + auth: { + acctId: '${connectionConfig.acctId}', + loginId: '${connectionConfig.loginId}', + key: '${apiKey}' + } + } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ + credentials: { type: 'API_KEY', apiKey: 'my-secret-key' }, + connection_config: { acctId: '26878', loginId: 'TestUser' } + }) + }); + expect(result).toEqual({ auth: { acctId: '26878', loginId: 'TestUser', key: 'my-secret-key' } }); + }); + + it('drops unresolved keys from a nested proxy.body map but keeps the resolved ones', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { + auth: { + acctId: '${connectionConfig.acctId}', + key: '${apiKey}' + } + } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' }, connection_config: {} }) + }); + expect(result).toEqual({ auth: { key: 'my-secret-key' } }); + }); + + it('omits a nested map entirely when none of its keys resolve', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { auth: { acctId: '${connectionConfig.acctId}' } } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ connection_config: {} }) + }); + expect(result).toBeNull(); + }); + + it('mixes flat and nested values in the same proxy.body map', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { + serviceId: '101', + auth: { key: '${apiKey}' } + } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + expect(result).toEqual({ serviceId: '101', auth: { key: 'my-secret-key' } }); + }); + + it('builds a value nested more than one level deep', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { + request: { + control: { + senderid: '${connectionConfig.senderId}', + password: '${apiKey}' + }, + operation: { + authentication: { + login: { + userid: '${connectionConfig.userId}' + } + } + } + } + } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ + credentials: { type: 'API_KEY', apiKey: 'my-secret-key' }, + connection_config: { senderId: 'sender-1', userId: 'user-1' } + }) + }); + expect(result).toEqual({ + request: { + control: { senderid: 'sender-1', password: 'my-secret-key' }, + operation: { authentication: { login: { userid: 'user-1' } } } + } + }); + }); + + it('drops an unresolved leaf several levels deep but keeps its siblings', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { + a: { + b: { + c: '${connectionConfig.missing}', + d: '${apiKey}' + } + } + } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' }, connection_config: {} }) + }); + expect(result).toEqual({ a: { b: { d: 'my-secret-key' } } }); + }); + + it('omits a deeply nested map entirely when every leaf under it fails to resolve', () => { + const config = getDefaultProxy({ + provider: { + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://example.com', + body: { + a: { b: { c: '${connectionConfig.missing}' } } + } + } + } + }); + const result = buildProxyBody({ + config, + connection: getTestConnection({ connection_config: {} }) + }); + expect(result).toBeNull(); + }); +}); + +describe('buildProxyBody merged into an existing request body', () => { + const provider = { + auth_mode: 'API_KEY' as const, + proxy: { + base_url: 'https://example.com', + body: { + auth: { + nested: { + key: '${apiKey}' + } + } + } + } + }; + + it('injects a value nested more than one level deep into an existing URLSearchParams body using bracket notation', () => { + const config = getDefaultProxy({ + provider, + method: 'POST', + data: new URLSearchParams({ existing: 'value' }) + }); + + const axiosConfig = getAxiosConfiguration({ + proxyConfig: config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + + expect((axiosConfig.data as URLSearchParams).toString()).toBe('existing=value&auth%5Bnested%5D%5Bkey%5D=my-secret-key'); + }); + + it('injects a value nested more than one level deep into an existing FormData body using bracket notation', () => { + const form = new FormData(); + form.append('existing', 'value'); + const config = getDefaultProxy({ + provider, + method: 'POST', + data: form + }); + + const axiosConfig = getAxiosConfiguration({ + proxyConfig: config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + + const resultForm = axiosConfig.data as FormData; + expect(resultForm).toBe(form); + const buffer = resultForm.getBuffer().toString('utf8'); + expect(buffer).toContain('name="existing"'); + expect(buffer).toContain('name="auth[nested][key]"'); + expect(buffer).toContain('my-secret-key'); + }); + + const providerDepth1 = { + auth_mode: 'API_KEY' as const, + proxy: { + base_url: 'https://example.com', + body: { + auth: { + key: '${apiKey}' + } + } + } + }; + + it('injects a value nested exactly one level deep into an existing URLSearchParams body using bracket notation', () => { + const config = getDefaultProxy({ + provider: providerDepth1, + method: 'POST', + data: new URLSearchParams({ existing: 'value' }) + }); + + const axiosConfig = getAxiosConfiguration({ + proxyConfig: config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + + expect((axiosConfig.data as URLSearchParams).toString()).toBe('existing=value&auth%5Bkey%5D=my-secret-key'); + }); + + it('injects a value nested exactly one level deep into an existing FormData body using bracket notation', () => { + const form = new FormData(); + form.append('existing', 'value'); + const config = getDefaultProxy({ + provider: providerDepth1, + method: 'POST', + data: form + }); + + const axiosConfig = getAxiosConfiguration({ + proxyConfig: config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + + const resultForm = axiosConfig.data as FormData; + expect(resultForm).toBe(form); + const buffer = resultForm.getBuffer().toString('utf8'); + expect(buffer).toContain('name="existing"'); + expect(buffer).toContain('name="auth[key]"'); + expect(buffer).toContain('my-secret-key'); + }); + + const providerDepth3 = { + auth_mode: 'API_KEY' as const, + proxy: { + base_url: 'https://example.com', + body: { + auth: { + a: { + b: { + key: '${apiKey}' + } + } + } + } + } + }; + + it('injects a value nested three levels deep into an existing URLSearchParams body using bracket notation', () => { + const config = getDefaultProxy({ + provider: providerDepth3, + method: 'POST', + data: new URLSearchParams({ existing: 'value' }) + }); + + const axiosConfig = getAxiosConfiguration({ + proxyConfig: config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + + expect((axiosConfig.data as URLSearchParams).toString()).toBe('existing=value&auth%5Ba%5D%5Bb%5D%5Bkey%5D=my-secret-key'); + }); + + it('injects a value nested three levels deep into an existing FormData body using bracket notation', () => { + const form = new FormData(); + form.append('existing', 'value'); + const config = getDefaultProxy({ + provider: providerDepth3, + method: 'POST', + data: form + }); + + const axiosConfig = getAxiosConfiguration({ + proxyConfig: config, + connection: getTestConnection({ credentials: { type: 'API_KEY', apiKey: 'my-secret-key' } }) + }); + + const resultForm = axiosConfig.data as FormData; + expect(resultForm).toBe(form); + const buffer = resultForm.getBuffer().toString('utf8'); + expect(buffer).toContain('name="existing"'); + expect(buffer).toContain('name="auth[a][b][key]"'); + expect(buffer).toContain('my-secret-key'); + }); +}); + +describe('sage-member (real provider config)', () => { + const provider = getProvider('sage-member'); + + it('is defined in providers.yaml with the expected nested auth body', () => { + // Guards against providers.yaml drifting out of sync with the nested-body test scenarios below. + expect(provider).toMatchObject({ + auth_mode: 'API_KEY', + proxy: { + base_url: 'https://www.promoplace.com/ws/ws.dll', + body: { + auth: { + acctId: '${connectionConfig.acctId}', + loginId: '${connectionConfig.loginId}', + key: '${apiKey}' + } + } + } + }); + }); + + const connection = getTestConnection({ + provider_config_key: 'sage-member', + credentials: { type: 'API_KEY', apiKey: 'deadbeefdeadbeefdeadbeefdeadbeef' }, + connection_config: { acctId: '4353478', loginId: 'TestUser' } + }); + + it('injects account credentials into the auth object', () => { + const config = getDefaultProxy({ providerConfigKey: 'sage-member', providerName: 'sage-member', provider: provider! }); + + const result = buildProxyBody({ config, connection }); + + expect(result).toEqual({ + auth: { acctId: '4353478', loginId: 'TestUser', key: 'deadbeefdeadbeefdeadbeefdeadbeef' } + }); + }); + + it('drops the injected auth entirely when connectionConfig is missing (no leaked ${...} placeholders)', () => { + const config = getDefaultProxy({ providerConfigKey: 'sage-member', providerName: 'sage-member', provider: provider! }); + + const result = buildProxyBody({ + config, + connection: getTestConnection({ + provider_config_key: 'sage-member', + credentials: { type: 'API_KEY', apiKey: 'deadbeefdeadbeefdeadbeefdeadbeef' }, + connection_config: {} + }) + }); + + expect(result).toEqual({ auth: { key: 'deadbeefdeadbeefdeadbeefdeadbeef' } }); + }); + + it('builds the full axios request: merges caller-supplied JSON body with the injected auth object, no auth header added', () => { + const config = getDefaultProxy({ + providerConfigKey: 'sage-member', + providerName: 'sage-member', + provider: provider!, + endpoint: '/ConnectAPI', + method: 'POST', + data: { serviceId: 101, apiVer: 130 } + }); + + const axiosConfig = getAxiosConfiguration({ proxyConfig: config, connection }); + + expect(axiosConfig.url).toBe('https://www.promoplace.com/ws/ws.dll/ConnectAPI'); + expect(axiosConfig.data).toEqual({ + serviceId: 101, + apiVer: 130, + auth: { acctId: '4353478', loginId: 'TestUser', key: 'deadbeefdeadbeefdeadbeefdeadbeef' } + }); + // API_KEY has no dedicated header template for this provider, so no default authorization header is added — + // the key only ever travels inside the JSON body's `auth` object. + expect(axiosConfig.headers).toEqual({}); + }); + + it('does not inject the body for GET requests (methodDataAllowed excludes GET)', () => { + const config = getDefaultProxy({ + providerConfigKey: 'sage-member', + providerName: 'sage-member', + provider: provider!, + endpoint: '/ConnectAPI', + method: 'GET' + }); + + const axiosConfig = getAxiosConfiguration({ proxyConfig: config, connection }); + + expect(axiosConfig.data).toBeUndefined(); + }); }); diff --git a/packages/types/lib/providers/provider.ts b/packages/types/lib/providers/provider.ts index 20df92ae93..db675b07a5 100644 --- a/packages/types/lib/providers/provider.ts +++ b/packages/types/lib/providers/provider.ts @@ -44,6 +44,9 @@ export interface SimplifiedJSONSchema { visible_when?: { field: string; equals: string }; } +// A `proxy.body` leaf is a string (interpolated) or a nested object of more leaves, to any depth. +export type ProxyBodyValue = string | { [key: string]: ProxyBodyValue }; + export interface BaseProvider { display_name: string; auth_mode: AuthModeType; @@ -53,7 +56,7 @@ export interface BaseProvider { headers?: Record; connection_config?: Record; query?: Record; - body?: Record; + body?: Record; retry?: RetryHeaderConfig; decompress?: boolean; forward_headers_on_redirect?: boolean; diff --git a/packages/webapp/public/images/template-logos/sage-member.svg b/packages/webapp/public/images/template-logos/sage-member.svg new file mode 100644 index 0000000000..42ce2180ea --- /dev/null +++ b/packages/webapp/public/images/template-logos/sage-member.svg @@ -0,0 +1 @@ + diff --git a/scripts/validation/providers/schema.json b/scripts/validation/providers/schema.json index 404fd17f6b..075fdd0987 100644 --- a/scripts/validation/providers/schema.json +++ b/scripts/validation/providers/schema.json @@ -298,7 +298,15 @@ "body": { "type": "object", "additionalProperties": { - "type": "string" + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] } }, "connection_config": {