On this occasion, I am here to share a piece of code that has been very useful when cosuming Dynamics 365 Finance and Operations data entities, whether they are standard entities or custom entities, from any type of application written in C#. In my specific case, it is a piece of code written to be consumed from Azure Functions.
Table of Contents
As many of you know, Microsoft has at our disposal a sample code in its GitHub repository to consume these entities using the OData Client. It is a more than valid example, which I have used more than once, and which I also highly recommend to learn how they work, as well as authentication through Azure Active Directory, but in my particular case, I decided to make this helper with http calls through the .NET standard HttpClient in order to generate a much lighter code, without the need to generate the large number of proxy classes that the OData client generates to be able to use all the entities of Dynamics 365 F&O.
As you already know at this point in the story, I am not an expert in C# code, I am not even close to being one, therefore, if thougout this post you see any point of improvement, I will be more thant grateful if you leave me a comment with the aim of improving it :).
One consideration to take in account when writting code to consume data entities, is that we can receive a 429 error at any time, due to the throttling priority, so, make sure you develop a consistent retry pattern to deal with it.
Without further ado, I am going to show you the generic code that I have written to be able to use it in a generic way with any of the existing entities in the system, which, as you already know, are not few.
As you will see below, I have developed a method for each of the operations that we can perform. These are GET, POST, PATCH and DELETE.
Authentication
The first step to be able to consume data entities is to get the access token through Azure Active Directory. To do this, we will need to generate an application registration in our tenant. To do this, you can follow the instructions I wrote in this post.
Next, we generate a data contract, which allows us to easily interact with the response we get when obtaining the token.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
[DataContract] public class TokenDC { [DataMember] public string token_type { get; set; } [DataMember] public string expires_in { get; set; } [DataMember] public string ext_expires_in { get; set; } [DataMember] public string expires_on { get; set; } [DataMember] public string not_before { get; set; } [DataMember] public string resource { get; set; } [DataMember] public string access_token { get; set; } } |
Now we can see, in a simple way, the method that we will use to obtain the access token, where:
– domain is your Azure tenant, for example, jatomas.com
– clientId is the clientId of the App Registration
– clientSecret is the secret generated within the App Registration
– resource is your F&O instance url (without the final bar ‘/’)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
public static string GetToken() { try { var domain = "tenant_azure" var clientId = "aad_client_id"; var clientSecret = "aad_client_secret"; var resource = "url_d365fo_without_bar"; HttpClient client = new HttpClient(); string requestUrl = $"https://login.microsoftonline.com/{domain}/oauth2/token"; string request_content = $"grant_type=client_credentials&resource={resource}&client_id={clientId}&client_secret={clientSecret}"; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUrl); try { request.Content = new StringContent(request_content, Encoding.UTF8, "application/x-www-form-urlencoded"); } catch (Exception ex) { var msg = ex.Message; } HttpResponseMessage response = client.SendAsync(request).Result; string responseString = response.Content.ReadAsStringAsync().Result; var token = JSONSerializer<TokenDC>.DeSerialize(responseString); var accessToken = token.access_token; return accessToken; } catch (Exception ex) { var message = $"There was an error retrieving the token:\r\n{ex.Message}"; throw new Exception(message); } } |
Once we have the access token, we can continue, performing the operation we need. Next, I leave you the method that we will use for each of these operations.
GET (Select)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
// GET https://jatomas.operations.dynamics.com/CustomersV3(dataAreaId='USMF',CustomerAccount='JAT0001')?cross-company=true public static string GetEntity(string dataEntityName, string dataEntityKey) { HttpClient client = new HttpClient(); //Get authorization token var erpToken = GetToken(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", erpToken); var endpointUrl = "url_d365fo_without_bar"; endpointUrl = endpointUrl + "/data/" + dataEntityName + dataEntityKey + "?cross-company=true"; string responseString = string.Empty; int retries = 0; int seconds = RetrySeconds; for (; ; ) { try { retries++; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, endpointUrl); HttpResponseMessage response = client.SendAsync(request).Result; responseString = response.Content.ReadAsStringAsync().Result; if (!response.IsSuccessStatusCode) { if ((int)response.StatusCode == 429 && retries < MaxRetries) { //Try to use the Retry-After header value if it is returned. if (response.Headers.Contains("Retry-After")) { seconds = int.Parse(response.Headers.GetValues("Retry-After").FirstOrDefault()); } Thread.Sleep(TimeSpan.FromSeconds(seconds)); continue; } else if ((int)response.StatusCode == 404) { // Entity not found, don't retry return responseString; } else { throw new Exception(responseString); } } return responseString; } catch (Exception ex) { string message = $"There was an error when trying to get the {dataEntityName} with the key {dataEntityKey}:\r\n{ex.Message}"; throw new Exception(message); } } } |
Use of method GetEntity
1 |
GetEntity("CustomersV3", "(dataAreaId='USMF',CustomerAccount='JAT0001')"); |
In the case of GET, omment that there are different ways to call it. The one I am using (https://fnourl.com/data/DataEntity(EntityKey=’Value’) will be used whenever we are looking for a specific record, through its entity key. It would be the closest thing to the methods find that we use in X++, but we also have the option of obtaining several records filtering by the fields that we want, depending on the information we have, following this nomenclature: https://fnourl.com/data/DataEntity?$filter =Field1 eq ‘Value’ and Field2 eq ‘Value’. The difference when obtaining the data is that, instead of obtaining a single object, we will obtain an array with all the records that meet the conditions indicated in the $filter.
POST (Insert)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
// POST https://jatomas.operations.dynamics.com/CustomersV3 // { // "dataAreaId":"USMF", // "CustomerAccont":"JAT001", // "CustomerGroupId":"NAC" // } public static string InsertEntity(string dataEntityName, string requestContent) { HttpClient client = new HttpClient(); //Get authorization token var erpToken = GetToken(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", erpToken); var endpointUrl = "url_d365fo_without_bar"; endpointUrl = endpointUrl + "/data/" + dataEntityName; string responseString = string.Empty; int retries = 0; int seconds = RetrySeconds; for ( ; ; ) { try { retries++; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, endpointUrl); request.Content = new StringContent(requestContent, Encoding.UTF8, "application/json"); HttpResponseMessage response = client.SendAsync(request).Result; responseString = response.Content.ReadAsStringAsync().Result; if (!response.IsSuccessStatusCode) { if ((int)response.StatusCode == 429 && retries < MaxRetries) { //Try to use the Retry-After header value if it is returned. if (response.Headers.Contains("Retry-After")) { seconds = int.Parse(response.Headers.GetValues("Retry-After").FirstOrDefault()); } Thread.Sleep(TimeSpan.FromSeconds(seconds)); continue; } else { throw new Exception(responseString); } } return responseString; } catch (Exception ex) { string message = $"There was an error when trying to insert into {dataEntityName}:\r\n{ex.Message}"; throw new Exception(message); } } } |
Use of method InsertEntity
1 2 3 4 5 6 |
string content = @"{ "dataAreaId":"USMF", "CustomerAccont":"JAT001", "CustomerGroupId":"NAC" }"; InsertEntity("CustomersV3", content); |
PATCH (Update)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
// PATCH https://jatomas.operations.dynamics.com/CustomersV3(dataAreaId='USMF',CustomerAccount='JAT0001')?cross-company=true // { // "fieldToUpdate":"newValue" // } public static string UpdateEntity(string dataEntityName, string dataEntityKey, string requestContent) { HttpClient client = new HttpClient(); //Get authorization token var erpToken = GetToken(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", erpToken); var endpointUrl = "url_d365fo_without_bar"; endpointUrl = endpointUrl + "/data/" + dataEntityName + dataEntityKey + "?cross-company=true"; string responseString = string.Empty; int retries = 0; int seconds = RetrySeconds; for (; ; ) { try { retries++; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Patch, endpointUrl); request.Content = new StringContent(requestContent, Encoding.UTF8, "application/json"); HttpResponseMessage response = client.SendAsync(request).Result; responseString = response.Content.ReadAsStringAsync().Result; if (!response.IsSuccessStatusCode) { if ((int)response.StatusCode == 429 && retries < MaxRetries) { //Try to use the Retry-After header value if it is returned. if (response.Headers.Contains("Retry-After")) { seconds = int.Parse(response.Headers.GetValues("Retry-After").FirstOrDefault()); } Thread.Sleep(TimeSpan.FromSeconds(seconds)); continue; } else { throw new Exception(responseString); } } return responseString; } catch (Exception ex) { string message = $"There was an error when trying to update into {dataEntityName}:\r\n{ex.Message}"; throw new Exception(message); } } } |
Use of method UpdateEntity
1 2 3 4 |
string content = @"{ "CustomerGroupId":"INT" }"; UpdateEntity("CustomersV3","(dataAreaId='USMF',CustomerAccount='JAT0001')" , content); |
DELETE (Delete)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
// DELETE https://jatomas.operations.dynamics.com/CustomersV3(dataAreaId='USMF',CustomerAccount='JAT0001')?cross-company=true public static string DeleteEntity(string dataEntityName, string dataEntityKey) { HttpClient client = new HttpClient(); //Get authorization token var erpToken = GetToken(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", erpToken); var endpointUrl = "url_d365fo_without_bar"; endpointUrl = endpointUrl + "/data/" + dataEntityName + dataEntityKey + "?cross-company=true"; string responseString = string.Empty; int retries = 0; int seconds = RetrySeconds; for (; ; ) { try { retries++; HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, endpointUrl); HttpResponseMessage response = client.SendAsync(request).Result; responseString = response.Content.ReadAsStringAsync().Result; if (!response.IsSuccessStatusCode) { if ((int)response.StatusCode == 429 && retries < MaxRetries) { //Try to use the Retry-After header value if it is returned. if (response.Headers.Contains("Retry-After")) { seconds = int.Parse(response.Headers.GetValues("Retry-After").FirstOrDefault()); } Thread.Sleep(TimeSpan.FromSeconds(seconds)); continue; } else { throw new Exception(responseString); } } return responseString; } catch (Exception ex) { string message = $"There was an error when trying to delete into {dataEntityName}:\r\n{ex.Message}"; throw new Exception(message); } } } |
Use of method DeleteEntity
1 |
DeleteEntity("CustomersV3", "(dataAreaId='USMF',CustomerAccount='JAT0001')"); |
Conclusion
So far today’s post, I hope you find it useful, and as I said at the beginning, any questions, (constructive) criticism or improvement, I will be happy to read them in the comments. Regards!
This is a good piece of work! Thank you so much.
I have a question about a situation where you have to read data from a third party system to D365FO. In that scenario, the third part doesn’t push information to D365FO, but rather D365FO is the one to consume information from third part application.
How do you approach this scenario?
Thank you for your reply in advance.
Hi! Thanks for reading and commenting!
Regarding your question, It really depends of the specific case. Could you give me mor details about your scenario? Do you have to consume a REST API? Do you have to get files from an SFTP?… I mean, how this third party exposes the data you need to consume?
Regards!!
Hi Tomas,
To my surprise I found myself reading the question I posted to you a year ago, lol. In the case I was referring to, you would be consuming a REST API, i.e getting data and feeding it into D365.
Hi again Huggins! 🙂
In this case, it also depends hahaha.
For example, one valid approach, if the volume of data is high and, after getting the data, you have to process it and “do things” in D365, like posting invoices, creating projects, etc, could be, consume this REST API, save the data in a custom table, and after that, create a batch process that reads the data and process it. It will be more efficient and performant, and you can create multiple tasks within the batch process to create the stuff in parallel.
Thanks for comment again!
Awesome peice of work, love it
Thanks for your feedback 🙂
Thank you for the nice post.
I assume, HTTP GET return only top 10000 records. In order to fetch other records we need to use next-link property from the current GET output array. I assume above code for GET does not handle this. May be you can extend this in existing code
Hello Ajay Kumar,
Thanks for comment. In this case, it is not necessary because I am using the GET method to get a specific record. You can see that I am using the “entity key” i.e. the primary key to get only one record.
But of course you are absolutely right, if I had to loop through a large number of records, the next-link property should be considered!