This blog post was on my list way too long… But now I found some time to sit down and write it.
Disclaimer
What I’m going to show here is officially not supported (yet). It is an undocumented feature that already exists for a couple of years. I believe it can even be used in Dynamics NAV 2018 and maybe earlier versions as well. In fact, Microsoft uses this feature themselves in the Power Automate Flow connector for approvals. So it is a feature that goes undocumented and officially unsupported, but I wouldn’t expect it to go away. Instead, I hope it is going to be turned into an officially supported feature.
As a matter of fact, the title of this blog post should be something like ‘Unbound actions with Codeunit web services in Business Central’. But I’m not sure if everybody would immediately recognize what it is about.
Bound vs. Unbound Actions
As you may know, it is possible to define actions on API pages that can be called with a restful API call. For example, you can call Post on a Sales Invoice like this:
post https://api.businesscentral.dynamics.com/v2.0/{environment}/api/v1.0/companies({id})/salesinvoices({id}})/Microsoft.NAV.Post Authorization: Bearer {token} Content-Type: application/json
This function Post is available on the API page for Sales Invoices and it looks like this:
[ServiceEnabled] [Scope('Cloud')] procedure Post(var ActionContext: WebServiceActionContext) var SalesHeader: Record "Sales Header"; SalesInvoiceHeader: Record "Sales Invoice Header"; SalesInvoiceAggregator: Codeunit "Sales Invoice Aggregator"; begin GetDraftInvoice(SalesHeader); PostInvoice(SalesHeader, SalesInvoiceHeader); SetActionResponse(ActionContext, SalesInvoiceAggregator.GetSalesInvoiceHeaderId(SalesInvoiceHeader)); end;
What is important here, that this function is called a ‘bound action’ because it is bound to an existing entity, in this case, a Sales Invoice.
But what if you want to call a function in a Codeunit with an API call? That is possible by publishing the Codeunit as a web service and call it with a SOAP web service call. Would it also be possible to do that with a restful API call, like the API pages? And the answer to that is, yes, that is possible! The web services page doesn’t show you an ODataV4 URL for a published Codeunit, but it actually is possible to call the Codeunit with an ODataV4 URL. That is called ‘unbound actions’. Calling a Codeunit is not bound to any entity at all. Not even to the company, which is normally the first entity you specify in the ODataV4 or API URL.
Simple Example of an Unbound Action
Let’s create a simple Codeunit and publish it as a web service.
codeunit 50100 "My Unbound Action API" { procedure Ping(): Text begin exit('Pong'); end; }
We can’t publish a Codeunit as an API, the only possibility is to publish it as a web service. For that, we add this XML file to the app:
<?xml version="1.0" encoding="UTF-8"?> <ExportedData> <TenantWebServiceCollection> <TenantWebService> <ObjectType>CodeUnit</ObjectType> <ObjectID>50100</ObjectID> <ServiceName>MyUnboundActions</ServiceName> <Published>true</Published> </TenantWebService> </TenantWebServiceCollection> </ExportedData>
After installation, the web service is available. But the ODataV4 URL is not applicable according to this page.
Let’s just ignore that and call the web service with the ODataV4 url nonetheless. I’m using the VS Code extension Rest Client for this. As you can see, the URL is build up as the normal ODataV4 url, but it ends with NAV.MyUnboundActions_Ping. The name of the function is composed as follows: /NAV.[service name]_[function name]
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_Ping Authorization: Basic {{username}} {{password}}
The result of this call (response headers removed for brevity):
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String", "value": "Pong" }
Isn’t that cool? We can publish Codeunits as web service and still use restful API calls to invoke them, instead of using SOAP!
Reading data
What about using data? Let’s try another example and see what happens. I’ve added another function that simply reads the first record of the Customer table. Since we haven’t specified any company, what would happen?
codeunit 50100 "My Unbound Action API" { procedure Ping(): Text begin exit('Pong'); end; procedure GetFirstCustomerName(): Text var Cust: Record Customer; begin Cust.FindFirst(); exit(Cust.Name); end; }
The call to the web service looks like this:
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_GetFirstCustomerName Authorization: Basic {{username}} {{password}}
And the result of this call is an error:
HTTP/1.1 400 You must choose a company before you can access the "Customer" table. { "error": { "code": "Internal_ServerError", "message": "You must choose a company before you can access the \"Customer\" table. CorrelationId: 7b627296-5aca-4e4a-8e46-9d54f199b702." } }
Obviously, we need to specify a company. Let’s try to do that by specifying the company in the url:
post https://bcsandbox.docker.local:7048/BC/ODataV4/Company('72e17ce1-664e-ea11-bb30-000d3a256c69')/NAV.MyUnboundActions_GetFirstCustomerName Authorization: Basic {{username}} {{password}}
However, we still get an error:
HTTP/1.1 404 No HTTP resource was found that matches the request URI 'https://bcsandbox.docker.local:7048/BC/ODataV4/Company(%2772e17ce1-664e-ea11-bb30-000d3a256c69%27)/NAV.MyUnboundActions_GetFirstCustomerName'. { "error": { "code": "BadRequest_NotFound", "message": "No HTTP resource was found that matches the request URI 'https://bcsandbox.docker.local:7048/BC/ODataV4/Company(%2772e17ce1-664e-ea11-bb30-000d3a256c69%27)/NAV.MyUnboundActions_GetFirstCustomerName'. CorrelationId: 04668a8d-1f2b-4e1e-aebe-883886e8fa2b." } }
What is going on? An OData url points to an entity. Every entity has its own unique url. But the Codeunit function is not bound to any entity, like an Item, Customer, Sales Order, etc. That’s why it is called an unbound action. But if the company was part of the url, then it is bound to the company entity and not considered to be an unbound action anymore. This is simply due to the fact that Business Central works with multiple companies in one database. If that was just one company, then you wouldn’t have the company in the url and the unbound action would work.
Instead of adding the company as an entity component to the url, it is possible to add a company query parameter. Then the call looks like this:
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_GetFirstCustomerName?company=72e17ce1-664e-ea11-bb30-000d3a256c69 Authorization: Basic {{username}} {{password}}
And this works:
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String", "value": "Adatum Corporation" }
Alternatively, you can also add the company as a header instead of a query parameter:
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_GetFirstCustomerName Authorization: Basic {{username}} {{password}} Company: 72e17ce1-664e-ea11-bb30-000d3a256c69
As you can see, we can use the company id instead of the company name. To get the company id, you can use this call (notice the get instead of post):
get https://bcsandbox.docker.local:7048/BC/ODataV4/Company Authorization: Basic {{username}} {{password}}
And use the id from the response.
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Company", "value": [ { "Name": "CRONUS USA, Inc.", "Evaluation_Company": true, "Display_Name": "", "Id": "72e17ce1-664e-ea11-bb30-000d3a256c69", "Business_Profile_Id": "" }, { "Name": "My Company", "Evaluation_Company": false, "Display_Name": "", "Id": "084479f8-664e-ea11-bb30-000d3a256c69", "Business_Profile_Id": "" } ] }
Using Parameters
What about passing in parameters? Well, that’s also possible. As you may have seen, all calls the to unbound actions use the HTTP POST command. That means we are sending data. So far, the demo didn’t do that. Let’s do that in the next demo. I have added a function Capitalize with a text input parameter.
codeunit 50100 "My Unbound Action API" { procedure Ping(): Text begin exit('Pong'); end; procedure GetFirstCustomerName(): Text var Cust: Record Customer; begin Cust.FindFirst(); exit(Cust.Name); end; procedure Capitalize(input: Text): Text begin exit(input.ToUpper); end; }
To add the parameter data to the call, we need to add content. Don’t forget to set the header Content-Type!
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_Capitalize Authorization: Basic {{username}} {{password}} Content-Type: application/json { "input": "business central rocks!" }
And here is the result of this call:
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String", "value": "BUSINESS CENTRAL ROCKS!" }
Be careful with capitals in parameter names! The first character must be lower case. Even when you use uppercase, it will be corrected. If you use uppercase in the call, then you might see this error message:
HTTP/1.1 400 Exception of type 'Microsoft.Dynamics.Nav.Service.OData.NavODataBadRequestException' was thrown. { "error": { "code": "BadRequest", "message": "Exception of type 'Microsoft.Dynamics.Nav.Service.OData.NavODataBadRequestException' was thrown. CorrelationId: e0003c52-0159-4cf5-974d-312ef4729c56." } }
Return Types
So far, the demo’s only returned text types. What happens if we return a different type, like an integer, a boolean or datetime? Here you have some examples:
codeunit 50100 "My Unbound Action API" { procedure Ping(): Text begin exit('Pong'); end; procedure GetFirstCustomerName(): Text var Cust: Record Customer; begin Cust.FindFirst(); exit(Cust.Name); end; procedure Capitalize(input: Text): Text begin exit(input.ToUpper); end; procedure ItemExists(itemNo: Text): Boolean var Item: Record Item; begin Item.SetRange("No.", itemNo); exit(not item.IsEmpty()); end; procedure GetCurrentDateTime(): DateTime begin exit(CurrentDateTime()); end; }
Functions ItemExists and GetCurrentDateTime are added to the Codeunit.
The call to ItemExists and the result:
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_ItemExists Authorization: Basic {{username}} {{password}} Content-Type: application/json Company: 72e17ce1-664e-ea11-bb30-000d3a256c69 { "itemNo": "1896-S" }
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.Boolean", "value": true }
And this is how the call to GetCurrentDateTime and the response looks like:
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_GetCurrentDateTime Authorization: Basic {{username}} {{password}} Content-Type: application/json
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.DateTimeOffset", "value": "2020-03-02T15:13:39.49Z" }
What about return complex types, like a Json payload? Unfortunately, that doesn’t work as you would like:
codeunit 50100 "My Unbound Action API" { procedure Ping(): Text begin exit('Pong'); end; procedure GetFirstCustomerName(): Text var Cust: Record Customer; begin Cust.FindFirst(); exit(Cust.Name); end; procedure Capitalize(input: Text): Text begin exit(input.ToUpper); end; procedure ItemExists(itemNo: Text): Boolean var Item: Record Item; begin Item.SetRange("No.", itemNo); exit(not item.IsEmpty()); end; procedure GetCurrentDateTime(): DateTime begin exit(CurrentDateTime()); end; procedure GetJsonData() ReturnValue: Text var Jobj: JsonObject; begin JObj.Add('key', 'value'); Jobj.WriteTo(ReturnValue); end; }
post https://bcsandbox.docker.local:7048/BC/ODataV4/NAV.MyUnboundActions_GetJsonData Authorization: Basic {{username}} {{password}} Content-Type: application/json
HTTP/1.1 200 OK { "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String", "value": "{\"key\":\"value\"}" }
The data is formatted as a Json text value instead of a real Json structure. And if you try to change the function to return a JsonObject rather than a text variable, then the whole web service is not valid anymore as a web service and you will not be able to call it. For this to work, we need an option to define custom entities and add it to the metadata. It would be great if Microsoft would enable this!
Support in the cloud
All these demos were on my local docker environment. But this works exactly the same on the cloud platform. Just change the url and it will work like a charm:
For basic authentication you need the use this url and specify your tenant:
post https://api.businesscentral.dynamics.com/v2.0/{tenantid}/{environment}/ODataV4/NAV.MyUnboundActions_Ping Authorization: Basic {{username}} {{password}}
For example, when I use the sandbox environment on my tenant, I can replace {tenantid} with kauffmann.nl and {environment} with sandbox:
post https://api.businesscentral.dynamics.com/v2.0/kauffmann.nl/sandbox/ODataV4/NAV.MyUnboundActions_Ping Authorization: Basic {{username}} {{password}}
For OAuth and production environments, you should use this url (no tenant id needed):
post https://api.businesscentral.dynamics.com/v2.0/{environment}/ODataV4/NAV.MyUnboundActions_Ping Authorization: Bearer {token}
Use the ODataV4 and not the API endpoint
Remember that this only works with the ODataV4 endpoint and not with the API endpoint. You need to publish the Codeunit as a web service first. To get this on the API endpoint, it should also implement namespaces and versioning as we know it in the API pages. Versioning is a key feature, as it allows us to implement versioned contracts. And personally, I wouldn’t mind if Microsoft also removes the word NAV from both bound and unbound actions.
That’s it! Hope you enjoyed it! Based on my conversations with Microsoft, I know that this topic is something they are discussing for the future. What do you think, should this be turned into a Codeunit type API or is it useless and can we stick with Page and Query API’s? Let me know in the comments!