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:
- The user requests the current weather for a city (e. g. London) using the OData V4 API.
- The app takes the user request, translates it into a GET request and sends it to the OpenWeatherMap REST API.
- 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