Skip to Content
Technical Articles
Author's profile photo Robert Witt

Consuming a REST Service with the SAP Cloud Application Programming Model

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. Jhodel Cailan 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:

  • By city name, e. g. http://localhost:4004/weather/CurrentWeather?$filter=city%20eq%20%27London%27
  • If you know the ID of a city: http://localhost:4004/weather/CurrentWeather/2643743

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

Assigned tags

      11 Comments
      You must be Logged on to comment or reply to a post.
      Author's profile photo Jhodel Cailan
      Jhodel Cailan

      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

      Author's profile photo Robert Witt
      Robert Witt
      Blog Post Author

      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.

      Author's profile photo Martin Koch
      Martin Koch

      Hi Rober,

      a great blog post. Really helped me!

      Thanks for sharing.

      Regards,

      Martin

      Author's profile photo Ronnie Kohring
      Ronnie Kohring

      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

      Author's profile photo Robert Witt
      Robert Witt
      Blog Post Author

      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

      Author's profile photo Ronnie Kohring
      Ronnie Kohring

      Hi Robert,

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

      Kind regards, Ronnie

       

      Author's profile photo Moudhaffer Azizi
      Moudhaffer Azizi

      Any solutions for the CREATE queries?

      The Cloud SDK OData client does not work with sap CAP in NodeJS

      Author's profile photo Robert Witt
      Robert Witt
      Blog Post Author

      Can you elaborate on your problem? I haven't use a remote service with CREATE queries yet.

      Author's profile photo Moudhaffer Azizi
      Moudhaffer Azizi

      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).

      Author's profile photo Robert Witt
      Robert Witt
      Blog Post Author

      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.

      Author's profile photo Dídac Casanovas
      Dídac Casanovas

      Any news on this?