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
Product and Topic Expert
Product and Topic Expert
14,677
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

20 Comments