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: 
wittro
Employee
Employee
In my recent work, I came across the problem to consume a plain REST (i. e. non-OData) service in a CAP based app. CAP supports importing definitions of external OData services quite conveniently. jhodel18 describes this nicely in his article series.

In this blog I want to share my experience consuming a REST service in a CAP app using CAP's RemoteService API.

 

Consuming Services in CAP


CAP has the notion of providing services and consuming services. A CAP app defines and implements providing services that expose the CDS data model. At runtime, it uses consuming services to connect to the data sources where data resides. Out-of-the-box CAP can connect to HANA and SQLite databases and can use external OData services as data sources.

An additional option is the consumption of REST services via the API cds.RemoteService. The RemoteService is not documented at the time of writing, so the demo in the next chapter is the result of trying out the API on my own. I do not claim it being a best practise but rather what worked for me.

 

The Sample Project


The following code samples are taken from a project I published at GitHub. You can clone the repository and follow the installation instructions in the README.

The app contains a simple weather data model with an OData V4 API to get the current weather conditions in a city requested by the user. The weather data is read from an REST-like service of OpenWeatherMap.org.

The general flow of this basic app is:

  1. The user requests the current weather for a city (e. g. London) using the OData V4 API.

  2. The app takes the user request, translates it into a GET request and sends it to the OpenWeatherMap REST API.

  3. The response is translated back to weather data model and returned to the user.


 

The Data Model


The data model in CDS is very simple. It consists of one entity only containing the location properties plus a structured type for the weather conditions.
type WeatherCondition : {
description : String;
temperature : Decimal(5, 2);
humidity : Decimal(4, 1);
windSpeed : Decimal(3, 1);
}

entity Weather {
key id : Integer64;
city : String;
country : String;
current : WeatherCondition
}

 

The Application Service


The application service that is exposed to the user is called weather-service. It is modelled in CDS and just contains one entity, i. e. it provides one resource to get the current weather. Notice that it is readonly.
using {db} from '../db/Weather';

@Capabilities.KeyAsSegmentSupported : true
service WeatherService {
@readonly
entity CurrentWeather as projection on db.Weather;
}

With the KeyAsSegmentSupported capability, the service accepts requests to get single resources with the ID provided as path segment, for example GET /weather/CurrentWeather/12345. This is a more REST-like way of reading resources by key, in contrast to OData's special syntax (GET /weather/CurrentWeather(12345)).

The application service implementation has an ON handler where the request is delegated to the OpenWeatherApi service. This is the heart of this demo and described next.
cds.service.impl(function () {
const { CurrentWeather } = this.entities;

this.on("READ", CurrentWeather, async (req) => {
const openWeatherApi = await cds.connect.to("OpenWeatherApi");
return openWeatherApi.tx(req).run(req.query);
});
});

 

Consuming OpenWeather API


I define an external service in .cdsrc.json named OpenWeatherApi. The url property points to the root path of the OpenWeather API endpoint.
{
"odata": {
"flavor": "x4"
},
"requires": {
"OpenWeatherApi": {
"kind": "rest",
"impl": "srv/external/OpenWeatherApi.js",
"credentials": {
"url": "https://api.openweathermap.org/data/2.5"
}
}
}
}

Notice the odata.flavor = x4 value. This is a new CAP feature and represents structured types as objects in payloads in contrast to the flattened version.

Back to the OpenWeatherApi. I created a new service class that extends CAP's RemoteService API. In the init method I add several handlers:

  • All events other than READ are rejected.

  • A BEFORE handler to translate the application service query to a query that the REST service understands.

  • An ON handler that execute the REST call and translates the result back to the application service model. Because I replace the result of the REST service entirely, I use an ON handler instead of an AFTER handler, which is the recommended approach in such scenarios.


class OpenWeatherApi extends cds.RemoteService {
async init() {
this.reject(["CREATE", "UPDATE", "DELETE"], "*");

this.before("READ", "*", (req) => {
// translate req.query into a query for the REST service
});

this.on("READ", "*", async (req, next) => {
// invoke the REST service and translate the response
});

super.init();
}
}

 

The BEFORE Handler


The request that is sent to the REST service is prepared in the BEFORE handler of the OpenWeatherApi service.
class OpenWeatherApi extends cds.RemoteService {
async init() {
...

this.before("READ", "*", (req) => {
try {
const queryParams = parseQueryParams(req.query.SELECT);
const queryString = Object.keys(queryParams)
.map((key) => `${key}=${queryParams[key]}`)
.join("&");
req.query = `GET /weather?${queryString}`;
} catch (error) {
req.reject(400, error.message);
}
});

...
}
}

The handler should prepare a URI string and set it to req.query. The string has to follow the pattern: "<method> /<resource>?<query parameters>"

The function parseQueryParams returns an object with query parameter key/value pairs. It uses a helper function parseExpression that returns the key/value pair of a CQN expression. A user can pass filters either by key or by $filter statements to the application service that result in these expressions.
function parseQueryParams(select) {
const filter = {};
Object.assign(
filter,
parseExpression(select.from.ref[0].where),
parseExpression(select.where)
);

if (!Object.keys(filter).length) {
throw new Error("At least one filter is required");
}

const apiKey = process.env.OPEN_WEATHER_API_KEY;
if (!apiKey) {
throw new Error("API key is missing.");
}

const params = {
appid: apiKey,
units: "metric",
};

for (const key of Object.keys(filter)) {
switch (key) {
case "id":
params["id"] = filter[key];
break;
case "city":
params["q"] = filter[key];
break;
default:
throw new Error(`Filter by '${key}' is not supported.`);
}
}

return params;
}

function parseExpression(expr) {
if (!expr) {
return {};
}
const [property, operator, value] = expr;
if (operator !== "=") {
throw new Error(`Expression with '${operator}' is not allowed.`);
}
const parsed = {};
if (property && value) {
parsed[property.ref[0]] = value.val;
}
return parsed;
}

Please be aware this implementation is just for demo purposes and not very robust. It ignores other filter parameters or operators, as well as other kinds of CQN expressions (like functions).

 

The ON Handler


The implementation of the ON handler is comparably simple. It calls the next ON handler in the queue which is the RemoteService's default ON handler. This handler invokes the external REST service. The retrieved response (i. e. the OpenWeather API data) is translated into the model of the application service.
class OpenWeatherApi extends cds.RemoteService {
async init() {
...

this.on("READ", "*", async (req, next) => {
const response = await next(req);
return parseResponse(response);
});

...
}
}

parseResponse is again implemented for demo purposes and lacks robustness.
function parseResponse(response) {
return {
id: response.id,
city: response.name,
country: response.sys.country,
current: {
description: response.weather[0].description,
temperature: response.main.temp,
humidity: response.main.humidity,
windSpeed: response.wind.speed,
},
};
}

 

Testing the App


After starting the app via cds run, you can get the weather data for a city in two ways:

The response data would look like this:
{
"@odata.context": "$metadata#CurrentWeather",
"value": [
{
"id": 2643743,
"city": "London",
"country": "GB",
"current": {
"description": "scattered clouds",
"temperature": 5.31,
"humidity": 66,
"windSpeed": 4.6
}
}
]
}

 

Conclusion


With the cds.RemoteService API you can use external REST services as data sources for your application service in a CAP app.

The described demo is not very feature-rich but concentrating on the core parts the RemoteService. Potential next steps to enhance the app include:

  • Robustness of the translation between models

  • More filter options

  • A Fiori Elements based UI to show the results

19 Comments
jhodel18
Active Contributor
0 Kudos

Thanks for the wonderful blog post robert-witt !

My key takeaways are the cds.RemoteService and odata.flavor = x4. Have you seen my Consume External Service – Part 3? At the time of writing that blog post, I’ve written the CDSE node module to bridge the gap of consuming RESTful APIs in a CAP-like way. But after reading your blog post, I can conclude that cds.RemoteService will be the official approach moving forward, isn't it?

Cheers!

Jhodel

wittro
Employee
Employee
Imo it's the simplest way to consume REST services as it still provides the consuming service features of CAP. For instance, the API uses the destination service under the hood, so you can define your destination with the endpoint and credentials in SCP and don't have to bother with these details in CAP (not shown in the demo but we did it this way in my project). So I can recommend give it a try.
martinkoch
Active Participant
0 Kudos
Hi Rober,

a great blog post. Really helped me!

Thanks for sharing.

Regards,

Martin
ronnie_kohring
Advisor
Advisor
0 Kudos
Hi Robert,

Thanks for putting this example together!

While trying it out I also tried changing the "before" handler to be more specific since there can be many different endpoints on the remote service, eg:


this.before("READ", "CurrentWeather", (req) => {

or
const { CurrentWeather } = this.entities;
this.before("READ", CurrentWeather, (req) => {

but those didn't work. Have you tried the same and found how to make it work?

Cheers, Ronnie



wittro
Employee
Employee
0 Kudos
Hi Ronnie,

this.entities returns the entities in the scope of the current service where you call it. CurrentWeather is defined in the providing service srv.WeatherService, not in OpenWeatherApi.

You would have to go via the model (which is the app's global model) like this:
const currentWeather = this.model.definitions["srv.WeatherService.CurrentWeather"];

this.before("READ", currentWeather, (req) => {
...
}

Hope that helps.

Regards, Robert
ronnie_kohring
Advisor
Advisor
0 Kudos
Hi Robert,

That worked perfectly! Thanks a lot for taking the time to provide your insight.

Kind regards, Ronnie

 
0 Kudos
Any solutions for the CREATE queries?

The Cloud SDK OData client does not work with sap CAP in NodeJS
wittro
Employee
Employee
0 Kudos
Can you elaborate on your problem? I haven't use a remote service with CREATE queries yet.
0 Kudos
Your method, as you mentioned in your post, only works for the READ operation. I am actually able to do this natively in CAP using:

let extSrv = cds.connect.to('ServiceName')  then delegating all the external entities to it.

this.on('READ', extSrvEntites, async (req) => {response = await extSrv.tx(req).run(req.query);response;});


The above code works perfectly.

However, this does not work for CREATE (post) to the same external API (via destination service).

I get 403 unauthorized, probably due to x-csrf-token validation.

I tried building my own odata client using the SAP Cloud SDK Odata client generator.

Unfortunately, the client is unable to execute any queries. (I am guessing this will be supported in a future release of the CAP NodeJS framework).
wittro
Employee
Employee
0 Kudos
I got your point.

Unfortunately, I don't have experience with the RemoteService and CREATE requests yet. I know from past developments with older CAP frameworks (without RemoteService) that you have to fetch an CSRF token upfront and then pass it as additional header. I would expect that the Cloud SDK that CAP is using would do this for you but obviously it is not. You could try getting in contact with the CAP team and ask for support (if not done already).

Also, I think you could try to fetch the token yourself and send it with the CREATE request. I know you can send additional headers like this: await srv.tx(req).send({ headers: { "x-csrf-token": "fetch" }, req.query}). But I don't know how to get it back from the RemoteService call. Maybe you'll find a way.
0 Kudos
Any news on this?
Steven_UM
Contributor
0 Kudos
This blog was a great inspiration to get external services working ... thanks !
franklinlin
Participant
pamesty
Explorer
0 Kudos
Did you find it or a similar example?
MMonis
Explorer
kevindass
Participant
0 Kudos
wittro link to github repo is broken/not found https://github.com/wittro-sap/cap-weather
wittro
Employee
Employee
0 Kudos
I had to remove the repo. Please find my latest topic around the consumption of REST services with CAP here: https://blogs.sap.com/2022/08/30/import-openapi-documented-apis-remotely-with-sap-cloud-application-...

It explains the new CAP-supported way of integrating OpenAPI-described remote services.
daniyalahmad
Discoverer
0 Kudos
Kindly provide any link for consume external rest api sap cap using java. Thanks
rgadirov
Participant
0 Kudos
Hi robert-witt

 

thank you very much for this great blog. In this case I was able to download the weather API from swagger open api hub. But how does it work with other REST based APIs where only URLs are known. How do you download the service definition - e.g. as json?

BR
Rufat