Creating a SAP DMS library with YaNco (Part 2)

This is the second post in a series of posts on how to connect to SAP Document Management with dbosoft YaNco.

by Frank Wagner on 5/3/2021

This is the second post in a series of posts on how to connect to SAP Document Management with dbosoft YaNco.

Part 1: https://dbosoft.eu/en-us/blog/creating-a-sap-dms-library-with-yanco-part-1

Within this post series we will implement step by step a library that can be used to read documents, transfer files and to update documents.

tl;dr  You can find the code of this post on github: https://github.com/dbosoft/sap-dmsclient/tree/blog_series/part-2.

Overview

In this part of our blog series, we will add the feature to download files from SAP DMS to our application. Sounds exciting? Yes, indeed!

Implementation steps

The SAP DMS provides the BAPI BAPI_DOCUMENT_CHECKOUTVIEW2 to checkout files from the SAP system. Checkout means that the files are copied to the client (in this case our program).

However - and if you know SAP you should have expected - the things are not so simple. Under the hood DMS uses the technologies KPRO and Content Server to store and retrieve files. That means that we have to consider how KPRO transfers files:

  1. KPRO Interface connects to a special program, either SAPFTP or SAPHTTP. If you use a Content Server to store documents SAPHTTP is prefered, but KPRO will fall back to SAPFTP in case of a error.

  2. SAPFTP/SAPHTTP retrieves the data directly from the Content Servers and stores it on the client

The question is now: how does KPRO connects to SAPFTP or SAPHTTP? This is where ABAP callbacks come into play.

As you should know, YaNco supports ABAP callbacks. That means functions called from the SAP on the client. There is a special RFM RFC_START_PROGRAM that will be executed on the client to request start of programs, in this case SAPFTP or SAPHTTP.

So we will now have to do following steps:

  1. extend data structures to store file informations

  2. add a function module call to checkout the document files

  3. enable calling SAPFTP/SAPHTTP

Download SAPFTP and SAPHTTP

Before start coding please ask again your favorite SAP team colleage to download you the files SAPFTP and SAPHTTP from a supported SAP kernel (we will use 7.53 for this blog):

Download it from Support Packages & Patches → ADDITIONAL COMPONENTS → SAP KERNEL → SAP KERNEL 64-BIT UNICODE → SAP KERNEL 7.53 64-BIT UNICODE

Please note that the files have to extracted first with SAPCAR (can be downloaded from same link).

SAPCAR -xvf [FileName]

So now you should have two executables SAPFTP.EXE and SAPHTTP.EXE. Store them somewhere, we will use them later.

Extending the library

As written above we will first have to extend our data structures and then add new function calls to our library.

Update of Type Definitions

Add a new file named DocumentFileInfo.cs to the SAPDms.Primitives project:


using LanguageExt;

namespace Dbosoft.SAPDms
{
    public class DocumentFileInfo : Record<DocumentFileInfo>
    {
        public readonly DocumentId DocumentId;
        public readonly string ApplicationId;
        public readonly string OriginalType;
        public readonly string FileId;

        public readonly string FileName;

        public static DocumentFileInfo Empty = new DocumentFileInfo(default, 
            default, default, default, default);

        public DocumentFileInfo(DocumentId documentId, 
            string applicationId, 
            string originalType, 
            string fileId, 
            string fileName)
        {
            DocumentId = documentId;
            ApplicationId = applicationId;
            OriginalType = originalType;
            FileId = fileId;
            FileName = fileName;

        }

    }
}
        

This type file be used to store file data in our existing document data class. Therefore add adopt now also the DocumentData class and add field Files for the DocumentFileInfo:

# DocumentData.cs

    public class DocumentData : Record<DocumentData>
    {
        public readonly DocumentId Id;
        public readonly string Description;
        public readonly string Status;

        public readonly Arr<DocumentFileInfo> Files;


        public DocumentData(DocumentId id, string description, string status, Arr<DocumentFileInfo> files)
        {
            Id = id;
            Description = description;
            Status = status;
            Files = files;
        }
    }
        

The Arr<T> type is a immutable array from the langauge.ext project. You can consider it like a normal array for now.

Extend function calls

Now we add the feature to download files to our function calls. Switch to project SAPDms.Core First update the ReadDocument.cs file.


using System.IO;
using Dbosoft.YaNco;
using LanguageExt;

namespace Dbosoft.SAPDms.Functions
{
    public static partial class DmsRfcFunctionExtensions
    {
        public static EitherAsync<RfcErrorInfo, DocumentData> DocumentGetDetail(
            this IRfcContext context, DocumentId documentId)
        {
            return context.CallFunction("BAPI_DOCUMENT_GETDETAIL2",
                Input: func => func
                    .SetField("DOCUMENTTYPE", documentId.Type)
                    .SetField("DOCUMENTNUMBER", documentId.Number)
                    .SetField("DOCUMENTPART", documentId.Part)
                    .SetField("DOCUMENTVERSION", documentId.Version)
                    .SetField("GETDOCDESCRIPTIONS", "X")
                    .SetField("GETDOCFILES", "X"),
                Output: func =>
                        from _ in func.HandleReturn()
                        from docData in func.MapStructure("DOCUMENTDATA", docData =>
                            from status in docData.GetField<string>("STATUSEXTERN")
                            from description in docData.GetField<string>("DESCRIPTION")
                            select new {status, description })

                        from fileData in func.MapTable("DOCUMENTFILES", s =>
                                from originalType in s.GetField<string>("ORIGINALTYPE")
                                from filePath in s.GetField<string>("DOCFILE")
                                from applicationId in s.GetField<string>("APPLICATION_ID")
                                from fileId in s.GetField<string>("FILE_ID")
                                from checkedIn in s.GetField<bool>("CHECKEDIN")
                                select new DocumentFileInfo(documentId,
                                    applicationId, originalType, fileId , Path.GetFileName(filePath))
                            )
                        select new DocumentData(documentId, docData.description, docData.status, fileData.ToArr())
                    );
        }

    }
}

If you compare this with the previous implementation you will see some differences.

First change is that we also set the parameter 'GETDOCFILES' to let the ABAP function know that we also requesting file information.

                    .SetField("GETDOCDESCRIPTIONS", "X")
                    .SetField("GETDOCFILES", "X"),
                Output: func =>

Second changes are for HandleReturn and mapping DOCUMENTDATA. As we will not only map the documentdata we add a linq query for output and a nested linq query for the DOCUMENTDATA.


                        from _ in func.HandleReturn()
                        from docData in func.MapStructure("DOCUMENTDATA", docData =>
                            from status in docData.GetField<string>("STATUSEXTERN")
                            from description in docData.GetField<string>("DESCRIPTION")
                            select new {status, description })

Finally we map the output of the table parameter DOCUMENTFILES with another nested linq query and putting all together.


                        from fileData in func.MapTable("DOCUMENTFILES", s =>
                                from originalType in s.GetField<string>("ORIGINALTYPE")
                                from filePath in s.GetField<string>("DOCFILE")
                                from applicationId in s.GetField<string>("APPLICATION_ID")
                                from fileId in s.GetField<string>("FILE_ID")
                                select new DocumentFileInfo(documentId,
                                    applicationId, originalType, fileId , Path.GetFileName(filePath))
                            )
                        select new DocumentData(documentId, docData.description, docData.status, fileData.ToArr())
                    );

Last step in the library is to implement the function to checkout files from SAP DMS. Add a new file CheckoutDocumentFile.cs to the project with following code:


using System.Threading.Tasks;
using Dbosoft.YaNco;
using LanguageExt;

namespace Dbosoft.SAPDms.Functions
{
    public static partial class DmsRfcFunctionExtensions
    {
        public static EitherAsync<RfcErrorInfo, Unit> DocumentCheckoutFile(
            this IRfcContext context, DocumentFileInfo fileInfo, string checkoutPath)
        {
            return context.CallFunction("BAPI_DOCUMENT_CHECKOUTVIEW2",
                Input: func => func
                    .SetField("DOCUMENTTYPE", fileInfo.DocumentId.Type)
                    .SetField("DOCUMENTNUMBER", fileInfo.DocumentId.Number)
                    .SetField("DOCUMENTPART", fileInfo.DocumentId.Part)
                    .SetField("DOCUMENTVERSION", fileInfo.DocumentId.Version)
                    .SetField("ORIGINALPATH", checkoutPath)
                    .SetStructure("DOCUMENTFILE", s => s
                        .SetField("ORIGINALTYPE", fileInfo.OriginalType)
                        .SetField("APPLICATION_ID", fileInfo.ApplicationId)
                        .SetField("FILE_ID", fileInfo.FileId)),
                Output: func => func
                    .HandleReturn()).Map(_ => Unit.Default);
        }

    }
}

Application updates

Of course, our application also needs some updates to call the new methods. First let is add a new method to App.cs checkout the files:

        private static async Task CheckoutFile(IRfcContext context, DocumentFileInfo fileInfo)
        {
            var checkoutDir = AppDomain.CurrentDomain.BaseDirectory;
            await context.DocumentCheckoutFile(fileInfo, checkoutDir)
                .Match(
                    r => Console.WriteLine(
                        $"    checked out to '{Path.Combine(checkoutDir, fileInfo.FileName)}'"),
                    l => Console.WriteLine($"    checkout failed:'{l.Message}'"));

        }

This method will checkout the files to the executeable directory if everything worked or write a error message.

Also add a call of the new method CheckoutFile after fetching the document:

            await context.DocumentGetDetail(documentId)
                .Match(r =>
                    {
                        Console.WriteLine($"Document : {r.Id.Type}/{r.Id.Number}/{r.Id.Part}/{r.Id.Version}, Status: {r.Status}, Description: {r.Description}");
                        r.Files.Iter(async f =>
                        {
                            Console.WriteLine($"  File : {f.FileName}/{f.OriginalType}/{f.ApplicationId}");
                            await CheckoutFile(context,f);
                        });
                    },
                    l =>
                    {
                        Console.Error.WriteLine(l.Message);
                    });

Running the application

Now start the application:

ExportDocument.exe /doc:type=ANY /doc:number=0000000000000010000004711 _
     /saprfc:ashost=<your SAP hostname> /saprfc:sysnr=01 _  
         /saprfc:client=800 /saprfc:<further arguments>

Boohm, what's that? You should see a message like this if your document contains a file:

checkout failed:'Function RFC_START_PROGRAM not found'

Oh yes, we forgot to enable the callback!

Ok lets do that. For the callback we need a method of delegate type StartProgramDelegate:


        private static RfcErrorInfo StartProgram(string command)
        {
            var programParts = command.Split(' ');
            var arguments = command.Replace(programParts[0], "");

            try
            {
                var pStart = new ProcessStartInfo(
                    AppDomain.CurrentDomain.BaseDirectory + @"\" + programParts[0] + ".exe",
                    arguments.TrimStart()) {UseShellExecute = true};
                
                var p = Process.Start(pStart);

                return RfcErrorInfo.Ok();
            }
            catch (Exception ex)
            {
                return new RfcErrorInfo(RfcRc.RFC_EXTERNAL_FAILURE, RfcErrorGroup.EXTERNAL_APPLICATION_FAILURE,
                    "", ex.Message, "", "", "", "", "", "", "");
            }
        }

Now let us use the ConnectionBuilder to enable the callback:

            using var context = new RfcContext(new ConnectionBuilder(rfcSettings)
                .WithStartProgramCallback(StartProgram)
                .Build());

And as last step grap the SAPFTP and SAPHTTP files stored previously and add them to the project Apps.ExportDocuments. Don't forget to enable "Copy to Output Directory" with "Copy if newer" for both files.

If you now run again your files should be checked out sucessfully.

Conclusion

The application ExportDocuments can now read the status, description and files of a given document id. Of course there are a lot more information in a document - if you like try to extend the library to extract further document metadata.

And you have learned how to use ABAP callbacks and how to map table data from ABAP to .NET.

code for this post: https://github.com/dbosoft/sap-dmsclient/tree/blog_series/part-2

Part 1: https://dbosoft.eu/en-us/blog/creating-a-sap-dms-library-with-yanco-part-1

  • Author:   Frank Wagner