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

Import OpenAPI-documented APIs remotely with SAP Cloud Application Programming Model

The team behind the SAP Cloud Application Programming Model recently released an enhancement to the CDS importer to generate service models from OpenAPI documents. You can now run cds import <openapi file path> with any OpenAPI document and the importer will create a service definition with unbound actions and functions for all operations of the document.

I took this for a test run and created a small project that invokes the OpenWeatherMap REST API remotely to get the current weather at a given location. I explain the important parts in this blog.

Project setup

My Node.js project contains a simple model with just one entity call Sites. This should represent a facility or building and is described by an ID, a name and an address.

namespace db;

using {
  Country,
  cuid
} from '@sap/cds/common';

type Address : {
  country     : Country;
  city        : String;
  postalCode  : String(10);
  addressLine : String;
}

entity Sites : cuid {
  name          : String;
  postalAddress : Address;
}

The service definition exposes this entity at the endpoint /Sites. The bound function getCurrentWeather should return the current weather at the address of the site.

using {db} from '../db/schema';

service FacilityService {

  type Weather : {
    condition   : String;
    temparature : Decimal(5, 2);
    humidity    : Decimal(5, 2);
    windSpeed   : Decimal(5, 2);
  }

  entity Sites as projection on db.Sites actions {
    function getCurrentWeather() returns Weather;
  }
};

Import OpenAPI document as external service

I haven’t found a published OpenAPI document of the Current Weather API on OpenWeatherMap.org. So I went to SwaggerHub and downloaded it there.

cds import translates this document into a CSN file and registers it as required service OpenWeatherMap.API in package.json:

"cds": {
  "requires": {
    "OpenWeatherMap.API": {
      "kind": "rest",
      "model": "srv/external/OpenWeatherMap.API"
    }
  }
}

When you examine the generated CSN file, you will see the importer preserved the OpenAPI paths, body and query parameter information as annotations in the group @openapi. This allows a generic implementation of the remote service call (more on that later).

"OpenWeatherMap.API.weather": {
  "kind": "function",
  "params": {
    "q": {
      "type": "cds.String",
      "@openapi.in": "query"
    },
    "id": {
      "type": "cds.String",
      "@openapi.in": "query"
    },
    "lat": {
      "type": "cds.String",
      "@openapi.in": "query"
    },
    "lon": {
      "type": "cds.String",
      "@openapi.in": "query"
    },
    "zip": {
      "type": "cds.String",
      "@openapi.in": "query"
    },
    "units": {
      "type": "cds.String",
      "@assert.range": true,
      "enum": {
        "standard": {},
        "metric": {},
        "imperial": {}
      },
      "@openapi.in": "query"
    },
    "lang": {
      "type": "cds.String",
      "@assert.range": true,
      "enum": {
        ...
      },
      "@openapi.in": "query"
    },
    "mode": {
      "type": "cds.String",
      "@assert.range": true,
      "enum": {
        "json": {},
        "xml": {},
        "html": {}
      },
      "@openapi.in": "query"
    }
  },
  "@openapi.path": "/weather",
  "returns": {
    "type": "OpenWeatherMap.API_types._200"
  }
}

Implement function handler

The function getCurrentFunction bound to the Sites entity should fetch and return the current weather data of the given instance. It can be invoked with /Sites/<ID>/getCurrentWeather().

An ON handler in the service implementation connects to the OpenWeatherMap.API, invokes its weather function, passes the sites postal code and country as parameters and returns the results. Some of the parameters are optional, however, the runtime requires all of them to be specified at the moment, even just with null.

class FacilityService extends cds.ApplicationService {
  async init() {
    const { Sites } = this.entities;

    this.on("getCurrentWeather", Sites, async (req) => {
      const sites = await req.query;
      if (!sites.length) {
        return req.reject(404);
      }

      const { postalCode, country_code: country } = sites[0].postalAddress;
      const weatherSrv = await cds.connect.to("OpenWeatherMap.API");
      const weatherData = await weatherSrv.send("weather", {
        q: null,
        id: null,
        lat: null,
        lon: null,
        zip: `${postalCode},${country}`,
        units: "metric",
        lang: "en",
        mode: "json",
      });

      return {
        condition: weatherData.weather[0]?.description ?? null,
        temparature: weatherData.main.temp,
        humidity: weatherData.main.humidity,
        windspeed: weatherData.wind.speed,
      };
    });

    await super.init();
  }
}

Implement remote service

As stated earlier, the CSN model describing the weather API is annotated such that it is possible to construct the remote service call. The most important annotations are

  • @openapi.method that defines the HTTP method for the remote service call (GET in our example)
  • @openapi.path that defines the endpoint for the remote service call (/weather)
  • @openapi.in at parameters that defines whether the parameter has to go into the body or the query string

With the help of the annotations it is possible to implement a generic remote service for any OpenAPI-documented API. The request object is altered in a BEFORE handler such that the remote API creates a correct HTTP request to the REST service.

The following is a first version of such generic implementation and is not complete (body parsing, proper error handling and other things are missing). It only works with the model and its annotations without knowledge that it processes the weather API.

class OpenApiRemoteService extends cds.RemoteService {
  async init() {
    this.before("*", "*", (req) => {
      const fullyQualifiedName = this.namespace + "." + req.event;
      const definition = this.model.definitions[fullyQualifiedName];

      req.method = this._getMethod(definition);
      req.path = this._getPath(definition, req.data || {});
      req.data = {};
      req.event = undefined;
    });

    await super.init();
  }

  _getMethod(definition) {
    return definition["@openapi.method"] || definition.kind === "action"
      ? "POST"
      : "GET";
  }

  _getPath(definition, data) {
    // Maps the parameters to path segments
    const mapPathSegment = (segment) => {
      const match = segment.match(/(?<=\{)(.*)(?=\})/g); // matches e. g. {placeholder}
      if (!match) {
        // No placeholder
        return segment;
      }

      const param = match[0];
      const paramValue = data[param];
      if (paramValue === undefined || paramValue === null) {
        throw new CapError(
          400,
          `Value for mandatory parameter '${param}' missing`
        );
      }

      return paramValue.toString();
    };

    // Construct the path to the endpoint by replacing placeholders with actual parameter values
    const path = definition["@openapi.path"]
      .split("/")
      .map(mapPathSegment)
      .join("/");

    const queryString = this._getQueryParams(definition, data).toString();
    return path + (queryString.length ? "?" + queryString : "");
  }

  _getQueryParams(definition, data) {
    const queryParams = new URLSearchParams();
    Object.entries(data)
      .filter(([key]) => definition.params?.[key]?.["@openapi.in"] === "query")
      .filter(([, value]) => value !== undefined && value !== null)
      .forEach(([key, value]) => queryParams.set(key, value.toString()));

    return queryParams;
  }
}

Invoke remote API

The project is ready for a test run. Reading a site by ID returns its address:

{
  "@odata.context": "$metadata#Sites/$entity",
  "ID": "3ee63bcf-68ec-4645-8ed1-eac74eb5c6c3",
  "name": "SAP Innovation Center",
  "postalAddress": {
    "city": "Potsdam",
    "postalCode": "14469",
    "addressLine": "Konrad-Zuse-Ring 10",
    "country": {
      "code": "DE"
    }
  }
}

To get the current weather at this site, I can invoke the getCurrentWeather function:

{
  "@odata.context": "../$metadata#FacilityService.Weather",
  "condition": "broken clouds",
  "temparature": 30.62,
  "humidity": 22
}

Conclusion

The OpenAPI importer allows SAP Cloud Application Programming Model applications to connect to even more remote APIs by implementing the service call generically and not individually per API.

Thank you for reading this far. Let me know your feedback and experience in the comments.

Assigned Tags

      Be the first to leave a comment
      You must be Logged on to comment or reply to a post.