mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat: Add Microsoft Dataverse core node (#34286)
Co-authored-by: Stephen Wright <sjw948@gmail.com>
This commit is contained in:
parent
d2af313653
commit
da910f5eaf
|
|
@ -0,0 +1,142 @@
|
|||
import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MicrosoftDataverseOAuth2Api implements ICredentialType {
|
||||
name = 'microsoftDataverseOAuth2Api';
|
||||
|
||||
extends = ['microsoftOAuth2Api'];
|
||||
|
||||
displayName = 'Microsoft Dataverse OAuth2 API';
|
||||
|
||||
documentationUrl = 'microsoftdataverse';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Authorization Code',
|
||||
value: 'authorizationCode',
|
||||
description: 'Delegated user access via interactive sign-in (recommended)',
|
||||
},
|
||||
{
|
||||
name: 'Client Credentials',
|
||||
value: 'clientCredentials',
|
||||
description: 'App-only access using a service principal (no signed-in user)',
|
||||
},
|
||||
],
|
||||
default: 'authorizationCode',
|
||||
description:
|
||||
'OAuth2 flow to use. ' +
|
||||
'"Authorization Code" signs in as a real user and uses refresh tokens for long-lived workflows (recommended). ' +
|
||||
'"Client Credentials" acts as the application itself — no signed-in user, access token re-acquired silently on expiry.',
|
||||
},
|
||||
{
|
||||
displayName: 'Tenant ID',
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
default: 'common',
|
||||
required: true,
|
||||
description:
|
||||
'Microsoft Entra tenant ID (GUID) or verified domain (e.g. contoso.onmicrosoft.com). ' +
|
||||
'Find it at portal.azure.com → Microsoft Entra ID → Overview.',
|
||||
hint:
|
||||
'Authorization Code: "common" works for multi-tenant apps; use a specific GUID or domain for single-tenant. ' +
|
||||
'Client Credentials: must be a specific tenant GUID — "common" is not accepted by Entra.',
|
||||
},
|
||||
{
|
||||
displayName: 'Environment URL',
|
||||
name: 'environmentUrl',
|
||||
type: 'string',
|
||||
placeholder: 'https://yourorg.crm.dynamics.com',
|
||||
default: '',
|
||||
required: true,
|
||||
validateType: 'url',
|
||||
description:
|
||||
'Base URL of your Dataverse environment. ' +
|
||||
'Find it in Power Platform admin center under your environment details, ' +
|
||||
'or inside the environment at Settings → Session details.',
|
||||
hint: 'Must start with https://. A trailing slash is accepted and normalized. Example: https://yourorg.crm.dynamics.com',
|
||||
},
|
||||
{
|
||||
displayName: 'National Cloud',
|
||||
name: 'cloud',
|
||||
type: 'options',
|
||||
default: 'global',
|
||||
description:
|
||||
'The Microsoft national cloud your Dataverse environment lives in. Controls the Entra login host used for authentication.',
|
||||
options: [
|
||||
{ name: 'Global (Public Cloud)', value: 'global' },
|
||||
{ name: 'US Government (GCC High)', value: 'usgov' },
|
||||
{ name: 'US Government (DoD)', value: 'dod' },
|
||||
{ name: 'China (21Vianet)', value: 'china' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Login host is derived from the selected national cloud so a sovereign
|
||||
// tenant authenticates against the matching login host. Values are a fixed
|
||||
// Microsoft host enum (mirrors LOGIN_HOSTS_BY_GRAPH_URL in the Entra
|
||||
// credential), keeping the token exchange on trusted hosts.
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/authorize" }}',
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/token" }}',
|
||||
},
|
||||
{
|
||||
// Scope resolution rules:
|
||||
// 1. `<environmentUrl>/.default` — always included; bundles the Dataverse API permissions
|
||||
// granted to the app in Entra (avoids enumerating individual scopes).
|
||||
// 2. `offline_access` — appended for Authorization Code only; tells Entra to return a
|
||||
// refresh token so n8n's oAuth2Api base can silently reissue access tokens on expiry.
|
||||
// 3. Omitted for Client Credentials — Entra rejects `offline_access` for app-only flows
|
||||
// with AADSTS70011 ("The provided value for the input parameter 'scope' is not valid").
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{ $self["environmentUrl"].trim().replace(/\\/+$/, "") + "/.default" + ($self["grantType"] === "clientCredentials" ? "" : " offline_access") }}',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'header',
|
||||
},
|
||||
{
|
||||
// Inherited from microsoftOAuth2Api but unused: Dataverse doesn't call
|
||||
// Microsoft Graph (the data URL comes from environmentUrl) and the login
|
||||
// host is driven by the National Cloud selector above. Hide it so the modal
|
||||
// doesn't render a control that does nothing.
|
||||
displayName: 'Microsoft Graph API Base URL',
|
||||
name: 'graphApiBaseUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://graph.microsoft.com',
|
||||
},
|
||||
];
|
||||
|
||||
// Runs on save so setup problems surface immediately instead of as a 403 at
|
||||
// execution time. Crucially this validates the Client Credentials flow, which
|
||||
// has no interactive connect step: Entra issues an app-only token even when the
|
||||
// application user hasn't been created in the environment, so a bare token
|
||||
// acquisition proves nothing. GET WhoAmI additionally catches a wrong tenant
|
||||
// and a mistyped environment URL. The trailing slash is stripped the same way
|
||||
// as the scope expression and resolveBaseUrl; the API version is kept in sync
|
||||
// with DATAVERSE_API_PATH in the node's constants.ts.
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.environmentUrl.trim().replace(/\\/+$/, "") }}',
|
||||
url: '/api/data/v9.2/WhoAmI',
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { DATAVERSE_API_PATH } from '../../nodes/Microsoft/Dataverse/constants';
|
||||
import { MicrosoftDataverseOAuth2Api } from '../MicrosoftDataverseOAuth2Api.credentials';
|
||||
|
||||
describe('MicrosoftDataverseOAuth2Api Credential', () => {
|
||||
const credential = new MicrosoftDataverseOAuth2Api();
|
||||
|
||||
const propertyNamed = (name: string): INodeProperties | undefined =>
|
||||
credential.properties.find((property) => property.name === name);
|
||||
|
||||
describe('metadata', () => {
|
||||
it('has the correct static metadata', () => {
|
||||
expect(credential.name).toBe('microsoftDataverseOAuth2Api');
|
||||
expect(credential.extends).toEqual(['microsoftOAuth2Api']);
|
||||
expect(credential.displayName).toBe('Microsoft Dataverse OAuth2 API');
|
||||
expect(credential.documentationUrl).toBe('microsoftdataverse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('static property defaults', () => {
|
||||
it('offers exactly the two supported grant types and defaults to authorization code', () => {
|
||||
const grantType = propertyNamed('grantType');
|
||||
expect(grantType?.type).toBe('options');
|
||||
expect(grantType?.default).toBe('authorizationCode');
|
||||
expect(grantType?.options?.map((o) => ('value' in o ? o.value : ''))).toEqual([
|
||||
'authorizationCode',
|
||||
'clientCredentials',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires the tenant ID and defaults it to "common"', () => {
|
||||
const tenantId = propertyNamed('tenantId');
|
||||
expect(tenantId?.type).toBe('string');
|
||||
expect(tenantId?.default).toBe('common');
|
||||
expect(tenantId?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a URL-validated environment URL with no default', () => {
|
||||
const environmentUrl = propertyNamed('environmentUrl');
|
||||
expect(environmentUrl?.type).toBe('string');
|
||||
expect(environmentUrl?.default).toBe('');
|
||||
expect(environmentUrl?.required).toBe(true);
|
||||
expect(environmentUrl?.validateType).toBe('url');
|
||||
});
|
||||
|
||||
it('sends authentication via the header', () => {
|
||||
const authentication = propertyNamed('authentication');
|
||||
expect(authentication?.type).toBe('hidden');
|
||||
expect(authentication?.default).toBe('header');
|
||||
});
|
||||
|
||||
it('offers the four national clouds and defaults to global', () => {
|
||||
const cloud = propertyNamed('cloud');
|
||||
expect(cloud?.type).toBe('options');
|
||||
expect(cloud?.default).toBe('global');
|
||||
expect(cloud?.options?.map((option) => ('value' in option ? option.value : ''))).toEqual([
|
||||
'global',
|
||||
'usgov',
|
||||
'dod',
|
||||
'china',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides the inherited Microsoft Graph API base URL field', () => {
|
||||
const graphApiBaseUrl = propertyNamed('graphApiBaseUrl');
|
||||
expect(graphApiBaseUrl?.type).toBe('hidden');
|
||||
});
|
||||
|
||||
it('does not override the inherited account-picker auth query parameters', () => {
|
||||
// Leaving it unset keeps the parent's `prompt=select_account`, which forces
|
||||
// the account picker and avoids silent wrong-tenant sign-in.
|
||||
expect(propertyNamed('authQueryParameters')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('$self expressions', () => {
|
||||
it('derives the authorization and token URLs from the national cloud and tenant ID', () => {
|
||||
expect(propertyNamed('authUrl')?.default).toBe(
|
||||
'={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/authorize" }}',
|
||||
);
|
||||
expect(propertyNamed('accessTokenUrl')?.default).toBe(
|
||||
'={{ ($self["cloud"] === "china" ? "https://login.partner.microsoftonline.cn" : ($self["cloud"] === "usgov" || $self["cloud"] === "dod" ? "https://login.microsoftonline.us" : "https://login.microsoftonline.com")) + "/" + $self["tenantId"].trim() + "/oauth2/v2.0/token" }}',
|
||||
);
|
||||
});
|
||||
|
||||
// Evaluate the login-host ternary the way n8n would, so the sovereign-cloud
|
||||
// mapping is guarded behaviorally, not just by string match.
|
||||
const loginHost = (cloud: string) =>
|
||||
cloud === 'china'
|
||||
? 'https://login.partner.microsoftonline.cn'
|
||||
: cloud === 'usgov' || cloud === 'dod'
|
||||
? 'https://login.microsoftonline.us'
|
||||
: 'https://login.microsoftonline.com';
|
||||
|
||||
it.each([
|
||||
['global', 'https://login.microsoftonline.com'],
|
||||
['usgov', 'https://login.microsoftonline.us'],
|
||||
['dod', 'https://login.microsoftonline.us'],
|
||||
['china', 'https://login.partner.microsoftonline.cn'],
|
||||
])('routes %s auth to the matching sovereign login host', (cloud, expected) => {
|
||||
expect(loginHost(cloud)).toBe(expected);
|
||||
});
|
||||
|
||||
it('normalizes the environment URL and adds offline_access only for the authorization code flow', () => {
|
||||
// Client Credentials must omit offline_access — Entra rejects it with AADSTS70011.
|
||||
expect(propertyNamed('scope')?.default).toBe(
|
||||
'={{ $self["environmentUrl"].trim().replace(/\\/+$/, "") + "/.default" + ($self["grantType"] === "clientCredentials" ? "" : " offline_access") }}',
|
||||
);
|
||||
});
|
||||
|
||||
// Evaluate the scope expression the way n8n would, so trailing-slash
|
||||
// normalization is guarded behaviorally rather than by string match alone.
|
||||
const evaluateScope = (environmentUrl: string, grantType: string) =>
|
||||
environmentUrl.trim().replace(/\/+$/, '') +
|
||||
'/.default' +
|
||||
(grantType === 'clientCredentials' ? '' : ' offline_access');
|
||||
|
||||
it.each([
|
||||
'https://yourorg.crm.dynamics.com',
|
||||
'https://yourorg.crm.dynamics.com/',
|
||||
'https://yourorg.crm.dynamics.com//',
|
||||
' https://yourorg.crm.dynamics.com/ ',
|
||||
])('collapses trailing slashes and whitespace to a single .default scope (%s)', (input) => {
|
||||
expect(evaluateScope(input, 'authorizationCode')).toBe(
|
||||
'https://yourorg.crm.dynamics.com/.default offline_access',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits offline_access for the client credentials flow', () => {
|
||||
expect(evaluateScope('https://yourorg.crm.dynamics.com/', 'clientCredentials')).toBe(
|
||||
'https://yourorg.crm.dynamics.com/.default',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential test', () => {
|
||||
it('probes WhoAmI so setup errors surface on save', () => {
|
||||
const request = credential.test.request;
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.url).toBe('/api/data/v9.2/WhoAmI');
|
||||
expect(request.headers).toEqual({ Accept: 'application/json' });
|
||||
});
|
||||
|
||||
it('keeps the test API version in sync with DATAVERSE_API_PATH', () => {
|
||||
// The test URL hardcodes the version because a credential can't idiomatically
|
||||
// import node constants; this guard fails if the two ever drift.
|
||||
expect(credential.test.request.url).toBe(`${DATAVERSE_API_PATH}/WhoAmI`);
|
||||
});
|
||||
|
||||
it('normalizes the environment URL for the test base URL', () => {
|
||||
expect(credential.test.request.baseURL).toBe(
|
||||
'={{ $credentials.environmentUrl.trim().replace(/\\/+$/, "") }}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
import { sleep } from '@n8n/utils/sleep';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
ILoadOptionsFunctions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { DATAVERSE_API_PATH, buildUserAgent } from './constants';
|
||||
|
||||
/** Headers we send to Dataverse must be string-valued. */
|
||||
export type DataverseHeaders = Record<string, string>;
|
||||
/** Query-string parameters Dataverse accepts. Numbers cover `$top`. */
|
||||
export type DataverseQuery = Record<string, string | number>;
|
||||
|
||||
export interface DataverseFullResponse {
|
||||
body: IDataObject;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexts that can issue Dataverse requests. Node execution uses
|
||||
* `IExecuteFunctions`; the in-editor `loadOptions` pickers use
|
||||
* `ILoadOptionsFunctions`. Both expose the same subset we rely on here
|
||||
* (`getCredentials`, `getNode`, `helpers.httpRequestWithAuthentication`), so
|
||||
* the request helpers accept either and the pickers don't need their own copy
|
||||
* of the base-URL / header / retry plumbing.
|
||||
*/
|
||||
export type DataverseContext = IExecuteFunctions | ILoadOptionsFunctions;
|
||||
|
||||
/**
|
||||
* Static OData v4 headers attached to every Dataverse request unless the
|
||||
* caller overrides them. Pulled into a constant so the headers stay in sync
|
||||
* between single-shot and paged requests.
|
||||
*/
|
||||
const ODATA_DEFAULT_HEADERS: DataverseHeaders = {
|
||||
Accept: 'application/json',
|
||||
'OData-MaxVersion': '4.0',
|
||||
'OData-Version': '4.0',
|
||||
};
|
||||
|
||||
const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
|
||||
|
||||
/**
|
||||
* Per-request HTTP timeout (milliseconds). Dataverse occasionally takes 30+
|
||||
* seconds for large list / metadata queries; a generous one-minute cap keeps
|
||||
* a hung request from blocking an n8n executor indefinitely while still
|
||||
* allowing legitimately slow operations to complete.
|
||||
*/
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* HTTP status codes we treat as transient and retry. 429 covers Dataverse
|
||||
* per-app throttling; 503/504 cover transient service/gateway failures.
|
||||
*/
|
||||
const RETRYABLE_STATUS_CODES: ReadonlySet<number> = new Set([429, 503, 504]);
|
||||
|
||||
/**
|
||||
* Socket-level error codes we treat as transient and retry. These arrive with
|
||||
* no `statusCode` (the request never got an HTTP response), so without this set
|
||||
* they would bypass the back-off logic and fail on the first attempt. Covers
|
||||
* the connection drops / DNS / timeout failures that internet-facing Dataverse
|
||||
* calls hit most often. `ECONNABORTED` is axios' own client-side request
|
||||
* timeout (from the `timeout` option), distinct from the socket-level
|
||||
* `ETIMEDOUT`. A bare "socket hang up" (no `code`) is handled separately in
|
||||
* {@link getNetworkErrorCode}.
|
||||
*/
|
||||
const RETRYABLE_NETWORK_CODES: ReadonlySet<string> = new Set([
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'ENETUNREACH',
|
||||
'EHOSTUNREACH',
|
||||
'EHOSTDOWN',
|
||||
]);
|
||||
|
||||
/**
|
||||
* HTTP methods whose repetition has no additional server-side effect, so
|
||||
* retrying one after an *ambiguous* failure — where Dataverse may already have
|
||||
* processed the request but the response was lost (504 gateway timeout,
|
||||
* `ECONNRESET`, socket hang-up, etc.) — is safe. Both write verbs are excluded:
|
||||
* `POST` (Create Row) would insert a duplicate record, and `PATCH` (Update /
|
||||
* Upsert Row) can re-trigger Dataverse plugins and business logic even though
|
||||
* the record-level result is idempotent. Writes only retry on failures proven
|
||||
* to have never reached Dataverse (see {@link isRetryable}).
|
||||
*/
|
||||
const IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([
|
||||
'GET',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Failures that guarantee the request never reached Dataverse, making a retry
|
||||
* safe even for a non-idempotent write (`POST` / `PATCH`). HTTP 429 is a
|
||||
* throttle rejection issued before any processing; the network codes are
|
||||
* connection-establishment failures (DNS / refused / no route) where the
|
||||
* request body was never sent.
|
||||
*/
|
||||
const PRE_DELIVERY_STATUS_CODES: ReadonlySet<number> = new Set([429]);
|
||||
const PRE_DELIVERY_NETWORK_CODES: ReadonlySet<string> = new Set([
|
||||
'ECONNREFUSED',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'ENETUNREACH',
|
||||
'EHOSTUNREACH',
|
||||
'EHOSTDOWN',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Retry policy: up to `MAX_RETRIES` extra attempts (so 1 + 3 = 4 total
|
||||
* dispatches in the worst case). Back-off is exponential starting from
|
||||
* `BASE_DELAY_MS` with full jitter, capped at `MAX_DELAY_MS`. A server-sent
|
||||
* `Retry-After` header (seconds or HTTP-date) overrides the computed delay,
|
||||
* clamped to `MAX_RETRY_AFTER_MS` so a large server-dictated wait can't stall
|
||||
* an executor across every attempt.
|
||||
*/
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 500;
|
||||
const MAX_DELAY_MS = 30_000;
|
||||
const MAX_RETRY_AFTER_MS = 120_000;
|
||||
|
||||
interface TransientErrorShape {
|
||||
statusCode?: number;
|
||||
httpCode?: number | string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
cause?: {
|
||||
code?: string;
|
||||
response?: { headers?: Record<string, string | string[] | undefined> };
|
||||
};
|
||||
response?: { headers?: Record<string, string | string[] | undefined> };
|
||||
}
|
||||
|
||||
function getStatusCode(error: unknown): number | undefined {
|
||||
const e = error as TransientErrorShape | null | undefined;
|
||||
const raw = e?.statusCode ?? e?.httpCode;
|
||||
if (typeof raw === 'number') return raw;
|
||||
if (typeof raw === 'string') {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a transient network error code from an error. Reads `error.code`
|
||||
* directly, falls back to `error.cause.code` (undici / got wrap the socket
|
||||
* error under `cause`), and recognizes the classic message-only "socket hang
|
||||
* up" failure that arrives without a `code`. Normalized to upper-case.
|
||||
*/
|
||||
function getNetworkErrorCode(error: unknown): string | undefined {
|
||||
const e = error as TransientErrorShape | null | undefined;
|
||||
const code = e?.code ?? e?.cause?.code;
|
||||
if (typeof code === 'string' && code) return code.toUpperCase();
|
||||
if (typeof e?.message === 'string' && /socket hang up/i.test(e.message)) return 'ECONNRESET';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed request should be retried. For idempotent methods, retries
|
||||
* fire on any known-transient failure — a throttling / gateway status
|
||||
* (429 / 503 / 504) or a transient socket / DNS error with no HTTP response.
|
||||
* For non-idempotent writes (`POST` / `PATCH`), retries are restricted to
|
||||
* failures that prove the request never reached Dataverse (429 or a
|
||||
* connection-establishment error), so a lost response can never silently
|
||||
* duplicate a created row or re-run Dataverse plugins / business logic.
|
||||
*/
|
||||
function isRetryable(error: unknown, method: IHttpRequestMethods): boolean {
|
||||
const idempotent = IDEMPOTENT_METHODS.has(method.toUpperCase());
|
||||
const status = getStatusCode(error);
|
||||
if (status !== undefined) {
|
||||
// An HTTP status means the request reached the server. Ambiguous
|
||||
// gateway/service failures (503 / 504) are only safe to retry when the
|
||||
// method is idempotent; 429 is always safe (rejected before processing).
|
||||
if (idempotent && RETRYABLE_STATUS_CODES.has(status)) return true;
|
||||
return PRE_DELIVERY_STATUS_CODES.has(status);
|
||||
}
|
||||
const networkCode = getNetworkErrorCode(error);
|
||||
if (networkCode === undefined) return false;
|
||||
if (idempotent) return RETRYABLE_NETWORK_CODES.has(networkCode);
|
||||
return PRE_DELIVERY_NETWORK_CODES.has(networkCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `Retry-After` delay (ms) from a failed request. Checks `error.response`
|
||||
* directly, then falls back to `error.cause.response` — `httpRequestWithAuthentication`
|
||||
* wraps the axios failure in `NodeApiError` and keeps the original (with its
|
||||
* `response.headers`) under `cause`, mirroring {@link getNetworkErrorCode}. The
|
||||
* result is clamped to `MAX_RETRY_AFTER_MS`.
|
||||
*/
|
||||
function parseRetryAfter(error: unknown): number | undefined {
|
||||
const e = error as TransientErrorShape | null | undefined;
|
||||
const headers = e?.response?.headers ?? e?.cause?.response?.headers;
|
||||
if (!headers) return undefined;
|
||||
const raw = headers['retry-after'] ?? headers['Retry-After'];
|
||||
if (!raw) return undefined;
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (value === undefined) return undefined;
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, seconds * 1000));
|
||||
const dateMs = Date.parse(String(value));
|
||||
if (Number.isFinite(dateMs))
|
||||
return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, dateMs - Date.now()));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function backoffDelay(attempt: number): number {
|
||||
const exponential = BASE_DELAY_MS * 2 ** attempt;
|
||||
const jitter = Math.floor(Math.random() * BASE_DELAY_MS);
|
||||
return Math.min(MAX_DELAY_MS, exponential + jitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request via `helpers.httpRequestWithAuthentication`, retrying on
|
||||
* transient failures — HTTP 429 / 503 / 504 and socket-level network errors
|
||||
* (`ECONNRESET`, `ETIMEDOUT`, DNS failures, socket hang-ups). Retries are
|
||||
* gated by {@link isRetryable}, which narrows the eligible failures for
|
||||
* non-idempotent writes (POST) so a Create Row whose response was lost is never
|
||||
* silently duplicated. Non-transient failures are re-thrown immediately so the
|
||||
* caller can wrap them in `NodeApiError` without an extra delay.
|
||||
*/
|
||||
async function dispatchWithRetry(
|
||||
ctx: DataverseContext,
|
||||
credentialType: string,
|
||||
options: IHttpRequestOptions,
|
||||
): Promise<unknown> {
|
||||
const method = options.method ?? 'GET';
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await ctx.helpers.httpRequestWithAuthentication.call(ctx, credentialType, options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetryable(error, method) || attempt === MAX_RETRIES) {
|
||||
// Caller (`dataverseApiRequest` / `dataverseApiRequestAllItems`)
|
||||
// wraps the upstream failure in `NodeApiError`. Wrapping here
|
||||
// would double-wrap and lose the original Dataverse metadata.
|
||||
throw error;
|
||||
}
|
||||
const retryAfter = parseRetryAfter(error);
|
||||
await sleep(retryAfter ?? backoffDelay(attempt));
|
||||
}
|
||||
}
|
||||
// Unreachable — the loop either returns or throws — but the type checker
|
||||
// can't see that, so re-throw the last observed failure as a safety net.
|
||||
throw lastError as Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Dataverse environment base URL from the n8n-stored credential,
|
||||
* stripping trailing slashes so `${baseUrl}${DATAVERSE_API_PATH}…` always has
|
||||
* exactly one slash between segments. Matches the credential's scope and
|
||||
* baseURL normalization (`/\/+$/`) so all three agree. Throws early with an
|
||||
* actionable message when the credential has no Environment URL, rather than
|
||||
* letting a malformed request fail obscurely at the HTTP layer.
|
||||
*/
|
||||
async function resolveBaseUrl(ctx: DataverseContext, credentialType: string): Promise<string> {
|
||||
const credentials = await ctx.getCredentials(credentialType);
|
||||
const url = String(credentials.environmentUrl ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
if (!url) {
|
||||
throw new NodeApiError(ctx.getNode(), {
|
||||
message: 'Dataverse credential is missing "Environment URL"',
|
||||
description:
|
||||
'Open the Microsoft Dataverse credential and set Environment URL to e.g. https://yourorg.crm.dynamics.com, then reconnect.',
|
||||
} as JsonObject);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and dispatch an authenticated Dataverse request, returning the raw
|
||||
* response and letting the *original* upstream error propagate (no
|
||||
* `NodeApiError` wrapping). This is the shared core behind
|
||||
* {@link dataverseApiRequest}; callers that need to surface their own error
|
||||
* shape — e.g. the `loadOptions` pickers, which extract the real Dataverse
|
||||
* message for the dropdown — use this directly and wrap failures themselves.
|
||||
*/
|
||||
export async function dataverseApiRequestRaw(
|
||||
ctx: DataverseContext,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
body: IDataObject = {},
|
||||
qs: DataverseQuery = {},
|
||||
headers: DataverseHeaders = {},
|
||||
credentialType: string,
|
||||
returnFullResponse = false,
|
||||
): Promise<IDataObject | DataverseFullResponse> {
|
||||
const baseUrl = await resolveBaseUrl(ctx, credentialType);
|
||||
let url: string;
|
||||
if (/^https?:\/\//i.test(resource)) {
|
||||
// An absolute resource is a server-issued `@odata.nextLink` (server-driven
|
||||
// paging). Require it to stay within the configured environment so a tampered
|
||||
// saved token can't redirect the authenticated request to another host.
|
||||
if (!resource.toLowerCase().startsWith(`${baseUrl.toLowerCase()}/`)) {
|
||||
throw new NodeApiError(ctx.getNode(), {
|
||||
message:
|
||||
'Refusing to follow a Dataverse pagination link outside the configured Environment URL',
|
||||
} as JsonObject);
|
||||
}
|
||||
url = resource;
|
||||
} else {
|
||||
url = `${baseUrl}${DATAVERSE_API_PATH}${resource}`;
|
||||
}
|
||||
|
||||
const options: IHttpRequestOptions = {
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
url,
|
||||
headers: {
|
||||
...ODATA_DEFAULT_HEADERS,
|
||||
'User-Agent': buildUserAgent(ctx.getNode().typeVersion),
|
||||
'Content-Type': CONTENT_TYPE_JSON,
|
||||
...headers,
|
||||
},
|
||||
json: true,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
...(returnFullResponse ? { returnFullResponse: true } : {}),
|
||||
};
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
return (await dispatchWithRetry(ctx, credentialType, options)) as
|
||||
| IDataObject
|
||||
| DataverseFullResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request against the Dataverse Web API.
|
||||
*
|
||||
* Auth (OAuth2 token acquisition and the Bearer header) is handled by the
|
||||
* `microsoftDataverseOAuth2Api` credential, so this helper only builds the
|
||||
* request and delegates to `requestWithAuthentication` (via `dispatchWithRetry`,
|
||||
* which adds transient-failure retries with exponential back-off + jitter).
|
||||
*
|
||||
* The caller MUST pass the credential type explicitly so a future refactor
|
||||
* cannot accidentally fall back to a stale literal that does not match any
|
||||
* registered credential.
|
||||
*/
|
||||
export async function dataverseApiRequest(
|
||||
ctx: IExecuteFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
body: IDataObject = {},
|
||||
qs: DataverseQuery = {},
|
||||
headers: DataverseHeaders = {},
|
||||
credentialType: string,
|
||||
): Promise<IDataObject> {
|
||||
try {
|
||||
return (await dataverseApiRequestRaw(
|
||||
ctx,
|
||||
method,
|
||||
resource,
|
||||
body,
|
||||
qs,
|
||||
headers,
|
||||
credentialType,
|
||||
)) as IDataObject;
|
||||
} catch (error) {
|
||||
throw new NodeApiError(ctx.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
export async function dataverseApiRequestWithResponse(
|
||||
ctx: IExecuteFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
body: IDataObject = {},
|
||||
qs: DataverseQuery = {},
|
||||
headers: DataverseHeaders = {},
|
||||
credentialType: string,
|
||||
): Promise<DataverseFullResponse> {
|
||||
try {
|
||||
return (await dataverseApiRequestRaw(
|
||||
ctx,
|
||||
method,
|
||||
resource,
|
||||
body,
|
||||
qs,
|
||||
headers,
|
||||
credentialType,
|
||||
true,
|
||||
)) as DataverseFullResponse;
|
||||
} catch (error) {
|
||||
throw new NodeApiError(ctx.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all pages of a Dataverse collection, following ``@odata.nextLink``.
|
||||
*
|
||||
* Dataverse controls the page size unless the caller explicitly supplies a
|
||||
* `Prefer` header. All headers are forwarded as-is. Each page dispatch uses
|
||||
* the same retry-with-back-off policy as single-shot requests.
|
||||
*/
|
||||
export async function dataverseApiRequestAllItems(
|
||||
ctx: IExecuteFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
qs: DataverseQuery = {},
|
||||
limit = 0,
|
||||
credentialType: string,
|
||||
extraHeaders: DataverseHeaders = {},
|
||||
): Promise<IDataObject[]> {
|
||||
const baseUrl = await resolveBaseUrl(ctx, credentialType);
|
||||
|
||||
const headers: DataverseHeaders = {
|
||||
...ODATA_DEFAULT_HEADERS,
|
||||
'User-Agent': buildUserAgent(ctx.getNode().typeVersion),
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
const results: IDataObject[] = [];
|
||||
let url = `${baseUrl}${DATAVERSE_API_PATH}${resource}`;
|
||||
let useQs: DataverseQuery | undefined = qs;
|
||||
|
||||
do {
|
||||
const options: IHttpRequestOptions = {
|
||||
method,
|
||||
url,
|
||||
qs: useQs,
|
||||
headers,
|
||||
json: true,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
let response: IDataObject;
|
||||
try {
|
||||
response = (await dispatchWithRetry(ctx, credentialType, options)) as IDataObject;
|
||||
} catch (error) {
|
||||
throw new NodeApiError(ctx.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
const page = (response.value as IDataObject[]) ?? [];
|
||||
for (const item of page) {
|
||||
results.push(item);
|
||||
if (limit > 0 && results.length >= limit) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
url = (response['@odata.nextLink'] as string) ?? '';
|
||||
// nextLink already encodes the query string, so don't resend qs.
|
||||
useQs = undefined;
|
||||
} while (url);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"node": "n8n-nodes-base.microsoftDataverse",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage", "Finance & Accounting", "Sales", "Development"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/microsoft/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.microsoftdataverse/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getEntitySets,
|
||||
getReadColumns,
|
||||
getWriteColumns,
|
||||
searchEntitySets,
|
||||
searchRows,
|
||||
} from './loadOptions';
|
||||
import { RECORD_OPERATIONS, resolveOperation, toDropdownOption } from './operations';
|
||||
|
||||
export class MicrosoftDataverse implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Microsoft Dataverse',
|
||||
name: 'microsoftDataverse',
|
||||
icon: 'file:microsoftDataverse.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Interact with the Microsoft Dataverse Web API',
|
||||
defaults: { name: 'Microsoft Dataverse' },
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'microsoftDataverseOAuth2Api',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
default: 'row',
|
||||
options: [
|
||||
{
|
||||
name: 'Row',
|
||||
value: 'row',
|
||||
description: 'Read or write rows in a Dataverse table',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
default: 'getAll',
|
||||
displayOptions: { show: { resource: ['row'] } },
|
||||
// Generated from the RECORD_OPERATIONS registry so the op list lives in
|
||||
// exactly one place; the registry is ordered alphabetically by name.
|
||||
options: RECORD_OPERATIONS.map(toDropdownOption),
|
||||
},
|
||||
...RECORD_OPERATIONS.flatMap((op) => op.properties),
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
getEntitySets,
|
||||
getReadColumns,
|
||||
getWriteColumns,
|
||||
},
|
||||
listSearch: {
|
||||
searchEntitySets,
|
||||
searchRows,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
let out: INodeExecutionData[] = [];
|
||||
const liveOps = RECORD_OPERATIONS.map((o) => o.value);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
const op = resolveOperation(operation);
|
||||
if (!op) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Unsupported operation "${operation}". Expected one of: ${liveOps.join(', ')}`,
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
const result = await op.execute(this, i, 'microsoftDataverseOAuth2Api');
|
||||
const rows = Array.isArray(result) ? result : [result];
|
||||
const wrapped = this.helpers.returnJsonArray(rows as IDataObject[]);
|
||||
const meta = this.helpers.constructExecutionMetaData(wrapped, {
|
||||
itemData: { item: i },
|
||||
});
|
||||
out = out.concat(meta);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const err = error as Error;
|
||||
out = out.concat({
|
||||
json: { error: err.message ?? String(err) },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Op modules already wrap raw HTTP failures in NodeApiError /
|
||||
// NodeOperationError. Preserve the original type so n8n's UI shows
|
||||
// the right context.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [out];
|
||||
}
|
||||
}
|
||||
28
packages/nodes-base/nodes/Microsoft/Dataverse/constants.ts
Normal file
28
packages/nodes-base/nodes/Microsoft/Dataverse/constants.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Shared constants for the Microsoft Dataverse node.
|
||||
*
|
||||
* Keeping these in one place means a future Web API version bump (or
|
||||
* scope-policy change) touches a single file instead of scattered string
|
||||
* literals across credentials, GenericFunctions, and operation modules.
|
||||
*/
|
||||
|
||||
/** Web API base path. Update here when Microsoft ships a new version. */
|
||||
export const DATAVERSE_API_PATH = '/api/data/v9.2';
|
||||
|
||||
/**
|
||||
* Product token for the `User-Agent` sent on every Dataverse Web API request.
|
||||
* A descriptive agent string lets Microsoft correlate traffic to this node in
|
||||
* support/telemetry scenarios.
|
||||
*/
|
||||
export const USER_AGENT_PREFIX = 'n8n-nodes-base.microsoftDataverse';
|
||||
|
||||
/**
|
||||
* Build the `User-Agent` including the node's version so requests are
|
||||
* attributable to a specific node version (e.g. `n8n-nodes-base.microsoftDataverse/1.0`).
|
||||
* Pass `this.getNode().typeVersion` from the execution/load-options context.
|
||||
* A whole-number version is normalized to include a minor (`1` -> `1.0`).
|
||||
*/
|
||||
export function buildUserAgent(version: number | string): string {
|
||||
const normalized = Number.isInteger(Number(version)) ? `${Number(version)}.0` : `${version}`;
|
||||
return `${USER_AGENT_PREFIX}/${normalized}`;
|
||||
}
|
||||
414
packages/nodes-base/nodes/Microsoft/Dataverse/loadOptions.ts
Normal file
414
packages/nodes-base/nodes/Microsoft/Dataverse/loadOptions.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import type {
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchResult,
|
||||
INodePropertyOptions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { DATAVERSE_API_PATH } from './constants';
|
||||
import {
|
||||
dataverseApiRequestRaw,
|
||||
type DataverseHeaders,
|
||||
type DataverseQuery,
|
||||
} from './GenericFunctions';
|
||||
import { isValidEntitySet, normalizeEntitySet } from './operations/shared';
|
||||
|
||||
/**
|
||||
* `loadOptions` handlers for the Dataverse node. These power the in-editor
|
||||
* pickers (tables and columns) so users don't have to memorize entity-set
|
||||
* names or column logical names.
|
||||
*
|
||||
* Each handler makes one or more authenticated GET requests against the Dataverse Web
|
||||
* API metadata endpoints and returns the result as `{ name, value }`
|
||||
* options. n8n caches the result per parameter, so the request only runs
|
||||
* when the user opens the dropdown or when a dependent parameter changes.
|
||||
*
|
||||
* Failures are re-thrown as `NodeApiError` so the editor surfaces the real
|
||||
* HTTP error in the dropdown (auth, scope, environment URL, ...) instead of
|
||||
* silently rendering an empty list — which is indistinguishable from a
|
||||
* table with no entities.
|
||||
*/
|
||||
|
||||
interface EntityDefinition {
|
||||
LogicalName: string;
|
||||
EntitySetName: string | null;
|
||||
PrimaryIdAttribute?: string;
|
||||
PrimaryNameAttribute?: string;
|
||||
DisplayName?: {
|
||||
UserLocalizedLabel?: { Label?: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface AttributeDefinition {
|
||||
LogicalName: string;
|
||||
AttributeOf?: string | null;
|
||||
IsValidODataAttribute?: boolean;
|
||||
IsValidForRead?: boolean;
|
||||
IsValidForCreate?: boolean;
|
||||
IsValidForUpdate?: boolean;
|
||||
AttributeType?: string;
|
||||
DisplayName?: {
|
||||
UserLocalizedLabel?: { Label?: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface ODataCollection<T> {
|
||||
value: T[];
|
||||
}
|
||||
|
||||
function labelOf(def: { DisplayName?: { UserLocalizedLabel?: { Label?: string } } }):
|
||||
| string
|
||||
| null {
|
||||
// Treat blank / whitespace labels as missing so the caller falls through
|
||||
// to the LogicalName rather than rendering an ugly `" (accounts)"`.
|
||||
const raw = def.DisplayName?.UserLocalizedLabel?.Label;
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parameterValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'object' && value !== null && 'value' in value) {
|
||||
return typeof value.value === 'string' ? value.value.trim() : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function dataverseGet<T>(
|
||||
ctx: ILoadOptionsFunctions,
|
||||
path: string,
|
||||
qs: DataverseQuery = {},
|
||||
headers: DataverseHeaders = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
// Delegate base-URL resolution, OData headers, User-Agent, and
|
||||
// transient-failure retries to the shared request helper. The `Raw` variant
|
||||
// skips the extra NodeApiError wrap that `dataverseApiRequest` adds, but
|
||||
// `httpRequestWithAuthentication` still pre-wraps the failure in one; we
|
||||
// unwrap it below so our extracted message survives.
|
||||
return (await dataverseApiRequestRaw(
|
||||
ctx,
|
||||
'GET',
|
||||
path,
|
||||
{},
|
||||
qs,
|
||||
headers,
|
||||
'microsoftDataverseOAuth2Api',
|
||||
)) as T;
|
||||
} catch (error) {
|
||||
// `httpRequestWithAuthentication` wraps the upstream HTTP failure in a
|
||||
// NodeApiError. The real Dataverse text can live in any of:
|
||||
// - `error.description` — n8n's own NodeApiError text field, which
|
||||
// usually holds the parsed upstream error message.
|
||||
// - `error.cause.response.body` — axios envelope under the wrapper.
|
||||
// - `error.response.body` — older n8n shape.
|
||||
// Crucially, n8n sets `error.message = "Bad request - please check your
|
||||
// parameters"` for any 4xx, which is useless on its own. And Dataverse
|
||||
// metadata endpoints can return the OData v3 `message: { lang, value }`
|
||||
// shape, which would render as "[object Object]" if interpolated raw.
|
||||
const { status, dvCode, dvMessage } = extractDataverseError(error);
|
||||
const prefix = `Failed to load options from Dataverse${status ? ` (HTTP ${status})` : ''}`;
|
||||
const codePart = dvCode ? ` [${dvCode}]` : '';
|
||||
const message = `${prefix}${codePart}: ${dvMessage}`;
|
||||
const description = `GET ${DATAVERSE_API_PATH}${path}`;
|
||||
// The failure is already a NodeApiError; its constructor short-circuits when
|
||||
// re-wrapped (ignoring message/description), so mutate it in place instead.
|
||||
if (error instanceof NodeApiError) {
|
||||
error.message = message;
|
||||
error.description = description;
|
||||
throw error;
|
||||
}
|
||||
throw new NodeApiError(ctx.getNode(), error as JsonObject, { message, description });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill through n8n's HTTP wrapper, axios's response envelope, and
|
||||
* Dataverse's two error-body shapes (v9.x string-message vs legacy
|
||||
* v3 object-message) to recover the actual `(httpStatus, errorCode,
|
||||
* humanMessage)` triple. Always returns strings — never an object — so
|
||||
* the caller can interpolate without producing "[object Object]".
|
||||
*/
|
||||
function extractDataverseError(error: unknown): {
|
||||
status: string;
|
||||
dvCode: string;
|
||||
dvMessage: string;
|
||||
} {
|
||||
const err = error as {
|
||||
message?: unknown;
|
||||
description?: unknown;
|
||||
statusCode?: number | string;
|
||||
httpCode?: number | string;
|
||||
cause?: {
|
||||
response?: { body?: unknown; statusCode?: number };
|
||||
statusCode?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
response?: { body?: unknown; statusCode?: number };
|
||||
};
|
||||
const status = String(
|
||||
err.httpCode ?? err.statusCode ?? err.cause?.statusCode ?? err.response?.statusCode ?? '',
|
||||
);
|
||||
// Walk every place a Dataverse JSON body could live.
|
||||
const candidates: unknown[] = [
|
||||
err.response?.body,
|
||||
err.cause?.response?.body,
|
||||
err.cause?.body,
|
||||
(err as { error?: unknown }).error,
|
||||
];
|
||||
for (const body of candidates) {
|
||||
const parsed = parseDvBody(body);
|
||||
if (parsed.message) return { status, dvCode: parsed.code, dvMessage: parsed.message };
|
||||
}
|
||||
// n8n's NodeApiError populates `.description` with the upstream error text
|
||||
// even when the body isn't reachable via response/cause (e.g. it pre-parsed
|
||||
// and discarded the body). Treat the wrapper's own message as a last resort.
|
||||
if (typeof err.description === 'string' && err.description) {
|
||||
return { status, dvCode: '', dvMessage: err.description };
|
||||
}
|
||||
const fallback = typeof err.message === 'string' ? err.message : safeStringify(err.message);
|
||||
return { status, dvCode: '', dvMessage: fallback || 'unknown error' };
|
||||
}
|
||||
|
||||
function parseDvBody(body: unknown): { code: string; message: string } {
|
||||
if (!body) return { code: '', message: '' };
|
||||
let obj: { error?: { code?: string; message?: unknown }; message?: unknown } | undefined;
|
||||
if (typeof body === 'string') {
|
||||
// Body might be a raw JSON string n8n didn't parse.
|
||||
try {
|
||||
obj = JSON.parse(body) as typeof obj;
|
||||
} catch {
|
||||
return { code: '', message: body };
|
||||
}
|
||||
} else if (typeof body === 'object') {
|
||||
obj = body as typeof obj;
|
||||
}
|
||||
if (!obj) return { code: '', message: '' };
|
||||
const code = obj.error?.code ?? '';
|
||||
const rawMessage = obj.error?.message ?? obj.message;
|
||||
let message = '';
|
||||
if (typeof rawMessage === 'string') {
|
||||
message = rawMessage;
|
||||
} else if (rawMessage && typeof rawMessage === 'object') {
|
||||
// Legacy OData v3 shape: { lang, value }
|
||||
const v = (rawMessage as { value?: unknown }).value;
|
||||
if (typeof v === 'string') message = v;
|
||||
else message = safeStringify(rawMessage);
|
||||
}
|
||||
return { code: String(code ?? ''), message };
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List every Dataverse table the user is likely to want to interact with.
|
||||
*
|
||||
* We deliberately keep the server-side `$filter` to simple primitive
|
||||
* Booleans only (`IsIntersect`, `IsLogicalEntity`, `IsPrivate`) — Dataverse
|
||||
* environments differ in whether they accept `$filter` against complex
|
||||
* managed-properties such as `IsValidForAdvancedFind/Value` and several
|
||||
* environments return 400 Bad Request for that shape. Everything else
|
||||
* (entities without an EntitySetName, system audit/sync tables, etc.) is
|
||||
* filtered client-side.
|
||||
*
|
||||
* Note: the Dataverse metadata API (`EntityDefinitions`) does NOT support
|
||||
* `$top`, `$skip`, `$count`, `$orderby`, `$search`, or `$apply` — only
|
||||
* `$select`, `$filter`, and `$expand`. Asking for `$top=500` returns
|
||||
* `400 Bad Request: "The query parameter $top is not supported"`. We
|
||||
* therefore fetch the full filtered set and sort it client-side.
|
||||
*
|
||||
* The dropdown is sorted by display name to match the n8n editor's other
|
||||
* table pickers (Airtable, Postgres, etc.). The full filtered set is
|
||||
* returned — the `$select` projection keeps each row tiny (3 fields), so
|
||||
* even large environments (800+ tables) stay well within a manageable
|
||||
* payload.
|
||||
*/
|
||||
export async function getEntitySets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const response = await dataverseGet<ODataCollection<EntityDefinition>>(
|
||||
this,
|
||||
'/EntityDefinitions',
|
||||
{
|
||||
// Only project fields we actually consume client-side. The three
|
||||
// `Is*` booleans are filtered server-side via `$filter`, so there's
|
||||
// no reason to ship them back over the wire.
|
||||
$select: 'LogicalName,EntitySetName,DisplayName',
|
||||
$filter: 'IsIntersect eq false and IsLogicalEntity eq false and IsPrivate eq false',
|
||||
},
|
||||
);
|
||||
const options: INodePropertyOptions[] = [];
|
||||
for (const def of response.value ?? []) {
|
||||
if (!def.EntitySetName) continue;
|
||||
const label = labelOf(def) ?? def.LogicalName;
|
||||
options.push({
|
||||
name: `${label} (${def.EntitySetName})`,
|
||||
value: def.EntitySetName,
|
||||
description: `Logical name: ${def.LogicalName}`,
|
||||
});
|
||||
}
|
||||
options.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function searchEntitySets(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const filterLower = filter?.trim().toLowerCase();
|
||||
const options = await getEntitySets.call(this);
|
||||
const results = options
|
||||
.filter((option) => !filterLower || option.name.toLowerCase().includes(filterLower))
|
||||
.map(({ name, value }) => ({ name, value }));
|
||||
return { results };
|
||||
}
|
||||
|
||||
export async function searchRows(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const entitySet = normalizeEntitySet(this.getCurrentNodeParameter('entitySet'));
|
||||
if (!entitySet) return { results: [] };
|
||||
if (!isValidEntitySet(entitySet)) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: 'Table name must be a valid Dataverse table name',
|
||||
} as JsonObject);
|
||||
}
|
||||
|
||||
// Resolve the primary id/name attributes used to map rows. Runs on every
|
||||
// invocation, including each pagination page: the nextLink is opaque to us, so
|
||||
// we can't thread these through it — one extra metadata GET per page.
|
||||
const metadata = await dataverseGet<ODataCollection<EntityDefinition>>(
|
||||
this,
|
||||
'/EntityDefinitions',
|
||||
{
|
||||
$select: 'PrimaryIdAttribute,PrimaryNameAttribute',
|
||||
$filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`,
|
||||
},
|
||||
);
|
||||
const { PrimaryIdAttribute: idAttribute, PrimaryNameAttribute: nameAttribute } =
|
||||
metadata.value?.[0] ?? {};
|
||||
if (!idAttribute) return { results: [] };
|
||||
|
||||
// `Prefer: odata.maxpagesize` gives server-driven paging with an
|
||||
// `@odata.nextLink`, unlike `$top`, which hard-caps the result set with no
|
||||
// continuation and silently hides rows past the cap.
|
||||
const pageHeaders: DataverseHeaders = { Prefer: 'odata.maxpagesize=100' };
|
||||
type RowPage = ODataCollection<Record<string, unknown>> & { '@odata.nextLink'?: string };
|
||||
let response: RowPage;
|
||||
if (paginationToken) {
|
||||
// The nextLink already encodes `$select`/`$filter` and the page cursor.
|
||||
response = await dataverseGet<RowPage>(this, paginationToken, {}, pageHeaders);
|
||||
} else {
|
||||
const qs: DataverseQuery = {
|
||||
$select: nameAttribute ? `${idAttribute},${nameAttribute}` : idAttribute,
|
||||
};
|
||||
const trimmedFilter = filter?.trim();
|
||||
if (trimmedFilter && nameAttribute) {
|
||||
qs.$filter = `contains(${nameAttribute},'${trimmedFilter.replace(/'/g, "''")}')`;
|
||||
}
|
||||
response = await dataverseGet<RowPage>(this, `/${entitySet}`, qs, pageHeaders);
|
||||
}
|
||||
|
||||
const results = (response.value ?? []).flatMap((row) => {
|
||||
const id = row[idAttribute];
|
||||
if (typeof id !== 'string' || !id) return [];
|
||||
const name = nameAttribute ? row[nameAttribute] : undefined;
|
||||
return [{ name: typeof name === 'string' && name ? `${name} (${id})` : id, value: id }];
|
||||
});
|
||||
const nextLink = response['@odata.nextLink'];
|
||||
return typeof nextLink === 'string' && nextLink
|
||||
? { results, paginationToken: nextLink }
|
||||
: { results };
|
||||
}
|
||||
|
||||
/**
|
||||
* List columns for the currently-selected table. Requires the caller to set
|
||||
* `loadOptionsDependsOn: ['entitySet']` so the picker reloads when the user
|
||||
* changes tables.
|
||||
*
|
||||
* The current `entitySet` parameter holds the plural URL name (e.g.
|
||||
* `accounts`). The Dataverse metadata API addresses tables by their singular
|
||||
* `LogicalName`, so we look up the LogicalName first, then request
|
||||
* attributes. Two short metadata calls.
|
||||
*/
|
||||
async function getColumns(
|
||||
ctx: ILoadOptionsFunctions,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const entitySet = parameterValue(ctx.getCurrentNodeParameter('entitySet'));
|
||||
if (!entitySet) return [];
|
||||
// `EntitySetName eq '<value>'` — the single quotes are OData literal
|
||||
// syntax (not URL syntax), so they go in the value verbatim. The HTTP
|
||||
// layer URL-encodes spaces/quotes; do not pre-encode.
|
||||
const lookup = await dataverseGet<ODataCollection<{ LogicalName: string }>>(
|
||||
ctx,
|
||||
'/EntityDefinitions',
|
||||
{
|
||||
$select: 'LogicalName',
|
||||
$filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`,
|
||||
},
|
||||
);
|
||||
const logicalName = lookup.value?.[0]?.LogicalName;
|
||||
if (!logicalName) return [];
|
||||
// Logical names are restricted to [a-z0-9_], so no encoding needed for the
|
||||
// path segment; the HTTP layer encodes the single quotes around it.
|
||||
const attrs = await dataverseGet<ODataCollection<AttributeDefinition>>(
|
||||
ctx,
|
||||
`/EntityDefinitions(LogicalName='${logicalName}')/Attributes`,
|
||||
{
|
||||
$select:
|
||||
'LogicalName,DisplayName,AttributeOf,IsValidODataAttribute,IsValidForRead,IsValidForCreate,IsValidForUpdate,AttributeType',
|
||||
},
|
||||
);
|
||||
const operation = parameterValue(ctx.getCurrentNodeParameter('operation'));
|
||||
const options: INodePropertyOptions[] = [];
|
||||
for (const attr of attrs.value ?? []) {
|
||||
// Skip virtual sub-attributes (e.g. <lookup>name, <lookup>yominame) —
|
||||
// they have an AttributeOf set and aren't queryable in their own right.
|
||||
if (attr.AttributeOf) continue;
|
||||
if (attr.IsValidODataAttribute === false) continue;
|
||||
if (mode === 'read' && attr.IsValidForRead === false) continue;
|
||||
if (mode === 'write') {
|
||||
if (operation === 'create' && attr.IsValidForCreate !== true) continue;
|
||||
if (operation === 'update' && attr.IsValidForUpdate !== true) continue;
|
||||
if (
|
||||
operation !== 'create' &&
|
||||
operation !== 'update' &&
|
||||
(attr.IsValidForCreate !== true || attr.IsValidForUpdate !== true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const label = labelOf(attr) ?? attr.LogicalName;
|
||||
const isLookup =
|
||||
attr.AttributeType === 'Lookup' ||
|
||||
attr.AttributeType === 'Customer' ||
|
||||
attr.AttributeType === 'Owner';
|
||||
const name = isLookup
|
||||
? `${label} (${attr.LogicalName}) — lookup`
|
||||
: `${label} (${attr.LogicalName})`;
|
||||
const value = mode === 'read' && isLookup ? `_${attr.LogicalName}_value` : attr.LogicalName;
|
||||
options.push({ name, value });
|
||||
}
|
||||
options.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function getReadColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
return await getColumns(this, 'read');
|
||||
}
|
||||
|
||||
export async function getWriteColumns(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
return await getColumns(this, 'write');
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4303_1230)">
|
||||
<g clip-path="url(#clip1_4303_1230)">
|
||||
<g clip-path="url(#clip2_4303_1230)">
|
||||
<path d="M13.8776 21.8242C29.1033 8.13791 49.7501 8.1861 62.955 18.9134C74.9816 28.6836 77.4697 44.3159 70.851 55.7801C64.2321 67.2443 52.5277 70.1455 39.5011 62.6247L31.7286 76.087L31.7234 76.0862C27.4181 83.5324 17.8937 86.0828 10.4437 81.7817C7.45394 80.0556 5.25322 77.4879 3.96665 74.551L3.96096 74.5511C-4.07832 55.7804 0.200745 34.1184 13.8776 21.8242Z" fill="url(#paint0_radial_4303_1230)"/>
|
||||
<path d="M13.8776 21.8242C29.1033 8.13791 49.7501 8.1861 62.955 18.9134C74.9816 28.6836 77.4697 44.3159 70.851 55.7801C64.2321 67.2443 52.5277 70.1455 39.5011 62.6247L31.7286 76.087L31.7234 76.0862C27.4181 83.5324 17.8937 86.0828 10.4437 81.7817C7.45394 80.0556 5.25322 77.4879 3.96665 74.551L3.96096 74.5511C-4.07832 55.7804 0.200745 34.1184 13.8776 21.8242Z" fill="url(#paint1_radial_4303_1230)" fill-opacity="0.8"/>
|
||||
<path d="M85.4327 14.2231C88.4528 15.9668 90.6686 18.569 91.9494 21.5433L91.9533 21.5444C99.9406 40.2943 95.6533 61.9068 81.9983 74.1814C66.7726 87.8677 46.1257 87.8196 32.9209 77.0923C20.8945 67.3221 18.4062 51.6897 25.0249 40.2256C31.6438 28.7614 43.3482 25.8601 56.3748 33.381L64.1434 19.9255L64.1482 19.9249C68.4516 12.4736 77.9805 9.92084 85.4327 14.2231Z" fill="url(#paint2_radial_4303_1230)"/>
|
||||
<path d="M85.4327 14.2231C88.4528 15.9668 90.6686 18.569 91.9494 21.5433L91.9533 21.5444C99.9406 40.2943 95.6533 61.9068 81.9983 74.1814C66.7726 87.8677 46.1257 87.8196 32.9209 77.0923C20.8945 67.3221 18.4062 51.6897 25.0249 40.2256C31.6438 28.7614 43.3482 25.8601 56.3748 33.381L64.1434 19.9255L64.1482 19.9249C68.4516 12.4736 77.9805 9.92084 85.4327 14.2231Z" fill="url(#paint3_radial_4303_1230)" fill-opacity="0.9"/>
|
||||
<path d="M39.5041 62.6261C52.5307 70.1469 64.2352 67.2456 70.8541 55.7814C77.2488 44.7055 75.1426 29.7389 64.147 19.9271L56.3791 33.3814L39.5041 62.6261Z" fill="url(#paint4_radial_4303_1230)"/>
|
||||
<path d="M56.3794 33.3815C43.3528 25.8607 31.6482 28.762 25.0294 40.2262C18.6347 51.3021 20.7409 66.2687 31.7364 76.0806L39.5043 62.6262L56.3794 33.3815Z" fill="url(#paint5_radial_4303_1230)"/>
|
||||
<path d="M33.3215 56.4453C37.9837 64.5204 48.3094 67.2872 56.3846 62.625C64.4598 57.9628 67.2266 47.6371 62.5643 39.5619C57.9021 31.4867 47.5764 28.72 39.5013 33.3822C31.4261 38.0444 28.6593 48.3701 33.3215 56.4453Z" fill="url(#paint6_radial_4303_1230)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(46.0001 49.4996) rotate(-148.717) scale(46.2195 47.5359)">
|
||||
<stop offset="0.465088" stop-color="#09442A"/>
|
||||
<stop offset="0.70088" stop-color="#136C6C"/>
|
||||
<stop offset="1" stop-color="#22918B"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(50.0001 32.4996) rotate(123.57) scale(66.0095 46.5498)">
|
||||
<stop offset="0.718705" stop-color="#1A7F7C" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#16BBDA"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(50.4999 44.5001) rotate(30.75) scale(45.9618 44.5095)">
|
||||
<stop offset="0.358097" stop-color="#136C6C"/>
|
||||
<stop offset="0.789474" stop-color="#42B870"/>
|
||||
<stop offset="1" stop-color="#76D45E"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint3_radial_4303_1230" cx="0" cy="0" r="1" gradientTransform="matrix(42.5 -36.0002 31.1824 36.8127 49.4998 55.5001)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.583166" stop-color="#76D45E" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#C8F5B7"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(47.5 48) rotate(-58.9042) scale(32.6898)">
|
||||
<stop offset="0.486266" stop-color="#22918B"/>
|
||||
<stop offset="0.729599" stop-color="#42B870"/>
|
||||
<stop offset="1" stop-color="#43E5CA"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint5_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(47.3833 49.0077) rotate(119.859) scale(31.1328 29.4032)">
|
||||
<stop offset="0.459553" stop-color="#08494E"/>
|
||||
<stop offset="0.742242" stop-color="#1A7F7C"/>
|
||||
<stop offset="1" stop-color="#309C61"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint6_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(52.5 40) rotate(120.784) scale(27.3542)">
|
||||
<stop stop-color="#C8F5B7"/>
|
||||
<stop offset="0.24583" stop-color="#98F0B0"/>
|
||||
<stop offset="0.643961" stop-color="#52D17C"/>
|
||||
<stop offset="1" stop-color="#119FC5"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_4303_1230">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_4303_1230">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_4303_1230">
|
||||
<rect width="95.9998" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
|
@ -0,0 +1,60 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import type { OperationDefinition } from './types';
|
||||
import {
|
||||
applyLookupBindings,
|
||||
bodyHasLookupCandidates,
|
||||
EMPTY_LOOKUP_FIELDS,
|
||||
resolveLookupFields,
|
||||
} from './lookups';
|
||||
import { assertNonEmptyBody, assertValidEntitySet, executeRequest, parseItemInput } from './shared';
|
||||
import {
|
||||
buildOptionsCollection,
|
||||
commonEntitySetProperty,
|
||||
commonReturnFullMetadataOption,
|
||||
commonReturnSessionTokenOption,
|
||||
commonRowItemProperties,
|
||||
commonSessionTokenOption,
|
||||
} from './sharedProperties';
|
||||
|
||||
/**
|
||||
* dv connector — "Add a new row" (`CreateRecord`).
|
||||
*
|
||||
* `POST /{entitySet}` with `Prefer: return=representation` so the created
|
||||
* record (including the server-generated GUID) is echoed back to the user.
|
||||
*/
|
||||
export const createRow: OperationDefinition = {
|
||||
displayName: 'Create',
|
||||
value: 'create',
|
||||
description: 'Add a new row to a Microsoft Dataverse table',
|
||||
action: 'Create a row',
|
||||
properties: [
|
||||
commonEntitySetProperty(['create']),
|
||||
...commonRowItemProperties(['create']),
|
||||
buildOptionsCollection('create', [
|
||||
commonReturnFullMetadataOption(),
|
||||
commonSessionTokenOption(),
|
||||
commonReturnSessionTokenOption(),
|
||||
]),
|
||||
],
|
||||
async execute(ctx, i, credentialType) {
|
||||
const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i));
|
||||
// Validate before resolving lookup metadata so an empty Row Item fails fast
|
||||
// without spending metadata requests. Lookup metadata is only resolved when the
|
||||
// body actually carries a lookup-style value, so a plain write stays a single
|
||||
// request and needs no metadata-read permission.
|
||||
const rawBody = assertNonEmptyBody(ctx, i, parseItemInput(ctx, i), 'Create');
|
||||
const lookupFields = bodyHasLookupCandidates(rawBody)
|
||||
? await resolveLookupFields(ctx, credentialType, entitySet)
|
||||
: EMPTY_LOOKUP_FIELDS;
|
||||
const body = applyLookupBindings(ctx, i, rawBody, lookupFields);
|
||||
const options = ctx.getNodeParameter('createOptions', i, {}) as IDataObject;
|
||||
return await executeRequest(ctx, credentialType, {
|
||||
method: 'POST',
|
||||
path: `/${entitySet}`,
|
||||
body,
|
||||
options,
|
||||
prefer: { returnRepresentation: true },
|
||||
returnSessionToken: Boolean(options.returnSessionToken),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import type { OperationDefinition } from './types';
|
||||
import {
|
||||
assertValidEntitySet,
|
||||
assertValidRecordId,
|
||||
buildRecordPath,
|
||||
executeRequest,
|
||||
} from './shared';
|
||||
import {
|
||||
buildOptionsCollection,
|
||||
commonEntitySetProperty,
|
||||
commonPartitionIdOption,
|
||||
commonRecordIdProperty,
|
||||
} from './sharedProperties';
|
||||
|
||||
/**
|
||||
* dv connector — "Delete a row" (`DeleteRecord`).
|
||||
*
|
||||
* `DELETE /{entitySet}({recordId})`. Dataverse responds 204 No Content on
|
||||
* success; we surface a small synthetic JSON payload so downstream nodes can
|
||||
* branch on `success`.
|
||||
*/
|
||||
export const deleteRow: OperationDefinition = {
|
||||
displayName: 'Delete',
|
||||
value: 'delete',
|
||||
description: 'Delete a row from a Microsoft Dataverse table',
|
||||
action: 'Delete a row',
|
||||
properties: [
|
||||
commonEntitySetProperty(['delete']),
|
||||
commonRecordIdProperty(['delete']),
|
||||
buildOptionsCollection('delete', [commonPartitionIdOption()]),
|
||||
],
|
||||
async execute(ctx, i, credentialType) {
|
||||
const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i));
|
||||
const recordId = assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i));
|
||||
await executeRequest(ctx, credentialType, {
|
||||
method: 'DELETE',
|
||||
path: buildRecordPath(entitySet, recordId),
|
||||
options: ctx.getNodeParameter('deleteOptions', i, {}) as IDataObject,
|
||||
});
|
||||
return { success: true, id: recordId };
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { type DataverseQuery } from '../GenericFunctions';
|
||||
import { assertValidEntitySet, buildODataQs, executeRequest } from './shared';
|
||||
import {
|
||||
buildOptionsCollection,
|
||||
commonEntitySetProperty,
|
||||
commonExpandOption,
|
||||
commonPartitionIdOption,
|
||||
commonReturnFullMetadataOption,
|
||||
commonSelectOption,
|
||||
commonSessionTokenOption,
|
||||
forOperation,
|
||||
} from './sharedProperties';
|
||||
import type { OperationDefinition } from './types';
|
||||
|
||||
const DATAVERSE_MAX_PAGE_SIZE = 5_000;
|
||||
|
||||
/**
|
||||
* dv connector — "Get Many" rows (`ListRecords`).
|
||||
*
|
||||
* `GET /{entitySet}` with the full Dataverse OData query surface:
|
||||
* `$select`, `$filter`, `$orderby`, `$expand`, and `$top`, plus FetchXml,
|
||||
* partitionId, and Return Full Metadata.
|
||||
*
|
||||
* Two paging modes:
|
||||
* - **Return All**: follows `@odata.nextLink` for OData queries until exhausted.
|
||||
* FetchXML queries cannot use Return All.
|
||||
* - **Limit**: stops after N rows (default 50). On the OData path, limits up to
|
||||
* 5,000 use `$top: N`; larger limits keep server-driven paging so Dataverse
|
||||
* can return more than one page. FetchXML uses client-side capping only.
|
||||
*/
|
||||
export const getManyRows: OperationDefinition = {
|
||||
displayName: 'Get Many',
|
||||
value: 'getAll',
|
||||
description: 'Get many rows in a Microsoft Dataverse table',
|
||||
action: 'Get many rows',
|
||||
properties: [
|
||||
commonEntitySetProperty(['getAll']),
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
displayOptions: forOperation(['getAll']),
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
typeOptions: { minValue: 1 },
|
||||
default: 50,
|
||||
description: 'Max number of results to return',
|
||||
displayOptions: {
|
||||
show: { ...forOperation(['getAll']).show, returnAll: [false] },
|
||||
},
|
||||
},
|
||||
buildOptionsCollection('getAll', [
|
||||
commonExpandOption(),
|
||||
{
|
||||
displayName: 'FetchXML Query',
|
||||
name: 'fetchXml',
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
placeholder: '<fetch>...</fetch>',
|
||||
description:
|
||||
'Advanced query — forwarded as ?fetchXml=. Overrides OData query options when set. Cannot be used with Return All.',
|
||||
},
|
||||
{
|
||||
displayName: 'Filter Rows',
|
||||
name: 'filter',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'statecode eq 0',
|
||||
description: 'OData $filter expression',
|
||||
},
|
||||
commonPartitionIdOption(),
|
||||
commonSessionTokenOption(),
|
||||
commonReturnFullMetadataOption(),
|
||||
{
|
||||
displayName: 'Row Count ($Top)',
|
||||
name: 'top',
|
||||
type: 'number',
|
||||
typeOptions: { minValue: 1 },
|
||||
default: 0,
|
||||
description:
|
||||
'Maximum number of rows Dataverse returns without paging. When unset, Limit is used here only when it is 5,000 or less.',
|
||||
},
|
||||
commonSelectOption(),
|
||||
{
|
||||
displayName: 'Sort Column Name or ID',
|
||||
name: 'orderbyColumn',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getReadColumns',
|
||||
loadOptionsDependsOn: ['entitySet.value'],
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Sort Direction',
|
||||
name: 'orderbyDirection',
|
||||
type: 'options',
|
||||
default: 'asc',
|
||||
options: [
|
||||
{ name: 'Ascending', value: 'asc' },
|
||||
{ name: 'Descending', value: 'desc' },
|
||||
],
|
||||
description: 'Direction for Sort Column. Ignored when Sort Column is empty.',
|
||||
},
|
||||
{
|
||||
displayName: 'Sort By Override',
|
||||
name: 'orderby',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'createdon desc, name asc',
|
||||
description:
|
||||
'Raw OData $orderby expression. Used only when Sort Column is empty — lets you pass multi-column sort or expressions the picker does not cover.',
|
||||
},
|
||||
]),
|
||||
],
|
||||
async execute(ctx, i, credentialType) {
|
||||
const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i));
|
||||
const returnAll = ctx.getNodeParameter('returnAll', i, false) as boolean;
|
||||
const limit = returnAll ? 0 : (ctx.getNodeParameter('limit', i, 50) as number);
|
||||
const options = ctx.getNodeParameter('getAllOptions', i, {}) as IDataObject;
|
||||
|
||||
const orderbyColumn = (options.orderbyColumn as string) ?? '';
|
||||
const orderbyDirection = (options.orderbyDirection as string) ?? 'asc';
|
||||
const orderby = orderbyColumn
|
||||
? `${orderbyColumn} ${orderbyDirection}`
|
||||
: ((options.orderby as string) ?? '');
|
||||
|
||||
const fetchXml = ((options.fetchXml as string) ?? '').trim();
|
||||
if (returnAll && fetchXml) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'FetchXML Query cannot be used with Return All because FetchXML pagination is not supported. Disable Return All and set a Limit instead.',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
const userTop = typeof options.top === 'number' && options.top > 0 ? options.top : undefined;
|
||||
// `$top` disables server-driven paging, so only derive it when the requested
|
||||
// limit fits within one standard Dataverse page. Explicit Row Count still
|
||||
// takes precedence, and FetchXML has no equivalent option.
|
||||
const limitTop = limit > 0 && limit <= DATAVERSE_MAX_PAGE_SIZE ? limit : undefined;
|
||||
const effectiveTop = userTop ?? limitTop;
|
||||
const qs: DataverseQuery = fetchXml
|
||||
? { fetchXml }
|
||||
: buildODataQs({
|
||||
select: options.select,
|
||||
filter: options.filter,
|
||||
orderby,
|
||||
expand: options.expand,
|
||||
top: effectiveTop,
|
||||
});
|
||||
|
||||
return await executeRequest(ctx, credentialType, {
|
||||
method: 'GET',
|
||||
path: `/${entitySet}`,
|
||||
qs,
|
||||
options,
|
||||
paged: true,
|
||||
limit,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import type { OperationDefinition } from './types';
|
||||
import {
|
||||
assertValidEntitySet,
|
||||
assertValidRecordId,
|
||||
buildODataQs,
|
||||
buildRecordPath,
|
||||
executeRequest,
|
||||
} from './shared';
|
||||
import {
|
||||
buildOptionsCollection,
|
||||
commonEntitySetProperty,
|
||||
commonExpandOption,
|
||||
commonPartitionIdOption,
|
||||
commonRecordIdProperty,
|
||||
commonReturnFullMetadataOption,
|
||||
commonSelectOption,
|
||||
commonSessionTokenOption,
|
||||
} from './sharedProperties';
|
||||
|
||||
/**
|
||||
* dv connector — "Get a row by ID" (`GetItem`).
|
||||
*
|
||||
* `GET /{entitySet}({recordId})` with optional `$select`, `$expand`,
|
||||
* partitionId, and Return Full Metadata.
|
||||
*/
|
||||
export const getRow: OperationDefinition = {
|
||||
displayName: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a single row by its primary key',
|
||||
action: 'Get a row',
|
||||
properties: [
|
||||
commonEntitySetProperty(['get']),
|
||||
commonRecordIdProperty(['get']),
|
||||
buildOptionsCollection('get', [
|
||||
commonSelectOption(),
|
||||
commonExpandOption(),
|
||||
commonReturnFullMetadataOption(),
|
||||
commonPartitionIdOption(),
|
||||
commonSessionTokenOption(),
|
||||
]),
|
||||
],
|
||||
async execute(ctx, i, credentialType) {
|
||||
const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i));
|
||||
const recordId = assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i));
|
||||
const options = ctx.getNodeParameter('getOptions', i, {}) as IDataObject;
|
||||
return await executeRequest(ctx, credentialType, {
|
||||
method: 'GET',
|
||||
path: buildRecordPath(entitySet, recordId),
|
||||
qs: buildODataQs({ select: options.select, expand: options.expand }),
|
||||
options,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Single registry of every record-level operation the node supports. Adding a
|
||||
* new operation = create a module under this folder and append it here.
|
||||
*/
|
||||
|
||||
import type { OperationDefinition } from './types';
|
||||
import { createRow } from './createRow';
|
||||
import { getRow } from './getRow';
|
||||
import { getManyRows } from './getManyRows';
|
||||
import { updateRow } from './updateRow';
|
||||
import { upsertRow } from './upsertRow';
|
||||
import { deleteRow } from './deleteRow';
|
||||
|
||||
export { toDropdownOption } from './types';
|
||||
|
||||
/** Display order is alphabetical by name, matching the n8n catalog convention. */
|
||||
export const RECORD_OPERATIONS: OperationDefinition[] = [
|
||||
createRow,
|
||||
upsertRow,
|
||||
deleteRow,
|
||||
getRow,
|
||||
getManyRows,
|
||||
updateRow,
|
||||
];
|
||||
|
||||
export const OPERATION_BY_VALUE: Record<string, OperationDefinition> = RECORD_OPERATIONS.reduce(
|
||||
(acc, op) => {
|
||||
acc[op.value] = op;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, OperationDefinition>,
|
||||
);
|
||||
|
||||
export function resolveOperation(value: string): OperationDefinition | undefined {
|
||||
return OPERATION_BY_VALUE[value];
|
||||
}
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError, setSafeObjectProperty } from 'n8n-workflow';
|
||||
|
||||
import { dataverseApiRequest } from '../GenericFunctions';
|
||||
import { isElasticTable, resolveTableMetadataByLogicalNames } from './metadata';
|
||||
import { hasUrlBreakingChars, normalizeEntitySet } from './shared';
|
||||
|
||||
/**
|
||||
* Lookup (relationship) column handling for write operations.
|
||||
*
|
||||
* Dataverse rejects a lookup's raw logical name in a create/update body. Writes
|
||||
* must go through the single-valued navigation property with the `@odata.bind`
|
||||
* annotation, e.g. `"primarycontactid@odata.bind": "/contacts(<guid>)"`. The
|
||||
* navigation property name is NOT always the logical name — for multi-table
|
||||
* (polymorphic) lookups such as Customer/Owner a single logical name maps to
|
||||
* several target-specific navigation properties (`customerid_account`,
|
||||
* `customerid_contact`, ...). We therefore resolve the navigation property and
|
||||
* target entity set from relationship metadata and rewrite the body key.
|
||||
*/
|
||||
|
||||
/** One writable target of a lookup column. Polymorphic lookups have several. */
|
||||
export interface LookupCandidate {
|
||||
/** Single-valued navigation property to bind (ReferencingEntityNavigationPropertyName). */
|
||||
navigationProperty: string;
|
||||
/** Referenced table logical name (e.g. `contact`). */
|
||||
referencedEntity: string;
|
||||
/** Referenced table entity-set name used in the bind path (e.g. `contacts`). */
|
||||
targetEntitySet: string;
|
||||
/** Target table classification reported by EntityMetadata.TableType. */
|
||||
tableType?: string;
|
||||
/** Target table primary key used in an elastic composite reference. */
|
||||
primaryIdAttribute?: string;
|
||||
}
|
||||
|
||||
/** Lookup logical name (lower-cased) → its writable navigation-property targets. */
|
||||
export type LookupFieldMap = Map<string, LookupCandidate[]>;
|
||||
|
||||
/** Shared read-only empty map for the common "no lookup candidates" fast path. */
|
||||
export const EMPTY_LOOKUP_FIELDS: LookupFieldMap = new Map();
|
||||
|
||||
interface RelationshipRow {
|
||||
ReferencingAttribute?: string;
|
||||
ReferencingEntityNavigationPropertyName?: string;
|
||||
ReferencedEntity?: string;
|
||||
}
|
||||
|
||||
const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// `entityset(<key>)` — a full navigation reference the user can paste verbatim.
|
||||
const ENTITYSET_PATH_PATTERN = /^\/?[a-z][a-z0-9_]*\([^)]+\)$/i;
|
||||
|
||||
/**
|
||||
* Abstract lookup targets Dataverse reports in relationship metadata but that
|
||||
* can't be bound directly. `ownerid` reports the abstract `owner` entity, yet
|
||||
* the Web API assigns it via `/systemusers(<id>)` or `/teams(<id>)`. Expanding
|
||||
* to the concrete members (sharing the one navigation property) lets both
|
||||
* documented forms validate and makes a bare GUID ambiguous — the same
|
||||
* disambiguation the Customer lookup already produces.
|
||||
*/
|
||||
const ABSTRACT_LOOKUP_TARGETS: Record<string, string[]> = {
|
||||
owner: ['systemuser', 'team'],
|
||||
};
|
||||
|
||||
// Per-execution cache so a Return All / multi-item write resolves the metadata
|
||||
// for a given table once, not once per item. Keyed by the execution context so
|
||||
// it clears automatically when the execution is garbage-collected.
|
||||
const cache = new WeakMap<object, Map<string, Promise<LookupFieldMap>>>();
|
||||
|
||||
/**
|
||||
* Resolve the lookup columns of `entitySet` to their navigation-property
|
||||
* targets, memoized per execution.
|
||||
*
|
||||
* Cost: up to three metadata requests per table (resolve the logical name, list
|
||||
* many-to-one relationships, resolve the referenced tables' entity-set names).
|
||||
* The per-execution {@link cache} collapses this to one resolution per table, so
|
||||
* a Return All / multi-item write pays it once, not once per item.
|
||||
*
|
||||
* Failure contract: metadata read is best-effort. An *empty result* (the table
|
||||
* isn't found, has no lookup columns, or the app lacks metadata-read permission
|
||||
* — a 401/403) yields an empty map; callers then send the body unchanged and
|
||||
* Dataverse surfaces its own error at write time. Any *other failed request*
|
||||
* (network / 5xx) propagates so the item fails loudly instead of silently
|
||||
* skipping lookup translation.
|
||||
*/
|
||||
export async function resolveLookupFields(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
entitySet: string,
|
||||
): Promise<LookupFieldMap> {
|
||||
const set = normalizeEntitySet(entitySet);
|
||||
let perCtx = cache.get(ctx);
|
||||
if (!perCtx) {
|
||||
perCtx = new Map();
|
||||
cache.set(ctx, perCtx);
|
||||
}
|
||||
const existing = perCtx.get(set);
|
||||
if (existing) return await existing;
|
||||
|
||||
const pending = buildLookupFieldMap(ctx, credentialType, set);
|
||||
perCtx.set(set, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
// Don't cache a failure — a later item should be able to retry.
|
||||
perCtx.delete(set);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildLookupFieldMap(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
entitySet: string,
|
||||
): Promise<LookupFieldMap> {
|
||||
try {
|
||||
return await collectLookupFieldMap(ctx, credentialType, entitySet);
|
||||
} catch (error) {
|
||||
// Metadata read is best-effort: a 401/403 (no metadata-read permission) falls
|
||||
// back to sending the body unchanged so Dataverse surfaces its own error.
|
||||
// Other failures (network / 5xx) propagate.
|
||||
if (isMetadataPermissionError(error)) return new Map();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed metadata request was purely a permission problem (401/403).
|
||||
* `dataverseApiRequest` wraps failures in `NodeApiError` (status on `httpCode`)
|
||||
* and keeps the original axios error under `cause`; check both.
|
||||
*/
|
||||
function isMetadataPermissionError(error: unknown): boolean {
|
||||
const sources = [
|
||||
error as { httpCode?: unknown; statusCode?: unknown } | null,
|
||||
(error as { cause?: unknown } | null)?.cause,
|
||||
];
|
||||
for (const src of sources) {
|
||||
if (!src) continue;
|
||||
const candidates = [
|
||||
(src as { httpCode?: unknown }).httpCode,
|
||||
(src as { statusCode?: unknown }).statusCode,
|
||||
(src as { response?: { status?: unknown } }).response?.status,
|
||||
];
|
||||
if (candidates.some((c) => c === 401 || c === 403 || c === '401' || c === '403')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function collectLookupFieldMap(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
entitySet: string,
|
||||
): Promise<LookupFieldMap> {
|
||||
const map: LookupFieldMap = new Map();
|
||||
|
||||
// 1. entity-set (plural) → table LogicalName. An unknown entity set yields no
|
||||
// match; degrade to an empty map (the write itself surfaces the real error).
|
||||
const logicalName = await resolveLogicalName(ctx, credentialType, entitySet);
|
||||
if (!logicalName) return map;
|
||||
|
||||
// 2. Many-to-one relationships — the lookup columns on this table.
|
||||
const rels = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
`/EntityDefinitions(LogicalName='${logicalName}')/ManyToOneRelationships`,
|
||||
{},
|
||||
{
|
||||
$select: 'ReferencingAttribute,ReferencingEntityNavigationPropertyName,ReferencedEntity',
|
||||
},
|
||||
{},
|
||||
credentialType,
|
||||
);
|
||||
const rows = (rels.value as RelationshipRow[] | undefined) ?? [];
|
||||
if (rows.length === 0) return map;
|
||||
|
||||
// 3. Referenced table logical names → entity-set names, in one batched call.
|
||||
// Abstract targets (e.g. `owner`) aren't bindable themselves; resolve their
|
||||
// concrete members instead so the documented bind paths validate.
|
||||
const referenced = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => row.ReferencedEntity)
|
||||
.filter((name): name is string => !!name)
|
||||
.flatMap((name) => ABSTRACT_LOOKUP_TARGETS[name.toLowerCase()] ?? [name]),
|
||||
),
|
||||
];
|
||||
const metadataByLogical = await resolveTableMetadataByLogicalNames(
|
||||
ctx,
|
||||
credentialType,
|
||||
referenced,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const attribute = row.ReferencingAttribute?.toLowerCase();
|
||||
const navigationProperty = row.ReferencingEntityNavigationPropertyName;
|
||||
const referencedEntity = row.ReferencedEntity;
|
||||
if (!attribute || !navigationProperty || !referencedEntity) continue;
|
||||
// Expand an abstract target (owner) into its concrete members (systemuser,
|
||||
// team), all sharing the single navigation property.
|
||||
const targets = ABSTRACT_LOOKUP_TARGETS[referencedEntity.toLowerCase()] ?? [referencedEntity];
|
||||
const candidates = map.get(attribute) ?? [];
|
||||
for (const target of targets) {
|
||||
const metadata = metadataByLogical.get(target);
|
||||
if (!metadata) continue;
|
||||
candidates.push({
|
||||
navigationProperty,
|
||||
referencedEntity: target,
|
||||
targetEntitySet: metadata.entitySetName,
|
||||
tableType: metadata.tableType,
|
||||
primaryIdAttribute: metadata.primaryIdAttribute,
|
||||
});
|
||||
}
|
||||
if (candidates.length > 0) map.set(attribute, candidates);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function resolveLogicalName(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
entitySet: string,
|
||||
): Promise<string | undefined> {
|
||||
const response = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/EntityDefinitions',
|
||||
{},
|
||||
{
|
||||
$select: 'LogicalName',
|
||||
$filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`,
|
||||
},
|
||||
{},
|
||||
credentialType,
|
||||
);
|
||||
const value = response.value as Array<{ LogicalName?: string }> | undefined;
|
||||
return value?.[0]?.LogicalName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pre-check: could `body` contain a value {@link applyLookupBindings} may
|
||||
* need relationship metadata to translate or validate? Returns true for any
|
||||
* non-`@odata.bind` key whose value is `null` (a possible disassociation) or a
|
||||
* non-empty string (which could be a lookup reference — or a *mistyped* one, a
|
||||
* plain name, that must still be validated). Numbers, booleans, empty strings,
|
||||
* and keys already carrying `@odata.bind` are never candidates.
|
||||
*
|
||||
* This lets a write op skip {@link resolveLookupFields} for a body with no such
|
||||
* values (numbers/booleans only, or `@odata.bind`-only). It errs toward
|
||||
* resolving: a false positive costs only a per-table cached metadata lookup,
|
||||
* whereas a false negative would forward a bad lookup value as a raw field and
|
||||
* surface Dataverse's opaque 400. Metadata read is best-effort
|
||||
* ({@link resolveLookupFields}), so this no longer forces metadata-read
|
||||
* permission on a plain write.
|
||||
*/
|
||||
export function bodyHasLookupCandidates(body: IDataObject): boolean {
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (key.includes('@odata.bind')) continue;
|
||||
if (value === null) return true;
|
||||
if (typeof value === 'string' && value.trim() !== '') return true;
|
||||
if (typeof value === 'object' && value !== null && 'id' in value) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite any lookup columns in `body` to their `@odata.bind` navigation-property
|
||||
* form. Non-lookup fields and keys that already carry `@odata.bind` are passed
|
||||
* through untouched. Throws a {@link NodeOperationError} for values that can't be
|
||||
* bound unambiguously (e.g. a bare GUID for a polymorphic lookup).
|
||||
*/
|
||||
export function applyLookupBindings(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
body: IDataObject,
|
||||
lookupFields: LookupFieldMap,
|
||||
): IDataObject {
|
||||
const out: IDataObject = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
// Advanced users can supply a ready-made bind key; leave it alone.
|
||||
if (key.includes('@odata.bind')) {
|
||||
setSafeObjectProperty(out, key, value);
|
||||
continue;
|
||||
}
|
||||
const candidates = lookupFields.get(key.toLowerCase());
|
||||
if (!candidates || candidates.length === 0) {
|
||||
setSafeObjectProperty(out, key, value);
|
||||
continue;
|
||||
}
|
||||
const binding = buildLookupBinding(ctx, itemIndex, key, value, candidates);
|
||||
if (binding.disassociate) {
|
||||
// Clear the relationship via the single-valued navigation property.
|
||||
setSafeObjectProperty(out, binding.navigationProperty, null);
|
||||
} else {
|
||||
setSafeObjectProperty(out, `${binding.navigationProperty}@odata.bind`, binding.path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
navigationProperty: string;
|
||||
path: string;
|
||||
disassociate: boolean;
|
||||
}
|
||||
|
||||
function buildLookupBinding(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
field: string,
|
||||
value: unknown,
|
||||
candidates: LookupCandidate[],
|
||||
): Binding {
|
||||
const single = candidates.length === 1 ? candidates[0] : undefined;
|
||||
const targets = candidates.map((candidate) => candidate.targetEntitySet).join(', ');
|
||||
|
||||
// null → disassociate. Only unambiguous for a single-target lookup.
|
||||
if (value === null) {
|
||||
if (!single) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Cannot clear the multi-table lookup "${field}" from a null value — it can point to ${targets}. Clear it via the specific navigation property instead.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return { navigationProperty: single.navigationProperty, path: '', disassociate: true };
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && 'id' in value) {
|
||||
if (!single) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup "${field}" can point to multiple tables (${targets}). Provide a complete "/entityset(key)" reference.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
const id = typeof value.id === 'string' ? value.id.trim() : '';
|
||||
if (!GUID_PATTERN.test(id)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" needs a valid row GUID.`,
|
||||
{
|
||||
itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!isElasticTable(single)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" only accepts an object value for an elastic table target.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
if (!('partitionId' in value)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" targets an elastic table. Provide both "id" and "partitionId".`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
if (value.partitionId === null) {
|
||||
return {
|
||||
navigationProperty: single.navigationProperty,
|
||||
path: `/${single.targetEntitySet}(${id})`,
|
||||
disassociate: false,
|
||||
};
|
||||
}
|
||||
const partitionId = typeof value.partitionId === 'string' ? value.partitionId.trim() : '';
|
||||
if (!partitionId || !single.primaryIdAttribute) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" needs a non-empty partition ID and target primary key metadata.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
if (hasUrlBreakingChars(partitionId)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" partition ID must not contain URL delimiters, path separators, or control characters.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return {
|
||||
navigationProperty: single.navigationProperty,
|
||||
path: `/${single.targetEntitySet}(${single.primaryIdAttribute}=${id},partitionid='${partitionId.replace(/'/g, "''")}')`,
|
||||
disassociate: false,
|
||||
};
|
||||
}
|
||||
|
||||
const raw = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" has an empty value. Provide a record GUID or a "/entityset(id)" reference.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
|
||||
// Full reference form: "/contacts(<id>)" or "contacts(<id>)". The pattern
|
||||
// already allows an optional leading slash, so it also rejects a malformed
|
||||
// slash-prefixed value like "/contacts" (no key) instead of forwarding it.
|
||||
if (ENTITYSET_PATH_PATTERN.test(raw)) {
|
||||
const path = raw.startsWith('/') ? raw : `/${raw}`;
|
||||
const targetEntitySet = path.slice(1, path.indexOf('('));
|
||||
// The reference's entity set must match one of the lookup's targets. Do NOT
|
||||
// fall back to `single` here: a single-target lookup given a reference to the
|
||||
// wrong table must fail node-side, not forward a mismatched path to Dataverse.
|
||||
const match = candidates.find((c) => c.targetEntitySet === targetEntitySet);
|
||||
if (!match) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`The target "${targetEntitySet}" for lookup "${field}" does not match any related table (${targets}).`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return { navigationProperty: match.navigationProperty, path, disassociate: false };
|
||||
}
|
||||
|
||||
// Bare GUID form — needs a single, unambiguous target table.
|
||||
if (GUID_PATTERN.test(raw)) {
|
||||
if (!single) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup "${field}" can point to multiple tables (${targets}). Provide a "/entityset(${raw})" reference so the target table is unambiguous.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
if (isElasticTable(single)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup "${field}" targets an elastic table. Provide { "id": "${raw}", "partitionId": "..." } or a complete "/entityset(key)" reference.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return {
|
||||
navigationProperty: single.navigationProperty,
|
||||
path: `/${single.targetEntitySet}(${raw})`,
|
||||
disassociate: false,
|
||||
};
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Lookup field "${field}" needs a record GUID or a "/entityset(id)" reference, got "${raw}".`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { dataverseApiRequest, type DataverseQuery } from '../GenericFunctions';
|
||||
import { normalizeEntitySet } from './shared';
|
||||
|
||||
export interface DataverseTableMetadata {
|
||||
logicalName: string;
|
||||
entitySetName: string;
|
||||
primaryIdAttribute?: string;
|
||||
tableType?: string;
|
||||
}
|
||||
|
||||
interface EntityDefinitionRow {
|
||||
LogicalName?: string;
|
||||
EntitySetName?: string;
|
||||
PrimaryIdAttribute?: string;
|
||||
TableType?: string;
|
||||
}
|
||||
|
||||
const entitySetCache = new WeakMap<
|
||||
object,
|
||||
Map<string, Promise<DataverseTableMetadata | undefined>>
|
||||
>();
|
||||
|
||||
export function isElasticTable(metadata: Pick<DataverseTableMetadata, 'tableType'>): boolean {
|
||||
return metadata.tableType?.toLowerCase() === 'elastic';
|
||||
}
|
||||
|
||||
export async function resolveTableMetadata(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
entitySet: string,
|
||||
): Promise<DataverseTableMetadata | undefined> {
|
||||
const normalizedEntitySet = normalizeEntitySet(entitySet);
|
||||
let perContext = entitySetCache.get(ctx);
|
||||
if (!perContext) {
|
||||
perContext = new Map();
|
||||
entitySetCache.set(ctx, perContext);
|
||||
}
|
||||
|
||||
const existing = perContext.get(normalizedEntitySet);
|
||||
if (existing) return await existing;
|
||||
|
||||
const pending = fetchTableMetadata(ctx, credentialType, normalizedEntitySet);
|
||||
perContext.set(normalizedEntitySet, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
perContext.delete(normalizedEntitySet);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTableMetadata(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
entitySet: string,
|
||||
): Promise<DataverseTableMetadata | undefined> {
|
||||
const response = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/EntityDefinitions',
|
||||
{},
|
||||
{
|
||||
$select: 'LogicalName,EntitySetName,PrimaryIdAttribute,TableType',
|
||||
$filter: `EntitySetName eq '${entitySet.replace(/'/g, "''")}'`,
|
||||
},
|
||||
{},
|
||||
credentialType,
|
||||
);
|
||||
const row = (response.value as EntityDefinitionRow[] | undefined)?.[0];
|
||||
return row ? toTableMetadata(row) : undefined;
|
||||
}
|
||||
|
||||
export async function resolveTableMetadataByLogicalNames(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
logicalNames: string[],
|
||||
): Promise<Map<string, DataverseTableMetadata>> {
|
||||
const result = new Map<string, DataverseTableMetadata>();
|
||||
const uniqueNames = [...new Set(logicalNames)];
|
||||
if (uniqueNames.length === 0) return result;
|
||||
|
||||
const filter = uniqueNames
|
||||
.map((name) => `LogicalName eq '${name.replace(/'/g, "''")}'`)
|
||||
.join(' or ');
|
||||
const response = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/EntityDefinitions',
|
||||
{},
|
||||
{
|
||||
$select: 'LogicalName,EntitySetName,PrimaryIdAttribute,TableType',
|
||||
$filter: filter,
|
||||
} as DataverseQuery,
|
||||
{},
|
||||
credentialType,
|
||||
);
|
||||
const rows = (response.value as EntityDefinitionRow[] | undefined) ?? [];
|
||||
for (const row of rows) {
|
||||
const metadata = toTableMetadata(row);
|
||||
if (metadata) result.set(metadata.logicalName, metadata);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toTableMetadata(row: EntityDefinitionRow): DataverseTableMetadata | undefined {
|
||||
if (!row.LogicalName || !row.EntitySetName) return undefined;
|
||||
return {
|
||||
logicalName: row.LogicalName,
|
||||
entitySetName: row.EntitySetName,
|
||||
primaryIdAttribute: row.PrimaryIdAttribute,
|
||||
tableType: row.TableType,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
import type { IDataObject, IExecuteFunctions, IHttpRequestMethods } from 'n8n-workflow';
|
||||
import { NodeOperationError, setSafeObjectProperty } from 'n8n-workflow';
|
||||
import {
|
||||
dataverseApiRequest,
|
||||
dataverseApiRequestAllItems,
|
||||
dataverseApiRequestWithResponse,
|
||||
type DataverseHeaders,
|
||||
type DataverseQuery,
|
||||
} from '../GenericFunctions';
|
||||
|
||||
/**
|
||||
* Names of OData system query options accepted by the Dataverse Web API in the
|
||||
* shape this module supports. Mirrors the dv connector (Power Automate) param
|
||||
* surface: `$select`, `$filter`, `$orderby`, `$expand`, `$top`.
|
||||
*/
|
||||
export type ODataOptionKey = 'select' | 'filter' | 'orderby' | 'expand' | 'top';
|
||||
|
||||
const OPTION_TO_QS: Record<ODataOptionKey, string> = {
|
||||
select: '$select',
|
||||
filter: '$filter',
|
||||
orderby: '$orderby',
|
||||
expand: '$expand',
|
||||
top: '$top',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate an options bag from the node UI into a Dataverse query string.
|
||||
*
|
||||
* Empty strings, undefined, and null values are dropped so the URL stays clean
|
||||
* and the server doesn't see stray `?$select=` parameters. Numeric values are
|
||||
* preserved so `$top` works.
|
||||
*
|
||||
* ```ts
|
||||
* buildODataQs({ select: 'name', filter: 'statecode eq 0' })
|
||||
* // → { $select: 'name', $filter: 'statecode eq 0' }
|
||||
* ```
|
||||
*/
|
||||
export function buildODataQs(options: Partial<Record<ODataOptionKey, unknown>>): DataverseQuery {
|
||||
const qs: DataverseQuery = {};
|
||||
for (const key of Object.keys(OPTION_TO_QS) as ODataOptionKey[]) {
|
||||
const value = options[key];
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue;
|
||||
qs[OPTION_TO_QS[key]] = value.join(',');
|
||||
continue;
|
||||
}
|
||||
qs[OPTION_TO_QS[key]] = value as string | number;
|
||||
}
|
||||
return qs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-mode item input — matches the dv connector's "Row Item" dynamic field
|
||||
* while staying ergonomic in n8n:
|
||||
*
|
||||
* - **JSON** (default): the user pastes / templates a record object.
|
||||
* - **Fields**: the user adds rows of `{ name, value }` pairs via a
|
||||
* `fixedCollection`.
|
||||
*
|
||||
* `value` strings are kept as-is so users can template lookup bindings
|
||||
* (`accountid@odata.bind`) without us mangling them.
|
||||
*/
|
||||
export interface FieldsCollection {
|
||||
field?: Array<{ name: string; value: unknown }>;
|
||||
}
|
||||
|
||||
export function parseItemInput(ctx: IExecuteFunctions, itemIndex: number): IDataObject {
|
||||
const mode = (ctx.getNodeParameter('inputMode', itemIndex, 'json') as string) ?? 'json';
|
||||
|
||||
if (mode === 'fields') {
|
||||
const raw = ctx.getNodeParameter('fieldsCollection', itemIndex, {}) as FieldsCollection;
|
||||
const out: IDataObject = {};
|
||||
for (const entry of raw.field ?? []) {
|
||||
if (!entry || !entry.name) continue;
|
||||
setSafeObjectProperty(out, entry.name, entry.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// JSON mode
|
||||
const raw = ctx.getNodeParameter('fieldsJson', itemIndex, {}) as string | IDataObject;
|
||||
if (raw === undefined || raw === null || raw === '') return {};
|
||||
if (typeof raw === 'object') return raw as IDataObject;
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Parameter "fieldsJson" must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return parsed as IDataObject;
|
||||
} catch (err) {
|
||||
// Already a NodeOperationError thrown by the validation above — re-throw as-is so the original itemIndex / message survive.
|
||||
if (err instanceof NodeOperationError) throw err;
|
||||
throw new NodeOperationError(ctx.getNode(), 'Parameter "fieldsJson" is not valid JSON', {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a `Prefer` header value. The dv connector's "Return Full Metadata"
|
||||
* toggle maps to OData annotations; we also let callers ask for the created /
|
||||
* updated row to be echoed back via `return=representation`.
|
||||
*/
|
||||
export interface PreferOptions {
|
||||
returnRepresentation?: boolean;
|
||||
includeFullMetadata?: boolean;
|
||||
maxPageSize?: number;
|
||||
extra?: string[];
|
||||
}
|
||||
|
||||
export function buildPreferHeader(opts: PreferOptions): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (opts.returnRepresentation) parts.push('return=representation');
|
||||
if (opts.includeFullMetadata) parts.push('odata.include-annotations="*"');
|
||||
if (typeof opts.maxPageSize === 'number' && opts.maxPageSize > 0) {
|
||||
parts.push(`odata.maxpagesize=${opts.maxPageSize}`);
|
||||
}
|
||||
if (opts.extra) parts.push(...opts.extra);
|
||||
return parts.length ? parts.join(',') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip whitespace / trailing slashes from a user-entered entity set name
|
||||
* (e.g. someone pasting "accounts/"). Dataverse rejects either of those
|
||||
* variants with an unhelpful 404, so we normalize aggressively up-front.
|
||||
*/
|
||||
function resourceLocatorValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object' && value !== null && 'value' in value) {
|
||||
return typeof value.value === 'string' ? value.value : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function normalizeEntitySet(value: unknown): string {
|
||||
return resourceLocatorValue(value)
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
const ENTITY_SET_PATTERN = /^[a-z][a-z0-9_]*$/i;
|
||||
|
||||
export function isValidEntitySet(value: unknown): boolean {
|
||||
return ENTITY_SET_PATTERN.test(normalizeEntitySet(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a table name before interpolating it into a request path.
|
||||
* Dataverse entity-set names are identifiers, so path separators, URL
|
||||
* delimiters, whitespace, and traversal segments are never valid here.
|
||||
*
|
||||
* @throws NodeOperationError naming the parameter.
|
||||
*/
|
||||
export function assertValidEntitySet(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
entitySet: unknown,
|
||||
paramName = 'entitySet',
|
||||
): string {
|
||||
const normalized = normalizeEntitySet(entitySet);
|
||||
if (!isValidEntitySet(normalized)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Parameter "${paramName}" must be a valid Dataverse table name`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OData path segment for addressing a record. Supports two
|
||||
* identifier shapes that the dv connector / Dataverse Web API both accept:
|
||||
*
|
||||
* - **GUID**: `/accounts(7c-..-...)`
|
||||
* - **Alternate key**: `/accounts(accountnumber='ACC-001')`
|
||||
*
|
||||
* `recordId` is forwarded as-is — Dataverse parses both shapes natively, so we
|
||||
* don't need to wrap quotes ourselves (callers must supply them when using
|
||||
* alt-keys with string values). Callers validate the entity set with
|
||||
* {@link assertValidEntitySet} and the identifier with {@link assertValidRecordId}
|
||||
* or {@link assertValidAlternateKey}, so URL-breaking input never reaches here.
|
||||
*/
|
||||
export function buildRecordPath(entitySet: string, recordId: string): string {
|
||||
const safeSet = normalizeEntitySet(entitySet);
|
||||
const id = String(recordId).trim();
|
||||
return `/${safeSet}(${id})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user-entered Row ID up front. n8n's `required: true` flag stops
|
||||
* a completely empty field from being submitted, but a string like `" "` or
|
||||
* an expression that resolves to `null`/`undefined` still slips through and
|
||||
* produces a confusing `/accounts()` URL.
|
||||
*
|
||||
* @throws NodeOperationError with a clear, parameter-named message.
|
||||
*/
|
||||
export function assertNonEmptyRecordId(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
recordId: unknown,
|
||||
paramName = 'recordId',
|
||||
): string {
|
||||
const trimmed = resourceLocatorValue(recordId).trim();
|
||||
if (!trimmed) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Parameter "${paramName}" is required and must be a non-empty string`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// Chars that would truncate or redirect the request URL if interpolated raw.
|
||||
const URL_BREAKING_CHARS = /[?#/\\]|[\x00-\x1f]/;
|
||||
|
||||
export function hasUrlBreakingChars(value: string): boolean {
|
||||
return URL_BREAKING_CHARS.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Row ID before interpolating it into the request path. The field is
|
||||
* a GUID by definition, so require that shape — a value carrying `?`/`#`/`/`
|
||||
* (from a mapped field or expression) would otherwise silently truncate the URL
|
||||
* and hit a different record than intended.
|
||||
*
|
||||
* @throws NodeOperationError naming the parameter.
|
||||
*/
|
||||
export function assertValidRecordId(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
recordId: unknown,
|
||||
paramName = 'recordId',
|
||||
): string {
|
||||
const id = assertNonEmptyRecordId(ctx, itemIndex, recordId, paramName);
|
||||
if (!GUID_PATTERN.test(id)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Parameter "${paramName}" must be a record GUID (e.g. 00000000-0000-0000-0000-000000000000)`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an alternate-key predicate before interpolating it into the path. The
|
||||
* predicate legitimately uses quotes and commas, so we don't constrain the
|
||||
* syntax. URL delimiters, path separators, and ASCII control characters would
|
||||
* be reinterpreted or stripped by the URL parser, so reject those.
|
||||
*
|
||||
* @throws NodeOperationError naming the parameter.
|
||||
*/
|
||||
export function assertValidAlternateKey(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
alternateKey: unknown,
|
||||
paramName = 'alternateKey',
|
||||
): string {
|
||||
const predicate = assertNonEmptyRecordId(ctx, itemIndex, alternateKey, paramName);
|
||||
if (hasUrlBreakingChars(predicate)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Parameter "${paramName}" must not contain URL delimiters, path separators, or control characters`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return predicate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty body for write operations (create / update / upsert).
|
||||
* Dataverse silently 204s on an empty PATCH and 400s on an empty POST; either
|
||||
* way the user wanted to send fields and didn't. Fail fast at the node level
|
||||
* with a clearer message.
|
||||
*/
|
||||
export function assertNonEmptyBody(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
body: IDataObject,
|
||||
opName: string,
|
||||
): IDataObject {
|
||||
if (!body || Object.keys(body).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`${opName} requires at least one field. Provide a non-empty Row Item.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Add a partition ID to the query string for supported read and delete requests. */
|
||||
export function applyPartitionIdToQuery(qs: IDataObject, partitionId?: unknown): IDataObject {
|
||||
if (typeof partitionId === 'string' && partitionId.trim()) {
|
||||
qs.partitionId = partitionId.trim();
|
||||
}
|
||||
return qs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request plan + executor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Declarative spec for a single Dataverse Web API call. Every op module fills
|
||||
* one of these out and hands it to {@link executeRequest}; the executor takes
|
||||
* care of the cross-cutting concerns that used to be re-implemented inline in
|
||||
* each op (partition-id, Prefer header, single vs. paged dispatch).
|
||||
*
|
||||
* - `body`/`qs`/`extraHeaders` are forwarded verbatim.
|
||||
* - `prefer` is converted to a `Prefer` header via {@link buildPreferHeader}
|
||||
* and merged with the caller's `extraHeaders.Prefer` if both are present.
|
||||
* - `options.partitionId` is folded into the query string for supported read
|
||||
* and delete requests.
|
||||
* - `options.returnFullMetadata` is folded into the Prefer header.
|
||||
*
|
||||
* For `paged: true`, the executor calls {@link dataverseApiRequestAllItems}
|
||||
* and returns an array of rows; otherwise it does a single-shot
|
||||
* {@link dataverseApiRequest} returning the raw response.
|
||||
*/
|
||||
export interface RequestPlan {
|
||||
method: IHttpRequestMethods;
|
||||
/** Web API path relative to `/api/data/v9.2` (e.g. `/accounts` or `/accounts(<id>)`). */
|
||||
path: string;
|
||||
body?: IDataObject;
|
||||
qs?: DataverseQuery;
|
||||
extraHeaders?: DataverseHeaders;
|
||||
prefer?: PreferOptions;
|
||||
/** Raw options bag from the op's `<op>Options` collection — supplies partitionId, returnFullMetadata. */
|
||||
options?: IDataObject;
|
||||
/** When true, fetch all pages; when false (default), single request. */
|
||||
paged?: boolean;
|
||||
/** Only used when `paged: true`. 0 = no limit. */
|
||||
limit?: number;
|
||||
/** Return the write response with its Dataverse logical-session token. */
|
||||
returnSessionToken?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a {@link RequestPlan}, folding partition-id, Return-Full-Metadata,
|
||||
* and `Prefer` header construction into one place. Returns the raw response
|
||||
* (single-shot) or the flat list of rows (paged).
|
||||
*/
|
||||
export async function executeRequest(
|
||||
ctx: IExecuteFunctions,
|
||||
credentialType: string,
|
||||
plan: RequestPlan,
|
||||
): Promise<IDataObject | IDataObject[]> {
|
||||
const qs: DataverseQuery = { ...(plan.qs ?? {}) };
|
||||
const options = plan.options ?? {};
|
||||
applyPartitionIdToQuery(qs as IDataObject, options.partitionId);
|
||||
|
||||
const prefer = buildPreferHeader({
|
||||
...(plan.prefer ?? {}),
|
||||
includeFullMetadata: plan.prefer?.includeFullMetadata ?? Boolean(options.returnFullMetadata),
|
||||
});
|
||||
const headers: DataverseHeaders = { ...(plan.extraHeaders ?? {}) };
|
||||
if (prefer) headers.Prefer = prefer;
|
||||
if (typeof options.sessionToken === 'string' && options.sessionToken.trim()) {
|
||||
headers['MSCRM.SessionToken'] = options.sessionToken.trim();
|
||||
}
|
||||
|
||||
if (plan.paged) {
|
||||
return await dataverseApiRequestAllItems(
|
||||
ctx,
|
||||
plan.method,
|
||||
plan.path,
|
||||
qs,
|
||||
plan.limit ?? 0,
|
||||
credentialType,
|
||||
headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (plan.returnSessionToken) {
|
||||
const response = await dataverseApiRequestWithResponse(
|
||||
ctx,
|
||||
plan.method,
|
||||
plan.path,
|
||||
plan.body ?? {},
|
||||
qs,
|
||||
headers,
|
||||
credentialType,
|
||||
);
|
||||
const sessionToken = Object.entries(response.headers).find(
|
||||
([name]) => name.toLowerCase() === 'x-ms-session-token',
|
||||
)?.[1];
|
||||
return {
|
||||
data: response.body,
|
||||
...(typeof sessionToken === 'string' ? { sessionToken } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return await dataverseApiRequest(
|
||||
ctx,
|
||||
plan.method,
|
||||
plan.path,
|
||||
plan.body ?? {},
|
||||
qs,
|
||||
headers,
|
||||
credentialType,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import type { IDisplayOptions, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Property descriptors that are reused by more than one operation. They live in
|
||||
* their own module so each op file stays a tight description of its op-only
|
||||
* shape (item input mode, partition id...). The `displayOptions` are built
|
||||
* with the caller-supplied list of operation values so the same property can
|
||||
* be opted in by several ops.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the standard `displayOptions.show` block scoping a property or
|
||||
* collection to one or more record-level operations. Replaces the
|
||||
* `{ show: { resource: ['row'], operation: ops } }` boilerplate that was
|
||||
* previously inlined in every shared property factory.
|
||||
*/
|
||||
export function forOperation(operations: string[]): IDisplayOptions {
|
||||
return { show: { resource: ['row'], operation: operations } };
|
||||
}
|
||||
|
||||
/** Plural Web API name of the table (e.g. `accounts`). */
|
||||
export function commonEntitySetProperty(operations: string[]): INodeProperties {
|
||||
return {
|
||||
displayName: 'Table Name or ID',
|
||||
name: 'entitySet',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description: 'The Dataverse table to operate on',
|
||||
displayOptions: forOperation(operations),
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchEntitySets',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. accounts',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** GUID of the record being targeted. */
|
||||
export function commonRecordIdProperty(operations: string[]): INodeProperties {
|
||||
return {
|
||||
displayName: 'Row ID',
|
||||
name: 'recordId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description: 'The Dataverse row to operate on',
|
||||
displayOptions: forOperation(operations),
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['entitySet.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchRows',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Row Item` payload. Exposes a `JSON ⇄ Fields` mode toggle so power users
|
||||
* can paste an object and beginners can use a key/value collection — same UX
|
||||
* as the dv connector's dynamic item builder, just spelled in n8n primitives.
|
||||
*/
|
||||
export function commonRowItemProperties(operations: string[]): INodeProperties[] {
|
||||
return [
|
||||
{
|
||||
displayName: 'Input Mode',
|
||||
name: 'inputMode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
default: 'json',
|
||||
options: [
|
||||
{ name: 'JSON', value: 'json', description: 'Paste a full record object' },
|
||||
{
|
||||
name: 'Fields (Key / Value Pairs)',
|
||||
value: 'fields',
|
||||
description: 'Build the row one field at a time',
|
||||
},
|
||||
],
|
||||
displayOptions: forOperation(operations),
|
||||
},
|
||||
{
|
||||
displayName: 'Row Item (JSON)',
|
||||
name: 'fieldsJson',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
required: true,
|
||||
description: 'Row to send to Dataverse, as a JSON object of column logical name to value',
|
||||
displayOptions: {
|
||||
show: { ...forOperation(operations).show, inputMode: ['json'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fieldsCollection',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
placeholder: 'Add Field',
|
||||
typeOptions: { multipleValues: true, sortable: true },
|
||||
displayOptions: {
|
||||
show: { ...forOperation(operations).show, inputMode: ['fields'] },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'field',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Field Name or ID',
|
||||
name: 'name',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getWriteColumns',
|
||||
loadOptionsDependsOn: ['entitySet.value', 'operation'],
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Field Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Value to set on the column',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Optional `Return Full Metadata` toggle (maps to `Prefer: odata.include-annotations="*"`). */
|
||||
export function commonReturnFullMetadataOption(): INodeProperties {
|
||||
return {
|
||||
displayName: 'Return Full Metadata',
|
||||
name: 'returnFullMetadata',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to ask Dataverse to include OData annotations (odata.include-annotations="*")',
|
||||
};
|
||||
}
|
||||
|
||||
/** Optional partition ID for supported elastic-table read and delete requests. */
|
||||
export function commonPartitionIdOption(): INodeProperties {
|
||||
return {
|
||||
displayName: 'Partition ID',
|
||||
name: 'partitionId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'partition-key-value',
|
||||
description: 'Partition key for an elastic table read or delete request',
|
||||
};
|
||||
}
|
||||
|
||||
/** Logical-session token used for consistent reads after an elastic-table write. */
|
||||
export function commonSessionTokenOption(): INodeProperties {
|
||||
return {
|
||||
displayName: 'Session Token',
|
||||
name: 'sessionToken',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-type-options-password-missing -- sessionToken is a consistency token, not a password
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Token from an earlier elastic-table write in the same logical session',
|
||||
};
|
||||
}
|
||||
|
||||
/** Include the Dataverse logical-session token in a write result. */
|
||||
export function commonReturnSessionTokenOption(): INodeProperties {
|
||||
return {
|
||||
displayName: 'Return Session Token',
|
||||
name: 'returnSessionToken',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return the token used for consistent reads after this write',
|
||||
};
|
||||
}
|
||||
|
||||
/** `$select` — multi-select of column logical names. */
|
||||
export function commonSelectOption(): INodeProperties {
|
||||
return {
|
||||
displayName: 'Select Column Names or IDs',
|
||||
name: 'select',
|
||||
type: 'multiOptions',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getReadColumns',
|
||||
loadOptionsDependsOn: ['entitySet.value'],
|
||||
},
|
||||
default: [],
|
||||
description:
|
||||
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
};
|
||||
}
|
||||
|
||||
/** `$expand` — comma-separated navigation properties (lookup / relationship). */
|
||||
export function commonExpandOption(): INodeProperties {
|
||||
return {
|
||||
displayName: 'Expand Query',
|
||||
name: 'expand',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'primarycontactid($select=fullname)',
|
||||
description: 'OData $expand expression to include related rows',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one or more option fields into the standard top-level "Options"
|
||||
* collection that every operation exposes. Removes the boilerplate where each
|
||||
* op file re-declared a 7-line `type: 'collection'` block with the same
|
||||
* placeholder, default, and displayOptions.
|
||||
*/
|
||||
export function buildOptionsCollection(
|
||||
opValue: string,
|
||||
items: INodeProperties[],
|
||||
overrides: { name?: string; displayName?: string } = {},
|
||||
): INodeProperties {
|
||||
return {
|
||||
displayName: overrides.displayName ?? 'Options',
|
||||
name: overrides.name ?? `${opValue}Options`,
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
displayOptions: forOperation([opValue]),
|
||||
options: items,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Single record-operation definition. The node composes UI from the registry
|
||||
* of these and dispatches `execute` by `value`. Keeping each op self-contained
|
||||
* makes it easy to add or remove actions without touching `Dataverse.node.ts`.
|
||||
*/
|
||||
export interface OperationDefinition {
|
||||
/** The dropdown label shown in the n8n editor (e.g. "Add a new row"). */
|
||||
displayName: string;
|
||||
/** Internal id used by `getNodeParameter('operation')`. */
|
||||
value: string;
|
||||
/** Short description displayed under the dropdown entry. */
|
||||
description: string;
|
||||
/** The action label that appears in n8n's "Action" column. */
|
||||
action: string;
|
||||
/** UI fields specific to this operation (entitySet, recordId, options...). */
|
||||
properties: INodeProperties[];
|
||||
/** Run the op for a single input item. Receives the per-item context and the credential type. */
|
||||
execute(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
credentialType: string,
|
||||
): Promise<IDataObject | IDataObject[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Operation` dropdown option entry generated from a definition.
|
||||
*/
|
||||
export function toDropdownOption(def: OperationDefinition): INodePropertyOptions {
|
||||
return {
|
||||
name: def.displayName,
|
||||
value: def.value,
|
||||
description: def.description,
|
||||
action: def.action,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import type { OperationDefinition } from './types';
|
||||
import {
|
||||
applyLookupBindings,
|
||||
bodyHasLookupCandidates,
|
||||
EMPTY_LOOKUP_FIELDS,
|
||||
resolveLookupFields,
|
||||
} from './lookups';
|
||||
import {
|
||||
assertNonEmptyBody,
|
||||
assertValidEntitySet,
|
||||
assertValidRecordId,
|
||||
buildRecordPath,
|
||||
executeRequest,
|
||||
parseItemInput,
|
||||
} from './shared';
|
||||
import {
|
||||
buildOptionsCollection,
|
||||
commonEntitySetProperty,
|
||||
commonRecordIdProperty,
|
||||
commonReturnFullMetadataOption,
|
||||
commonReturnSessionTokenOption,
|
||||
commonRowItemProperties,
|
||||
commonSessionTokenOption,
|
||||
} from './sharedProperties';
|
||||
|
||||
/**
|
||||
* dv connector — "Update a row" (`UpdateRecord`).
|
||||
*
|
||||
* `PATCH /{entitySet}({recordId})` with `If-Match: *` so the server returns
|
||||
* `412 Precondition Failed` if the row doesn't exist (this is what makes it
|
||||
* an Update rather than an Upsert — see {@link upsertRow} for the upsert
|
||||
* variant). `Prefer: return=representation` echoes the post-update row.
|
||||
*/
|
||||
export const updateRow: OperationDefinition = {
|
||||
displayName: 'Update',
|
||||
value: 'update',
|
||||
description: 'Modify any selected row in a Microsoft Dataverse table',
|
||||
action: 'Update a row',
|
||||
properties: [
|
||||
commonEntitySetProperty(['update']),
|
||||
{
|
||||
...commonRecordIdProperty(['update']),
|
||||
description:
|
||||
'GUID of the row to update. For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.',
|
||||
},
|
||||
...commonRowItemProperties(['update']),
|
||||
buildOptionsCollection('update', [
|
||||
commonReturnFullMetadataOption(),
|
||||
commonSessionTokenOption(),
|
||||
commonReturnSessionTokenOption(),
|
||||
]),
|
||||
],
|
||||
async execute(ctx, i, credentialType) {
|
||||
const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i));
|
||||
const recordId = assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i));
|
||||
// Validate before resolving lookup metadata so an empty Row Item fails fast
|
||||
// without spending metadata requests. Lookup metadata is only resolved when the
|
||||
// body actually carries a lookup-style value, so a plain write stays a single
|
||||
// request and needs no metadata-read permission.
|
||||
const rawBody = assertNonEmptyBody(ctx, i, parseItemInput(ctx, i), 'Update');
|
||||
const lookupFields = bodyHasLookupCandidates(rawBody)
|
||||
? await resolveLookupFields(ctx, credentialType, entitySet)
|
||||
: EMPTY_LOOKUP_FIELDS;
|
||||
const body = applyLookupBindings(ctx, i, rawBody, lookupFields);
|
||||
const options = ctx.getNodeParameter('updateOptions', i, {}) as IDataObject;
|
||||
return await executeRequest(ctx, credentialType, {
|
||||
method: 'PATCH',
|
||||
path: buildRecordPath(entitySet, recordId),
|
||||
body,
|
||||
options,
|
||||
prefer: { returnRepresentation: true },
|
||||
extraHeaders: { 'If-Match': '*' },
|
||||
returnSessionToken: Boolean(options.returnSessionToken),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { type DataverseHeaders } from '../GenericFunctions';
|
||||
import {
|
||||
applyLookupBindings,
|
||||
bodyHasLookupCandidates,
|
||||
EMPTY_LOOKUP_FIELDS,
|
||||
resolveLookupFields,
|
||||
} from './lookups';
|
||||
import { isElasticTable, resolveTableMetadata } from './metadata';
|
||||
import {
|
||||
assertNonEmptyBody,
|
||||
assertValidAlternateKey,
|
||||
assertValidEntitySet,
|
||||
assertValidRecordId,
|
||||
buildRecordPath,
|
||||
executeRequest,
|
||||
parseItemInput,
|
||||
} from './shared';
|
||||
import {
|
||||
buildOptionsCollection,
|
||||
commonEntitySetProperty,
|
||||
commonRecordIdProperty,
|
||||
commonReturnFullMetadataOption,
|
||||
commonReturnSessionTokenOption,
|
||||
commonRowItemProperties,
|
||||
commonSessionTokenOption,
|
||||
forOperation,
|
||||
} from './sharedProperties';
|
||||
import type { OperationDefinition } from './types';
|
||||
|
||||
/**
|
||||
* dv connector — "Upsert a row" (`PATCH /{entitySet}({recordId})`).
|
||||
*
|
||||
* Dataverse's Web API treats PATCH-by-id as an upsert by default: if the row
|
||||
* exists it's updated, otherwise it's inserted with the supplied GUID. The
|
||||
* dv connector's "Upsert a row" action wraps this same behavior.
|
||||
*
|
||||
* We expose two extra dimensions vs. the dv connector for flexibility:
|
||||
* - **Identifier Type**: GUID (default) *or* an alternate-key predicate
|
||||
* (e.g. `accountnumber='ACC-001'`) — both are valid Dataverse PATCH targets.
|
||||
* - **Behavior**:
|
||||
* - `upsert` (default) — no precondition headers
|
||||
* - `updateOnly` — adds `If-Match: *` (server returns 404 if row missing)
|
||||
* - `createOnly` — adds `If-None-Match: *` (server returns 412 if row exists)
|
||||
*/
|
||||
export const upsertRow: OperationDefinition = {
|
||||
displayName: 'Create or Update',
|
||||
value: 'upsert',
|
||||
description: 'Create a new record, or update the current one if it already exists (upsert)',
|
||||
action: 'Create or update a row',
|
||||
properties: [
|
||||
commonEntitySetProperty(['upsert']),
|
||||
{
|
||||
displayName: 'Identifier Type',
|
||||
name: 'identifierType',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
default: 'guid',
|
||||
displayOptions: forOperation(['upsert']),
|
||||
options: [
|
||||
{ name: 'Row ID (GUID)', value: 'guid', description: 'Address the row by its primary key' },
|
||||
{
|
||||
name: 'Alternate Key',
|
||||
value: 'alternateKey',
|
||||
description: 'Address the row by a configured alternate key',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...commonRecordIdProperty(['upsert']),
|
||||
description:
|
||||
'GUID of the row to upsert. Dataverse will create the row with this GUID if it does not exist yet. For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.',
|
||||
displayOptions: {
|
||||
show: { ...forOperation(['upsert']).show, identifierType: ['guid'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Alternate Key Predicate',
|
||||
name: 'alternateKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: "accountnumber='ACC-001'",
|
||||
description:
|
||||
"OData alternate-key predicate, e.g. accountnumber='ACC-001'. For a partitioned elastic table, include its primary key and partitionid.",
|
||||
displayOptions: {
|
||||
show: { ...forOperation(['upsert']).show, identifierType: ['alternateKey'] },
|
||||
},
|
||||
},
|
||||
...commonRowItemProperties(['upsert']),
|
||||
buildOptionsCollection('upsert', [
|
||||
{
|
||||
displayName: 'Behavior',
|
||||
name: 'behavior',
|
||||
type: 'options',
|
||||
default: 'upsert',
|
||||
options: [
|
||||
{
|
||||
name: 'Create or Update',
|
||||
value: 'upsert',
|
||||
description:
|
||||
'Create a new record, or update the current one if it already exists (upsert)',
|
||||
},
|
||||
{
|
||||
name: 'Update Only',
|
||||
value: 'updateOnly',
|
||||
description: 'Send If-Match: * — fail if row does not exist',
|
||||
},
|
||||
{
|
||||
name: 'Create Only',
|
||||
value: 'createOnly',
|
||||
description: 'Send If-None-Match: * — fail if row exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Allow Elastic Table Replacement',
|
||||
name: 'allowElasticTableReplacement',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to allow Create or Update to replace an existing elastic-table row. Values omitted from the Row Item are removed.',
|
||||
},
|
||||
commonReturnFullMetadataOption(),
|
||||
commonSessionTokenOption(),
|
||||
commonReturnSessionTokenOption(),
|
||||
]),
|
||||
],
|
||||
async execute(ctx, i, credentialType) {
|
||||
const entitySet = assertValidEntitySet(ctx, i, ctx.getNodeParameter('entitySet', i));
|
||||
const identifierType = ctx.getNodeParameter('identifierType', i, 'guid') as string;
|
||||
const identifier =
|
||||
identifierType === 'alternateKey'
|
||||
? assertValidAlternateKey(ctx, i, ctx.getNodeParameter('alternateKey', i), 'alternateKey')
|
||||
: assertValidRecordId(ctx, i, ctx.getNodeParameter('recordId', i));
|
||||
|
||||
const rawBody = parseItemInput(ctx, i);
|
||||
// Validate before resolving lookup metadata so an empty Row Item fails fast
|
||||
// without spending metadata requests. Lookup metadata is only resolved when the
|
||||
// body actually carries a lookup-style value, so a plain write stays a single
|
||||
// request and needs no metadata-read permission.
|
||||
assertNonEmptyBody(ctx, i, rawBody, 'Create or Update');
|
||||
const lookupFields = bodyHasLookupCandidates(rawBody)
|
||||
? await resolveLookupFields(ctx, credentialType, entitySet)
|
||||
: EMPTY_LOOKUP_FIELDS;
|
||||
const body = applyLookupBindings(ctx, i, rawBody, lookupFields);
|
||||
const options = ctx.getNodeParameter('upsertOptions', i, {}) as IDataObject;
|
||||
const behavior = (options.behavior as string) ?? 'upsert';
|
||||
if (behavior === 'upsert' && !options.allowElasticTableReplacement) {
|
||||
const metadata = await resolveTableMetadata(ctx, credentialType, entitySet);
|
||||
if (metadata && isElasticTable(metadata)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Create or Update replaces the complete row for an elastic table. Enable "Allow Elastic Table Replacement" only when the Row Item contains every value to keep.',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
}
|
||||
const extraHeaders: DataverseHeaders = {};
|
||||
if (behavior === 'updateOnly') extraHeaders['If-Match'] = '*';
|
||||
if (behavior === 'createOnly') extraHeaders['If-None-Match'] = '*';
|
||||
|
||||
return await executeRequest(ctx, credentialType, {
|
||||
method: 'PATCH',
|
||||
path: buildRecordPath(entitySet, identifier),
|
||||
body,
|
||||
options,
|
||||
prefer: { returnRepresentation: true },
|
||||
extraHeaders,
|
||||
returnSessionToken: Boolean(options.returnSessionToken),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
import { sleep } from '@n8n/utils/sleep';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
dataverseApiRequest,
|
||||
dataverseApiRequestAllItems,
|
||||
dataverseApiRequestWithResponse,
|
||||
} from '../GenericFunctions';
|
||||
|
||||
// Neutralize the retry back-off so tests don't actually wait.
|
||||
vi.mock('@n8n/utils/sleep', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api';
|
||||
const BASE_URL = 'https://org.crm.dynamics.com';
|
||||
const API_PATH = '/api/data/v9.2';
|
||||
|
||||
describe('Microsoft Dataverse GenericFunctions', () => {
|
||||
let ctx: ReturnType<typeof mockDeep<IExecuteFunctions>>;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
|
||||
const node: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Microsoft Dataverse',
|
||||
type: 'n8n-nodes-base.microsoftDataverse',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(node);
|
||||
ctx.getCredentials.mockResolvedValue({ environmentUrl: BASE_URL });
|
||||
request = vi.fn();
|
||||
ctx.helpers.httpRequestWithAuthentication = request as never;
|
||||
});
|
||||
|
||||
describe('dataverseApiRequest', () => {
|
||||
it('builds the URL, sets versioned User-Agent, and returns the response', async () => {
|
||||
request.mockResolvedValue({ value: 'ok' });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 'ok' });
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
const [credType, options] = request.mock.calls[0];
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
expect(options.url).toBe(`${BASE_URL}${API_PATH}/accounts`);
|
||||
expect(options.json).toBe(true);
|
||||
expect(options.headers).toMatchObject({
|
||||
Accept: 'application/json',
|
||||
'OData-MaxVersion': '4.0',
|
||||
'OData-Version': '4.0',
|
||||
'User-Agent': 'n8n-nodes-base.microsoftDataverse/1.0',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the body when it is empty', async () => {
|
||||
request.mockResolvedValue({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options).not.toHaveProperty('body');
|
||||
});
|
||||
|
||||
it('sends the body when provided', async () => {
|
||||
request.mockResolvedValue({});
|
||||
|
||||
await dataverseApiRequest(
|
||||
ctx,
|
||||
'POST',
|
||||
'/accounts',
|
||||
{ name: 'Acme' },
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.body).toEqual({ name: 'Acme' });
|
||||
});
|
||||
|
||||
it('strips a trailing slash from the environment URL', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({ environmentUrl: `${BASE_URL}/` });
|
||||
request.mockResolvedValue({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.url).toBe(`${BASE_URL}${API_PATH}/accounts`);
|
||||
});
|
||||
|
||||
it('strips multiple trailing slashes from the environment URL', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({ environmentUrl: `${BASE_URL}//` });
|
||||
request.mockResolvedValue({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.url).toBe(`${BASE_URL}${API_PATH}/accounts`);
|
||||
});
|
||||
|
||||
it('wraps a failure in NodeApiError', async () => {
|
||||
request.mockRejectedValue({ statusCode: 400, message: 'Bad Request' });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
|
||||
it('does not retry a non-transient 400', async () => {
|
||||
request.mockRejectedValue({ statusCode: 400 });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a transient 429 then succeeds', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({ statusCode: 429 })
|
||||
.mockResolvedValueOnce({ value: 'recovered' });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 'recovered' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors the Retry-After header over computed back-off', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({
|
||||
httpCode: '429',
|
||||
cause: { response: { headers: { 'retry-after': '2' } } },
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
expect(sleep).toHaveBeenCalledWith(2000);
|
||||
});
|
||||
|
||||
it('honors a capitalized Retry-After header', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({
|
||||
httpCode: '429',
|
||||
cause: { response: { headers: { 'Retry-After': '3' } } },
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
expect(sleep).toHaveBeenCalledWith(3000);
|
||||
});
|
||||
|
||||
it('honors a Retry-After HTTP-date over computed back-off', async () => {
|
||||
const twoSecondsFromNow = new Date(Date.now() + 2000).toUTCString();
|
||||
request
|
||||
.mockRejectedValueOnce({
|
||||
httpCode: '429',
|
||||
cause: { response: { headers: { 'retry-after': twoSecondsFromNow } } },
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
const [delay] = (sleep as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
// Allow a little slack for the clock advancing between throw and read.
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
expect(delay).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('caps a large Retry-After at the maximum honored delay', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({
|
||||
httpCode: '429',
|
||||
cause: { response: { headers: { 'retry-after': '600' } } },
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
expect(sleep).toHaveBeenCalledWith(120_000);
|
||||
});
|
||||
|
||||
it('retries a transient network error (ECONNRESET) then succeeds', async () => {
|
||||
request.mockRejectedValueOnce({ code: 'ECONNRESET' }).mockResolvedValueOnce({ value: 1 });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 1 });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries a bare "socket hang up" message with no error code', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({ message: 'socket hang up' })
|
||||
.mockResolvedValueOnce({ value: 1 });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 1 });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries a socket error nested under error.cause.code', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({ cause: { code: 'ETIMEDOUT' } })
|
||||
.mockResolvedValueOnce({ value: 1 });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 1 });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries a transient 504 then succeeds', async () => {
|
||||
request.mockRejectedValueOnce({ statusCode: 504 }).mockResolvedValueOnce({ value: 'ok' });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 'ok' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries a client-side request timeout (ECONNABORTED) then succeeds', async () => {
|
||||
request.mockRejectedValueOnce({ code: 'ECONNABORTED' }).mockResolvedValueOnce({ value: 1 });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 1 });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('gives up after the maximum number of retries on a persistent 503', async () => {
|
||||
request.mockRejectedValue({ statusCode: 503 });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
// 1 initial + 3 retries = 4 dispatches, 3 back-off sleeps.
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
expect(sleep).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not retry a POST after an ambiguous 504 (avoids duplicate rows)', async () => {
|
||||
request.mockRejectedValue({ statusCode: 504 });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry a POST after an ambiguous 503', async () => {
|
||||
request.mockRejectedValue({ statusCode: 503 });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry a POST after an ambiguous socket error (ECONNRESET)', async () => {
|
||||
request.mockRejectedValue({ code: 'ECONNRESET' });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry a POST after a client-side timeout (may have reached the server)', async () => {
|
||||
request.mockRejectedValue({ code: 'ECONNABORTED' });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(ctx, 'POST', '/accounts', { name: 'Acme' }, {}, {}, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a POST after a 429 (rejected before processing)', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({ statusCode: 429 })
|
||||
.mockResolvedValueOnce({ value: 'created' });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'POST',
|
||||
'/accounts',
|
||||
{ name: 'Acme' },
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 'created' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a POST after a connection-establishment error (ECONNREFUSED)', async () => {
|
||||
request
|
||||
.mockRejectedValueOnce({ code: 'ECONNREFUSED' })
|
||||
.mockResolvedValueOnce({ value: 'created' });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'POST',
|
||||
'/accounts',
|
||||
{ name: 'Acme' },
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 'created' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a PATCH after an ambiguous 504 (avoids re-running plugins)', async () => {
|
||||
request.mockRejectedValue({ statusCode: 504 });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequest(
|
||||
ctx,
|
||||
'PATCH',
|
||||
'/accounts(1)',
|
||||
{ name: 'Acme' },
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a PATCH after a 429 (rejected before processing)', async () => {
|
||||
request.mockRejectedValueOnce({ statusCode: 429 }).mockResolvedValueOnce({ value: 'ok' });
|
||||
|
||||
const result = await dataverseApiRequest(
|
||||
ctx,
|
||||
'PATCH',
|
||||
'/accounts(1)',
|
||||
{ name: 'Acme' },
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 'ok' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sets a request timeout and a version-derived User-Agent', async () => {
|
||||
ctx.getNode.mockReturnValue({ ...node, typeVersion: 2 });
|
||||
request.mockResolvedValue({});
|
||||
|
||||
await dataverseApiRequest(ctx, 'GET', '/accounts', {}, {}, {}, CREDENTIAL_TYPE);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.timeout).toBe(60_000);
|
||||
expect(options.headers['User-Agent']).toBe('n8n-nodes-base.microsoftDataverse/2.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataverseApiRequestWithResponse', () => {
|
||||
it('returns the full HTTP response when requested', async () => {
|
||||
const response = {
|
||||
body: { accountid: 'account-id' },
|
||||
headers: { 'x-ms-session-token': 'session-token' },
|
||||
statusCode: 200,
|
||||
};
|
||||
request.mockResolvedValue(response);
|
||||
|
||||
const result = await dataverseApiRequestWithResponse(
|
||||
ctx,
|
||||
'PATCH',
|
||||
'/accounts(account-id)',
|
||||
{ name: 'Acme' },
|
||||
{},
|
||||
{},
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual(response);
|
||||
expect(request.mock.calls[0][1]).toMatchObject({ returnFullResponse: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataverseApiRequestAllItems', () => {
|
||||
it('follows @odata.nextLink and aggregates every page', async () => {
|
||||
request
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ id: 1 }],
|
||||
'@odata.nextLink': 'https://org.crm.dynamics.com/next',
|
||||
})
|
||||
.mockResolvedValueOnce({ value: [{ id: 2 }] });
|
||||
|
||||
const result = await dataverseApiRequestAllItems(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
0,
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
const [, secondOptions] = request.mock.calls[1];
|
||||
expect(secondOptions.url).toBe('https://org.crm.dynamics.com/next');
|
||||
// nextLink already encodes the query string, so qs must not be resent.
|
||||
expect(secondOptions.qs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops once the requested limit is reached', async () => {
|
||||
request.mockResolvedValueOnce({
|
||||
value: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
'@odata.nextLink': 'https://org.crm.dynamics.com/next',
|
||||
});
|
||||
|
||||
const result = await dataverseApiRequestAllItems(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
1,
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ id: 1 }]);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves a caller Prefer header', async () => {
|
||||
request.mockResolvedValueOnce({ value: [] });
|
||||
|
||||
await dataverseApiRequestAllItems(ctx, 'GET', '/accounts', {}, 0, CREDENTIAL_TYPE, {
|
||||
Prefer: 'return=representation',
|
||||
});
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.headers.Prefer).toBe('return=representation');
|
||||
});
|
||||
|
||||
it('does not set Prefer when the caller does not provide one', async () => {
|
||||
request.mockResolvedValueOnce({ value: [] });
|
||||
|
||||
await dataverseApiRequestAllItems(ctx, 'GET', '/accounts', {}, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.headers.Prefer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards $top without a conflicting page-size preference', async () => {
|
||||
request.mockResolvedValueOnce({ value: [] });
|
||||
|
||||
await dataverseApiRequestAllItems(ctx, 'GET', '/accounts', { $top: 250 }, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.qs).toEqual({ $top: 250 });
|
||||
expect(options.headers.Prefer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns an empty array when a page has no value field', async () => {
|
||||
request.mockResolvedValueOnce({});
|
||||
|
||||
const result = await dataverseApiRequestAllItems(
|
||||
ctx,
|
||||
'GET',
|
||||
'/accounts',
|
||||
{},
|
||||
0,
|
||||
CREDENTIAL_TYPE,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wraps a paging failure in NodeApiError', async () => {
|
||||
request.mockRejectedValue({ statusCode: 400, message: 'Bad Request' });
|
||||
|
||||
await expect(
|
||||
dataverseApiRequestAllItems(ctx, 'GET', '/accounts', {}, 0, CREDENTIAL_TYPE),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
getEntitySets,
|
||||
getReadColumns,
|
||||
getWriteColumns,
|
||||
searchEntitySets,
|
||||
searchRows,
|
||||
} from '../loadOptions';
|
||||
import { MicrosoftDataverse } from '../MicrosoftDataverse.node';
|
||||
import { createRow } from '../operations/createRow';
|
||||
import { getManyRows } from '../operations/getManyRows';
|
||||
|
||||
const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api';
|
||||
|
||||
/**
|
||||
* These tests exercise the node's real routing: `resolveOperation` picks the
|
||||
* op module from the shared registry, and the node wraps its output. Only the
|
||||
* leaf `execute` of each op module is stubbed (via `vi.spyOn`), so aliasing and
|
||||
* error handling run against production code.
|
||||
*/
|
||||
describe('MicrosoftDataverse Node', () => {
|
||||
let ctx: ReturnType<typeof mockDeep<IExecuteFunctions>>;
|
||||
|
||||
const node: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Microsoft Dataverse',
|
||||
type: 'n8n-nodes-base.microsoftDataverse',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(node);
|
||||
ctx.continueOnFail.mockReturnValue(false);
|
||||
|
||||
// Minimal faithful implementations so we can assert on wrapping/paired items.
|
||||
ctx.helpers.returnJsonArray.mockImplementation((data) => {
|
||||
const arr = Array.isArray(data) ? data : [data];
|
||||
return arr.map((json) => ({ json })) as INodeExecutionData[];
|
||||
});
|
||||
ctx.helpers.constructExecutionMetaData.mockImplementation(
|
||||
(data, { itemData }) =>
|
||||
(data as INodeExecutionData[]).map((d) => ({
|
||||
...d,
|
||||
pairedItem: itemData,
|
||||
})) as unknown as ReturnType<typeof ctx.helpers.constructExecutionMetaData>,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses searchable resource locators for every table and row ID property', () => {
|
||||
const instance = new MicrosoftDataverse();
|
||||
const tableProperties = instance.description.properties.filter(
|
||||
(property) => property.name === 'entitySet',
|
||||
);
|
||||
const rowProperties = instance.description.properties.filter(
|
||||
(property) => property.name === 'recordId',
|
||||
);
|
||||
|
||||
expect(tableProperties).toHaveLength(6);
|
||||
expect(tableProperties.every((property) => property.type === 'resourceLocator')).toBe(true);
|
||||
expect(
|
||||
tableProperties.every(
|
||||
(property) => property.modes?.[0]?.typeOptions?.searchListMethod === 'searchEntitySets',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(rowProperties).toHaveLength(4);
|
||||
expect(rowProperties.every((property) => property.type === 'resourceLocator')).toBe(true);
|
||||
expect(
|
||||
rowProperties.every(
|
||||
(property) => property.modes?.[0]?.typeOptions?.searchListMethod === 'searchRows',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(instance.methods.listSearch).toEqual({
|
||||
searchEntitySets,
|
||||
searchRows,
|
||||
});
|
||||
expect(instance.methods.loadOptions).toEqual({
|
||||
getEntitySets,
|
||||
getReadColumns,
|
||||
getWriteColumns,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists operations alphabetically by name, matching the n8n catalog convention', () => {
|
||||
const instance = new MicrosoftDataverse();
|
||||
const operation = instance.description.properties.find(
|
||||
(property) => property.name === 'operation',
|
||||
);
|
||||
const names = (operation?.options ?? []).map((option) => (option as { name: string }).name);
|
||||
|
||||
expect(names).toEqual(['Create', 'Create or Update', 'Delete', 'Get', 'Get Many', 'Update']);
|
||||
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
|
||||
it('labels Update and Upsert request settings as Options', () => {
|
||||
const instance = new MicrosoftDataverse();
|
||||
const optionCollections = instance.description.properties.filter((property) =>
|
||||
['updateOptions', 'upsertOptions'].includes(property.name),
|
||||
);
|
||||
|
||||
expect(optionCollections).toHaveLength(2);
|
||||
expect(optionCollections.every((property) => property.displayName === 'Options')).toBe(true);
|
||||
expect(
|
||||
optionCollections.every(
|
||||
(property) => !property.options?.some((option) => option.name === 'partitionId'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('routes an operation to the matching op module and wraps the result', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: { in: 1 } }]);
|
||||
ctx.getNodeParameter.mockReturnValue('create');
|
||||
const executeSpy = vi.spyOn(createRow, 'execute').mockResolvedValue({ id: 'row-1' });
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledWith(ctx, 0, CREDENTIAL_TYPE);
|
||||
expect(result).toEqual([[{ json: { id: 'row-1' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('flattens an array result from an op module into multiple items', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNodeParameter.mockReturnValue('getAll');
|
||||
vi.spyOn(getManyRows, 'execute').mockResolvedValue([{ id: 'a' }, { id: 'b' }]);
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0].map((r) => r.json)).toEqual([{ id: 'a' }, { id: 'b' }]);
|
||||
});
|
||||
|
||||
it('throws NodeOperationError for an unsupported operation', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNodeParameter.mockReturnValue('does-not-exist');
|
||||
|
||||
await expect(MicrosoftDataverse.prototype.execute.call(ctx)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
await expect(MicrosoftDataverse.prototype.execute.call(ctx)).rejects.toThrow(
|
||||
/Unsupported operation "does-not-exist"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('collects the error instead of throwing when continueOnFail is on', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNodeParameter.mockReturnValue('create');
|
||||
ctx.continueOnFail.mockReturnValue(true);
|
||||
vi.spyOn(createRow, 'execute').mockRejectedValue(new Error('boom'));
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'boom' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('processes every input item and preserves the paired item index', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: { i: 0 } }, { json: { i: 1 } }]);
|
||||
const executeSpy = vi
|
||||
.spyOn(createRow, 'execute')
|
||||
.mockImplementation(async (_ctx, i) => ({ id: `row-${i}` }));
|
||||
// Route both items to the create op.
|
||||
ctx.getNodeParameter.mockReturnValue('create');
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(executeSpy).toHaveBeenNthCalledWith(1, ctx, 0, CREDENTIAL_TYPE);
|
||||
expect(executeSpy).toHaveBeenNthCalledWith(2, ctx, 1, CREDENTIAL_TYPE);
|
||||
expect(result[0]).toEqual([
|
||||
{ json: { id: 'row-0' }, pairedItem: { item: 0 } },
|
||||
{ json: { id: 'row-1' }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps prior successes and collects the failure for a mixed batch when continueOnFail is on', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: { i: 0 } }, { json: { i: 1 } }]);
|
||||
ctx.getNodeParameter.mockReturnValue('create');
|
||||
ctx.continueOnFail.mockReturnValue(true);
|
||||
vi.spyOn(createRow, 'execute')
|
||||
.mockResolvedValueOnce({ id: 'row-0' })
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(result[0]).toEqual([
|
||||
{ json: { id: 'row-0' }, pairedItem: { item: 0 } },
|
||||
{ json: { error: 'boom' }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to String(error) when a non-Error value is thrown', async () => {
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNodeParameter.mockReturnValue('create');
|
||||
ctx.continueOnFail.mockReturnValue(true);
|
||||
vi.spyOn(createRow, 'execute').mockRejectedValue('plain failure');
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'plain failure' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('returns a single empty branch when there are no input items', async () => {
|
||||
ctx.getInputData.mockReturnValue([]);
|
||||
|
||||
const result = await MicrosoftDataverse.prototype.execute.call(ctx);
|
||||
|
||||
expect(result).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
// Fixtures and expected values below mirror Dataverse API responses (lowercase
|
||||
// logical names, `{ name, value }` option shapes) — not node display-name params.
|
||||
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
|
||||
|
||||
import type { ILoadOptionsFunctions, INode, JsonObject } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
getEntitySets,
|
||||
getReadColumns,
|
||||
getWriteColumns,
|
||||
searchEntitySets,
|
||||
searchRows,
|
||||
} from '../loadOptions';
|
||||
|
||||
const BASE_URL = 'https://org.crm.dynamics.com';
|
||||
|
||||
const node: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Microsoft Dataverse',
|
||||
type: 'n8n-nodes-base.microsoftDataverse',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('Microsoft Dataverse loadOptions', () => {
|
||||
let ctx: ReturnType<typeof mockDeep<ILoadOptionsFunctions>>;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<ILoadOptionsFunctions>();
|
||||
ctx.getNode.mockReturnValue(node);
|
||||
ctx.getCredentials.mockResolvedValue({ environmentUrl: BASE_URL });
|
||||
request = vi.fn();
|
||||
ctx.helpers.httpRequestWithAuthentication = request as never;
|
||||
});
|
||||
|
||||
describe('getEntitySets', () => {
|
||||
it('maps entity definitions to sorted dropdown options', async () => {
|
||||
request.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
LogicalName: 'account',
|
||||
EntitySetName: 'accounts',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Account' } },
|
||||
},
|
||||
{
|
||||
LogicalName: 'contact',
|
||||
EntitySetName: 'contacts',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Contact' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const options = await getEntitySets.call(ctx);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ name: 'Account (accounts)', value: 'accounts', description: 'Logical name: account' },
|
||||
{ name: 'Contact (contacts)', value: 'contacts', description: 'Logical name: contact' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips definitions without an EntitySetName', async () => {
|
||||
request.mockResolvedValue({
|
||||
value: [
|
||||
{ LogicalName: 'audit', EntitySetName: null },
|
||||
{ LogicalName: 'account', EntitySetName: 'accounts' },
|
||||
],
|
||||
});
|
||||
|
||||
const options = await getEntitySets.call(ctx);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ name: 'account (accounts)', value: 'accounts', description: 'Logical name: account' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the logical name when the label is blank', async () => {
|
||||
request.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
LogicalName: 'account',
|
||||
EntitySetName: 'accounts',
|
||||
DisplayName: { UserLocalizedLabel: { Label: ' ' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [option] = await getEntitySets.call(ctx);
|
||||
|
||||
expect(option.name).toBe('account (accounts)');
|
||||
});
|
||||
|
||||
it('throws when the credential is missing an environment URL', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({ environmentUrl: '' });
|
||||
|
||||
await expect(getEntitySets.call(ctx)).rejects.toThrow(NodeApiError);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps an upstream failure in NodeApiError with the Dataverse message', async () => {
|
||||
request.mockRejectedValue({
|
||||
statusCode: 400,
|
||||
response: { body: { error: { code: '0x80060888', message: 'Invalid $select' } } },
|
||||
});
|
||||
|
||||
await expect(getEntitySets.call(ctx)).rejects.toThrow(/Invalid \$select/);
|
||||
});
|
||||
|
||||
it('recovers the message from the legacy OData v3 error shape', async () => {
|
||||
request.mockRejectedValue({
|
||||
statusCode: 400,
|
||||
response: { body: { error: { message: { lang: 'en-US', value: 'Legacy message' } } } },
|
||||
});
|
||||
|
||||
await expect(getEntitySets.call(ctx)).rejects.toThrow(/Legacy message/);
|
||||
});
|
||||
|
||||
it('recovers the message from a nested cause.response.body envelope', async () => {
|
||||
request.mockRejectedValue({
|
||||
message: 'Bad request - please check your parameters',
|
||||
cause: {
|
||||
statusCode: 403,
|
||||
response: { body: { error: { code: '0x80072560', message: 'Insufficient privileges' } } },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getEntitySets.call(ctx)).rejects.toThrow(/Insufficient privileges/);
|
||||
});
|
||||
|
||||
it('applies the crafted message when the failure is already a NodeApiError', async () => {
|
||||
// Production shape: httpRequestWithAuthentication pre-wraps the axios
|
||||
// failure in a NodeApiError, which lifts the body text onto `.description`.
|
||||
const cause = Object.assign(new Error('Forbidden - perhaps check your credentials?'), {
|
||||
statusCode: 403,
|
||||
response: { data: { error: { code: '0x80072560', message: 'Insufficient privileges' } } },
|
||||
});
|
||||
request.mockRejectedValue(new NodeApiError(node, cause as unknown as JsonObject));
|
||||
|
||||
const error = await getEntitySets.call(ctx).catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect((error as Error).message).toContain('Insufficient privileges');
|
||||
expect((error as Error).message).not.toContain('perhaps check your credentials');
|
||||
});
|
||||
|
||||
it('parses a raw JSON string error body', async () => {
|
||||
request.mockRejectedValue({
|
||||
statusCode: 400,
|
||||
response: { body: JSON.stringify({ error: { message: 'Stringified body' } }) },
|
||||
});
|
||||
|
||||
await expect(getEntitySets.call(ctx)).rejects.toThrow(/Stringified body/);
|
||||
});
|
||||
|
||||
it('falls back to the wrapper description when no body is reachable', async () => {
|
||||
request.mockRejectedValue({
|
||||
statusCode: 400,
|
||||
message: 'Bad request - please check your parameters',
|
||||
description: 'The authorization server denied the request',
|
||||
});
|
||||
|
||||
await expect(getEntitySets.call(ctx)).rejects.toThrow(
|
||||
/The authorization server denied the request/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReadColumns', () => {
|
||||
it('returns an empty list when no table is selected', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue('');
|
||||
|
||||
expect(await getReadColumns.call(ctx)).toEqual([]);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up the logical name then maps readable attributes', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' });
|
||||
request.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }).mockResolvedValueOnce({
|
||||
value: [
|
||||
{
|
||||
LogicalName: 'name',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Name' } },
|
||||
},
|
||||
{
|
||||
LogicalName: 'primarycontactid',
|
||||
AttributeType: 'Lookup',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Primary Contact' } },
|
||||
},
|
||||
{
|
||||
LogicalName: 'customerid',
|
||||
AttributeType: 'Customer',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Customer' } },
|
||||
},
|
||||
{
|
||||
LogicalName: 'ownerid',
|
||||
AttributeType: 'Owner',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Owner' } },
|
||||
},
|
||||
{ LogicalName: 'accountidname', AttributeOf: 'accountid' },
|
||||
{ LogicalName: 'hidden', IsValidForRead: false },
|
||||
{
|
||||
LogicalName: 'to',
|
||||
AttributeType: 'PartyList',
|
||||
IsValidForRead: true,
|
||||
IsValidODataAttribute: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const options = await getReadColumns.call(ctx);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ name: 'Customer (customerid) — lookup', value: '_customerid_value' },
|
||||
{ name: 'Name (name)', value: 'name' },
|
||||
{ name: 'Owner (ownerid) — lookup', value: '_ownerid_value' },
|
||||
{
|
||||
name: 'Primary Contact (primarycontactid) — lookup',
|
||||
value: '_primarycontactid_value',
|
||||
},
|
||||
]);
|
||||
const [, attrOptions] = request.mock.calls[1];
|
||||
expect(attrOptions.qs.$select).toContain('AttributeType');
|
||||
expect(attrOptions.qs.$select).toContain('IsValidODataAttribute');
|
||||
expect(attrOptions.qs.$select).toContain('IsValidForCreate');
|
||||
expect(attrOptions.qs.$select).toContain('IsValidForUpdate');
|
||||
});
|
||||
|
||||
it('returns an empty list when the table lookup finds nothing', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue('accounts');
|
||||
request.mockResolvedValueOnce({ value: [] });
|
||||
|
||||
expect(await getReadColumns.call(ctx)).toEqual([]);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('escapes single quotes in the entity-set lookup filter', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue("o'brien");
|
||||
request.mockResolvedValueOnce({ value: [] });
|
||||
|
||||
await getReadColumns.call(ctx);
|
||||
|
||||
const [, options] = request.mock.calls[0];
|
||||
expect(options.qs.$filter).toBe("EntitySetName eq 'o''brien'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWriteColumns', () => {
|
||||
it.each([
|
||||
['create', 'IsValidForCreate', 'createonly'],
|
||||
['update', 'IsValidForUpdate', 'updateonly'],
|
||||
] as const)('filters %s fields by %s', async (operation, validityProperty, includedName) => {
|
||||
ctx.getCurrentNodeParameter.mockImplementation((parameterName) =>
|
||||
parameterName === 'entitySet' ? 'accounts' : operation,
|
||||
);
|
||||
request.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }).mockResolvedValueOnce({
|
||||
value: [
|
||||
{ LogicalName: includedName, [validityProperty]: true },
|
||||
{ LogicalName: 'computed', [validityProperty]: false },
|
||||
{
|
||||
LogicalName: 'primarycontactid',
|
||||
AttributeType: 'Lookup',
|
||||
[validityProperty]: true,
|
||||
},
|
||||
{
|
||||
LogicalName: 'requiredattendees',
|
||||
AttributeType: 'PartyList',
|
||||
[validityProperty]: true,
|
||||
IsValidODataAttribute: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const options = await getWriteColumns.call(ctx);
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: `${includedName} (${includedName})`, value: includedName },
|
||||
{
|
||||
name: 'primarycontactid (primarycontactid) — lookup',
|
||||
value: 'primarycontactid',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('only offers fields valid for both create and update during upsert', async () => {
|
||||
ctx.getCurrentNodeParameter.mockImplementation((parameterName) =>
|
||||
parameterName === 'entitySet' ? 'accounts' : 'upsert',
|
||||
);
|
||||
request.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] }).mockResolvedValueOnce({
|
||||
value: [
|
||||
{ LogicalName: 'name', IsValidForCreate: true, IsValidForUpdate: true },
|
||||
{ LogicalName: 'createdon', IsValidForCreate: false, IsValidForUpdate: false },
|
||||
{ LogicalName: 'createonly', IsValidForCreate: true, IsValidForUpdate: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(await getWriteColumns.call(ctx)).toEqual([{ name: 'name (name)', value: 'name' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSearch', () => {
|
||||
it('filters table locator results by display name', async () => {
|
||||
request.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
LogicalName: 'account',
|
||||
EntitySetName: 'accounts',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Account' } },
|
||||
},
|
||||
{
|
||||
LogicalName: 'contact',
|
||||
EntitySetName: 'contacts',
|
||||
DisplayName: { UserLocalizedLabel: { Label: 'Contact' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(await searchEntitySets.call(ctx, 'acc')).toEqual({
|
||||
results: [{ name: 'Account (accounts)', value: 'accounts' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('searches rows by the selected table primary name and returns primary IDs', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: ' /accounts/\r\n' });
|
||||
request
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ accountid: 'row-1', name: 'Acme' }],
|
||||
});
|
||||
|
||||
expect(await searchRows.call(ctx, "Ac'me")).toEqual({
|
||||
results: [{ name: 'Acme (row-1)', value: 'row-1' }],
|
||||
});
|
||||
const [, metadataRequest] = request.mock.calls[0];
|
||||
expect(metadataRequest.qs.$filter).toBe("EntitySetName eq 'accounts'");
|
||||
const [, rowsRequest] = request.mock.calls[1];
|
||||
expect(rowsRequest.url).toContain('/accounts');
|
||||
expect(rowsRequest.qs).toEqual({
|
||||
$select: 'accountid,name',
|
||||
$filter: "contains(name,'Ac''me')",
|
||||
});
|
||||
// Server-driven paging, not a `$top` cap.
|
||||
expect(rowsRequest.qs.$top).toBeUndefined();
|
||||
expect(rowsRequest.headers.Prefer).toBe('odata.maxpagesize=100');
|
||||
});
|
||||
|
||||
it('rejects an invalid table name before requesting metadata', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue('accounts/../contacts');
|
||||
|
||||
await expect(searchRows.call(ctx)).rejects.toThrow(/valid Dataverse table name/);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the @odata.nextLink as the pagination token', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' });
|
||||
const nextLink = `${BASE_URL}/api/data/v9.2/accounts?$select=accountid,name&$skiptoken=x`;
|
||||
request
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ accountid: 'row-1', name: 'Acme' }],
|
||||
'@odata.nextLink': nextLink,
|
||||
});
|
||||
|
||||
expect(await searchRows.call(ctx)).toEqual({
|
||||
results: [{ name: 'Acme (row-1)', value: 'row-1' }],
|
||||
paginationToken: nextLink,
|
||||
});
|
||||
});
|
||||
|
||||
it('follows the pagination token as the request URL without resending the query', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' });
|
||||
const token = `${BASE_URL}/api/data/v9.2/accounts?$select=accountid,name&$skiptoken=x`;
|
||||
request
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [{ accountid: 'row-2', name: 'Beta' }],
|
||||
});
|
||||
|
||||
expect(await searchRows.call(ctx, undefined, token)).toEqual({
|
||||
results: [{ name: 'Beta (row-2)', value: 'row-2' }],
|
||||
});
|
||||
const [, pageRequest] = request.mock.calls[1];
|
||||
expect(pageRequest.url).toBe(token);
|
||||
// The nextLink already encodes the query; don't resend it.
|
||||
expect(pageRequest.qs).toEqual({});
|
||||
expect(pageRequest.headers.Prefer).toBe('odata.maxpagesize=100');
|
||||
});
|
||||
|
||||
it('rejects a pagination token pointing outside the environment URL', async () => {
|
||||
ctx.getCurrentNodeParameter.mockReturnValue({ mode: 'list', value: 'accounts' });
|
||||
const evilToken = 'https://evil.example.com/api/data/v9.2/accounts?$skiptoken=x';
|
||||
request.mockResolvedValueOnce({
|
||||
value: [{ PrimaryIdAttribute: 'accountid', PrimaryNameAttribute: 'name' }],
|
||||
});
|
||||
|
||||
await expect(searchRows.call(ctx, undefined, evilToken)).rejects.toThrow(
|
||||
/outside the configured Environment URL/,
|
||||
);
|
||||
// Only the metadata call ran; the cross-host page request was never dispatched.
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { dataverseApiRequest } from '../GenericFunctions';
|
||||
import {
|
||||
applyLookupBindings,
|
||||
bodyHasLookupCandidates,
|
||||
resolveLookupFields,
|
||||
type LookupFieldMap,
|
||||
} from '../operations/lookups';
|
||||
|
||||
vi.mock('../GenericFunctions', () => ({
|
||||
dataverseApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api';
|
||||
|
||||
const node: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Microsoft Dataverse',
|
||||
type: 'n8n-nodes-base.microsoftDataverse',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('Microsoft Dataverse lookups', () => {
|
||||
let ctx: ReturnType<typeof mockDeep<IExecuteFunctions>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(node);
|
||||
});
|
||||
|
||||
describe('resolveLookupFields', () => {
|
||||
// Wire the three metadata calls: entity-set→logical name, relationships,
|
||||
// referenced logical names→entity-set names.
|
||||
const mockMetadata = () => {
|
||||
vi.mocked(dataverseApiRequest)
|
||||
.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] })
|
||||
.mockResolvedValueOnce({
|
||||
value: [
|
||||
{
|
||||
ReferencingAttribute: 'primarycontactid',
|
||||
ReferencingEntityNavigationPropertyName: 'primarycontactid',
|
||||
ReferencedEntity: 'contact',
|
||||
},
|
||||
{
|
||||
ReferencingAttribute: 'customerid',
|
||||
ReferencingEntityNavigationPropertyName: 'customerid_account',
|
||||
ReferencedEntity: 'account',
|
||||
},
|
||||
{
|
||||
ReferencingAttribute: 'customerid',
|
||||
ReferencingEntityNavigationPropertyName: 'customerid_contact',
|
||||
ReferencedEntity: 'contact',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [
|
||||
{
|
||||
LogicalName: 'contact',
|
||||
EntitySetName: 'contacts',
|
||||
PrimaryIdAttribute: 'contactid',
|
||||
TableType: 'Standard',
|
||||
},
|
||||
{
|
||||
LogicalName: 'account',
|
||||
EntitySetName: 'accounts',
|
||||
PrimaryIdAttribute: 'accountid',
|
||||
TableType: 'Standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
it('builds a map of single-target and polymorphic lookups', async () => {
|
||||
mockMetadata();
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.get('primarycontactid')).toEqual([
|
||||
{
|
||||
navigationProperty: 'primarycontactid',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
tableType: 'Standard',
|
||||
primaryIdAttribute: 'contactid',
|
||||
},
|
||||
]);
|
||||
expect(map.get('customerid')).toEqual([
|
||||
{
|
||||
navigationProperty: 'customerid_account',
|
||||
referencedEntity: 'account',
|
||||
targetEntitySet: 'accounts',
|
||||
tableType: 'Standard',
|
||||
primaryIdAttribute: 'accountid',
|
||||
},
|
||||
{
|
||||
navigationProperty: 'customerid_contact',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
tableType: 'Standard',
|
||||
primaryIdAttribute: 'contactid',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('memoizes metadata per execution and entity set', async () => {
|
||||
mockMetadata();
|
||||
|
||||
await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
// Three metadata calls total, not six.
|
||||
expect(dataverseApiRequest).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('expands the abstract owner target into systemuser and team candidates', async () => {
|
||||
vi.mocked(dataverseApiRequest)
|
||||
.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] })
|
||||
.mockResolvedValueOnce({
|
||||
value: [
|
||||
{
|
||||
ReferencingAttribute: 'ownerid',
|
||||
ReferencingEntityNavigationPropertyName: 'ownerid',
|
||||
ReferencedEntity: 'owner',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
value: [
|
||||
{ LogicalName: 'systemuser', EntitySetName: 'systemusers' },
|
||||
{ LogicalName: 'team', EntitySetName: 'teams' },
|
||||
],
|
||||
});
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.get('ownerid')).toEqual([
|
||||
{
|
||||
navigationProperty: 'ownerid',
|
||||
referencedEntity: 'systemuser',
|
||||
targetEntitySet: 'systemusers',
|
||||
},
|
||||
{
|
||||
navigationProperty: 'ownerid',
|
||||
referencedEntity: 'team',
|
||||
targetEntitySet: 'teams',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty map when the table has no lookups', async () => {
|
||||
vi.mocked(dataverseApiRequest)
|
||||
.mockResolvedValueOnce({ value: [{ LogicalName: 'account' }] })
|
||||
.mockResolvedValueOnce({ value: [] });
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty map when the entity set is unknown', async () => {
|
||||
// No matching EntityDefinition — an empty result, not an error.
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValueOnce({ value: [] });
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'unknowns');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
// Only the logical-name lookup runs; no relationship calls follow.
|
||||
expect(dataverseApiRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('propagates a failed metadata request instead of degrading', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await expect(resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts')).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('degrades to an empty map when metadata read is forbidden (403)', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockRejectedValue({ httpCode: '403' });
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('degrades to an empty map when metadata read is unauthorized (401)', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockRejectedValue({ httpCode: '401' });
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('detects a permission error nested under error.cause', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockRejectedValue({
|
||||
cause: { response: { status: 403 } },
|
||||
});
|
||||
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('caches the best-effort empty map so a later item does not re-request', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockRejectedValue({ httpCode: '403' });
|
||||
|
||||
await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
// The forbidden metadata endpoint is hit once, not once per item.
|
||||
expect(dataverseApiRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not cache a failed resolution so a later item can retry', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts')).rejects.toThrow('boom');
|
||||
|
||||
// A retry re-issues the metadata calls and succeeds.
|
||||
mockMetadata();
|
||||
const map = await resolveLookupFields(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
|
||||
expect(map.get('primarycontactid')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLookupBindings', () => {
|
||||
const singleTarget: LookupFieldMap = new Map([
|
||||
[
|
||||
'primarycontactid',
|
||||
[
|
||||
{
|
||||
navigationProperty: 'primarycontactid',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const polymorphic: LookupFieldMap = new Map([
|
||||
[
|
||||
'customerid',
|
||||
[
|
||||
{
|
||||
navigationProperty: 'customerid_account',
|
||||
referencedEntity: 'account',
|
||||
targetEntitySet: 'accounts',
|
||||
},
|
||||
{
|
||||
navigationProperty: 'customerid_contact',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const guid = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
// `ownerid` reports the abstract `owner` entity, expanded into its two
|
||||
// concrete members sharing the one navigation property.
|
||||
const owner: LookupFieldMap = new Map([
|
||||
[
|
||||
'ownerid',
|
||||
[
|
||||
{
|
||||
navigationProperty: 'ownerid',
|
||||
referencedEntity: 'systemuser',
|
||||
targetEntitySet: 'systemusers',
|
||||
},
|
||||
{
|
||||
navigationProperty: 'ownerid',
|
||||
referencedEntity: 'team',
|
||||
targetEntitySet: 'teams',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
it('binds a systemusers reference for the owner lookup', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { ownerid: `/systemusers(${guid})` }, owner);
|
||||
|
||||
expect(out).toEqual({ 'ownerid@odata.bind': `/systemusers(${guid})` });
|
||||
});
|
||||
|
||||
it('binds a teams reference for the owner lookup', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { ownerid: `/teams(${guid})` }, owner);
|
||||
|
||||
expect(out).toEqual({ 'ownerid@odata.bind': `/teams(${guid})` });
|
||||
});
|
||||
|
||||
it('rejects a bare GUID for the owner lookup', () => {
|
||||
expect(() => applyLookupBindings(ctx, 0, { ownerid: guid }, owner)).toThrow(
|
||||
/multiple tables/,
|
||||
);
|
||||
});
|
||||
|
||||
it('binds a bare GUID for a single-target lookup', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { primarycontactid: guid }, singleTarget);
|
||||
|
||||
expect(out).toEqual({ 'primarycontactid@odata.bind': `/contacts(${guid})` });
|
||||
});
|
||||
|
||||
it('rejects a bare GUID for an elastic-table lookup', () => {
|
||||
const elasticTarget: LookupFieldMap = new Map([
|
||||
[
|
||||
'primarycontactid',
|
||||
[
|
||||
{
|
||||
navigationProperty: 'primarycontactid',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
tableType: 'Elastic',
|
||||
primaryIdAttribute: 'contactid',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect(() => applyLookupBindings(ctx, 0, { primarycontactid: guid }, elasticTarget)).toThrow(
|
||||
/partitionId/,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a partition-aware reference for an elastic-table lookup', () => {
|
||||
const elasticTarget: LookupFieldMap = new Map([
|
||||
[
|
||||
'primarycontactid',
|
||||
[
|
||||
{
|
||||
navigationProperty: 'primarycontactid',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
tableType: 'Elastic',
|
||||
primaryIdAttribute: 'contactid',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const out = applyLookupBindings(
|
||||
ctx,
|
||||
0,
|
||||
{ primarycontactid: { id: guid, partitionId: "device's partition" } },
|
||||
elasticTarget,
|
||||
);
|
||||
|
||||
expect(out).toEqual({
|
||||
'primarycontactid@odata.bind': `/contacts(contactid=${guid},partitionid='device''s partition')`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['device?query', 'device#fragment', 'device/path', 'device\\path', 'device\npath'])(
|
||||
'rejects a URL-breaking elastic lookup partition ID: %j',
|
||||
(partitionId) => {
|
||||
const elasticTarget: LookupFieldMap = new Map([
|
||||
[
|
||||
'primarycontactid',
|
||||
[
|
||||
{
|
||||
navigationProperty: 'primarycontactid',
|
||||
referencedEntity: 'contact',
|
||||
targetEntitySet: 'contacts',
|
||||
tableType: 'Elastic',
|
||||
primaryIdAttribute: 'contactid',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
applyLookupBindings(
|
||||
ctx,
|
||||
0,
|
||||
{ primarycontactid: { id: guid, partitionId } },
|
||||
elasticTarget,
|
||||
),
|
||||
).toThrow(/must not contain URL delimiters/);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes a full reference path through and selects the matching navigation property', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { customerid: `/accounts(${guid})` }, polymorphic);
|
||||
|
||||
expect(out).toEqual({ 'customerid_account@odata.bind': `/accounts(${guid})` });
|
||||
});
|
||||
|
||||
it('normalizes an entityset(id) reference without a leading slash', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { customerid: `contacts(${guid})` }, polymorphic);
|
||||
|
||||
expect(out).toEqual({ 'customerid_contact@odata.bind': `/contacts(${guid})` });
|
||||
});
|
||||
|
||||
it('leaves non-lookup fields untouched', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { name: 'Acme', revenue: 100 }, singleTarget);
|
||||
|
||||
expect(out).toEqual({ name: 'Acme', revenue: 100 });
|
||||
});
|
||||
|
||||
it('passes an already-formed @odata.bind key through', () => {
|
||||
const out = applyLookupBindings(
|
||||
ctx,
|
||||
0,
|
||||
{ 'primarycontactid@odata.bind': `/contacts(${guid})` },
|
||||
singleTarget,
|
||||
);
|
||||
|
||||
expect(out).toEqual({ 'primarycontactid@odata.bind': `/contacts(${guid})` });
|
||||
});
|
||||
|
||||
it('disassociates a single-target lookup on null', () => {
|
||||
const out = applyLookupBindings(ctx, 0, { primarycontactid: null }, singleTarget);
|
||||
|
||||
expect(out).toEqual({ primarycontactid: null });
|
||||
});
|
||||
|
||||
it('rejects a bare GUID for a polymorphic lookup', () => {
|
||||
expect(() => applyLookupBindings(ctx, 0, { customerid: guid }, polymorphic)).toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
expect(() => applyLookupBindings(ctx, 0, { customerid: guid }, polymorphic)).toThrow(
|
||||
/multiple tables/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects clearing a polymorphic lookup with null', () => {
|
||||
expect(() => applyLookupBindings(ctx, 0, { customerid: null }, polymorphic)).toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a value that is neither a GUID nor a reference', () => {
|
||||
expect(() =>
|
||||
applyLookupBindings(ctx, 0, { primarycontactid: 'not-a-guid' }, singleTarget),
|
||||
).toThrow(/record GUID or a/);
|
||||
});
|
||||
|
||||
it('rejects a malformed slash-prefixed reference with no key', () => {
|
||||
// "/contacts" starts with a slash but has no "(id)" — must not be forwarded.
|
||||
expect(() =>
|
||||
applyLookupBindings(ctx, 0, { primarycontactid: '/contacts' }, singleTarget),
|
||||
).toThrow(/record GUID or a/);
|
||||
});
|
||||
|
||||
it('binds a matching-table reference for a single-target lookup', () => {
|
||||
const out = applyLookupBindings(
|
||||
ctx,
|
||||
0,
|
||||
{ primarycontactid: `/contacts(${guid})` },
|
||||
singleTarget,
|
||||
);
|
||||
|
||||
expect(out).toEqual({ 'primarycontactid@odata.bind': `/contacts(${guid})` });
|
||||
});
|
||||
|
||||
it('rejects a reference to the wrong table for a single-target lookup', () => {
|
||||
// The lookup only targets "contacts"; an "/accounts(...)" reference must fail
|
||||
// node-side instead of forwarding a mismatched path to Dataverse.
|
||||
expect(() =>
|
||||
applyLookupBindings(ctx, 0, { primarycontactid: `/accounts(${guid})` }, singleTarget),
|
||||
).toThrow(/does not match any related table/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyHasLookupCandidates', () => {
|
||||
const guid = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
it('returns false for a body of only non-string scalars', () => {
|
||||
expect(bodyHasLookupCandidates({ revenue: 100, active: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for any non-empty string value (may be a mistyped lookup)', () => {
|
||||
// A plain name on a lookup column must still trigger resolution so it is
|
||||
// validated node-side rather than forwarded as a raw field.
|
||||
expect(bodyHasLookupCandidates({ name: 'Acme' })).toBe(true);
|
||||
expect(bodyHasLookupCandidates({ primarycontactid: 'John Smith' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an empty or whitespace-only string', () => {
|
||||
expect(bodyHasLookupCandidates({ name: '' })).toBe(false);
|
||||
expect(bodyHasLookupCandidates({ name: ' ' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a bare GUID value', () => {
|
||||
expect(bodyHasLookupCandidates({ primarycontactid: guid })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a reference-path value (with or without leading slash)', () => {
|
||||
expect(bodyHasLookupCandidates({ customerid: `/accounts(${guid})` })).toBe(true);
|
||||
expect(bodyHasLookupCandidates({ customerid: `accounts(${guid})` })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a null value (potential disassociation)', () => {
|
||||
expect(bodyHasLookupCandidates({ primarycontactid: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a structured elastic lookup value', () => {
|
||||
expect(
|
||||
bodyHasLookupCandidates({ primarycontactid: { id: guid, partitionId: 'device' } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores keys that already carry @odata.bind', () => {
|
||||
expect(bodyHasLookupCandidates({ 'primarycontactid@odata.bind': `/contacts(${guid})` })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { dataverseApiRequest } from '../GenericFunctions';
|
||||
import { isElasticTable, resolveTableMetadata } from '../operations/metadata';
|
||||
|
||||
vi.mock('../GenericFunctions', () => ({
|
||||
dataverseApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api';
|
||||
|
||||
describe('Microsoft Dataverse table metadata', () => {
|
||||
it('resolves and caches table metadata per execution', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue({ name: 'Microsoft Dataverse' } as INode);
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
LogicalName: 'sensordata',
|
||||
EntitySetName: 'sensordatas',
|
||||
PrimaryIdAttribute: 'sensordataid',
|
||||
TableType: 'Elastic',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await resolveTableMetadata(ctx, CREDENTIAL_TYPE, 'sensordatas');
|
||||
const second = await resolveTableMetadata(ctx, CREDENTIAL_TYPE, 'sensordatas');
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first && isElasticTable(first)).toBe(true);
|
||||
expect(dataverseApiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(dataverseApiRequest).mock.calls[0]?.[4]).toMatchObject({
|
||||
$select: 'LogicalName,EntitySetName,PrimaryIdAttribute,TableType',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
dataverseApiRequest,
|
||||
dataverseApiRequestAllItems,
|
||||
dataverseApiRequestWithResponse,
|
||||
} from '../GenericFunctions';
|
||||
import { applyLookupBindings, resolveLookupFields } from '../operations/lookups';
|
||||
import { createRow } from '../operations/createRow';
|
||||
import { deleteRow } from '../operations/deleteRow';
|
||||
import { getRow } from '../operations/getRow';
|
||||
import { getManyRows } from '../operations/getManyRows';
|
||||
import { resolveTableMetadata } from '../operations/metadata';
|
||||
import { updateRow } from '../operations/updateRow';
|
||||
import { upsertRow } from '../operations/upsertRow';
|
||||
|
||||
vi.mock('../GenericFunctions', () => ({
|
||||
dataverseApiRequest: vi.fn(),
|
||||
dataverseApiRequestAllItems: vi.fn(),
|
||||
dataverseApiRequestWithResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../operations/metadata', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../operations/metadata')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveTableMetadata: vi.fn().mockResolvedValue({
|
||||
logicalName: 'account',
|
||||
entitySetName: 'accounts',
|
||||
primaryIdAttribute: 'accountid',
|
||||
tableType: 'Standard',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Lookup translation is unit-tested in lookups.test.ts. Here we stub only the
|
||||
// network-bound resolve/apply pair to a pass-through so the write-op assertions
|
||||
// stay focused on request shape, while keeping the real `bodyHasLookupCandidates`
|
||||
// gate + `EMPTY_LOOKUP_FIELDS` so the "skip metadata for plain bodies" behavior
|
||||
// is exercised end-to-end.
|
||||
vi.mock('../operations/lookups', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../operations/lookups')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveLookupFields: vi.fn().mockResolvedValue(new Map()),
|
||||
applyLookupBindings: vi.fn((_ctx: unknown, _i: number, body: unknown) => body),
|
||||
};
|
||||
});
|
||||
|
||||
const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api';
|
||||
const ROW_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const node: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Microsoft Dataverse',
|
||||
type: 'n8n-nodes-base.microsoftDataverse',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('Microsoft Dataverse operations', () => {
|
||||
let ctx: ReturnType<typeof mockDeep<IExecuteFunctions>>;
|
||||
|
||||
/**
|
||||
* Drive `ctx.getNodeParameter(name, index, fallback)` from a plain map,
|
||||
* honoring the caller's fallback when a key is absent — mirrors n8n's real
|
||||
* behavior so each op's optional-param defaults are exercised.
|
||||
*/
|
||||
const withParams = (params: Record<string, unknown>) => {
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, _i: number, fallback?: unknown) =>
|
||||
(name in params ? params[name] : fallback) as never,
|
||||
);
|
||||
};
|
||||
|
||||
/** Args passed to the single-shot request: [ctx, method, path, body, qs, headers, credType]. */
|
||||
const singleCall = () => vi.mocked(dataverseApiRequest).mock.calls[0]!;
|
||||
/** Args passed to the paged request: [ctx, method, path, qs, limit, credType, headers]. */
|
||||
const pagedCall = () => vi.mocked(dataverseApiRequestAllItems).mock.calls[0]!;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(node);
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({ id: 'row-1' });
|
||||
vi.mocked(dataverseApiRequestAllItems).mockResolvedValue([{ id: 'row-1' }]);
|
||||
vi.mocked(dataverseApiRequestWithResponse).mockResolvedValue({
|
||||
body: { id: 'row-1' },
|
||||
headers: { 'x-ms-session-token': 'session-token' },
|
||||
statusCode: 200,
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRow', () => {
|
||||
it('POSTs the parsed body with return=representation', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
createOptions: {},
|
||||
});
|
||||
|
||||
const result = await createRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
expect(result).toEqual({ id: 'row-1' });
|
||||
const [, method, path, body, , headers, credType] = singleCall();
|
||||
expect(method).toBe('POST');
|
||||
expect(path).toBe('/accounts');
|
||||
expect(body).toEqual({ name: 'Acme' });
|
||||
expect(headers?.Prefer).toBe('return=representation');
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('resolves lookup fields and translates the body when it carries a lookup-style value', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"primarycontactid":"00000000-0000-0000-0000-000000000001"}',
|
||||
createOptions: {},
|
||||
});
|
||||
|
||||
await createRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
expect(resolveLookupFields).toHaveBeenCalledWith(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
expect(applyLookupBindings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips lookup metadata for a body with only non-string scalars', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"revenue":100,"active":true}',
|
||||
createOptions: {},
|
||||
});
|
||||
|
||||
await createRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
// No string / null value in the body → no metadata GETs, but the body
|
||||
// still passes through applyLookupBindings (prototype-pollution sanitizing).
|
||||
expect(resolveLookupFields).not.toHaveBeenCalled();
|
||||
expect(applyLookupBindings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves lookup metadata for a body with a plain string value', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
createOptions: {},
|
||||
});
|
||||
|
||||
await createRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
// A plain string could be a mistyped lookup value; metadata read is
|
||||
// best-effort, so resolving it is safe even without permission.
|
||||
expect(resolveLookupFields).toHaveBeenCalledWith(ctx, CREDENTIAL_TYPE, 'accounts');
|
||||
});
|
||||
|
||||
it('throws when the body is empty', async () => {
|
||||
withParams({ entitySet: 'accounts', inputMode: 'json', fieldsJson: '{}', createOptions: {} });
|
||||
|
||||
await expect(createRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(
|
||||
/Create requires at least one field/,
|
||||
);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
// The empty body is rejected before any lookup metadata is fetched.
|
||||
expect(resolveLookupFields).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRow', () => {
|
||||
it('GETs the record path and maps select/expand into the query string', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
recordId: ROW_ID,
|
||||
getOptions: { select: 'name', expand: 'primarycontactid', partitionId: 'partition-1' },
|
||||
});
|
||||
|
||||
await getRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, method, path, , qs, , credType] = singleCall();
|
||||
expect(method).toBe('GET');
|
||||
expect(path).toBe(`/accounts(${ROW_ID})`);
|
||||
expect(qs).toMatchObject({
|
||||
$select: 'name',
|
||||
$expand: 'primarycontactid',
|
||||
partitionId: 'partition-1',
|
||||
});
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('throws for a whitespace-only record id before dispatching', async () => {
|
||||
withParams({ entitySet: 'accounts', recordId: ' ', getOptions: {} });
|
||||
|
||||
await expect(getRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/required/);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a session token for a consistent read', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
recordId: ROW_ID,
|
||||
getOptions: { sessionToken: 'token-from-write' },
|
||||
});
|
||||
|
||||
await getRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , , , headers] = singleCall();
|
||||
expect(headers?.['MSCRM.SessionToken']).toBe('token-from-write');
|
||||
});
|
||||
|
||||
it('resolves table and row resource locator values', async () => {
|
||||
withParams({
|
||||
entitySet: { mode: 'list', value: 'accounts' },
|
||||
recordId: { mode: 'list', value: ROW_ID },
|
||||
getOptions: {},
|
||||
});
|
||||
|
||||
await getRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
expect(singleCall()[2]).toBe(`/accounts(${ROW_ID})`);
|
||||
});
|
||||
|
||||
it('rejects a table name containing path traversal before dispatching', async () => {
|
||||
withParams({ entitySet: 'accounts\\..\\contacts', recordId: ROW_ID, getOptions: {} });
|
||||
|
||||
await expect(getRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"entitySet"/);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getManyRows', () => {
|
||||
it('rejects control characters inside the table name before dispatching', async () => {
|
||||
withParams({ entitySet: 'accounts\ncontacts', returnAll: true, getAllOptions: {} });
|
||||
|
||||
await expect(getManyRows.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"entitySet"/);
|
||||
expect(dataverseApiRequestAllItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not expose a Skip Token option', () => {
|
||||
const options = getManyRows.properties.find((property) => property.name === 'getAllOptions');
|
||||
|
||||
expect(options?.options?.some((option) => option.name === 'skiptoken')).toBe(false);
|
||||
});
|
||||
|
||||
it('pages with a limit and derives $orderby from column + direction', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: false,
|
||||
limit: 25,
|
||||
getAllOptions: {
|
||||
orderbyColumn: 'name',
|
||||
orderbyDirection: 'desc',
|
||||
filter: 'statecode eq 0',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
expect(result).toEqual([{ id: 'row-1' }]);
|
||||
const [, method, path, qs, limit, credType] = pagedCall();
|
||||
expect(method).toBe('GET');
|
||||
expect(path).toBe('/accounts');
|
||||
expect(qs).toMatchObject({ $orderby: 'name desc', $filter: 'statecode eq 0' });
|
||||
// Limit is mapped to a server-side $top so a full page isn't fetched.
|
||||
expect(qs).toMatchObject({ $top: 25 });
|
||||
expect(limit).toBe(25);
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('maps a Limit of 5,000 to $top', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: false,
|
||||
limit: 5000,
|
||||
getAllOptions: {},
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs, limit] = pagedCall();
|
||||
expect(qs).toMatchObject({ $top: 5000 });
|
||||
expect(limit).toBe(5000);
|
||||
});
|
||||
|
||||
it('keeps server-driven paging for a Limit above 5,000', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: false,
|
||||
limit: 6000,
|
||||
getAllOptions: {},
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs, limit] = pagedCall();
|
||||
expect(qs).not.toHaveProperty('$top');
|
||||
expect(limit).toBe(6000);
|
||||
});
|
||||
|
||||
it('lets an explicit Row Count override the limit-derived $top', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: false,
|
||||
limit: 25,
|
||||
getAllOptions: { top: 10 },
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs, limit] = pagedCall();
|
||||
expect(qs).toMatchObject({ $top: 10 });
|
||||
expect(limit).toBe(25);
|
||||
});
|
||||
|
||||
it('uses limit 0 (unbounded) when returnAll is true', async () => {
|
||||
withParams({ entitySet: 'accounts', returnAll: true, getAllOptions: {} });
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs, limit] = pagedCall();
|
||||
expect(limit).toBe(0);
|
||||
// Return All must not cap the server page.
|
||||
expect(qs).not.toHaveProperty('$top');
|
||||
});
|
||||
|
||||
it('forwards Row Count as $top', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: true,
|
||||
getAllOptions: { top: 250 },
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs] = pagedCall();
|
||||
expect(qs).toMatchObject({ $top: 250 });
|
||||
});
|
||||
|
||||
it('ignores a stale saved Skip Token option', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: true,
|
||||
getAllOptions: { skiptoken: 'already%2Fencoded' },
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs] = pagedCall();
|
||||
expect(qs).not.toHaveProperty('$skiptoken');
|
||||
});
|
||||
|
||||
it('rejects FetchXML queries when returnAll is enabled', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: true,
|
||||
getAllOptions: { fetchXml: '<fetch>x</fetch>' },
|
||||
});
|
||||
|
||||
await expect(getManyRows.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(
|
||||
'FetchXML Query cannot be used with Return All because FetchXML pagination is not supported',
|
||||
);
|
||||
expect(dataverseApiRequestAllItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a limited FetchXML query verbatim and ignores OData options', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: false,
|
||||
limit: 25,
|
||||
getAllOptions: {
|
||||
fetchXml: '<fetch>x</fetch>',
|
||||
filter: 'ignored',
|
||||
select: ['ignored'],
|
||||
},
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, method, path, qs, limit] = pagedCall();
|
||||
expect(method).toBe('GET');
|
||||
expect(path).toBe('/accounts');
|
||||
expect(qs).toEqual({ fetchXml: '<fetch>x</fetch>' });
|
||||
expect(limit).toBe(25);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only FetchXML as unset and uses OData options', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: false,
|
||||
limit: 25,
|
||||
getAllOptions: { fetchXml: ' ', filter: 'statecode eq 0' },
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , qs] = pagedCall();
|
||||
expect(qs).not.toHaveProperty('fetchXml');
|
||||
expect(qs).toMatchObject({ $filter: 'statecode eq 0', $top: 25 });
|
||||
});
|
||||
|
||||
it('forwards a session token to every paged request', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
returnAll: true,
|
||||
getAllOptions: { sessionToken: 'token-from-write' },
|
||||
});
|
||||
|
||||
await getManyRows.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , , , , headers] = pagedCall();
|
||||
expect(headers?.['MSCRM.SessionToken']).toBe('token-from-write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRow', () => {
|
||||
it('explains how to address a partitioned elastic row', () => {
|
||||
const rowId = updateRow.properties.find((property) => property.name === 'recordId');
|
||||
|
||||
expect(rowId?.description).toContain(
|
||||
'For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.',
|
||||
);
|
||||
});
|
||||
|
||||
it('PATCHes with If-Match: * and return=representation', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"New"}',
|
||||
updateOptions: {},
|
||||
});
|
||||
|
||||
await updateRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, method, path, body, , headers, credType] = singleCall();
|
||||
expect(method).toBe('PATCH');
|
||||
expect(path).toBe(`/accounts(${ROW_ID})`);
|
||||
expect(body).toEqual({ name: 'New' });
|
||||
expect(headers?.['If-Match']).toBe('*');
|
||||
expect(headers?.Prefer).toBe('return=representation');
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('throws with the update-specific message when the body is empty', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{}',
|
||||
updateOptions: {},
|
||||
});
|
||||
|
||||
await expect(updateRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(
|
||||
/Update requires at least one field/,
|
||||
);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
// The empty body is rejected before any lookup metadata is fetched.
|
||||
expect(resolveLookupFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the session token when requested', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"New"}',
|
||||
updateOptions: { returnSessionToken: true, sessionToken: 'earlier-token' },
|
||||
});
|
||||
|
||||
const result = await updateRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
expect(result).toEqual({ data: { id: 'row-1' }, sessionToken: 'session-token' });
|
||||
const [, method, , , , headers] = vi.mocked(dataverseApiRequestWithResponse).mock.calls[0]!;
|
||||
expect(method).toBe('PATCH');
|
||||
expect(headers?.['MSCRM.SessionToken']).toBe('earlier-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertRow', () => {
|
||||
it('explains how to address a partitioned elastic row by GUID', () => {
|
||||
const rowId = upsertRow.properties.find((property) => property.name === 'recordId');
|
||||
|
||||
expect(rowId?.description).toContain(
|
||||
'For a partitioned elastic table, add partitionid to the Row Item, or use Create or Update with the alternate-key form.',
|
||||
);
|
||||
});
|
||||
|
||||
it('PATCHes by GUID with no precondition header for the default behavior', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'guid',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
upsertOptions: {},
|
||||
});
|
||||
|
||||
await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, method, path, body, , headers, credType] = singleCall();
|
||||
expect(method).toBe('PATCH');
|
||||
expect(path).toBe(`/accounts(${ROW_ID})`);
|
||||
expect(body).toEqual({ name: 'Acme' });
|
||||
expect(headers?.['If-Match']).toBeUndefined();
|
||||
expect(headers?.['If-None-Match']).toBeUndefined();
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('adds If-Match: * for updateOnly behavior', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'guid',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
upsertOptions: { behavior: 'updateOnly' },
|
||||
});
|
||||
|
||||
await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , , , headers] = singleCall();
|
||||
expect(headers?.['If-Match']).toBe('*');
|
||||
});
|
||||
|
||||
it('adds If-None-Match: * for createOnly behavior', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'guid',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
upsertOptions: { behavior: 'createOnly' },
|
||||
});
|
||||
|
||||
await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , , , , headers] = singleCall();
|
||||
expect(headers?.['If-None-Match']).toBe('*');
|
||||
});
|
||||
|
||||
it('blocks replacement upsert for an elastic table without acknowledgment', async () => {
|
||||
vi.mocked(resolveTableMetadata).mockResolvedValueOnce({
|
||||
logicalName: 'sensordata',
|
||||
entitySetName: 'sensordatas',
|
||||
primaryIdAttribute: 'sensordataid',
|
||||
tableType: 'Elastic',
|
||||
});
|
||||
withParams({
|
||||
entitySet: 'sensordatas',
|
||||
identifierType: 'guid',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"reading"}',
|
||||
upsertOptions: {},
|
||||
});
|
||||
|
||||
await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(
|
||||
/Allow Elastic Table Replacement/,
|
||||
);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows replacement upsert for an elastic table after acknowledgment', async () => {
|
||||
withParams({
|
||||
entitySet: 'sensordatas',
|
||||
identifierType: 'guid',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"reading"}',
|
||||
upsertOptions: { allowElasticTableReplacement: true },
|
||||
});
|
||||
|
||||
await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, method, path] = singleCall();
|
||||
expect(method).toBe('PATCH');
|
||||
expect(path).toBe(`/sensordatas(${ROW_ID})`);
|
||||
expect(resolveTableMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addresses the row by an alternate-key predicate', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'alternateKey',
|
||||
alternateKey: "accountnumber='ACC-001'",
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
upsertOptions: {},
|
||||
});
|
||||
|
||||
await upsertRow.execute(ctx, 0, CREDENTIAL_TYPE);
|
||||
|
||||
const [, , path, body] = singleCall();
|
||||
expect(path).toBe("/accounts(accountnumber='ACC-001')");
|
||||
expect(body).toEqual({ name: 'Acme' });
|
||||
});
|
||||
|
||||
it('throws with the upsert-specific message when the body is empty', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'guid',
|
||||
recordId: ROW_ID,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{}',
|
||||
upsertOptions: {},
|
||||
});
|
||||
|
||||
await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(
|
||||
/Create or Update requires at least one field/,
|
||||
);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
// The empty body is rejected before any lookup metadata is fetched.
|
||||
expect(resolveLookupFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws naming the alternate-key parameter when it is empty', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'alternateKey',
|
||||
alternateKey: ' ',
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
upsertOptions: {},
|
||||
});
|
||||
|
||||
await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"alternateKey"/);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"accountnumber='x\\..\\contacts(00000000-0000-0000-0000-000000000002'",
|
||||
"accountnumber='ACC\t001'",
|
||||
"accountnumber='ACC\n001'",
|
||||
])('rejects a URL-breaking alternate key before dispatching: %j', async (alternateKey) => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
identifierType: 'alternateKey',
|
||||
alternateKey,
|
||||
inputMode: 'json',
|
||||
fieldsJson: '{"name":"Acme"}',
|
||||
upsertOptions: {},
|
||||
});
|
||||
|
||||
await expect(upsertRow.execute(ctx, 0, CREDENTIAL_TYPE)).rejects.toThrow(/"alternateKey"/);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRow', () => {
|
||||
it('DELETEs the record path and returns a synthetic success payload', async () => {
|
||||
withParams({
|
||||
entitySet: 'accounts',
|
||||
recordId: ROW_ID,
|
||||
deleteOptions: { partitionId: 'partition-1' },
|
||||
});
|
||||
|
||||
const result = (await deleteRow.execute(ctx, 0, CREDENTIAL_TYPE)) as IDataObject;
|
||||
|
||||
expect(result).toEqual({ success: true, id: ROW_ID });
|
||||
const [, method, path, , qs, , credType] = singleCall();
|
||||
expect(method).toBe('DELETE');
|
||||
expect(path).toBe(`/accounts(${ROW_ID})`);
|
||||
expect(qs).toEqual({ partitionId: 'partition-1' });
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('returns the synthetic payload regardless of the request response', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({ unexpected: 'body' });
|
||||
withParams({ entitySet: 'accounts', recordId: ROW_ID, deleteOptions: {} });
|
||||
|
||||
const result = (await deleteRow.execute(ctx, 0, CREDENTIAL_TYPE)) as IDataObject;
|
||||
|
||||
expect(result).toEqual({ success: true, id: ROW_ID });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
// `{ name, value }` objects below are field-input fixtures, not node params.
|
||||
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
|
||||
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { dataverseApiRequest, dataverseApiRequestAllItems } from '../GenericFunctions';
|
||||
import {
|
||||
applyPartitionIdToQuery,
|
||||
assertNonEmptyBody,
|
||||
assertNonEmptyRecordId,
|
||||
assertValidAlternateKey,
|
||||
assertValidEntitySet,
|
||||
assertValidRecordId,
|
||||
buildODataQs,
|
||||
buildPreferHeader,
|
||||
buildRecordPath,
|
||||
executeRequest,
|
||||
normalizeEntitySet,
|
||||
parseItemInput,
|
||||
} from '../operations/shared';
|
||||
|
||||
vi.mock('../GenericFunctions', () => ({
|
||||
dataverseApiRequest: vi.fn(),
|
||||
dataverseApiRequestAllItems: vi.fn(),
|
||||
}));
|
||||
|
||||
const CREDENTIAL_TYPE = 'microsoftDataverseOAuth2Api';
|
||||
|
||||
const node: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Microsoft Dataverse',
|
||||
type: 'n8n-nodes-base.microsoftDataverse',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('Microsoft Dataverse operations/shared', () => {
|
||||
let ctx: ReturnType<typeof mockDeep<IExecuteFunctions>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(node);
|
||||
});
|
||||
|
||||
describe('buildODataQs', () => {
|
||||
it('maps known option keys to their $-prefixed OData equivalents', () => {
|
||||
expect(buildODataQs({ select: 'name', filter: 'statecode eq 0' })).toEqual({
|
||||
$select: 'name',
|
||||
$filter: 'statecode eq 0',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops undefined, null, and empty-string values', () => {
|
||||
expect(
|
||||
buildODataQs({ select: '', filter: undefined, orderby: null as unknown as string }),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('joins array values with commas and drops empty arrays', () => {
|
||||
expect(buildODataQs({ select: ['name', 'accountid'], expand: [] })).toEqual({
|
||||
$select: 'name,accountid',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves numeric values so $top works', () => {
|
||||
expect(buildODataQs({ top: 25 })).toEqual({ $top: 25 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseItemInput', () => {
|
||||
it('returns a parsed object from a JSON string', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) =>
|
||||
name === 'inputMode' ? 'json' : '{"name":"Acme"}',
|
||||
);
|
||||
|
||||
expect(parseItemInput(ctx, 0)).toEqual({ name: 'Acme' });
|
||||
});
|
||||
|
||||
it('returns an object value as-is in JSON mode', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) =>
|
||||
name === 'inputMode' ? 'json' : { name: 'Acme' },
|
||||
);
|
||||
|
||||
expect(parseItemInput(ctx, 0)).toEqual({ name: 'Acme' });
|
||||
});
|
||||
|
||||
it('returns an empty object for empty JSON input', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) => (name === 'inputMode' ? 'json' : ''));
|
||||
|
||||
expect(parseItemInput(ctx, 0)).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects a JSON array with a descriptive error', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) => (name === 'inputMode' ? 'json' : '[1,2]'));
|
||||
|
||||
expect(() => parseItemInput(ctx, 0)).toThrow(/must be a JSON object, got array/);
|
||||
});
|
||||
|
||||
it('rejects a JSON primitive with a descriptive error', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) => (name === 'inputMode' ? 'json' : '42'));
|
||||
|
||||
expect(() => parseItemInput(ctx, 0)).toThrow(/must be a JSON object, got number/);
|
||||
});
|
||||
|
||||
it('throws NodeOperationError for malformed JSON', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) =>
|
||||
name === 'inputMode' ? 'json' : '{not json}',
|
||||
);
|
||||
|
||||
expect(() => parseItemInput(ctx, 0)).toThrow(NodeOperationError);
|
||||
expect(() => parseItemInput(ctx, 0)).toThrow(/is not valid JSON/);
|
||||
});
|
||||
|
||||
it('collects name/value pairs in fields mode', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) =>
|
||||
name === 'inputMode'
|
||||
? 'fields'
|
||||
: {
|
||||
field: [
|
||||
{ name: 'name', value: 'Acme' },
|
||||
{ name: 'revenue', value: 100 },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseItemInput(ctx, 0)).toEqual({ name: 'Acme', revenue: 100 });
|
||||
});
|
||||
|
||||
it('skips fields entries without a name', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) =>
|
||||
name === 'inputMode'
|
||||
? 'fields'
|
||||
: {
|
||||
field: [
|
||||
{ name: '', value: 'ignored' },
|
||||
{ name: 'keep', value: 1 },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseItemInput(ctx, 0)).toEqual({ keep: 1 });
|
||||
});
|
||||
|
||||
it('sanitizes unsafe dynamic field names in fields mode', () => {
|
||||
ctx.getNodeParameter.mockImplementation((name) =>
|
||||
name === 'inputMode'
|
||||
? 'fields'
|
||||
: {
|
||||
field: [
|
||||
{ name: 'before', value: 1 },
|
||||
{ name: '__proto__', value: { polluted: true } },
|
||||
{ name: 'constructor', value: 'dropped' },
|
||||
{ name: 'after', value: 2 },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const result = parseItemInput(ctx, 0);
|
||||
|
||||
expect(result).toEqual({ before: 1, after: 2 });
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPreferHeader', () => {
|
||||
it('returns undefined when nothing is requested', () => {
|
||||
expect(buildPreferHeader({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits return=representation', () => {
|
||||
expect(buildPreferHeader({ returnRepresentation: true })).toBe('return=representation');
|
||||
});
|
||||
|
||||
it('emits the annotations token for full metadata', () => {
|
||||
expect(buildPreferHeader({ includeFullMetadata: true })).toBe(
|
||||
'odata.include-annotations="*"',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits maxpagesize only for a positive page size', () => {
|
||||
expect(buildPreferHeader({ maxPageSize: 100 })).toBe('odata.maxpagesize=100');
|
||||
expect(buildPreferHeader({ maxPageSize: 0 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('joins every requested part with commas', () => {
|
||||
expect(
|
||||
buildPreferHeader({
|
||||
returnRepresentation: true,
|
||||
includeFullMetadata: true,
|
||||
extra: ['custom=1'],
|
||||
}),
|
||||
).toBe('return=representation,odata.include-annotations="*",custom=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEntitySet', () => {
|
||||
it('returns an empty string for non-string input', () => {
|
||||
expect(normalizeEntitySet(undefined)).toBe('');
|
||||
expect(normalizeEntitySet(42)).toBe('');
|
||||
});
|
||||
|
||||
it('trims whitespace and strips leading/trailing slashes', () => {
|
||||
expect(normalizeEntitySet(' /accounts/ ')).toBe('accounts');
|
||||
});
|
||||
|
||||
it('unwraps resource locator values', () => {
|
||||
expect(normalizeEntitySet({ mode: 'list', value: 'contacts' })).toBe('contacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertValidEntitySet', () => {
|
||||
it('returns a normalized table name from strings and resource locators', () => {
|
||||
expect(assertValidEntitySet(ctx, 0, ' /accounts/ ')).toBe('accounts');
|
||||
expect(assertValidEntitySet(ctx, 0, 'accounts\r\n')).toBe('accounts');
|
||||
expect(assertValidEntitySet(ctx, 0, { mode: 'list', value: 'custom_table' })).toBe(
|
||||
'custom_table',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'123accounts',
|
||||
'account-name',
|
||||
'accounts/../contacts',
|
||||
'accounts\\..\\contacts',
|
||||
'accounts?query',
|
||||
'accounts#fragment',
|
||||
'account name',
|
||||
'accounts\tcontacts',
|
||||
])('rejects an invalid table name: %j', (entitySet) => {
|
||||
expect(() => assertValidEntitySet(ctx, 0, entitySet)).toThrow(/"entitySet"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRecordPath', () => {
|
||||
it('builds a GUID path', () => {
|
||||
expect(buildRecordPath('accounts', '7c-guid')).toBe('/accounts(7c-guid)');
|
||||
});
|
||||
|
||||
it('normalizes the entity set and trims the id', () => {
|
||||
expect(buildRecordPath('/accounts/', " accountnumber='ACC-001' ")).toBe(
|
||||
"/accounts(accountnumber='ACC-001')",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNonEmptyRecordId', () => {
|
||||
it('returns the trimmed id when present', () => {
|
||||
expect(assertNonEmptyRecordId(ctx, 0, ' abc ')).toBe('abc');
|
||||
expect(assertNonEmptyRecordId(ctx, 0, { mode: 'list', value: 'row-1' })).toBe('row-1');
|
||||
});
|
||||
|
||||
it('throws for whitespace-only, non-string, or empty ids', () => {
|
||||
expect(() => assertNonEmptyRecordId(ctx, 0, ' ')).toThrow(NodeOperationError);
|
||||
expect(() => assertNonEmptyRecordId(ctx, 0, null)).toThrow(/required/);
|
||||
});
|
||||
|
||||
it('uses the provided parameter name in the message', () => {
|
||||
expect(() => assertNonEmptyRecordId(ctx, 0, '', 'rowId')).toThrow(/"rowId"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertValidRecordId', () => {
|
||||
const guid = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
it('returns a valid GUID (trimmed, from a resource locator)', () => {
|
||||
expect(assertValidRecordId(ctx, 0, ` ${guid} `)).toBe(guid);
|
||||
expect(assertValidRecordId(ctx, 0, { mode: 'list', value: guid })).toBe(guid);
|
||||
});
|
||||
|
||||
it('rejects a non-GUID value, naming the parameter', () => {
|
||||
expect(() => assertValidRecordId(ctx, 0, 'abc-123')).toThrow(/must be a record GUID/);
|
||||
expect(() => assertValidRecordId(ctx, 0, 'abc-123', 'rowId')).toThrow(/"rowId"/);
|
||||
});
|
||||
|
||||
it('rejects a GUID carrying URL-breaking characters', () => {
|
||||
expect(() => assertValidRecordId(ctx, 0, `${guid}?$select=x`)).toThrow(
|
||||
/must be a record GUID/,
|
||||
);
|
||||
expect(() => assertValidRecordId(ctx, 0, `${guid}#frag`)).toThrow(/must be a record GUID/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertValidAlternateKey', () => {
|
||||
it('returns a predicate with quotes and commas untouched', () => {
|
||||
expect(assertValidAlternateKey(ctx, 0, " accountnumber='ACC-001' ")).toBe(
|
||||
"accountnumber='ACC-001'",
|
||||
);
|
||||
expect(assertValidAlternateKey(ctx, 0, "key1='a',key2=123")).toBe("key1='a',key2=123");
|
||||
});
|
||||
|
||||
it('rejects URL delimiters and path separators, naming the parameter', () => {
|
||||
expect(() => assertValidAlternateKey(ctx, 0, "name='a?b'")).toThrow(/"alternateKey"/);
|
||||
expect(() => assertValidAlternateKey(ctx, 0, "name='a#b'")).toThrow(/must not contain/);
|
||||
expect(() => assertValidAlternateKey(ctx, 0, "name='a/b'")).toThrow(/must not contain/);
|
||||
expect(() => assertValidAlternateKey(ctx, 0, "name='a\\b'")).toThrow(/must not contain/);
|
||||
});
|
||||
|
||||
it.each(["name='a\0b'", "name='a\tb'", "name='a\nb'"])(
|
||||
'rejects ASCII control characters in %j',
|
||||
(predicate) => {
|
||||
expect(() => assertValidAlternateKey(ctx, 0, predicate)).toThrow(/control characters/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('assertNonEmptyBody', () => {
|
||||
it('returns the body when it has at least one field', () => {
|
||||
expect(assertNonEmptyBody(ctx, 0, { name: 'Acme' }, 'Add a New Row')).toEqual({
|
||||
name: 'Acme',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for an empty body, naming the operation', () => {
|
||||
expect(() => assertNonEmptyBody(ctx, 0, {}, 'Add a New Row')).toThrow(
|
||||
/Add a New Row requires at least one field/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPartitionIdToQuery', () => {
|
||||
it('adds a trimmed partitionId to the query string', () => {
|
||||
expect(applyPartitionIdToQuery({}, ' p1 ')).toEqual({ partitionId: 'p1' });
|
||||
});
|
||||
|
||||
it('leaves the query string untouched for empty or non-string input', () => {
|
||||
expect(applyPartitionIdToQuery({}, ' ')).toEqual({});
|
||||
expect(applyPartitionIdToQuery({}, undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeRequest', () => {
|
||||
it('dispatches a single-shot request folding partitionId into the query', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({ id: 'row-1' });
|
||||
|
||||
const result = await executeRequest(ctx, CREDENTIAL_TYPE, {
|
||||
method: 'GET',
|
||||
path: '/accounts',
|
||||
options: { partitionId: 'p1', returnFullMetadata: true },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: 'row-1' });
|
||||
expect(dataverseApiRequestAllItems).not.toHaveBeenCalled();
|
||||
const [, method, path, body, qs, headers] = vi.mocked(dataverseApiRequest).mock.calls[0];
|
||||
expect(method).toBe('GET');
|
||||
expect(path).toBe('/accounts');
|
||||
expect(body).toEqual({});
|
||||
expect(qs).toEqual({ partitionId: 'p1' });
|
||||
expect(headers?.Prefer).toBe('odata.include-annotations="*"');
|
||||
});
|
||||
|
||||
it('omits the Prefer header when no metadata or prefer options are set', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({});
|
||||
|
||||
await executeRequest(ctx, CREDENTIAL_TYPE, {
|
||||
method: 'GET',
|
||||
path: '/accounts',
|
||||
options: { returnFullMetadata: false },
|
||||
});
|
||||
|
||||
const [, , , , , headers] = vi.mocked(dataverseApiRequest).mock.calls[0];
|
||||
expect(headers?.Prefer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets the computed Prefer override a caller-supplied extraHeaders.Prefer', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({});
|
||||
|
||||
await executeRequest(ctx, CREDENTIAL_TYPE, {
|
||||
method: 'POST',
|
||||
path: '/accounts',
|
||||
extraHeaders: { Prefer: 'caller-value' },
|
||||
prefer: { returnRepresentation: true },
|
||||
});
|
||||
|
||||
const [, , , , , headers] = vi.mocked(dataverseApiRequest).mock.calls[0];
|
||||
// Current behavior: computed Prefer wins (caller value is dropped).
|
||||
expect(headers?.Prefer).toBe('return=representation');
|
||||
});
|
||||
|
||||
it('forwards the credential type on the single-shot path', async () => {
|
||||
vi.mocked(dataverseApiRequest).mockResolvedValue({});
|
||||
|
||||
await executeRequest(ctx, CREDENTIAL_TYPE, { method: 'GET', path: '/accounts' });
|
||||
|
||||
const [, , , , , , credType] = vi.mocked(dataverseApiRequest).mock.calls[0];
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
|
||||
it('dispatches a paged request when plan.paged is set', async () => {
|
||||
vi.mocked(dataverseApiRequestAllItems).mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const result = await executeRequest(ctx, CREDENTIAL_TYPE, {
|
||||
method: 'GET',
|
||||
path: '/accounts',
|
||||
paged: true,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: 1 }]);
|
||||
expect(dataverseApiRequest).not.toHaveBeenCalled();
|
||||
const [, , , , limit] = vi.mocked(dataverseApiRequestAllItems).mock.calls[0];
|
||||
expect(limit).toBe(10);
|
||||
});
|
||||
|
||||
it('folds partitionId and returnFullMetadata into the paged request', async () => {
|
||||
vi.mocked(dataverseApiRequestAllItems).mockResolvedValue([]);
|
||||
|
||||
await executeRequest(ctx, CREDENTIAL_TYPE, {
|
||||
method: 'GET',
|
||||
path: '/accounts',
|
||||
qs: { $select: 'name' },
|
||||
paged: true,
|
||||
options: { partitionId: 'p1', returnFullMetadata: true },
|
||||
});
|
||||
|
||||
const [, , , qs, , credType, headers] = vi.mocked(dataverseApiRequestAllItems).mock.calls[0];
|
||||
expect(qs).toEqual({ $select: 'name', partitionId: 'p1' });
|
||||
expect(headers?.Prefer).toBe('odata.include-annotations="*"');
|
||||
expect(credType).toBe(CREDENTIAL_TYPE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -258,6 +258,7 @@
|
|||
"dist/credentials/MetabaseApi.credentials.js",
|
||||
"dist/credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials.js",
|
||||
"dist/credentials/MicrosoftAzureMonitorOAuth2Api.credentials.js",
|
||||
"dist/credentials/MicrosoftDataverseOAuth2Api.credentials.js",
|
||||
"dist/credentials/MicrosoftDynamicsOAuth2Api.credentials.js",
|
||||
"dist/credentials/MicrosoftEntraOAuth2Api.credentials.js",
|
||||
"dist/credentials/MicrosoftEntraServicePrincipalApi.credentials.js",
|
||||
|
|
@ -687,6 +688,7 @@
|
|||
"dist/nodes/MessageBird/MessageBird.node.js",
|
||||
"dist/nodes/Metabase/Metabase.node.js",
|
||||
"dist/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.js",
|
||||
"dist/nodes/Microsoft/Dataverse/MicrosoftDataverse.node.js",
|
||||
"dist/nodes/Microsoft/Dynamics/MicrosoftDynamicsCrm.node.js",
|
||||
"dist/nodes/Microsoft/Entra/MicrosoftEntra.node.js",
|
||||
"dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user