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:
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.
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
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.
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:
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,
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:
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:
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" } ] }
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`.
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:
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
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.
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).
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
26 | |
13 | |
11 | |
10 | |
8 | |
7 | |
7 | |
7 | |
5 | |
5 |