Dynamics CRM Plugin Development: Create Custom API to Get File from File DataType

Today we will learn how to create a Custom API to get a file from File DataType in CRM. I only know that CRM got this DataType when I browsed our beloved community forum last week. I can’t find the thread already. But in short, the thread starter questioning how we can get the content of the file.

Microsoft got documentation about the File columns that explain the Actions that we can call to get the file. But the problem, they did not give us examples of how to execute itLuckily, we got a blog post from Debaijit – how to get the file content in this article. From the implementation (in the blog post), to get the file content need to execute 2 actions and we want to simplify it. So, here is the blog post on how to implement it in the Custom API.

For this purpose, I create a custom table that has these attributes:

Create a custom table that contains name(string) and file(File datatype) columns.

Every time I explore something that I didn’t know, I will create a simple code (can be console app, testing in the developer tab, etc) to understand what’s the function is all about (including learning about the response). And here is my sample of exe that I create to prove the result:

using Entities;
using Microsoft.Crm.Sdk.Messages;
using Microsoft.Xrm.Sdk.Query;
using Microsoft.Xrm.Tooling.Connector;
using System;
using System.IO;
namespace CrmConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            var client = new CrmServiceClient("AuthType=OAuth;Username=temmy@xxx.onmicrosoft.com;Password=xxx;Url=https://xxx.crm5.dynamics.com;AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97");
            var query = new QueryExpression(new_document.EntityLogicalName)
            {
                ColumnSet = new ColumnSet(true),
                NoLock = true,
            };
            query.Criteria.AddCondition("statecode", ConditionOperator.Equal, (int)new_document.Options.statecode.Active);
            var result = client.RetrieveMultiple(query);
            var data = result.Entities.ToArray();
            foreach (var datum in data)
            {
                try
                {
                    var initializeFile = new InitializeFileBlocksDownloadRequest
                    {
                        FileAttributeName = "new_file",
                        Target = datum.ToEntityReference()
                    };
                    var fileResponse = (InitializeFileBlocksDownloadResponse)client.Execute(initializeFile);
                    var req = new DownloadBlockRequest { FileContinuationToken = fileResponse.FileContinuationToken, BlockLength = fileResponse.FileSizeInBytes };
                    var test = (DownloadBlockResponse)client.Execute(req);
                    var filePath = Directory.GetCurrentDirectory() + "//" + fileResponse.FileName;
                    File.WriteAllBytes(filePath, test.Data);
                    Console.WriteLine($"FileName: {fileResponse.FileName}. FileSize: {fileResponse.FileSizeInBytes}. Content: {Convert.ToBase64String(test.Data)}. FilePath: {filePath}.");
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.ToString());
                }
            }
            Console.ReadLine();
        }
    }
}

And below is the result if we run the console:

From the code above, we know that we need to execute the InitializeFileBlocksDownloadRequest. If we execute it, we can get the information about the file name, the size of the file, and also the token to get it. Some of that information is needed to execute the next action: DownloadBlockRequest and once we execute it, we can get the content in bytes. From the code, I convert it to Base64String to print it in the console screen.

If the entity don’t have the file, we will get error: “No file attachment found for attribute: new_file…”. With this information, we need to keep it in our mind how we skip this error when we implement the code.

Create The Custom API

Once we know this information, we only needs to implement the code in Custom API. The important things from the upper experimental is to helps us as Developer designing more robust code. So based on the knowledge I get, here is the unit testing that I can apply:

using Microsoft.Crm.Sdk.Messages;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Xrm.Sdk;
using Niam.XRM.Framework.Interfaces.Plugin;
using NSubstitute;
using System;
using System.Text;
namespace Insurgo.Custom.Api.Tests.Business
{
    [TestClass]
    public class GetFileInAttributeTests
    {
        [TestMethod]
        public void GetFileInAttribute_NoFileFound_ReturnEmptyResponse()
        {
            var transactionContext = Substitute.For<ITransactionContext<Entity>>();
            var pluginContext = Substitute.For<IPluginExecutionContext>();
            var parameterCollection = new ParameterCollection
            {
                [Api.Business.GetFileInAttribute.InputEntityLogicalName] = "account",
                [Api.Business.GetFileInAttribute.InputEntityGuid] = Guid.NewGuid(),
                [Api.Business.GetFileInAttribute.InputFileAttributeName] = "new_file"
            };
            pluginContext.InputParameters.Returns(parameterCollection);
            transactionContext.PluginExecutionContext.Returns(pluginContext);
            var organizationService = Substitute.For<IOrganizationService>();
            organizationService.Execute(Arg.Any<InitializeFileBlocksDownloadRequest>()).Returns(x =>
            {
                throw new Exception("No file attachment found for attribute: new_file EntityId: 2e19aa2f-5505-ec11-b6e6-00224816ca54.");
            });
            transactionContext.Service.Returns(organizationService);
            new Api.Business.GetFileInAttribute(transactionContext).Execute();
            Assert.IsNull(pluginContext.OutputParameters);
        }

        [TestMethod]
        public void GetFileInAttribute_NoFileFound_ReturnResponse()
        {
            var transactionContext = Substitute.For<ITransactionContext<Entity>>();
            var pluginContext = Substitute.For<IPluginExecutionContext>();
            var parameterCollection = new ParameterCollection
            {
                [Api.Business.GetFileInAttribute.InputEntityLogicalName] = "account",
                [Api.Business.GetFileInAttribute.InputEntityGuid] = Guid.NewGuid(),
                [Api.Business.GetFileInAttribute.InputFileAttributeName] = "new_file"
            };
            pluginContext.InputParameters.Returns(parameterCollection);
            pluginContext.OutputParameters.Returns(new ParameterCollection());
            transactionContext.PluginExecutionContext.Returns(pluginContext);
            var organizationService = Substitute.For<IOrganizationService>();
            organizationService.Execute(Arg.Any<InitializeFileBlocksDownloadRequest>()).Returns(x =>
            {
                return new InitializeFileBlocksDownloadResponse
                {
                    ["FileContinuationToken"] = "token",
                    ["FileName"] = "test.txt",
                    ["FileSizeInBytes"] = 10l
                };
            });
            organizationService.Execute(Arg.Any<DownloadBlockRequest>()).Returns(x =>
            {
                return new DownloadBlockResponse
                {
                    ["Data"] = Encoding.ASCII.GetBytes("Hello world")
                };
            });
            transactionContext.Service.Returns(organizationService);
            new Api.Business.GetFileInAttribute(transactionContext).Execute();
            Assert.IsNotNull(pluginContext.OutputParameters);
            Assert.AreEqual("test.txt", pluginContext.OutputParameters[Api.Business.GetFileInAttribute.OutputFileName]);
            Assert.IsNotNull(pluginContext.OutputParameters[Api.Business.GetFileInAttribute.OutputFileContent]);
        }
    }
}

If you see from the code, I only limiting the scenario into 2 scenarios: when the record doesn’t have file, and when the record have the file. Meaning that other scenario than that, I will throw the actual error. 

Because this is a new things, I need to mock all the response from the IOrganizationService using NSubstitute. We replace the actual object that we want to an object that can return predefined value. In the upper scenario, I will throw error and return predefined response to fullfilled my scenario.

And here is the implementation of the business logic:

using Microsoft.Crm.Sdk.Messages;
using Microsoft.Xrm.Sdk;
using Niam.XRM.Framework.Interfaces.Plugin;
using Niam.XRM.Framework.Plugin;
using System;
namespace Insurgo.Custom.Api.Business
{
    public class GetFileInAttribute : OperationBase<Entity>
    {
        public const string InputEntityLogicalName = "EntityLogicalName";
        public const string InputEntityGuid = "EntityGuid";
        public const string InputFileAttributeName = "FileAttributeName";
        public const string OutputFileName = "FileName";
        public const string OutputFileContent = "FileContent";

        public GetFileInAttribute(ITransactionContext<Entity> context) : base(context)
        {
        }

        protected override void HandleExecute()
        {
            var fileInformation = GetFileContinuationToken();
            if (string.IsNullOrEmpty(fileInformation.FileContinuationToken)) return;
            var req = new DownloadBlockRequest { FileContinuationToken = fileInformation.FileContinuationToken, BlockLength = fileInformation.FileSizeInBytes };
            var result = (DownloadBlockResponse)Service.Execute(req);
            Context.PluginExecutionContext.OutputParameters[OutputFileName] = fileInformation.FileName;
            Context.PluginExecutionContext.OutputParameters[OutputFileContent] = Convert.ToBase64String(result.Data);
        }

        private InitializeFileBlocksDownloadResponse GetFileContinuationToken()
        {
            var entityLogicalName = Context.PluginExecutionContext.InputParameters[InputEntityLogicalName].ToString();
            var entityId = new Guid(Context.PluginExecutionContext.InputParameters[InputEntityGuid].ToString());
            var attributeName = Context.PluginExecutionContext.InputParameters[InputFileAttributeName].ToString();
            try
            {
                var initializeFile = new InitializeFileBlocksDownloadRequest
                {
                    FileAttributeName = attributeName,
                    Target = new EntityReference(entityLogicalName, entityId)
                };
                return (InitializeFileBlocksDownloadResponse)Service.Execute(initializeFile);
            }
            catch (Exception ex)
            {
                if (ex.Message.Contains("No file attachment found for attribute:")) return new InitializeFileBlocksDownloadResponse();
                throw;
            }
        }
    }
}

The design of the Custom API, we need 3 inputs: EntityLogicalNameEntityGuid, and the FileAttributeName. After the process done, we will put all the output in: FileName and FileContent. The FileContent will be the Base64String.

After all the code that we prepare, we can deploy the plugin and create the Custom API. Like usual, I will create the Custom API using Dataverse Custom API Manager by David Rivard in XrmToolbox:

The Custom API Definition

Demonstration

To demonstrate the result, I create a simple Flow to prove that the Custom API is correct:

Flow input+result

All the source code can be found on my GitHub repository in here (including the unit testing too!).

2 thoughts on “Dynamics CRM Plugin Development: Create Custom API to Get File from File DataType

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.