CAPでREST APIを呼び出してみた


みなさまはじめまして。初投稿です。よろしくお願い致します。

※この記事は chillSAP 夏の自由研究2021 の8/27分の記事として執筆しています。
※日付を跨いでしまいました。この場を借りてお詫び申し上げます・・・

はじめに

私のCAP(SAP Cloud Application Programming Model)の経験値としては、チュートリアルにあるような、SAP HANA CloudのDBからデータを呼び出すODataサービスやFioriアプリを作ったことがある程度でしたが、外部のREST APIを呼び出すODataサービスをCAPで作成している記事に巡り合ったので、早速実践してみようと思います。

今回呼び出してみるもの

身近なデータとして真っ先に思い浮かんだのはCOVID-19関連のデータでした。
探してみたところ、以下サイトに記載されているAPIで各都道府県の日付ごとの累積感染者数を取得できるようですので、使ってみようと思います(他にも各種統計データを取得するAPIが公開されていました)。
https://corona.go.jp/dashboard/

開発環境

SAP Business Application Studio(トライアル環境。以下BAS)

作り方

1. 事前準備

以下のチュートリアルを参照して、Dev Spaceの作成、CAPプロジェクトの作成を済ませておきましょう。
https://developers.sap.com/tutorials/hana-cloud-cap-create-project.html

2. データモデル定義

dbフォルダ配下に「PatientsTotal.cds」を作成し、ODataのデータモデルを定義します。

対象APIのGETリクエストのレスポンスに合わせて項目を定義していきます。

リクエスト

https://opendata.corona.go.jp/api/Covid19JapanAll?date=20200509&dataName=北海道

レスポンス

{
    "errorInfo": {
        "errorFlag": "0",
        "errorCode": null,
        "errorMessage": null
    },
    "itemList": [
        {
            "date": "2020-05-09",
            "name_jp": "北海道",
            "npatients": "934"
        }
    ]
}

PatientsTotal.cds

namespace db;

entity  patients{
  key date      : Date;
  key name_jp   : String;
      npatients : Decimal;      
}

3. サービス定義

3.1 CDSファイルの作成

srvフォルダ配下に「opendata-service.cds」を作成し、ODataサービスの定義を行います。

namespace srv;

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

service opendataService {
  entity TotalPatients as projection on db.patients;
}

3.2 外部サービス定義

srvフォルダ配下の「.cdsrc.json」に、接続先のAPIの情報を設定します。

"url" にAPIのエンドポイントとして "https://opendata.corona.go.jp/api" を設定しています。
"impl" に定義されている "srv/external/OpendataApi.js" については後続でファイルを作成していきます。

{
  "odata": {
    "flavor": "x4"
  },
  "requires": {
    "OpendataApi": {
      "kind": "rest",
      "impl": "srv/external/OpendataApi.js",
      "credentials": {
        "url": "https://opendata.corona.go.jp/api"
      }
    }
  }
}

odata.flavorにx4を定義することで、ペイロードを構造化することができるようです。
今回の実装方法では必要な設定となります。

(参照:https://cap.cloud.sap/docs/releases/sep20#structured-elements-in-odata-v4-for-apis)

3.3 jsファイルの作成

3.3.1 opendata-service.jsの作成

srvフォルダ配下に「opendata-service.js」を作成します。ファイル名は「opendata-service.cds」と同じ名称とする必要があります。

"cds.connect.to("OpendataApi")" で「.cdsrc.json」で設定した外部サービスに接続し、リクエストが実行されます。外部サービスへリクエストを送信するためにはRemote Servicesクラスを使用する必要があり、次に作成する「OpendataApi.js」で拡張クラスとして処理が実装されています。

const cds = require("@sap/cds");

module.exports = cds.service.impl(function () {
  const { TotalPatients } = this.entities;

  this.on("READ", TotalPatients, async (req) => {
    const opendataApi = await cds.connect.to("OpendataApi");
    return opendataApi.tx(req).run(req.query);
  });
});

3.3.2 OpendataApi.jsの作成

srvフォルダ配下にexternalフォルダを作成し、その配下に「OpendataApi.js」を作成します。こちらでRemoteServiceクラスの拡張を行っていきます。

OpendataApi.js
const cds = require("@sap/cds");

class OpendataApi extends cds.RemoteService {
  async init() {
    this.reject(["CREATE", "UPDATE", "DELETE"], "*");

    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 /Covid19JapanAll?${queryString}`;
      } catch (error) {
        req.reject(400, error.message);
      }
    });

    this.on("READ", "*", async (req, next) => {
      const response = await next(req);
      return parseResponse(response);
    });



    super.init();
  }
}

function parseQueryParams(select) {
  const filter = {};

  Object.assign(
    filter,
    parseExpression(select.from.ref[0].where),
    parseExpression(select.where)
  );

  const params = {
    /*
    appid: apiKey,
    */
  };

  for (const key of Object.keys(filter)) {
    switch (key) {
      case "date":
        params["date"] = formatDate(filter[key]);
        break;
      case "name_jp":
        params["dataName"] = 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;
}

function parseResponse(response) {
    var patients = [];
    response.itemList.forEach((j) => {
        var i = new Object();
        i.date = j.date;
        i.name_jp = j.name_jp;
        i.npatients = j.npatients;
        patients.push(i);
    });
    return patients;
}

function formatDate(date) {
    var dt = new Date(date);
    var y = dt.getFullYear();
    var m = ('00' + (dt.getMonth()+1)).slice(-2);
    var d = ('00' + dt.getDate()).slice(-2);
  return (y + m + d);
}

module.exports = OpendataApi;

少々長いので、いくつかに分けて紹介していきます。

this.reject

CRUDの内、READ以外のイベントを全て拒否します。

・・・
    this.reject(["CREATE", "UPDATE", "DELETE"], "*");
・・・
this.before

ODataサービスに対して渡されたクエリを、RESTサービス側で解釈できるように変換処理を実施します。

・・・
    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 /Covid19JapanAll?${queryString}`;
      } catch (error) {
        req.reject(400, error.message);
      }
    });
・・・

例えば、

https://<hostname>:<port>/opendata/TotalPatients?$filter=name_jp eq '北海道'

をODataサービスとして実行するとparseQueryParams関数で$filterの内容が変換され、req.queryに "GET /Covid19JapanAll?dataName=北海道" が渡されます。
結果として「.cdsrc.json」で定義したAPI情報と合わせて、
https://opendata.corona.go.jp/api/Covid19JapanAll?dataName=北海道
のGETリクエストが実行されることになります。

parseQueryParams
function parseQueryParams(select) {
  const filter = {};

  Object.assign(
    filter,
    parseExpression(select.from.ref[0].where),
    parseExpression(select.where)
  );

  const params = {
    //apiキーや他のパラメーターを設定する必要がある場合に使用  
    /*
    appid: apiKey,
    */
  };

  //$filterをREST APIのパラメータにマッピング
  for (const key of Object.keys(filter)) {
    switch (key) {
      case "date":
        params["date"] = formatDate(filter[key]);
        break;
      case "name_jp":
        params["dataName"] = filter[key];
        break;
      default:
        throw new Error(`Filter by '${key}' is not supported.`);
    }
  }

  return params;
}
this.on

REST APIの呼び出しを実施し、レスポンスをODataサービスで定義されたフォーマットに変換します。

・・・
    this.on("READ", "*", async (req, next) => {
      const response = await next(req);
      return parseResponse(response);
    });
・・・

REST APIのレスポンスのうち "itemList" から値を取得していきますが、複数の日付/都道府県の組み合わせでデータが格納される入れ子の構造となります。そのため、parseResponse関数にてforループを回してデータを取得し、ODataサービスのレスポンスに項目をマッピングしていきます。

レスポンス(再掲)
{
    "errorInfo": {
        "errorFlag": "0",
        "errorCode": null,
        "errorMessage": null
    },
    "itemList": [
        {
            "date": "2020-05-09",
            "name_jp": "北海道",
            "npatients": "934"
        },
        {
            "date": "2020-05-09",
            "name_jp": "青森県",
            "npatients": "27"
        }
・・・
    ]
}
parseResponse
function parseResponse(response) {
    var patients = [];
    response.itemList.forEach((j) => {
        var i = new Object();
        i.date = j.date;
        i.name_jp = j.name_jp;
        i.npatients = j.npatients;
        patients.push(i);
    });
    return patients;
}

4. テスト実行

まずはターミナルを開き、"cds run" を実行しましょう。

"Open in New Tab" を押下します。

以下画面が表示されたら、"Service Endpoints" の "TotalPatients" を押下してみます。

画面遷移の結果、以下のJsonデータがレスポンスとして返ってきます。
$filterを指定しない状態でODataサービスが実行されているため、APIが提供している全ての日付×47都道府県の組み合わせで累積感染者数が取得されます。

https://<hostname>:<port>/opendata/TotalPatients

$filterで日付を指定すると、指定日付時点の47都道府県での累積感染者数が取得されます。

https://<hostname>:<port>/opendata/TotalPatients?$filter=date eq 2020-08-23

$filterで日付と都道府県名を指定して実行してみます。すると・・・

https://<hostname>:<port>/opendata/TotalPatients?$filter=name_jp eq '北海道' and date eq 2020-05-09

明らかに日付のフィルタが効いていない結果が返ってきます。and条件を考慮できていない実装になっているようです。
修正した結果も合わせて紹介させていただきたいところですが、それは次回以降の記事に持ち越しとさせてください・・・

おわりに

CAPでREST APIサービスを呼び出して何が嬉しいのかという点ですが、ODataサービスとして公開できるという所で、Fiori Elementsのデータソースとして指定でき、ローコードにアプリを作成できることは一つあるのではないでしょうか。
一方で、JavaScriptでの実装が必要な箇所があるという点で、久々にJavaScriptを書いた身としては、今回参照した記事のコードをほとんど流用する形になっているとはいえ多少骨の折れる面もありました。
今後もCAPで何ができるか、活用できるか、というところの研究を続けていこうと思います。また記事にできればうれしいです。

参考リンク

Consuming a REST Service with the SAP Cloud Application Programming Model
SAP CAP Remote Services & SAP Fiori Elements