Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(integration): add support for sage intacct xml #3014

Merged
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
2 changes: 1 addition & 1 deletion docs-v2/integrations/all/quickbooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Quickbooks
sidebarTitle: Quickbooks
---

API configuration: [`quickbooks`](https://nango.dev/providers.yaml)
API configuration: [`quickbooks`](https://nango.dev/providers.yaml), [`quickbooks-sandbox`](https://nango.dev/providers.yaml)

## Features

Expand Down
10 changes: 7 additions & 3 deletions docs-v2/integrations/all/sage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ title: Sage
sidebarTitle: Sage
---

API configuration: [`sage`](https://nango.dev/providers.yaml)
API configuration: [`sage`](https://nango.dev/providers.yaml), [`sage-intacct`](https://nango.dev/providers.yaml)

## Features

| Features | Status |
| - | - |
| [Auth (OAuth)](/integrate/guides/authorize-an-api) | ✅ |
| [Auth (OAuth + TwoStep)](/integrate/guides/authorize-an-api) | ✅ |
| [Sync data](/integrate/guides/sync-data-from-an-api) | ✅ |
| [Perform workflows](/integrate/guides/perform-workflows-with-an-api) | ✅ |
| [Proxy requests](/integrate/guides/proxy-requests-to-an-api) | ✅ |
Expand All @@ -22,11 +22,15 @@ API configuration: [`sage`](https://nango.dev/providers.yaml)
- [How to register an Application](https://developer.sage.com/accounting/guides/getting-started/client_app_registration/)
- [OAuth-related docs](https://developer.sage.com/accounting/guides/authenticating/authentication/)
- [API](https://developer.sage.com/accounting/reference/)
- [Sage Intacct Auth docs](https://developer.intacct.com/web-services/your-first-api-calls/)
- [Sage Intacct API docs](https://developer.intacct.com/api/)

<Tip>Need help getting started? Get help in the [community](https://nango.dev/slack).</Tip>

## API gotchas

- Scopes can only be `readonly` or `full_access`.
- Sage OAuth scopes can only be `readonly` or `full_access`.
- Sage also supports XML integration. When creating a new connection on Nango for Sage Intacct, please provide your web service `senderId` and `password`, as well as credentials to access the target company. These credentials include `userId`, `companyId`, and `userPassword`. Additionally, the target company must [authorize your sender ID]https://developer.intacct.com/support/faq/#why-am-i-getting-an-error-about-an-invalid-web-services-authorization to allow API calls.
- After successfully creating a new connection, you can use [getConnection](/reference/scripts#get-the-connection-credentials) in your integration scripts to retrieve the `sessionId`, for subsequent api calls.

<Note>Add Getting Started links and Gotchas by [editing this page](https://github.com/nangohq/nango/tree/master/docs-v2/integrations/all/sage.mdx)</Note>
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 27 additions & 4 deletions packages/shared/lib/services/connection.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import jwt from 'jsonwebtoken';
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
import type { Knex } from '@nangohq/database';
import db, { schema, dbNamespace } from '@nangohq/database';
import analytics, { AnalyticsTypes } from '../utils/analytics.js';
Expand Down Expand Up @@ -1749,7 +1750,9 @@ class ConnectionService {
const strippedTokenUrl = typeof provider.token_url === 'string' ? provider.token_url.replace(/connectionConfig\./g, '') : '';
const url = new URL(interpolateString(strippedTokenUrl, connectionConfig)).toString();

const postBody: Record<string, any> = {};
const bodyFormat = provider.body_format || 'json';

const postBody: Record<string, any> | string = {};

if (provider.token_params) {
for (const [key, value] of Object.entries(provider.token_params)) {
Expand All @@ -1776,15 +1779,35 @@ class ConnectionService {
try {
const requestOptions = { headers };

const response = await axios.post(url.toString(), JSON.stringify(postBody), requestOptions);
const bodyContent =
bodyFormat === 'xml'
? new XMLBuilder({
format: true,
indentBy: ' ',
attributeNamePrefix: '$',
ignoreAttributes: false
}).build(postBody)
: JSON.stringify(postBody);

const response = await axios.post(url.toString(), bodyContent, requestOptions);

if (response.status !== 200) {
return { success: false, error: new NangoError('invalid_two_step_credentials'), response: null };
}

const { data } = response;
let responseData: any = response.data;

if (bodyFormat === 'xml' && typeof response.data === 'string') {
const parser = new XMLParser({
ignoreAttributes: false,
parseAttributeValue: true,
trimValues: true
});

responseData = parser.parse(response.data);
}

const parsedCreds = this.parseRawCredentials(data, 'TWO_STEP', provider) as TwoStepCredentials;
const parsedCreds = this.parseRawCredentials(responseData, 'TWO_STEP', provider) as TwoStepCredentials;

for (const [key, value] of Object.entries(dynamicCredentials)) {
if (value !== undefined) {
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/lib/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ export function getWebsocketsPath(): string {
*/
export function interpolateString(str: string, replacers: Record<string, any>) {
return str.replace(/\${([^{}]*)}/g, (a, b) => {
if (b === 'now') {
return new Date().toISOString();
}
const r = replacers[b];
return typeof r === 'string' || typeof r === 'number' ? (r as string) : a; // Typecast needed to make TypeScript happy
});
Expand Down
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"braintree": "^3.15.0",
"dd-trace": "5.21.0",
"exponential-backoff": "^3.1.1",
"fast-xml-parser": "^4.5.0",
"form-data": "4.0.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
Expand Down
66 changes: 66 additions & 0 deletions packages/shared/providers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4473,6 +4473,12 @@ perimeter81:
pattern: '^(perimeter81|eu\.sase\.checkpoint)$'
example: 'perimeter81 | eu.sase.checkpoint'
doc_section: '#step-1-finding-your-perimeter81-domain-and-perimeter81-api-key'
credentials:
apiKey:
type: string
title: API Key
description: The API key for your Perimeter81 account
secret: true

personio:
display_name: Personio
Expand Down Expand Up @@ -4928,6 +4934,66 @@ sage:
base_url: https://api.accounting.sage.com
docs: https://docs.nango.dev/integrations/all/sage

sage-intacct:
display_name: Sage Intacct
categories:
- accounting
- erp
auth_mode: TWO_STEP
proxy:
base_url: https://api.intacct.com/ia/xml/xmlgw.phtml
token_url: https://api.intacct.com/ia/xml/xmlgw.phtml
body_format: xml
token_params:
request:
control:
senderid: ${credential.senderId}
password: ${credential.senderPassword}
controlid: ${now}
uniqueid: false
dtdversion: '3.0'
includewhitespace: false
operation:
authentication:
login:
userid: ${credential.userId}
companyid: ${credential.companyId}
password: ${credential.userPassword}
content:
function:
$controlid: '{{$guid}}'
getAPISession: ''
token_headers:
Content-Type: application/xml
token_response:
token: response.operation.result.data.api.sessionid
token_expiration: response.operation.authentication.sessiontimeout
token_expiration_strategy: expireAt
docs: https://docs.nango.dev/integrations/all/sage
credentials:
senderId:
type: string
title: Sender ID
description: Your Sage Intacct Sender ID
senderPassword:
type: string
title: Sender Password
description: Your Sage Intacct Sender Password
secret: true
userId:
type: string
title: User ID
description: Your Sage Intacct User ID
companyId:
type: string
title: Company ID
description: Your Sage Intacct Company ID
userPassword:
type: string
title: User Password
description: Your Sage Intacct User Password
secret: true

salesforce:
display_name: Salesforce
categories:
Expand Down
3 changes: 2 additions & 1 deletion packages/types/lib/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export interface ProviderJwt extends BaseProvider {
};
};
}
export interface ProviderTwoStep extends BaseProvider {
export interface ProviderTwoStep extends Omit<BaseProvider, 'body_format'> {
token_headers?: Record<string, string>;
token_response: {
token: string;
Expand All @@ -136,6 +136,7 @@ export interface ProviderTwoStep extends BaseProvider {
};
token_expires_in_ms?: number;
proxy_header_authorization?: string;
body_format?: 'xml' | 'json';
}
export interface ProviderSignature extends BaseProvider {
signature: {
Expand Down
2 changes: 1 addition & 1 deletion scripts/validation/providers/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@
"enum": ["authorization_code", "client_credentials"]
},
"request": {
"type": "string"
"anyOf": [{ "type": "string" }, { "type": "object" }]
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions scripts/validation/providers/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ if (validator.errors) {

const invalidInterpolation = /(?<!(\$|]))\{/g;
for (const [providerKey, provider] of Object.entries(providersJson)) {
// Skip validation for 'sage-intacct' provider, we need this so that we can specify the element attribute
if (providerKey === 'sage-intacct') {
continue;
}
const { credentials, connection_config, ...providerWithoutSensitive } = provider;
const strippedProviderYaml = jsYaml.dump({ [providerKey]: providerWithoutSensitive });
const match = [...strippedProviderYaml.matchAll(invalidInterpolation)];
Expand Down Expand Up @@ -145,6 +149,10 @@ function validateProvider(providerKey: string, provider: Provider) {
if (!provider.proxy?.verification) {
console.warn(chalk.yellow('warning'), chalk.blue(providerKey), `does not have "proxy" > "verification" set`);
}
} else if (provider.auth_mode === 'TWO_STEP') {
if (!provider.credentials) {
console.warn(chalk.yellow('warning'), chalk.blue(providerKey), `"credentials" are not defined for TWO_STEP auth mode`);
}
} else {
if (provider.credentials) {
console.error(chalk.red('error'), chalk.blue(providerKey), `"credentials" is defined but not required`);
Expand Down
Loading