Import OpenAPI-documented APIs remotely with CAP

The team behind the Cloud Application Programming model recently released an enhancement to the CDS importer to generate CAP service models from OpenAPI documents. You can now run cds import <openapi file path> with any OpenAPI document and CAP 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. The complete project code can be found on GitHub.

The Node.js CAP project contains a simple model with just one entity 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; }
};

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" } }
}

If you examine the generated CSN file, you will see that 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" }
}

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, CAP 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(); }
}

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 CAP’s 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; }
}

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
}

The OpenAPI importer allows CAP 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.