@hso/d365-cli
Version:
Dynamics 365 Command Line Interface for TypeScript projects for Dataverse
437 lines (399 loc) • 22.6 kB
text/typescript
import {Http, jsonHttpHeaders} from '../Http/Http';
const filterConditions = ['eq' , 'ne', 'gt', 'ge', 'lt', 'le'] as const; // See SystemQueryOptions type FilterCondition
interface Binding {
[index:string]: string;
}
interface RelationMetadata {
ReferencingEntityNavigationPropertyName: string;
ReferencedEntity: string;
ReferencingEntity: string;
ReferencingAttribute: string;
}
type ActionData<K extends string, T> = {
[P in K]?: T
}
const dateReviver = <V>(key: string, value: V | Date): V | Date => {
if (typeof value === 'string') {
const d = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?::(\d*))?Z$/.exec(value);
if (d) {
return new Date(Date.UTC(+d[1], +d[2] - 1, +d[3], +d[4], +d[5], +d[6], +d[7] || 0));
}
}
return value;
};
export class WebApi {
public static get apiUrl(): string {
const globalContext = Xrm.Utility.getGlobalContext(),
clientUrl = globalContext.getClientUrl(),
version = globalContext.getVersion().split('.').splice(0, 2).join('.');
return `${clientUrl}/api/data/v${version}/`;
}
public static async retrieveMultipleRecords(entityLogicalName: string, options: MultipleSystemQueryOptions, maxPageSize?: number): Promise<Model[]> {
const systemQueryOptions = await WebApi.getSystemQueryOptions(entityLogicalName, options),
result = await Xrm.WebApi.retrieveMultipleRecords(entityLogicalName, systemQueryOptions, maxPageSize);
return WebApi.parseModels(result.entities, options);
}
public static async retrieveRecord(entityLogicalName: string, id: string, options: SystemQueryOptions): Promise<Model> {
const systemQueryOptions = await WebApi.getSystemQueryOptions(entityLogicalName, options),
entity = await Xrm.WebApi.retrieveRecord(entityLogicalName, id, systemQueryOptions);
return WebApi.parseModel(entity, options);
}
public static async updateRecord(entityLogicalName: string, id: string, model: Model): Promise<Model> {
const attributes = Object.keys(model),
metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName, attributes),
requestData = await WebApi.populateBindings(model, metadata);
if (!id) {
id = requestData[metadata.PrimaryIdAttribute as keyof Model] as string;
}
delete requestData[metadata.PrimaryIdAttribute as keyof Model];
for (const attribute of attributes) {
const attributeMetadata = metadata.Attributes.get(attribute),
attributeType = attributeMetadata && attributeMetadata.AttributeType;
if (attributeType === 6) { // Lookup
const bindingId = requestData[attribute as keyof Model];
if (!bindingId) {
await WebApi.disassociateEntity(entityLogicalName, id, attribute);
}
}
if ((await WebApi.getManyToOneMetadata(attribute, metadata) && typeof requestData[attribute as keyof Model] !== 'string')) {
// navigationProperty maybe equal to attribute name
delete requestData[attribute as keyof Model];
}
}
await Xrm.WebApi.updateRecord(entityLogicalName, id, requestData);
return model;
}
public static async createRecord(entityLogicalName: string, model: Model): Promise<Model> {
const attributes = Object.keys(model),
metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName, attributes),
requestData = await WebApi.populateBindings(model, metadata);
/*for (const attribute of attributes) {
if ((await WebApi.getManyToOneMetadata(attribute, metadata) && typeof requestData[attribute] !== 'string')) {
// navigationProperty maybe equal to attribute name
delete requestData[attribute];
}
}*/
const result = await Xrm.WebApi.createRecord(entityLogicalName, requestData);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
model[metadata.PrimaryIdAttribute] = result.id;
return model;
}
public static async upsertRecord(entityLogicalName: string, model: Model): Promise<Model> {
const metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName);
const primaryId = model[metadata.PrimaryIdAttribute as keyof Model] as string;
if (Object.keys(model).includes(metadata.PrimaryIdAttribute) && primaryId) {
return this.updateRecord(entityLogicalName, primaryId, model);
} else {
return this.createRecord(entityLogicalName, model);
}
}
public static async count(entityLogicalName: string, filters?: Filter[]): Promise<number> {
const attributes = WebApi.getMetadataAttributes([], filters),
metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName, attributes),
entitySetName = metadata.EntitySetName,
primaryIdAttribute = metadata.PrimaryIdAttribute,
$filter = await WebApi.generateFilter(filters, metadata),
optionParts = ['$count=true', `$select=${primaryIdAttribute}`, '$top=1'];
if ($filter) {
optionParts.push($filter);
}
const uri = `${entitySetName}?${optionParts.join('&')}`,
request = await WebApi.request('GET', uri),
data = JSON.parse(request.response);
return data['@odata.count'];
}
// https://docs.microsoft.com/en-us/dynamics365/customer-engagement/developer/webapi/associate-disassociate-entities-using-web-api
public static async disassociateEntity(entityLogicalName: string, id: string, relAttribute: string, relId?: string): Promise<XMLHttpRequest> {
const metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName),
entitySetName = metadata.EntitySetName,
relMetadata = await WebApi.getManyToOneMetadata(relAttribute, metadata),
{ReferencingEntityNavigationPropertyName: navigationPropertyName, ReferencingEntity: relEntityLogicalName} = relMetadata;
let uri = `${entitySetName}(${id})/${navigationPropertyName}/$ref`;
if (relId) {
const relEntityMetadata = await Xrm.Utility.getEntityMetadata(relEntityLogicalName),
relEntitySetName = relEntityMetadata.EntitySetName;
uri += `?$id=${this.apiUrl}${relEntitySetName}(${relId})`;
}
return WebApi.request('DELETE', uri);
}
public static async executeFunction(functionName: string): Promise<JSON> {
const xmlHttpRequest = await WebApi.request('GET', `${functionName}`);
return xmlHttpRequest.response && JSON.parse(xmlHttpRequest.response, dateReviver);
}
public static async executeAction<R, I extends string, V>(actionName: string, data?: ActionData<I, V>, entityLogicalName?: string, id?: string): Promise<R> {
if (entityLogicalName) {
return this.executeBoundAction(actionName, data, entityLogicalName, id);
} else {
return this.executeUnboundAction(actionName, data);
}
}
private static async executeBoundAction<R>(actionName: string, data: unknown, entityLogicalName: string, id: string): Promise<R> {
const metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName),
xmlHttpRequest = await WebApi.request('POST', `${metadata.EntitySetName}(${id})/Microsoft.Dynamics.CRM.${actionName}`, data);
return xmlHttpRequest.response && JSON.parse(xmlHttpRequest.response, dateReviver);
}
private static async executeUnboundAction<R>(actionName: string, data?: unknown): Promise<R> {
const xmlHttpRequest = await WebApi.request('POST', `${actionName}`, data);
return xmlHttpRequest.response && JSON.parse(xmlHttpRequest.response, dateReviver);
}
public static async populateBindings(model: Model, metadata: Xrm.Metadata.EntityMetadata): Promise<Model> {
const attributes = Object.keys(model),
requestData = {...model};
for (const attribute of attributes) {
const attributeMetadata = metadata.Attributes.get(attribute);
if (attributeMetadata) {
if ([1, 6, 9].includes(attributeMetadata.AttributeType)) { // Customer, Lookup, Owner
const bindingId = requestData[attribute as keyof Model] as string;
if (bindingId) {
const targetEntity = model[`_${attribute}_value@Microsoft.Dynamics.CRM.lookuplogicalname` as unknown as keyof Model] as string;
const binding = await WebApi.getBinding(attribute, bindingId, metadata, targetEntity);
Object.assign(requestData, binding);
}
delete requestData[attribute as keyof Model];
delete requestData[`_${attribute}_value@Microsoft.Dynamics.CRM.lookuplogicalname` as unknown as keyof Model];
}
}
}
return requestData;
}
public static async getBinding(attribute: string, id: string, metadata: Xrm.Metadata.EntityMetadata, targetEntity?: string): Promise<Binding> {
const manyToOneMetadata = await WebApi.getManyToOneMetadata(attribute, metadata, targetEntity),
{ReferencedEntity: referencedEntity, ReferencingEntityNavigationPropertyName: referencingEntityNavigationPropertyName} = manyToOneMetadata,
referencedMetadata = await Xrm.Utility.getEntityMetadata(referencedEntity),
referencedEntitySetName = referencedMetadata.EntitySetName,
key = `${referencingEntityNavigationPropertyName}@odata.bind`,
cleanId = id.replace('{', '').replace('}', ''),
binding: Binding = {};
binding[key] = `/${referencedEntitySetName}(${cleanId})`;
return binding;
}
private static async request<D>(method: Method, uri: string, data?: D, httpHeaders: JsonHttpHeaders = jsonHttpHeaders): Promise<XMLHttpRequest> {
const url = `${this.apiUrl}${uri}`;
try {
return await Http.request(method, url, data, httpHeaders);
} catch (e) {
const responseJSON = JSON.parse(e.message);
throw new Error(responseJSON.error.message);
}
}
private static parseModels(models: Model[], options: SystemQueryOptions): Model[] {
for (const model of models) {
WebApi.parseModel(model, options);
}
return models;
}
private static parseModel(model: Model, options: SystemQueryOptions): Model {
const {select, expands = []} = options;
WebApi.parseValues(model, select);
for (const expand of expands) {
WebApi.parseValues(model[expand.attribute as keyof Model] as Model, expand.select);
}
return model;
}
private static parseValues(model: Model, select: string[] = []): Model {
if (!model) {
return model;
}
const modelKeys = Object.keys(model);
for (const attribute of select) {
if (modelKeys.includes(`_${attribute}_value`)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
model[attribute] = model[`_${attribute}_value` as keyof Model];
}
if (modelKeys.includes(`_${attribute}_value@Microsoft.Dynamics.CRM.lookuplogicalname`)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
model[`${attribute}@Microsoft.Dynamics.CRM.lookuplogicalname`] = model[`_${attribute}_value@Microsoft.Dynamics.CRM.lookuplogicalname`];
}
if (modelKeys.includes(`_${attribute}_value@Microsoft.Dynamics.CRM.associatednavigationproperty`)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line max-len
model[`${attribute}@Microsoft.Dynamics.CRM.associatednavigationproperty`] = model[`_${attribute}_value@Microsoft.Dynamics.CRM.associatednavigationproperty`];
}
if (modelKeys.includes(`_${attribute}_value@OData.Community.Display.V1.FormattedValue`)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
model[`${attribute}@OData.Community.Display.V1.FormattedValue`] = model[`_${attribute}_value@OData.Community.Display.V1.FormattedValue`];
}
}
return model;
}
private static async getSystemQueryOptions(entityLogicalName: string, options: MultipleSystemQueryOptions): Promise<string> {
const {select, filters, expands, orders, top} = options,
attributes = WebApi.getMetadataAttributes(select, filters, expands),
metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName, attributes),
$select = await WebApi.generateSelect(select, metadata),
$filter = await WebApi.generateFilter(filters, metadata),
$expand = await WebApi.generateExpand(expands, metadata),
$orderby = await WebApi.generateOrderby(orders, metadata),
$top = top ? `$top=${top}` : null,
optionParts = [];
if ($select) {
optionParts.push($select);
}
if ($filter) {
optionParts.push($filter);
}
if ($expand) {
optionParts.push($expand);
}
if ($orderby) {
optionParts.push($orderby);
}
if ($top) {
optionParts.push($top);
}
return optionParts.length > 0 ? `?${optionParts.join('&')}` : '';
}
private static async generateSelect(attributes: string[] = [], metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const selectAttributes: string[] = [];
if (attributes.length > 0) {
for (const attribute of attributes) {
selectAttributes.push(await WebApi.getOptionsName(attribute, metadata));
}
}
return selectAttributes.length > 0 ? `$select=${selectAttributes.join(',')}` : null;
}
private static async generateFilter(filters: Filter[] = [], metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const filterAttributes: string[] = [];
if (filters.length > 0) {
for (const filter of filters) {
filterAttributes.push(await WebApi.parseFilter(filter, metadata));
}
}
return filterAttributes.length > 0 ? `$filter=${filterAttributes.join(' and ')}` : null;
}
private static async parseFilter(filter: Filter, metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const {type = 'and', conditions, filters = []} = filter,
filterParts: string[] = [];
for (const condition of conditions) {
const {operator = 'eq'} = condition;
if (filterConditions.includes(operator as FilterCondition)) {
filterParts.push(await WebApi.parseFilterCondition(condition, metadata));
} else {
filterParts.push(await WebApi.parseQueryFunction(condition, metadata));
}
}
for (const fltr of filters) {
filterParts.push(await WebApi.parseFilter(fltr, metadata));
}
return `(${filterParts.join(` ${type} `)})`;
}
private static async parseFilterCondition(condition: Condition, metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const {attribute, operator = 'eq', value} = condition,
attributeMetadata = metadata.Attributes.get(attribute),
attributeType = attributeMetadata && attributeMetadata.AttributeType,
optionsName = await WebApi.getOptionsName(attribute, metadata),
valueEscaped = attributeType === 14 ? `'${value}'` : `${value}`;
return `${optionsName} ${operator} ${valueEscaped}`;
}
private static async parseQueryFunction(condition: Condition, metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const {attribute, operator = 'eq', value} = condition,
optionsName = await WebApi.getOptionsName(attribute, metadata);
let filterStr = `Microsoft.Dynamics.CRM.${operator}(PropertyName='${optionsName}'`;
if (value !== undefined) {
if (Array.isArray(value)) {
const values = value.map(val => `'${val}'`);
filterStr += `,PropertyValues=[${values.join(',')}]`;
} else {
filterStr += `,PropertyValue='${value}'`;
}
}
filterStr += `)`;
return filterStr;
}
private static async generateExpand(expands: Expand[] = [], metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const expandAttributes: string[] = [];
if (expands.length > 0) {
for (const expand of expands) {
const {attribute, select} = expand;
let navigationPropertyName = attribute;
if (select && select.length > 0) {
const manyToOneMetadata = await WebApi.getManyToOneMetadata(attribute, metadata);
const {ReferencedEntity} = manyToOneMetadata,
referencedMetadata = await Xrm.Utility.getEntityMetadata(ReferencedEntity, select),
selectOptionNames: string[] = [];
for (const selectName of select) {
const optionName = await WebApi.getOptionsName(selectName, referencedMetadata);
selectOptionNames.push(optionName);
}
navigationPropertyName += `($select=${selectOptionNames.join(',')})`;
}
expandAttributes.push(navigationPropertyName);
}
}
return expandAttributes.length > 0 ? `$expand=${expandAttributes.join(',')}` : null;
}
private static async generateOrderby(orders: OrderBy[] = [], metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const orderParts: string[] = [];
for (const {attribute, order} of orders) {
const optionsName = await WebApi.getOptionsName(attribute, metadata);
orderParts.push(`${optionsName} ${order || 'asc'}`);
}
return orderParts.length > 0 ? `$orderby=${orderParts.join(',')}` : null;
}
private static async getOptionsName(attribute: string, metadata: Xrm.Metadata.EntityMetadata): Promise<string> {
const isLookupAttribute: boolean = await WebApi.isLookupAttribute(attribute, metadata);
return isLookupAttribute ? `_${attribute}_value` : attribute;
}
private static async isLookupAttribute(attribute: string, metadata: Xrm.Metadata.EntityMetadata): Promise<boolean> {
if ((metadata as unknown as {ManyToOneRelationships: unknown }).ManyToOneRelationships) {
const attributeMetadata = metadata.Attributes.get(attribute),
attributeType = attributeMetadata && attributeMetadata.AttributeType;
return [1, 6].includes(attributeType); // 1: Customer, 6: Lookup
} else {
const manyToOneMetadata = await WebApi.getManyToOneMetadata(attribute, metadata);
return !!manyToOneMetadata;
}
}
public static async getManyToOneMetadata(attribute: string, metadata: Xrm.Metadata.EntityMetadata, targetEntity?: string): Promise<RelationMetadata> {
const manyToOneMetadatas = await WebApi.getManyToOneMetadatas(metadata);
let relationMetadata = manyToOneMetadatas.find(relMetadata => {
const {ReferencingEntityNavigationPropertyName} = relMetadata;
return ReferencingEntityNavigationPropertyName === attribute;
});
if (!relationMetadata) {
relationMetadata = manyToOneMetadatas.find(relMetadata => {
const {ReferencingAttribute, ReferencedEntity} = relMetadata;
return ReferencingAttribute === attribute && (!targetEntity || targetEntity === ReferencedEntity);
});
}
return relationMetadata ? relationMetadata : null;
}
private static async getManyToOneMetadatas(metadata: Xrm.Metadata.EntityMetadata): Promise<RelationMetadata[]> {
const manyToOneRelationships = (metadata as unknown as {ManyToOneRelationships: {getAll: () => RelationMetadata[]} }).ManyToOneRelationships;
if (manyToOneRelationships) {
return manyToOneRelationships.getAll();
} else {
const uri = `EntityDefinitions(LogicalName='${metadata.LogicalName}')/ManyToOneRelationships`;
const request = await WebApi.request('GET', uri);
const {value: manyToOneMetadatas} = JSON.parse(request.response);
return manyToOneMetadatas;
}
}
public static async getAttributesMetadata(entityLogicalName: string, select: string[]): Promise<unknown[]> {
const uri = `EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes?$select=${select.join(',')}`,
request = await WebApi.request('GET', uri);
const {value: attributesMetadata} = JSON.parse(request.response);
return attributesMetadata;
}
private static getMetadataAttributes(attributes: string[] = [], filters: Filter[] = [], expands: Expand[] = []): string[] {
const metadataAttributes = [...attributes];
for (const {conditions} of filters) {
for (const {attribute} of conditions) {
if (!metadataAttributes.includes(attribute)) {
metadataAttributes.push(attribute);
}
}
}
for (const {attribute} of expands) {
if (!metadataAttributes.includes(attribute)) {
metadataAttributes.push(attribute);
}
}
return metadataAttributes;
}
}