Homelab, Linux, JS & ABAP (~˘▾˘)~
 

[CAP] Display file content from external resource in Fiori Elements app via an Association

I was having a situation, where I needed to access file content via an association. This led to two problems, one in the backend and one in the frontend.

My data-model.cds looked like this.

entity MainEntity: cuid, managed {
    file            : Association to Files @mandatory  @assert.target;
}

entity Files : cuid, managed {
    content         : LargeBinary @stream  @Core.MediaType: mediaType  @Core.ContentDisposition.Filename: fileName  @Core.ContentDisposition.Type: 'inline'; 
    mediaType       : String      @Core.IsMediaType: true;
    fileName        : String      @mandatory;
    size            : Integer;
}

The file content is actually stored in an external system and is only read when the content is explicitly requested, with a call like this:

### Get file content
GET http://localhost:4004/odata/v4/admin/Files({{ID}})/content
Authorization: Basic user:password

For this kind of scenario, I have found the perfect sample code here: https://github.com/SAP-samples/cloud-cap-samples/blob/main/media/srv/media-service.js
But in my case, I needed to call the file content via an association like this:

### Get file content via association
GET http://localhost:4004/odata/v4/admin/MainEntity({{ID}})/file/content
Authorization: Basic user:password

This did not work, because in this case, we don’t get the required file ID in the Files handler in req.data.ID (find the reason here), which is needed to read the file from the external system. Therefore, I had to implement the following workaround (line 5-8), which checks from which entity we are coming and is fetching the requested file ID from the DB.

    srv.on('READ', Files, async (req, next) => {
        //if file content is requested, return only file as stream
        if (req.context.req.url.includes('content')) {

            // workaround: when File is requested via Association from MainEntity, as the ID is then not provided directly
            if (req.context.req.url.includes('MainEntity')) {
                req.data.ID = await SELECT.one.from(req.subject).columns('ID')
            }

            const file = await SELECT.from(Files, req.data.ID)
            if (!file) return next() // if file not found, just handover to default handler to get 404 response

            try {
                const stream = await getMyStreamFromExternalSystem(req)
                return [{ value: stream }]
            } catch (err) {
                req.error(`Could not read file content`)
            }

        } else return next() // else delegate to next/default handlers without file content
    })

This way, the file content can now be read directly via File and also via MainEntity following the association.

The next challenge was to display this file content in a Fiori Elements app. This works out of the box, if the file content is called directly from the Files entity, means not over an association. But if the file content is coming via an association, it seems like the Fiori Elements framework is creating an incorrect backend call. It tries to call the mediaType from the MainEntity instead of the Files entity, resulting in a failing odata call, which looks like this
/odata/v4/service/MainEntity(key)/mediaType
instead of
/odata/v4/service/MainEntity(key)/file/mediaType.
The only workaround I found was to overwrite the @Core.MediaType annotation coming from the Files entity by setting the mediaType to a hard value in the annotation.yaml of the Fiori Elements App.

annotate service.fileservice@(
    UI.FieldGroup #FileGroup      : {
        $Type: 'UI.FieldGroupType',
        Data : [
            {
                $Type: 'UI.DataField',
                label: 'Main ID',
                Value: ID,
            },
            {
                $Type: 'UI.DataField',
                label: 'File ID',
                Value: file.ID,
            },
            {
                $Type: 'UI.DataField',
                Value: file.content,
            },
            {
                $Type: 'UI.DataField',
                Value: file.mediaType,
            },
            {
                $Type: 'UI.DataField',
                Value: file.fileName,
            },
            {
                $Type: 'UI.DataField',
                Value: file.size,
            },
        ],
    },
    UI.Facets                     : [
        {
            $Type : 'UI.ReferenceFacet',
            ID    : 'GeneratedFacet2',
            Label : 'File Information',
            Target: '@UI.FieldGroup#FileGroup',
        },
    ],
);

// Workaround as currently display file content via an association in Fiori Elements is incorrectly trying to fetch the media type.
// Therefore add a fix value for the media type. Of course, this only works, if you only expect a specific file type.
annotate service.Files with {
  @Core.MediaType : 'application/pdf'
  content
};

In the Fiori Elements App it will now be displayed like this and by clicking on the Context, it will successfully load the file from the backend:

[VSC] Create/Read/Delete File in CMIS Repository via VSC Rest-API Client

API Specification: http://docs.oasis-open.org/cmis/CMIS/v1.1/os/examples/browser/

Helpful Postman Collection: https://gist.github.com/aborroy/3f1f2360b0e85067675643aa648a8112

Create:

@folderId = <myFolderID>
@fileName = <myFileName>

### Create Object
# @name object
POST http://localhost:8080/alfresco/api/-default-/public/cmis/versions/1.1/browser/root
Authorization: Basic user:password
Content-Type: multipart/form-data; boundary=boundary

--boundary
Content-Disposition: form-data; name="succinct"
Content-Type: text/plain; charset=utf-8

true
--boundary
Content-Disposition: form-data; name="cmisaction"
Content-Type: text/plain; charset=utf-8

createDocument
--boundary
Content-Disposition: form-data; name="objectId"
Content-Type: text/plain; charset=utf-8

{{folderId}}
--boundary
Content-Disposition: form-data; name="propertyId[0]"
Content-Type: text/plain; charset=utf-8

cmis:name
--boundary
Content-Disposition: form-data; name="propertyValue[0]"
Content-Type: text/plain; charset=utf-8

{{fileName}}
--boundary
Content-Disposition: form-data; name="propertyId[1]"
Content-Type: text/plain; charset=utf-8

cmis:objectTypeId
--boundary
Content-Disposition: form-data; name="propertyValue[1]"
Content-Type: text/plain; charset=utf-8

cmis:document
--boundary
Content-Disposition: form-data; name="content"; filename=image.png
Content-Type: image/png
Content-Transfer-Encoding: binary

< image.png

--boundary--

### Fill Variable from Response
@ID = {{object.response.body.succinctProperties.cmis:objectId}}

Read:

### Get Object properties
GET http://localhost:8080/alfresco/api/-default-/public/cmis/versions/1.1/browser/root
?cmisselector=properties
&objectId={{ID}}
Authorization: Basic user:password

### Get Object content
GET http://localhost:8080/alfresco/api/-default-/public/cmis/versions/1.1/browser/root
?cmisselector=content
&objectId={{ID}}
Authorization: Basic user:password

### Get Object content stream
GET http://localhost:8080/alfresco/api/-default-/public/cmis/versions/1.1/browser/root
?getContentStream
&objectId={{ID}}
Authorization: Basic user:password

Delete:

### Delete Object
POST http://localhost:8080/alfresco/api/-default-/public/cmis/versions/1.1/browser/root
Authorization: Basic user:password
Content-Type: multipart/form-data; boundary=boundary

--boundary
Content-Disposition: form-data; name="objectId"
Content-Type: text/plain; charset=utf-8

{{ID}}
--boundary
Content-Disposition: form-data; name="cmisaction"
Content-Type: text/plain; charset=utf-8

delete
--boundary--

[nodejs] Create buffer from stream

Using a promise

const streamToBuffer= async () => {
        return new Promise(function (resolve, reject) {
                const chunks = []
                stream.on('data', chunk => chunks.push(chunk))
                stream.on('end', () => resolve(Buffer.concat(chunks)))
                stream.on("error", err => reject(err))
        })
}
const buffer = await streamToBuffer()

A stream is also iterable (see here), so you can also use for await...of (example)

        const chunks = []
        for await (const chunk of stream) {
            chunks.push(chunk)
        }
        const buffer = Buffer.concat(chunks)