How to Integrate External REST APIs in SAP CAP: A Beginner’s Guide

Introduction

Here we look at calling external REST APIs from CAP service handlers.

Original article available here.

Prerequisites

Ensure you have a development environment set up for CAP, described here.

Setting up the Project

Create a new CAP project and set up the basic structure.

cds init weather-integration
cd weather-integration
npm install axios
npm install dotenv --save-dev

We will use axios for making the REST calls and use dotenv for managing environment variables.

Get WeatherAPI Key

Go to https://www.weatherapi.com/signup.aspx, sign up, and get an API key. The integration pattern will remain the same regardless of which API you choose.

Configure Environment Variables

Create a .env file in your project root:

WEATHER_API_KEY=API_KEY
WEATHER_API_BASE_URL=https://api.weatherapi.com/v1

Add .env to your .gitignore if you plan on committing:

echo “.env” >> .gitignore

To actually use the above settings, create a ./srv/server.js file in your project root to load the environment variables from the .env file:

// Load environment variables from .env file
require(’dotenv’).config();
const cds = require(’@sap/cds’);
cds.once(’bootstrap’, app => {
});
module.exports = cds.server;

Define the Data Model

Next we will define the data model in db/schema.cds:

namespace com.logistics;
entity Locations {
  key ID          : UUID;
      name        : String(100);
      city        : String(100);
      country     : String(2);
      latitude    : Decimal(10, 7);
      longitude   : Decimal(10, 7);
      address     : String(255);
}
entity DeliveryRoutes {
  key ID          : UUID;
      routeName   : String(100);
      location    : Association to Locations;
      scheduledAt : DateTime;
      status      : String(20);
}

The scenario above is for a logistics company with delivery routes. The idea is to have the weather conditions incorporated into the routes.

Define the Service

Next create srv/logistics-service.cds:

using { com.logistics as db } from ‘../db/schema’;
service LogisticsService {
  entity Locations as projection on db.Locations;
  entity DeliveryRoutes as projection on db.DeliveryRoutes;
  function getWeatherForLocation(locationID: UUID) returns {
    city: String;
    temperature: Decimal;
    condition: String;
    humidity: Integer;
    windSpeed: Decimal;
    description: String;
  };
  type RouteWithWeather {
    routeID: UUID;
    routeName: String;
    location: String;
    scheduledAt: DateTime;
    weather: {
      temperature: Decimal;
      condition: String;
      windSpeed: Decimal;
    };
  }
  
  function getRouteWithWeather(routeID: UUID) returns RouteWithWeather;
}

The above service will accommodate GET requests. For POST requests, instead of using function, use action:

using { com.logistics as db } from ‘../db/schema’;
service LogisticsService {
  entity Locations as projection on db.Locations;
  entity DeliveryRoutes as projection on db.DeliveryRoutes;
  action getWeatherForLocation(locationID: UUID) returns {
    city: String;
    temperature: Decimal;
    condition: String;
    humidity: Integer;
    windSpeed: Decimal;
    description: String;
  };
  type RouteWithWeather {
    routeID: UUID;
    routeName: String;
    location: String;
    scheduledAt: DateTime;
    weather: {
      temperature: Decimal;
      condition: String;
      windSpeed: Decimal;
    };
  }
  action getRouteWithWeather(routeID: UUID) returns RouteWithWeather;
}

Implement Service Handler

For the implementation, create srv/logistics-service.jsas follows:

const cds = require(’@sap/cds’);
const axios = require(’axios’);
module.exports = async function() {
  const { Locations, DeliveryRoutes } = this.entities;
  const WEATHER_API_KEY = process.env.WEATHER_API_KEY;
  const WEATHER_API_BASE_URL = process.env.WEATHER_API_BASE_URL || ‘https://api.weatherapi.com/v1’;
  /**
   * Fetch weather data from external API
   */
  async function fetchWeatherData(city) {
    if (!WEATHER_API_KEY) {
      throw new Error(’WEATHER_API_KEY is not configured’);
    }
    try {
      const response = await axios.get(`${WEATHER_API_BASE_URL}/current.json`, {
        params: {
          key: WEATHER_API_KEY,
          q: city,
          aqi: ‘no’ // We don’t need air quality data
        },
        timeout: 5000 // 5 second timeout
      });
      return response.data;
    } catch (error) {
      if (error.response) {
        console.error(’Weather API error:’, error.response.status, error.response.data);
        throw new Error(`Weather API returned error: ${error.response.data.error?.message || ‘Unknown error’}`);
      } else if (error.request) {
        console.error(’Weather API timeout or network error’);
        throw new Error(’Unable to connect to weather service. Please try again later.’);
      } else {
        console.error(’Weather fetch error:’, error.message);
        throw error;
      }
    }
  }
  /**
   * Get weather for a specific location
   */
  this.on(’getWeatherForLocation’, async (req) => {
    const { locationID } = req.data;
    const location = await SELECT.one.from(Locations).where({ ID: locationID });
    
    if (!location) {
      req.reject(404, `Location with ID ${locationID} not found`);
    }
    try {
      const weatherData = await fetchWeatherData(location.city);
      return {
        city: weatherData.location.name,
        temperature: weatherData.current.temp_c,
        condition: weatherData.current.condition.text,
        humidity: weatherData.current.humidity,
        windSpeed: weatherData.current.wind_kph,
        description: `${weatherData.current.condition.text} with ${weatherData.current.temp_c}Ôö¼Ã”ûæC`
      };
    } catch (error) {
      req.reject(500, error.message);
    }
  });
  /**
   * Get delivery route with weather information
   */
  this.on(’getRouteWithWeather’, async (req) => {
    const { routeID } = req.data;
    const route = await SELECT.one.from(DeliveryRoutes)
      .where({ ID: routeID })
      .columns(r => {
        r.ID, r.routeName, r.scheduledAt, r.status,
        r.location(l => {
          l.ID, l.name, l.city
        })
      });
    
    if (!route) {
      req.reject(404, `Route with ID ${routeID} not found`);
    }
    try {
      const weatherData = await fetchWeatherData(route.location.city);
      return {
        routeID: route.ID,
        routeName: route.routeName,
        location: route.location.name,
        scheduledAt: route.scheduledAt,
        weather: {
          temperature: weatherData.current.temp_c,
          condition: weatherData.current.condition.text,
          windSpeed: weatherData.current.wind_kph
        }
      };
    } catch (error) {
      console.warn(`Weather fetch failed for route ${routeID}:`, error.message);
      return {
        routeID: route.ID,
        routeName: route.routeName,
        location: route.location.name,
        scheduledAt: route.scheduledAt,
        weather: {
          temperature: null,
          condition: ‘Weather data unavailable’,
          windSpeed: null
        }
      };
    }
  });
  /**
   * Auto-fetch weather when reading routes
   */
  this.after(’READ’, DeliveryRoutes, async (routes, req) => {
    if (!routes || routes.length > 10) return routes;
    const routesArray = Array.isArray(routes) ? routes : [routes];
    for (const route of routesArray) {
      if (route.location && route.location.city) {
        try {
          const weatherData = await fetchWeatherData(route.location.city);
          route.currentWeather = {
            temperature: weatherData.current.temp_c,
            condition: weatherData.current.condition.text
          };
        } catch (error) {
          console.warn(`Failed to fetch weather for route ${route.ID}:`, error.message);
          route.currentWeather = null;
        }
      }
    }
    return routes;
  });
};

Adding Data

Now you can add some test data. For the locations, create db/data/com.logistics-Locations.csv:

ID;name;city;country;latitude;longitude;address
550e8400-e29b-41d4-a716-446655440001;Downtown Warehouse;London;GB;51.5074;-0.1278;123 Main St, London
550e8400-e29b-41d4-a716-446655440002;Airport Hub;Paris;FR;48.8566;2.3522;456 Charles de Gaulle, Paris
550e8400-e29b-41d4-a716-446655440003;Port Terminal;Hamburg;DE;53.5511;9.9937;789 Harbor Rd, Hamburg
550e8400-e29b-41d4-a716-446655440004;Distribution Center;Berlin;DE;52.5200;13.4050;101 Brandenburg Ave, Berlin
550e8400-e29b-41d4-a716-446655440005;Coastal Depot;Amsterdam;NL;52.3676;4.9041;202 Canal St, Amsterdam

Also, for the delivery routes, create db/data/com.logistics-DeliveryRoutes.csv:

ID;routeName;location_ID;scheduledAt;status
660e8400-e29b-41d4-a716-446655440001;Route A1;550e8400-e29b-41d4-a716-446655440001;2024-03-15T09:00:00Z;scheduled
660e8400-e29b-41d4-a716-446655440002;Route B2;550e8400-e29b-41d4-a716-446655440002;2024-03-15T14:00:00Z;scheduled
660e8400-e29b-41d4-a716-446655440003;Route C3;550e8400-e29b-41d4-a716-446655440003;2024-03-16T08:00:00Z;in_progress
660e8400-e29b-41d4-a716-446655440004;Route D4;550e8400-e29b-41d4-a716-446655440004;2024-03-16T10:30:00Z;scheduled
660e8400-e29b-41d4-a716-446655440005;Route E5;550e8400-e29b-41d4-a716-446655440005;2024-03-17T07:00:00Z;pending

Configure Package.json

Make sure your package.json uses dotenv in development. Also make sure the database is set to sqlite for testing this locally:

{
  ...
  “dependencies”: {
    ...
  },
  “devDependencies”: {
    “dotenv”: “^16.0”,
    ...
  },
  “cds”: {
    “requires”: {
      “db”: {
        “kind”: “sqlite”,
        “credentials”: {
          “database”: “db.sqlite”
        }
      }
    }
  }
}

You can now deploy this to db.sqlite using cds deploy to verify that everything is correct up to this point and there are no errors.

If there are no errors, you should be able to start the application using the following:

npm run watch

The server should start on

http://localhost:4004

Test The Integration

With the server running we can now test the services.

Getting Weather for a Location

For GET calls use the following:

curl “http://localhost:4004/odata/v4/logistics/getWeatherForLocation(locationID=’550e8400-e29b-41d4-a716-446655440001’)”

For POST calls, use the following:

#!/bin/bash
curl -X POST http://localhost:4004/odata/v4/logistics/getWeatherForLocation \
	-H “Content-Type: application/json” \
	-d ‘{”locationID”: “550e8400-e29b-41d4-a716-446655440001”}’

You should get a response similar to the following:

{
  “@odata.context”: “$metadata#LogisticsService.return_LogisticsService_getWeatherForLocation”,
  “city”: “London”,
  “temperature”: 8.2,
  “condition”: “Light rain”,
  “humidity”: 93,
  “windSpeed”: 14,
  “description”: “Light rain with 8.2┬░C”
}

Getting Route with Weather

For GET calls use the following:

curl “http://localhost:4004/odata/v4/logistics/getRouteWithWeather(routeID=’660e8400-e29b-41d4-a716-446655440001’)”

For POST calls use the following:

curl -X POST http://localhost:4004/odata/v4/logistics/getRouteWithWeather \
	-H “Content-Type: application/json” \
	-d ‘{”routeID”: “660e8400-e29b-41d4-a716-446655440001”}’

The response should look something like this:

{
  “@odata.context”: “$metadata#LogisticsService.RouteWithWeather”,
  “routeID”: “660e8400-e29b-41d4-a716-446655440001”,
  “routeName”: “Route A1”,
  “location”: “Downtown Warehouse”,
  “scheduledAt”: “2024-03-15T09:00:00Z”,
  “weather”: {
    “temperature”: 8.2,
    “condition”: “Light rain”,
    “windSpeed”: 14
  }
}

Reading Routes with Weather Added

curl “http://localhost:4004/odata/v4/logistics/DeliveryRoutes?\$expand=location”

The response will include the currentWeather field automatically added by our after handler, and should look something like the following:

{
  “@odata.context”: “$metadata#DeliveryRoutes”,
  “value”: [
    {
      ...
      “location”: {
        ...
      },
      “currentWeather”: {
        “temperature”: 8.2,
        “condition”: “Light rain”
      }
    },
    ...
  ]
}

Additional Resources

Comments

Popular Posts