Skip to Content
Technical Articles
Author's profile photo Ioana Stefania Santimbrean

Part 2: How to build an extension application for SAP S/4HANA Cloud using CAP, SAP Cloud SDK and SAP Fiori elements

Recap

This blog post series covers how to build an application using SAP Cloud Application Programming Model – Node.js, SAP Cloud SDK – JavaScript and SAP Fiori elements for Side-by-Side Extensibility of SAP S/4HANA Cloud.

In Part 0 you can find how to use SAP API Business Hub in order to find the documentation for the SAP S/4HANA Cloud API that meets your business requirement and how to expose those APIs by creating a communication arrangement, system and user.

Part 1 includes building a CAP application which is needed as a starting point for this part.

Part 2: Annotations, Fiori Elements and SAP Cloud SDK

This part will cover how to:

  • add Create, Update, Delete service handlers using SAP Cloud SDK
  • generate a Fiori app using Fiori Tools
  • add annotations in the CAP application

What are the prerequisites for this part?

You should follow the blog post Part 1 from this series, as this one continues to build the application.

Add Create, Update, Delete service handlers

Steps to follow:

  1. SAP Cloud SDK expects the Destination in SAP Cloud Platform to point only to the SAP S/4HANA Cloud system, so we need to create another one:
  2. We will need some functions which prepare data in the correct format. In the srv folder create a file helpers.js and paste the following code in it:
    const { BusinessPartnerAddress } = require("@sap/cloud-sdk-vdm-business-partner-service");
    
    const _prepareBody = (address) => {
        return {
            businessPartner: address.BusinessPartner,
            country: address.Country,
            postalCode: address.PostalCode,
            cityName: address.CityName,
            streetName: address.StreetName,
            houseNumber: address.HouseNumber
        }
    }
    
    const buildAddressForCreate = (req) => {
        const address = BusinessPartnerAddress.builder().fromJson(_prepareBody(req.data));
        if (req.params[0]) {
            const { BusinessPartner } = req.params[0];
            address.businessPartner = BusinessPartner;
        }
        return address;
    }
    
    const buildAddressForUpdate = (req) => {
        const { BusinessPartner, AddressID } = req.params[0];
        const address = BusinessPartnerAddress.builder().fromJson(_prepareBody(req.data));
        address.businessPartner = BusinessPartner;
        address.addressId = AddressID;
        return address;
    }
    
    const prepareResult = (address) => {
        return {
            BusinessPartner: address.businessPartner,
            AddressID: address.addressId,
            Country: address.country,
            PostalCode: address.postalCode,
            CityName: address.cityName,
            StreetName: address.streetName,
            HouseNumber: address.houseNumber
        }
    }
    
    module.exports = {
        buildAddressForCreate,
        buildAddressForUpdate,
        prepareResult
    }
  3. Open the address-manager-service.js and add the dependency and declare the destination at the beginning of the file:
    const { BusinessPartnerAddress } = require("@sap/cloud-sdk-vdm-business-partner-service");
    const sdkDest = { "destinationName": 's4hc_simple' };
    const {
        buildAddressForCreate,
        buildAddressForUpdate,
        prepareResult,
        prepareBody
    } = require('./helpers')​
  4. Create the event handlers with the following code:
    //this event handler is triggered when we call
    //POST http://localhost:4004/address-manager/BusinessPartnerAddresses
    this.on('CREATE', BusinessPartnerAddresses, async (req) => {
        try {
            const address = buildAddressForCreate(req);
            const result = await BusinessPartnerAddress
                .requestBuilder()
                .create(address)
                .execute(sdkDest);
            return prepareResult(result);
        } catch (err) {
            req.reject(err);
        }
    });
    
    //this event handler is triggered when we call
    //PUT http://localhost:4004/address-manager/BusinessPartnerAddresses(BusinessPartner='',AddressID='')
    this.on('UPDATE', BusinessPartnerAddresses, async (req) => {
        try {
            const address = buildAddressForUpdate(req);
            const result = await BusinessPartnerAddress
                .requestBuilder()
                .update(address)
                .execute(sdkDest);
            return prepareResult(result);
        } catch (err) {
            req.reject(err);
        }
    });
    
    //this event handler is triggered when we call
    //DELETE http://localhost:4004/address-manager/BusinessPartnerAddresses(BusinessPartner='',AddressID='')
    this.on('DELETE', BusinessPartnerAddresses, async (req) => {
        try {
            const { BusinessPartner, AddressID } = req.params[0];
            await BusinessPartnerAddress
                .requestBuilder()
                .delete(BusinessPartner, AddressID)
                .execute(sdkDest);
        } catch (err) {
            req.reject(err);
        }
    });
  5. Run in Terminal: npm install @sap/cloud-sdk-vdm-business-partner-service
  6. Start the app in watch mode. Go to Postman and test the CREATE with this request body:
    {
        "BusinessPartner": "10300001",
        "CityName": "string",
        "Country": "DE",
        "HouseNumber": "string",
        "PostalCode": "12345",
        "StreetName": "string"
    }​
  7. Take the BusinessPartner and AddressID from the response of the POST request to use it when updating the same address. The URL should look like this: http://localhost:4004/address-manager/BusinessPartnerAddresses(BusinessPartner=’10300001′,AddressID=’24642′) and the request body like this:
    {
        "CityName": "Timisoara",
        "Country": "DE",
        "HouseNumber": "string",
        "PostalCode": "12345",
        "StreetName": "string"
    }​
  8. Try now to DELETE the same entry by calling: http://localhost:4004/address-manager/BusinessPartnerAddresses(BusinessPartner=’10300001′,AddressID=’24642′)

Generate a Fiori app using Fiori Tools

Steps to follow:

  1. Make sure you have SAP Fiori tools – Extensions Pack VSCode extension installed. Press CTRL + SHIFT + P and type “fiori” in the search and choose Fiori: Open Application Generator.
  2. We will be using the localhost app for the OData reference so open a Terminal and start the app with npm start. Keep the terminal where the server is started open while you create the UI app.
  3. In the Yeoman Generator select SAP Fiori elements application and press Next.
  4. Choose List Report Object Page and press Next.
  5. For OData source choose Connect to an OData Service.
  6. In OData v2 service URL put http://localhost:4004/v2/address-manager. It has to be a valid OData v2 service, if you try it with v4 version it is not going to work. The Yeoman Generator calls this URL so make sure that your service is started in the Terminal.
  7. Choose the main entity to be BusinessPartners and the Navigation entity to_BusinessPartnerAddress. Then press Next.
  8. Add the details like in the below image. Be careful to choose in the Project Folder Path the path to the app folder where we are storing the UIs. Then click Finish. Wait for the project to be generated and the node_modules to be installed.
  9. Go now in the Terminal in app/address-manager-ui and run npm start. This will automatically open http://localhost:8080/test/flpSandbox.html#masterDetail-display where you will see an empty Fiori app. We will add annotations in the OData service so that the UI shows data.

Add annotations in the CAP app

Steps to follow:

  1. Open package.json and add the following script:
    "ui": "cd app/address-manager-ui && npm start"​
  2. Open a Terminal and do: npm run ui
  3. http://localhost:8080/test/flpSandbox.html#masterDetail-display will automatically open up in Chrome. You will see an empty Fiori app. To change this we will add annotations in the OData service.
  4. Open another Terminal or use split Terminal, in the root folder start the server with npm run watch. It is important to keep both the UI and the server started.
  5. In srv folder create a file named annotations.cds and paste the following:
    annotate AddressManagerService.BusinessPartners with @title : 'Business Partners' {
        BusinessPartner @title                                  : 'Business Partner';
        LastName        @title                                  : 'Last Name';
        FirstName       @title                                  : 'First Name';
    };
    
    annotate AddressManagerService.BusinessPartners with @(UI : {
    
        HeaderInfo : {
            TypeName       : 'Business Partner',
            TypeNamePlural : 'Business Partners'
        },
        LineItem   : [
        {
            Value : BusinessPartner,
            Label : 'Business Partner'
        },
        {
            Value : LastName,
            Label : 'Last Name'
        },
        {
            Value : FirstName,
            Label : 'First Name'
        }
        ],
    });​
  6. cds watch will automatically detect the change and restart the server. You can now go to http://localhost:8080/test/flpSandbox.html#masterDetail-display and refresh the page so that the call to the OData V2 metadata is performed. Now you can press on the button Go. You should see this:
  7. We want now to add also the Search functionality in the backend. Since I received some code suggestions for improvement after I posted Part 1, I will also share the refactoring. In helpers.js file, add the following functions and export them:
    const constructBusinessPartnerFilter = (req) => {
        if (req && req.params && req.params[0]) {
            return {
                'BusinessPartner': req.params[0].BusinessPartner
            }
        } else if (req && req._.odataReq._queryOptions && req._.odataReq._queryOptions.$search) {
            const searchValue = JSON.parse(req._.odataReq._queryOptions.$search);
            return `BusinessPartner = ${searchValue} or FirstName = ${searchValue} or LastName = ${searchValue}`
        }
    }
    
    const constructBusinessPartnerAddressFilter = (req) => {
        if (req && req.params && req.params[0]) {
            return req.params[0].AddressID ? {
                'BusinessPartner': req.params[0].BusinessPartner,
                'AddressID': req.params[0].AddressID
            } : {
                    'BusinessPartner': req.params[0].BusinessPartner
                }
        }
    }
    
    const buildQuery = (entity, columns, filter) => {
        if (filter) {
            return SELECT.from(entity)
                .columns(columns)
                .where(filter)
        } else {
            return SELECT.from(entity)
                .columns(columns)
        }
    }
    
    module.exports = {
        buildAddressForCreate,
        buildAddressForUpdate,
        prepareResult,
        constructBusinessPartnerAddressFilter,
        constructBusinessPartnerFilter,
        buildQuery
    }​
  8. Create a file constants.js and paste the following:
    module.exports.ENTITIES = {
        BusinessPartner: {
            name: 'A_BusinessPartner',
            columns: ['BusinessPartner', 'FirstName', 'LastName']
        },
        BusinessPartnerAddress: {
            name: 'A_BusinessPartnerAddress',
            columns: ['BusinessPartner', 'AddressID', 'Country', 'PostalCode', 'CityName', 'StreetName', 'HouseNumber']
        }
    }​
  9. At the beginning of address-manager-service.js add:
    const {
        buildAddressForCreate,
        buildAddressForUpdate,
        prepareResult,
        constructBusinessPartnerAddressFilter,
        constructBusinessPartnerFilter,
        buildQuery
    } = require('./helpers')
    
    const { ENTITIES } = require('./constants');​
  10. Go in address-manager-service.js, add the function _buildHandler and modify the READ handler for BusinessPartner as follows:
    const service = await cds.connect.to('API_BUSINESS_PARTNER');
    
    const _buildHandler = async (entityName, req, filterFunction) => {
        try {
            const { name: entity, columns } = ENTITIES[entityName];
            const filter = filterFunction(req);
            const query = buildQuery(entity, columns, filter);
            return await service.transaction().run(query);
        } catch (err) {
            req.reject(err);
        }
    }
    
    //this event handler is triggered when we call
    //GET http://localhost:4004/address-manager/BusinessPartners
    //GET http://localhost:4004/address-manager/BusinessPartners('1000000')
    this.on('READ', BusinessPartners, async (req) => {
        return await _buildHandler('BusinessPartner', req, constructBusinessPartnerFilter);
    });
  11. Go back to the Browser, write in the Search Box “10100002”, this will automatically work if your server is in watch mode and adapted to the changes in the handler. The ListReport page is complete. You should see:
  12. Notice that there is no Create and Delete button. It is because the entity BusinessPartners is annotated with @readonly in address-manager-service.cds file.
  13. But if we want to navigate to the Object Page, it is empty. We need to add more annotations to make Object Page in the UI functional.
  14. In the annotation file, modify the HeaderInfo of the BusinessPartners to also contain Title and Description:
    HeaderInfo : {
        TypeName       : 'Business Partner',
        TypeNamePlural : 'Business Partners',
        Title          : {Value : BusinessPartner},
        Description    : {Value : FirstName}
    }​
  15. After LineItem add a comma and the following:
    Facets     : [{
        $Type  : 'UI.ReferenceFacet',
        Target : 'to_BusinessPartnerAddress/@UI.LineItem',
        Label  : 'Business Partner Addresses'
    }]​
  16. If you go back in Browser and refresh the page, then click on the line in the table, you will navigate to the Business Partner object page. You can see that in the Header we have the BusinessPartner and the FirstName values. You can also see the Business Partner Addresses facet. Notice the Create and Delete buttons which are present because the entity BusinessPartnerAddresses is not annotated with @readonly. Now we add annotations for BusinessPartnerAddresses to see data in this section.
  17. Go to annotations and add the following:
    annotate AddressManagerService.BusinessPartnerAddresses with @(UI : {
        HeaderInfo          : {
            TypeName       : 'Business Partner Address',
            TypeNamePlural : 'Business Partner Addresses',
            Title          : {Value : AddressID}
        },
    
        LineItem            : [
        {
            Value : AddressID,
            Label : 'Address ID'
        },
        {
            Value : Country,
            Label : 'Country'
        },
        {
            Value : PostalCode,
            Label : 'Postal Code'
        },
        {
            Value : CityName,
            Label : 'City Name'
        },
        {
            Value : StreetName,
            Label : 'Street Name'
        },
        {
            Value : HouseNumber,
            Label : 'House Number'
        }
        ]
    });
    ​
  18. When these annotations will be applied, the UI will call the OData service using the navigation: BusinessPartners(‘1000000’)/to_BusinessPartnerAddress for which we do not have yet an implementation in the JavaScript handler. Let’s add one. By default the navigations will go in the handler of the entity where we navigate to, in this case the READ BusinessPartnerAddresses. Since we added some refactoring, all we need to change in the handler to treat this case is:
    //this event handler is triggered when we call
    //GET http://localhost:4004/address-manager/BusinessPartnerAddresses
    //GET http://localhost:4004/address-manager/BusinessPartnerAddresses(BusinessPartner='10300001',AddressID='24642')
    //GET http://localhost:4004/address-manager/BusinessPartners('1000000')/to_BusinessPartnerAddress
    this.on('READ', BusinessPartnerAddresses, async (req) => {
        return await _buildHandler('BusinessPartnerAddress', req, constructBusinessPartnerAddressFilter);
    });
  19. Go in the Browser and refresh the page so that the OData service metadata where the annotations can be found by the UI is called again and refreshed. You should see:
  20. Since we already created a DELETE handler for the BusinessPartnerAddress, you can simply select a line and the DELETE button will automatically be enabled. When prompted with the Delete pop-up message click on Delete. The item is deleted and the page is refreshed having only the remaining items.
  21. If we do not want to create Addresses from the app and only create them in the SAP S/4HANA Cloud system we can add the following annotation for the BusinessPartnerAddress:
    annotate AddressManagerService.BusinessPartnerAddresses with @(
        Capabilities : {InsertRestrictions : {Insertable : false}},
        UI           : {…}
    );​
  22. Go now in the Browser and refresh the page, you will see that the CREATE button is absent now.
  23. For updating the address, we need to add annotations for the second object page of the BusinessPartnerAddress. If you press now on a Address line you will see only Header Info.
  24. In the UI, after LineItem add a comma and add the following:
    Facets  : [{
        $Type  : 'UI.ReferenceFacet',
        Target : '@UI.FieldGroup#Address',
        Label  : 'Address'
    }],
    FieldGroup #Address : {Data : [
    {
        Value : Country,
        Label : 'Country'
    },
    {
        Value : PostalCode,
        Label : 'Postal Code'
    },
    {
        Value : CityName,
        Label : 'City Name'
    },
    {
        Value : StreetName,
        Label : 'Street Name'
    },
    {
        Value : HouseNumber,
        Label : 'House Number'
    }
    ]}​
  25. In Browser refresh the page and press on an Address Line, you will be taken to the BusinessPartnerAddress Object Page where you can press on Edit. Write in the Street Name a different text and press Save.

Lessons learned

The Create, Update, Delete operations require a X-CSRF-Token handling. Currently, SAP Cloud SDK handles the X-CSRF-Token which is why I used it for these service handlers.

For the SAP Cloud SDK usage, I liked this tutorial: Build an Address Manager with the SAP Cloud SDK’s OData Virtual Data Model

Since I discovered CAP, I have been interested to learn how to use annotations. I finally got a better understanding from these 2 blog posts:

SAP Fiori Element using CAP Model – Introduction

SAP Fiori Element using CAP Model – List Report App

As documentation for annotations, I only found SAP OData UI Vocabulary.

Conclusion

There are many ways of doing Side-by-Side Extensibility on SAP Cloud Platform, this blog post series covered one of the options. The idea behind it was to present as detailed as possible initial steps on how to use these technologies. It was intended for developers who want to learn and start using them.

Please feel free to comment with questions or suggestions on the topics of the blog.

 

Assigned tags

      10 Comments
      You must be Logged on to comment or reply to a post.
      Author's profile photo Florian Bähler
      Florian Bähler

      Hi Ioana

      That blog is really helpful and you did a great job here!

      I have a question about the step 8. where you create this constants file. The CSN metadata of the S/4HANA API does include if the elements are key. In my opinion only the keys should be searchable in UI. So maybe we could dynamically generate the possible search columns out of the api definition, other than hardcoding it? In my scenario I consume two different SAP Cloud SDK APIs and therefore it already would pay off. What were your thoughts on this?

       

      Regards Florian

      Author's profile photo Ioana Stefania Santimbrean
      Ioana Stefania Santimbrean
      Blog Post Author

      Hello Florian,

      Thanks for your comment! Let me give my thoughts on the topic a bit more elaborate.

      If in your scenario you need to do both read and create/update/delete operations then my suggestion is definitely implementing the READ with SAP Cloud SDK (like the create, update, delete handlers are implemented in my blog post). Something like this:

      this.on('READ', BusinessPartner, async (req) => {
        return await BusinessPartner.requestBuilder()
          .getByKey(req.params[0].BusinessPartner)
          .execute(sdkDest);
      }

      And changing the .cds file to have the fields how the SAP Cloud SDK returns them (first letter lowercase). Something like this:

      service AddressManagerService {
          entity BusinessPartners {
              businessPartner, lastName, firstName, to_BusinessPartnerAddress
          };
      };

      In this way the SAP Cloud SDK handles everything you need about the API and you have a uniform way of handling all the operations.

       

      But if you scenario includes only reading operations, the CAP way of consuming an external service fits. Now regarding the dynamic generation, there was a way of using the framework capabilities, but I was having some serialization issues with that one:

      this.on('READ', BusinessPartners, async (req) => {
        return await service.transaction().run(req.query);
      }

      I'm not sure if this is what you're referring to, but if I remember correctly req.query will reflect if you use OData $search/$filter in the request. My serialization issue came from the fact that in my .cds file I only had "BusinessPartner, LastName, FirstName, to_BusinessPartnerAddress" and the SELECT query was fetching all the fields. What I'm saying is that it might be possible, but I have not tried it. If you choose this option and make it work, please share your findings 🙂

       

      (Behind the scenes thoughts) The idea behind my blog post series was one of exploration/try out, which is why first I tried the CAP way of consuming external services, then because of the X-CSRF-Token I chose to use the SAP Cloud SDK. I kept both ways of doing it to share the knowledge.

       

      If there's anybody who knows more info or has better knowledge on the subject, please feel free to comment, correct and suggest better ways! 🙂

       

      Florian, I hope I answered your questions. If not please elaborate more and I'd be happy to share my thoughts.

       

      Best regards,

      Stefania

      Author's profile photo Florian Bähler
      Florian Bähler

      Hi Stefania

      I ran into the same problems with CDS generic handlers and X-CSRF-Tokens until I met SAP Cloud SDK VDMs and their usage in CDS. So I totally agree with you, that there is a differentiation if only read method is needed or more.

      I am still confused why the VDM NPM modules have a entity schema with lowercase attributes, while API Hub S/4HANA OData service definitions (CSN) have capital letters for attributes. Your workaround with the prepare functions is fine but maybe SAP can consolidate that in the future.

      In my scenario there is also a OData v2 proxy used, we have to verify if the combination of VDMs and Odata v2 do work as in v4.

      Best Regards Florian

       

       

      Author's profile photo Moudhaffer Azizi
      Moudhaffer Azizi

      I see you used an SAP-provided OData service.

      Is it possible to post data to a customer's OData service? (on-premise via destinations)

      SAP Cloud SDK allows us to build custom OData clients for a specific service, but I am not sure how this fits with SAP CAP.

      Author's profile photo Stefania Santimbrean
      Stefania Santimbrean

      Hello Moudhaffer,

       

      I used an OData service specific to SAP S/4HANA Cloud just as an example, but you can adapt coding based on your scenario and how the entities of the customer's OData service look like.

      So in your .cds file where you defined the service that you want to use/expose (in my case it was called address-manager-service.cds) you define the name of the entities and properties according to what you have in the OData service. And in the .js file for that service (in my case address-manager-service.js) implement the service handlers by using SAP Cloud SDK custom OData clients.

      I haven't tried it myself, but this is a way that I'm thinking is possible.

      I hope this helped. If you have further questions or I haven't yet answered what you where looking for, just let me know.

       

      Best regards,

      Stefania

       

       

      Author's profile photo Trần Thành Trung
      Trần Thành Trung

      Hello

       

      I would need your advise how to control HeaderInfor tab as it was default whenever I changed data even also show Text 'Object Information' that I could not change or translate it

      Header%20Info

      Header Info

      Author's profile photo Stefania Santimbrean
      Stefania Santimbrean

      Hello,

       

      Could you point me to which code sample are you using?

       

      Regards,

      Stefania

      Author's profile photo Tim Douvis
      Tim Douvis

      Hi Stefania,

      Any idea why my list spans only half the screen?

       

      Thanks in advance,

      T

      Author's profile photo Tim Douvis
      Tim Douvis

      Hi Stefania,

      Found out here that we need to annotate each column's width. Have posted my code changes in the comments.

      Not sure I like this design decision but ok...

      Again thanks for a great tutorial!

      Best,

      T

      Author's profile photo Stefania Santimbrean
      Stefania Santimbrean

      Hi Tim,

      Cool that you shared your findings!

      Kind regards,

      Stefania