Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
Hongjun_Qian
Product and Topic Expert
Product and Topic Expert

See my previous blog Develop and deploy a HTML (Angular/Vue/React) app on SAP BTP (Cloud Foundry) access S/4 On-premise, ... for the context.

 
This post provides detail step to develop and deploy a HTML app (based on Angular) which deployed to SAP BTP and access data from two sources:

    • S/4 On-premise system;
    • A Public API.

 

Development Steps

 

Step 1. Create an OData Service in S/4 On-premises system.


It is suggested to use RAP for such development in ABAP development world, but it depends on personal choices.

In my example, I'd created a managed (with Draft) RAP application which can create/display/delete all Test Cases.

With RAP's 'Preview' functionality, the app look likes as following screenshot. And with this RAP, the OData service has been published from S/4 OP system, so that it can be accessed from SAP BTP in the coming steps.


 

Step 2. Create an empty project.


Add package.json file as following and run `npm i` to install it.

{
	"name": "btp-webapp",
	"description": "My Web app on BTP",
	"dependencies": {
		"mbt": "^1.2.7"
	},
	"scripts": {
		"clean": "npx rimraf mta_archives resources",		
		"build": "npm run clean && mbt build",
		"deploy": "cf deploy mta_archives/btp-webapp_1.0.0.mtar"
	},
	"version": "1.0.0"
}


If you need push your code into git-based repository, don't forget to create a `.gitignore` file to skip unnecessary files, especailly the node_modules.

A sample .gitignore file as following:

node_modules/
*/node_modules/
resources/
mta_archives/
*/.angular/
mta-op*/
webapp-content.zip
*/webapp-content.zip

  

Step 3.  Create `router` folder for app router


Create a new folder named `router` under the project created in `Step 2`.  Under the new create folder, create package.json as following (also run npm installation after creation):

{
	"name": "approuter",
	"description": "Node.js based application router service for html5-apps",
	"dependencies": {
		"@sap/approuter": "^10.10.1",
		"axios": "^0.18.0"
	},
	"scripts": {
		"start": "node index.js"
	}
}


Create an empty `index.js` in this router folder for now.

 

Step 4. Create your HTML app (in this case, use Angular)


Under the new create project (in step 2), use `ng new webapp` to create an Angular app. I assume that the Angular CLI has been installed globally. Refer to Angular official website for creating a new Angular program.

After the Angular app create successfully, in folder `webapp`, create manifest.json:

{
  "_version": "1.12.0",
  "sap.app": {
    "id": "webapp",
    "applicationVersion": {
      "version": "1.0.1"
    }
  },
  "sap.cloud": {
    "public": true,
    "service": "cloud.service"
  }
}


Though manifest.json file, this HTML app now can be recognized by SAP HTML App. Repository.

Change to this new Angular project:

    • Firstly, change angular.json file to change the output path: remove the sub folder. Refer to Angular official document for such change if you don't know how.
    • Then, change the app.component.ts.
    • Change the app.component.html.


The angular.json file:

          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist",
            "index": "src/index.html",

 
The app.component.ts file (please refer to official  Angular Document for the usage of HttpClient) :

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
  title = 'webapp';
  arCases: any[] = [];
  arProducts: any[] = [];

  constructor(private http: HttpClient) {    
  }

  ngOnInit(): void {
  }
  onFetchDataFromService() {
    this.http.get('/v2/Northwind/Northwind.svc/Products?$top=30&$format=json').subscribe({
      next: (val: any) => {
        this.arProducts = val.d.results.slice();
      },
      error: err => {
        console.error(err);
      }
    });
  }

  onFetchDataFromERP() {
    const url = `/erp/sap/opu/odata4/sap/zac_fb2testcases_o4/srvd/sap/zac_fb2testcases/0001/TestCases?sap-client=001&$count=true&$select=CaseID,CaseType,CaseTypeName,CaseUUID,Description,ExcludedFlag,LastChangedAt&$skip=0&$top=30`;
    this.http.get(url).subscribe({
      next: (val: any) => {
        this.arCases = val.value.slice();
      },
      error: err => {
        console.error(err);
      }
    });
  }
}


 Here there are two methods create to retrieve data from different sources, respectively,

    • First one call to 'service.odata.org'
    • Second one call to backend S/4 HANA

The app.component.html look like:

<div>
    <button (click)="onFetchDataFromService()">Fetch data from Service.odata.org</button>
    <button (click)="onFetchDataFromERP()">Fetch data from ERP</button>
</div>

<h3 class="p-3 text-center">Display a list of cases</h3>
<!-- Content from S/4 OP -->
<div class="container">
    <table class="table table-striped table-bordered">
        <thead>
            <tr>
                <th>CaseID</th>
                <th>CaseType</th>
                <th>CaseTypeName</th>
                <th>Description</th>
            </tr>
        </thead>
      <tbody>
            <tr *ngFor="let case of arCases">
                <td>{{case.CaseID}}</td>
                <td>{{case.CaseType}}</td>
                <td>{{case.CaseTypeName}}</td>
                <td>{{case.Description}}</td>
            </tr>
        </tbody>
    </table>
</div>
<hr />

<!-- Content from Service --> <h3 class="p-3 text-center">Display a list of products</h3> <div class="container"> <table class="table table-striped table-bordered"> <thead> <tr> <th>ProductID</th> <th>ProductName</th> </tr> </thead> <tbody> <tr *ngFor="let prod of arProducts"> <td>{{prod.ProductID}}</td> <td>{{prod.ProductName}}</td> </tr> </tbody> </table> </div>

There are two buttons, which call to the two methods above.

The expected app behavior as following:

    • Firstly, the app opened as following:



    • After clicked 'Fetch data from Service.odata.org'


 

    • After click 'Fetch data from ERP'





The HTML app (Angular app in this post) is not yet done.

Enhance 'package.json' file with following scripts:

    "build-btp": "npm run clean-btp && ng build --configuration production && npm run zip",
    "zip": "cd dist/ && npx bestzip ../webapp-content.zip * ../manifest.json",
    "clean-btp": "npx rimraf webapp-content.zip dist"

Those three commends:

    • build-btp: build the project for BTP runtime information.
    • zip: zip the content for BTP
    • clean-btp: clean the temporary file and folder



And the last piece of this Angular app is create another file named 'xs-app.json':

{
  "welcomeFile": "/index.html",
  "authenticationMethod": "route",
  "logout": {
    "logoutEndpoint": "/do/logout"
  },
  "routes": [
    {
      "source": "^(.*)$",
      "target": "$1",
      "service": "html5-apps-repo-rt",
      "authenticationType": "xsuaa"
    }   
  ]
}

 

Step 5. Complete the approuter module


After the completion of the angular app, we need enrich the approuter module by updating the `index.js` file.

This step performed in router folder.

 

const approuter = require('@sap/approuter');
const axios = require('axios');
const ar = approuter();

ar.beforeRequestHandler.use('/erp', async (req, res, next)=>{
    const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES);
    const destSrvCred = VCAP_SERVICES.destination[0].credentials;
    const conSrvCred = VCAP_SERVICES.connectivity[0].credentials;

    // call destination service
    const destJwtToken = await _fetchJwtToken(destSrvCred.url, destSrvCred.clientid, destSrvCred.clientsecret);    

    //console.debug(destJwtToken);
    const destiConfi = await _readDestinationConfig('YOUR_ERP', destSrvCred.uri, destJwtToken);

    //console.debug(destiConfi);
    // call onPrem system via connectivity service and Cloud Connector
    const connJwtToken = await _fetchJwtToken(conSrvCred.token_service_url, conSrvCred.clientid, conSrvCred.clientsecret);

    //console.debug(connJwtToken);
    const result =  await _callOnPrem(conSrvCred.onpremise_proxy_host, conSrvCred.onpremise_proxy_http_port, connJwtToken, destiConfi, req.originalUrl, req.method);

    res.end(Buffer.from(JSON.stringify(result)));
});

const _fetchJwtToken = async function(oauthUrl, oauthClient, oauthSecret) {
    const tokenUrl = oauthUrl + '/oauth/token?grant_type=client_credentials&response_type=token'  
    const config = {
        headers: {
           Authorization: "Basic " + Buffer.from(oauthClient + ':' + oauthSecret).toString("base64")
        }
    };

    const response = await axios.get(tokenUrl, config);
    return response.data.access_token;
};

// Call Destination Service. Result will be an object with Destination Configuration info
const _readDestinationConfig = async function(destinationName, destUri, jwtToken) {
    const destSrvUrl = destUri + '/destination-configuration/v1/destinations/' + destinationName  
    const config = {
        headers: {
           Authorization: 'Bearer ' + jwtToken
        }
    };

    const response = await axios.get(destSrvUrl, config);
    return response.data.destinationConfiguration;
};

const _callOnPrem = async function(connProxyHost, connProxyPort, connJwtToken, destiConfi, originalUrl, reqmethod) {
    const targetUrl = originalUrl.replace("/erp/", destiConfi.URL);
    const encodedUser = Buffer.from(destiConfi.User + ':' + destiConfi.Password).toString("base64");

    try {
        const config = {
            headers: {
                Authorization: "Basic " + encodedUser,
                'Proxy-Authorization': 'Bearer ' + connJwtToken
                // 'SAP-Connectivity-SCC-Location_ID': destiConfi.CloudConnectorLocationId        
            },
            proxy: {
				host: connProxyHost, 
				port: connProxyPort 
            }
        }
        
        if (reqmethod === 'GET') {
            const response = await axios.get(targetUrl, config);
            return response.data;
        } else {
} } catch (error) { if (error.response) { // get response with a status code not in range 2xx console.error(error.response.data); console.error(error.response.status); console.error(error.response.headers); } else if (error.request) { // no response console.error(error.request); } else { // Something wrong in setting up the request console.error('Error', error.message); } console.error(error.config); } }; ar.start();



This file is the key part insider approuter which will handle the call from Angular app starting with `/erp`.

    • It will figure out the destination service binding with this app, and replace the prefix `/erp` with the virutal URL in destination service.
    • The destination `YOUR_ERP` must exist in destination service.
    • And then the logic will perform the call to the final URL with connectivity service.
    • The sample code here handles only HTTP `GET` call. It can be extended to all HTTP METHODS easily.
    • The sample code using library `axios` and it can be easily switch to a different library such as jQuery.


Add another `xs-app.json` to `router` folder:

{
  "welcomeFile": "/index.html",
  "authenticationMethod": "route",
  "routes": [
    {
      "source": "/user-api/currentUser$",
      "target": "/currentUser",
      "service": "sap-approuter-userapi"
    },
    {
      "authenticationType": "none",
      "csrfProtection": false,
      "source": "^/v2/(.*)$",
      "destination": "Northwind"
    },
    {
      "authenticationType": "none",
      "csrfProtection": false,
      "source": "^/erp/(.*)$",
      "target": "/$1",
      "destination": "YOUR_ERP"
    },
    {
      "source": "(.*)",
      "target": "/webapp/$1",
      "service": "html5-apps-repo-rt"
    }
  ]
}

 
This `xs-app.json` is also the key part which defined the routing rules. From the file:

    • Call start with `/erp` will be forwarded to destination 'YOUR_ERP'.
    • Call start with `/v2' will be forwarded to destination 'Northwind'.
    • Others will default forward to HTML5 app repository runtime.


 

Step 6. Complete the develop


The final step here is complete the definition and the approach to deploy.
This step performed in project root folder.


Create `destination.json` file:

{
  "HTML5Runtime_enabled": true,
  "version": "1.0.0",
  "init_data": {
    "instance": {
      "existing_destinations_policy": "update",
      "destinations": [
        {
          "Name": "Northwind",
          "Description": "Automatically generated Northwind destination",
          "Authentication": "NoAuthentication",
          "ProxyType": "Internet",
          "Type": "HTTP",
          "URL": "https://services.odata.org",
          "HTML5.DynamicDestination": true
        }
      ]
    }
  }
}


This destination defines `Northwind` part which is public to all. The destination 'YOUR_ERP' cannot be defined here in code level.

 
Create `xs-security.json`:

{
  "xsappname": "webapp_repo_router",
  "tenant-mode": "dedicated",
  "description": "Security profile of called application",
  "scopes": [
    {
      "name": "uaa.user",
      "description": "UAA"
    }
  ],
  "role-templates": [
    {
      "name": "Token_Exchange",
      "description": "UAA",
      "scope-references": [
        "uaa.user"
      ]
    }
  ],
  "oauth2-configuration": {
    "redirect-uris": [
      "https://*.us10-001.hana.ondemand.com/login/callback"
    ]
  }
}


Since my BTP account for this post is us10-001.hana.ondemand.com, the redirect-uris have been put in this way, you need adjust to your BTP account regions accordingly.

 
Then add the `mta.yaml` file for deploy:

ID: btp-webapp
_schema-version: "2.1"
version: 1.0.0

modules:
  - name: AngularWebApp
    type: html5
    path: webapp
    build-parameters:
      builder: custom
      commands:
        - npm run build-btp
      supported-platforms: []
  - name: webapp_deployer
    type: com.sap.application.content
    path: .
    requires:
      - name: webapp_repo_host
        parameters:
          content-target: true
    build-parameters:
      build-result: resources
      requires:
        - artifacts:
            - webapp-content.zip
          name: AngularWebApp
          target-path: resources/
  - name: webapp_router
    type: approuter.nodejs
    path: router
    parameters:
      disk-quota: 256M
      memory: 256M
    requires:
      - name: webapp_repo_runtime
      - name: webapp_conn
      - name: webapp_destination
      - name: webapp_uaa
resources:
  - name: webapp_repo_host
    type: org.cloudfoundry.managed-service
    parameters:
      service: html5-apps-repo
      service-plan: app-host
  - name: webapp_repo_runtime
    parameters:
      service-plan: app-runtime
      service: html5-apps-repo
    type: org.cloudfoundry.managed-service
  - name: webapp_destination
    type: org.cloudfoundry.managed-service
    parameters:
      service-plan: lite
      service: destination
      path: ./destination.json
  - name: webapp_uaa
    parameters:
      path: ./xs-security.json
      service-plan: application
      service: xsuaa
    type: org.cloudfoundry.managed-service
  - name: webapp_conn
    type: org.cloudfoundry.managed-service
    parameters:
      service-plan: lite
      service: connectivity

 

Step 7. Deploy it


After all steps above completed, you can deploy the change to your CF space.

After run `npm run deploy` on project root folder, the deploy will take place after you have logon to your CF account/space successfully.
 

Step 8. Final step


After develop and deploy, your HTML app now available in your BTP account.
But if you test it, you will find the `fetch data from ERP` won't work while `fetch data from service.odata.org` works fine.

The reason behind is, the destination service still one final step: add your destination (setup in SAP Cloud Connection, and defined in your BTP subaccount) into destination service.

You can download the destination service from your subaccount, and then upload to the destination service.

After the deploy, those services shall be runnable in your BTP sub account. Choose the '...' button of your destination, and choose 'View Dashboard', and upload your destination file there, and do not forget enter your password (the download destination won't store password for you) again and ensure the connection is working.



After the destination is defined, then you completed the whole steps.
Open your browser for testing, it shall work:


 

===

In coming Part III, I would like to describe the second approach by using SAP CAP to achieve same target: 'a HTML app on SAP BTP (Cloud Foundry) to access S/4 OP system).
 
 

 

 

 

 

 

 

 

 

 

2 Comments