Codeunit API’s in Business Central

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.

web services list

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!

Comment List
Related
Recommended