@mqa/dataverse-client
v1.0.0
Published
V1-compatible Dataverse / Dynamics 365 Web API client for Node.js. Drop-in replacement for [email protected] with a cached MSAL client-credentials token provider.
Maintainers
Readme
@mqa/dataverse-client
A V1-compatible Microsoft Dataverse / Dynamics 365 Web API
client for Node.js, built on axios and @azure/msal-node.
It is a drop-in replacement for [email protected]: method names, argument order and
response shapes are unchanged, so existing call sites can be swapped mechanically.
- TypeScript, compiled to CommonJS — works with
require()in plain-JS apps and withimport. - Ships compiled
.js+.d.tstype declarations + source maps. - Cached MSAL client-credentials token provider (
getAccessToken()/getAccessTokenCallback()). - Automatic paging over
@odata.nextLinkfor collection queries.
Install
npm install @mqa/dataverse-clientNode.js >= 18 is required.
Quick start
const { DataverseClient, getAccessTokenCallback } = require('@mqa/dataverse-client');
// 1. Provide a token source (see "Token provider" below).
// getAccessTokenCallback() is a ready-made MSAL client-credentials callback:
const onTokenRefresh = getAccessTokenCallback();
// 2. Create the client.
const client = new DataverseClient({
webApiUrl: process.env.D365_WEBAPI_URL, // e.g. https://org.api.crm.dynamics.com/api/data/v9.2/
onTokenRefresh,
});
// 3. Use it.
async function main() {
const contact = await client.retrieve('11111111-1111-1111-1111-111111111111', 'contacts', [
'fullname',
'emailaddress1',
]);
console.log(contact?.fullname);
}Configuration
interface DataverseClientConfig {
/** Full Web API URL (trailing slash optional, normalized internally). */
webApiUrl?: string;
/** Alternative: organization URL, e.g. https://org.crm.dynamics.com. */
serverUrl?: string;
/** Used with serverUrl. Defaults to version "9.2". */
dataApi?: { version?: string } | string;
/** V1-compatible token callback: called with a (token) => void callback. */
onTokenRefresh?: (callback: (token: string) => void, errorCallback?: (error: unknown) => void) => void;
/** Static bearer token. When set, onTokenRefresh is bypassed. */
token?: string;
/** Impersonate a Dynamics user on every request (sets MSCRMCallerID). */
impersonate?: string;
/** Request timeout in milliseconds. */
timeout?: number;
/** Prefer: odata.maxpagesize for collection requests. */
maxPageSize?: number;
}Exactly one of webApiUrl or serverUrl is required. Provide either token or
onTokenRefresh (requests reject with a clear error if neither is set).
Token provider
getAccessToken() returns a Promise of a client-credentials access token. A single cached
ConfidentialClientApplication is reused (tokens cached, refreshed on expiry), concurrent
calls share one in-flight acquisition, and failures reject rather than being swallowed.
const { getAccessToken } = require('@mqa/dataverse-client');
const token = await getAccessToken();Configuration comes from environment variables:
| Variable | Description |
| ------------------- | ------------------------------------------------- |
| MSAL_CLIENTID | Azure AD / Entra ID app (client) id |
| MSAL_AUTHORITY | e.g. https://login.microsoftonline.com/<tenant> |
| MSAL_CLIENTSECRET | Client secret |
| MSAL_SCOPE | e.g. https://org.api.crm.dynamics.com/.default |
getAccessTokenCallback() returns a v1-style (callback, errorCallback) => void function
suitable for onTokenRefresh. It accepts an optional second argument so authentication
failures can reject the pending request instead of hanging.
API reference
All methods return Promises. collection is an entity set name (e.g. contacts).
Entity = Record<string, any>.
retrieve(key, collection, select?, expand?)
const contact = await client.retrieve(
'11111111-1111-1111-1111-111111111111',
'contacts',
['fullname'],
[{ property: 'aas_campaigncode', select: ['name', 'statecode'] }],
);Resolves a single entity. Resolves null when the record is not found (HTTP 404) — check
with if (!contact) throw .... Expanded navigation properties appear as direct properties.
retrieveMultiple(collection, select?, filter?)
const entities = await client.retrieveMultiple('contacts', ['fullname'], 'statecode eq 0');
entities.value.forEach((e) => {});Resolves { value: Entity[], ... }. filter is a plain OData $filter string (may be
omitted). Pages are fetched internally over @odata.nextLink, so .value always contains
all pages. See Error handling for the { value: [], error } behavior.
create(object, collection, prefer?, select?)
const id = await client.create({ firstname: 'John' }, 'contacts'); // -> new GUID string
// or, to get the created entity back:
const entity = await client.create(obj, 'contacts', ['return=representation'], ['fullname']);prefer may be an array (["return=representation"]), a string, or null. Without
return=representation the new record's GUID string is resolved (from the Location
header); with it, the created entity is resolved. Payloads are passed through verbatim,
including @odata.bind navigation properties.
update(key, collection, object, prefer?, select?)
await client.update('11111111-1111-1111-1111-111111111111', 'contacts', { firstname: 'Jane' });Without prefer, resolves undefined when done; with return=representation it resolves the
updated entity.
deleteRecord(key, collection)
await client.deleteRecord('11111111-1111-1111-1111-111111111111', 'contacts');Resolves when done.
retrieveRequest(request)
const contact = await client.retrieveRequest({ collection: 'contacts', key: id, select: ['fullname'] });Same as retrieve; 404 resolves to null.
retrieveMultipleRequest(request)
const result = await client.retrieveMultipleRequest({
collection: 'contacts',
select: ['fullname'],
filter: 'statecode eq 0',
orderBy: ['createdon desc'],
top: 1,
});Resolves { value: Entity[], ... } with paging, $orderby (string[]) and $top (number)
support.
updateRequest(request)
const entity = await client.updateRequest({
key: id,
collection: 'contacts',
entity: { firstname: 'Jane' },
returnRepresentation: true,
select: ['fullname'],
impersonate: '22222222-2222-2222-2222-222222222222', // optional per-request user
});impersonate (a user GUID) is applied per-request via the MSCRMCallerID header. With
returnRepresentation: true resolves the updated entity, otherwise void.
executeBoundAction(id, collection, actionName)
await client.executeBoundAction(
'11111111-1111-1111-1111-111111111111',
'products',
'Microsoft.Dynamics.CRM.PublishProductHierarchy',
);
// id may also be an entity object; its primary key is extracted:
await client.executeBoundAction(createdProduct, 'products', 'Microsoft.Dynamics.CRM.PublishProductHierarchy');When id is an entity object, the primary key is extracted from {collection}id
(e.g. productid), then id, then @odata.id.
executeUnboundAction(actionName, requestObject?)
const requestObject = {
ToastType: 200000000,
Title: 'Hello',
getMetadata: function () {
return {
boundParameter: null,
parameterTypes: { ToastType: { typeName: 'Edm.Int32', structuralProperty: 1 } },
operationType: 0,
operationName: 'SendAppNotification',
};
},
};
await client.executeUnboundAction('SendAppNotification', requestObject);If requestObject has a getMetadata() function, it is called to build the metadata payload
and the function itself is stripped from the body sent over the wire (matching v1).
executeFetchXml(collection, fetchXml)
const result = await client.executeFetchXml('contacts', '<fetch><entity name="contact">...</entity></fetch>');
result.value.forEach((e) => {});Resolves { value: Entity[], ... }. page="1" is injected into the <fetch> element when
paging is not already configured. The passed collection is used verbatim for the URL.
executeFetchXmlAll(collection, fetchXml)
Same as executeFetchXml but loops internally over @odata.nextLink until exhausted, so
.value contains every record.
Error handling
- Non-2xx responses reject with an error object carrying HTTP status plus the parsed error
payload — callers can inspect
e.status === 400/406/422,e.error === 'application',e.code, ande.messageexactly like v1. retrieve/retrieveRequest: HTTP 404 resolves tonull(not a rejection).retrieveMultiple/retrieveMultipleRequest: when the server returns an error payload, they resolve{ value: [], error, status, message, ... }instead of rejecting, so call sites that checkif (!result.error)short-circuit and never crash. Transport failures (network, DNS, timeout) and token-refresh failures still reject.executeFetchXml/executeFetchXmlAll: non-2xx rejects (catch and inspecte.status); a 2xx body that carries.erroris resolved as-is.- Token refresh failures reject the pending request(s) — nothing is silently swallowed.
Import styles
// CommonJS
const { DataverseClient, getAccessToken, getAccessTokenCallback } = require('@mqa/dataverse-client');
const DataverseClientDefault = require('@mqa/dataverse-client').default;
// ES modules
import { DataverseClient } from '@mqa/dataverse-client';
import DataverseClient from '@mqa/dataverse-client';Migration note — swap from [email protected]
@mqa/dataverse-client exposes the same method names, argument order and response shapes as
[email protected], so only the require / new lines and config change. No method
rename and no argument reshaping is needed across ~150 existing call sites.
Before:
const DynamicsWebApi = require('dynamics-web-api');
const { getTokenCallback } = require('./msal_token');
const dynamicsWebApi = new DynamicsWebApi({
webApiUrl: process.env.D365_WEBAPI_URL,
onTokenRefresh: getTokenCallback,
});After:
const { DataverseClient } = require('@mqa/dataverse-client');
const { getAccessTokenCallback } = require('@mqa/dataverse-client'); // or keep your own callback
const dynamicsWebApi = new DataverseClient({
webApiUrl: process.env.D365_WEBAPI_URL,
onTokenRefresh: getAccessTokenCallback(), // v1-style: call it to get the callback
});Notes:
new DataverseClient({ webApiUrl, onTokenRefresh })— same shape as v1. The oldgetTokenCallbackhelper also works unchanged;getAccessTokenCallback()just adds caching and failure propagation.- If a static token is available, pass
token: "<jwt>"and omitonTokenRefresh. webApiUrlmay keep a trailing slash — it is normalized internally.- Intentional deviation:
retrieveMultiple/retrieveMultipleRequestresolve{ value: [], error }on server error payloads instead of rejecting. Sites that previously caught and re-checkede.statusthere can instead checkresult.statuson the resolved object; existingif (!result.error)guards already behave correctly. - Everything else (retrieve 404 →
null, prefer semantics, impersonation, paging, fetch XML, bound/unbound actions,getMetadatahandling) mirrors v1.
Development
npm install
npm run lint
npm test
npm run test:coverage
npm run buildnpm run build emits CommonJS + .d.ts + source maps into dist/. The package is published
via GitHub Actions on a v* tag push; npm publish is never run without an explicit release.
License
MIT
