CAP JavaでリモートのODataサービスを利用する

## はじめに
CAP JavaでリモートのODataサービスを利用したい場合、2つの方法が考えられます。一つはCAPの[Querying API](https://cap.cloud.sap/docs/java/query-execution)を使用して、SQLライクな書き方でクエリを実行する方法、もう一つはSAP Cloud SDKの[OData Client](https://sap.github.io/cloud-sdk/docs/java/features/odata/vdm-generator)を使う方法です。
この記事では、Querying APIを使ってリモートサービスに接続し、データを読み込んだり更新する方法について紹介します。接続先はBTPにDestinationとして登録されていることを前提とします。

CAPireの[ドキュメント](https://cap.cloud.sap/docs/guides/using-services)に説明がありますが、ドキュメントでは省略されているステップもあって**かなり**苦労したので、記事に残しておきます。ソースコードは以下のGitリポジトリにあります。

https://github.com/miyasuta/cap-java-consumer2


参考：初回執筆時点のリポジトリ (@sap/cds-dk: 7.4.0)
https://github.com/miyasuta/cap-java-remote-consumer

## 手順
1. ODataサービスの仕様（edmxファイル）を取得
1. edmxファイルをインポート
1. サービスの定義
1. モックで実行
1. イベントハンドラの実装
1. デプロイ
1. 開発環境からリモートサービスに接続

※`@sap/cds-dk`のバージョン：8.8.0

## 使用するリモートODataサービス
自由に読み書きできるODataサービスが欲しかったので、簡単なODataサービスを作成し、Cloud Foundryにデプロイしました。ソースコードは以下にあります（最後まで設定したものはdeploymentブランチにあります）。

https://github.com/miyasuta/cap-java-simple

## 1. ODataサービスの仕様（edmxファイル）を取得
リモートODataサービス側のプロジェクトで以下のコマンドを実行し、edmxファイルを取得します。接続先がS/4HANAのODataサービスであれば[SAP Business Accelerator Hub](https://api.sap.com/)から取得できます。

```
cds compile srv -s CatalogService -2 edmx > CatalogService.edmx
```

## 2. edmxファイルをインポート
### 2.1. edmxファイルをインポート
リモートODataサービスを使用する側のプロジェクトのルートにedmxファイルを置いた状態で以下のコマンドを実行し、サービス定義を取り込みます。

```
cds import CatalogService.edmx --as cds
```
srv/externalフォルダに以下のファイルができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/dacc02fa-fd6e-01f4-129e-108ef10a9aaa.png)

:::note warn
後からリモートサービスの定義が変更になった場合、作成されたファイルを消して取り込みなおす必要があります。
:::

### 2.2. application.yamlの設定
srv/src/main/resources/application.yamlに以下の設定が追加されます。

```yaml
---  
cds:
  ...
  remote:
    services:
      '[CatalogService]':
        destination:
          name: CatalogService
        type: odata-v4      
```
`http.suffix`でサービスまでのパスを指定します。`destination.name`は使用するDestinationの名前に変更します。
```yaml
cds:
  ...
  remote:
    services:
      '[CatalogService]':
        destination:
          name: catalogservice
        type: odata-v4
        http:
          suffix: /odata/v4 
```

### 2.3. pom.xmlの設定
プロジェクトルートの`pom.xml`に以下の設定を追加します。

```xml
	<properties>
		<cloud.sdk.version><最新のSDKバージョン></cloud.sdk.version>
        ...
  	</properties>

	<dependencyManagement>
		<dependencies>
            ...
			<dependency>
				<groupId>com.sap.cloud.sdk</groupId>
				<artifactId>sdk-modules-bom</artifactId>
				<version>${cloud.sdk.version}</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
   		</dependencies>
   	</dependencyManagement>
   ```

`srv/pom.xml`に以下の設定を追加します。これはCloud FoundryのDestinationを使うために必要です。
```xml		
        <dependency>
			<groupId>com.sap.cloud.sdk</groupId>
			<artifactId>sdk-core</artifactId>
		</dependency>
  ```

## 3. サービスの定義
以下のサービスを定義しました。Booksエンティティはリモートサービスのエンティティの定義をそのまま使っています。アクション`createBook`および`updateBook`により、リモートのBooksエンティティを登録、更新します。

```srv/books-service.cds
using { CatalogService as external } from './external/CatalogService';

service BooksService {
    @readonly
    entity LocalBooks as projection on external.Books 
        actions {
            action updateStock(stock: Integer);
        };

    action createBook(book: LocalBooks) returns LocalBooks;    
}
```

## 4. モックで実行
作成したサービスをローカル（モック）で実行します。CAPでのモック実行には2段階あります。
①簡単なモック（HTTPプロトコルを使わない）⇒ `@sap/cds-sk`:8.8.0では動かなかった
②リモートサービスをODataサービスとしてモック

①は設定は簡単ですが、モックのリモートサービスが実際のODataサービスと同じ動きをしないケースもあるので、より本物に近いテストをする場合は②を使用します。

### 4.1. 簡単なモック
:::note warn
`@sap/cds-sk`:8.8.0で作成したプロジェクトの場合、"**Failed to get destination**"というエラーになりました。このため②の方法を使うことを推奨します。
:::

#### 4.1.1. モックデータを作成
モック用のCSVファイルを作成し、db/dataフォルダに格納します。dbフォルダに格納するのは意外に感じますが、Javaの場合はモックデータをH2データベースにデプロイするためこのような設定になっています。なお、Node.jsの場合はsrv/external/dataフォルダにモックデータを格納します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/ed171beb-d211-895c-ce89-5990a0282f9d.png)

```CatalogService-Books.csv
ID;title;stock
1;Test A;100
2;Test B;500
```

#### 4.1.2. pom.xmlの設定
srv/pom.xmlに以下のコマンドがあります。H2データベースにデプロイするためのコマンドです。

```
<command>deploy --to h2 --dry &gt; "${project.basedir}/src/main/resources/schema-h2.sql"</command>
```
ここに`-- with mocks`というオプションを追加します。

```
<command>deploy --to h2 --with-mocks --dry &gt; "${project.basedir}/src/main/resources/schema-h2.sql"</command>
```

#### 4.1.3. モックで実行
以下のコマンドでサービスを実行します。

```
mvn spring-boot:run
```

test/test.httpファイルを作成し、以下のリクエストを設定します。

```
@server=http://localhost:8080
@auth=Basic authenticated:

###
GET {{server}}/odata/v4/BooksService/LocalBooks
Authorization: {{auth}}
```

設定したモックデータが返されます。
```json
{
  "@context": "$metadata#LocalBooks",
  "@metadataEtag": "W/\"295b951f32c05e27c1fed90764dd1b8d541735a928830475c77c51897dab1fe7\"",
  "value": [
    {
      "ID": 1,
      "title": "Test A",
      "stock": 100
    },
    {
      "ID": 2,
      "title": "Test B",
      "stock": 500
    }
  ]
}
```
:::note info
この方法で実行できるのは、**イベントハンドラの実装前**までです。イベントハンドラの実装後は、4.2.の方法でリモートサービスをモックする必要があります。
:::

### 4.2. リモートサービスをODataサービスとしてモック
※4.1.の設定が前提

#### 4.2.1. application.xmlの設定
application.yamlにモック用のプロファイルを追加します。[ドキュメント](https://cap.cloud.sap/docs/guides/using-services?impl-variant=java#mock-remote-service-as-odata-service-java)にはsuffixの指定がありませんが、これがないとリモートサービスに接続したときに404エラーになってしまいます。

```yaml
---
spring:
  config.activate.on-profile: mocked
cds:
  application.services:
    CatalogService-mocked:
      model: CatalogService
      serve:
        path: CatalogService      
```

#### 4.2.2. DestinationConfigurationを追加
リモートサービスをODataサービスとしてモックするときはdestinationを使用します。実際にはこのdestinationが指すのはCAPのサービスそのものなのですが、destinationを使用するために以下のようなJavaのコードが必要になります。

```java:srv/main/java/namespace/config/DestinationCondifuration.java
@Component
@Profile("mocked")
public class DestinationCondifuration {
    @Autowired
    private Environment environment;

    Logger logger = LoggerFactory.getLogger(DestinationCondifuration.class);

    @EventListener
    void applicationReady(ApplicationReadyEvent ready) {
        logger.info("Application is ready, configuring destinations.");
        Integer port = environment.getProperty("local.server.port", Integer.class);
        String destinationName = environment.getProperty("cds.remote.services.[CatalogService].destination.name");
        if(port != null && destinationName != null) {
            DefaultHttpDestination mockDestination = DefaultHttpDestination
            .builder("http://localhost:" + port)
            .basicCredentials(new BasicCredentials("authenticated", ""))
            .name(destinationName).build();

            DestinationAccessor.prependDestinationLoader(
                new DefaultDestinationLoader().registerDestination(mockDestination)
            );
            logger.info("Destination has configured with name: " + destinationName);
        }        
    }   
```

#### 4.2.3. モックで実行
以下のコマンドでサービスを実行します。
```
mvn spring-boot:run -Dspring-boot.run.profiles=default,mocked
```
4.1.3.で作成したリクエストが実行できることを確認します。

## 5. イベントハンドラの実装
### 5.1. クラスの形
リモートサービスにアクセスするためのCqnService型の変数を定義します。

```java
@Component
@ServiceName(BooksService_.CDS_NAME)
public class BooksServiceHandler implements EventHandler{
    @Autowired
    @Qualifier(CatalogService_.CDS_NAME)
    CqnService catalogService;

    //メソッドがここに来る
}
```

### 5.2. Readリクエスト
Booksエンティティに対するReadリクエストをそのままリモートサービスに渡して実行します。CqnServiceのrunメソッドにコンテクストから取得したクエリを渡しています。

```java
    @On(event = CqnService.EVENT_READ, entity = LocalBooks_.CDS_NAME)
    public Result readBooks(CdsReadEventContext context) {
        return catalogService.run(context.getCqn());
    }
```

### 5.3. Createリクエスト
createBookアクションに対するイベントハンドラを実装します。`Insert.into...`でインサート用のクエリを自分で組み立て、それをCqnServiceのrunメソッドに渡します。

```java
    @On(event = CreateBookContext.CDS_NAME)
    public void createBook(CreateBookContext context) {
        LocalBooks book = context.getBook();
        CqnInsert insert = Insert.into("CatalogService.Books").entry(book); 
        Result result = catalogService.run(insert);
        LocalBooks createdBook = result.single(LocalBooks.class);
        context.setResult(createdBook);      
    }
```

### 5.4. Updateリクエスト
updateStockアクションに対するイベントハンドラを実装します。このアクションはBoundアクションなので、`/Books(1)/BooksService.updateStock`という形でキーが指定されます。このキーを取得するために[CqnAnalyzer](https://cap.cloud.sap/docs/java/query-introspection#cqnanalyzer)なるものを使います。

まず、コンストラクタでCqnAnalyzerのインスタンスを作成します。
```java
    private final CqnAnalyzer analyzer;

    BooksServiceHandler(CdsModel model) {
        this.analyzer = CqnAnalyzer.create(model);
    }
```

そして、イベントハンドラの中でコンテキストから取得したCQNをアナライザに渡してキーを取得します。
```java
analyzer.analyze(context.getCqn()).targetKeys().get(Books.ID);
```

updateStockのハンドラは以下のようになります。このアクションには返り値がないので、`context.setCompleted()`により処理が終わったことをフレームワークに伝えます。
```java
    @On(event = LocalBooksUpdateStockContext.CDS_NAME)
    public void updateStock(LocalBooksUpdateStockContext context) {
        //更新用のbookオブジェクトを作成
        LocalBooks book = LocalBooks.create();
        Integer bookId = (Integer) analyzer.analyze(context.getCqn()).targetKeys().get(LocalBooks.ID);
        book.setId(bookId);
        book.setStock(context.getStock());

        //更新実行
        CqnUpdate update = Update.entity("CatalogService.Books").data(book);
        catalogService.run(update);
        context.setCompleted();
    }
```

### 5.5. テスト
Create, Update処理を以下のHTTPリクエストでテストします。サービスは「4.2. リモートサービスをODataサービスとしてモック」の方法で実行してください。

```
###
POST {{server}}/odata/v4/BooksService/createBook
Content-Type: application/json
Authorization: {{auth}}

{
    "book": {
        "ID": 11,
        "title": "My Test",
        "stock": 99
    }
}

### 
POST {{server}}/odata/v4/BooksService/LocalBooks(1)/BooksService.updateStock
Content-Type: application/json
Authorization: {{auth}}

{
    "stock": 7
}
```

## 6. デプロイ
### 6.1. xsuaa, destinationを追加
以下のコマンドでxsuaaとdestinationサービスを使用するための設定を追加します。リモートサービスがオンプレミスにある場合は、`connectivity`も追加します。

```
cds add xsuaa,destination --for production
```
### 6.2. mta.yamlを追加
以下のコマンドでmta.yamlファイルを追加します。

```
cds add mta
```

### 6.3. cloud用のプロファイル作成
dbフォルダでドメインモデルを定義していない場合、application.yamlに`sql.init.schema-locations`の設定を含まないcloud用のプロファイルを用意します。

```yaml
---
spring:
  config:
    activate:
      on-profile: cloud
  # sql:
  #   init:
  #     schema-locations: classpath:schema-h2.sql
cds:
  data-source:
    auto-config:
      enabled: false
  remote:
    services:
      '[CatalogService]':
        destination:
          name: catalogservice
        type: odata-v4
        http:
          suffix: /odata/v4   
```
:::note 
`cds.remote.services`の設定は`default`プロフィアイルと重複していますが、これがないとリモートサービスではなくDBを見に行こうとする動きになっていました。
:::

### 6.3. ビルド、デプロイ
以下のコマンドでビルド、デプロイします。

```
mbt build -t gen --mtar mta.tar
cf deploy gen/mta.tar
```

## 7. 開発環境からリモートサービスに接続
最後はBASなどの開発環境から実際のリモートサービスに接続する方法です。デプロイ時にxsuaaとdestination（およびconnectivity）のサービスインスタンスが登録されているので、それらを利用します。

### 7.1. サービスにバインド
以下のコマンドで、サービスにバインドします。

```
cds bind -2  <destinationサービスインスタンス>
```
※xsuaaサービスインスタンスもバインドした場合、xsuaaによる認証が必要になるためここではスキップ

これにより.cdsrc-private.jsonファイルが作成されます。ここにはhybridプロファイル用の設定が入っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/7f042809-35f8-81b9-bf42-1c6b52a296f8.png)

### 7.3. application.yamlの設定
application.yamlにhybridプロファイルの設定を追加します。

```yaml
---
spring:
  config.activate.on-profile: hybrid
  sql.init.schema-locations:
  - "classpath:schema-nomocks.sql"
cds:
  remote.services:
    CatalogService:
      destination:
        type: "odata-v4"
        suffix: "/odata/v4"
        name: "catalogservice"
```

:::note warn
dbフォルダでドメインモデルを定義していない場合、sql.init.schema-locationsの設定は不要です。
:::

### 7.4. pom.xmlの設定
:::note warn
dbフォルダでドメインモデルを定義していない場合、以下の設定は不要です。
:::

srv/pom.xmlの`<command>deploy --to h2 --with-mocks...</command>`の後に以下のコマンドを追加します。これは、モックを使わない版のスキーマの設定です。
```xml
<command>deploy --to h2 --dry &gt; "${project.basedir}/src/main/resources/schema-nomocks.sql"</command>
```


### 7.5. サービスの実行
以下のコマンドでサービスを実行します。

```
cds bind --exec -- mvn spring-boot:run \
  -Dspring-boot.run.profiles=default,hybrid
```

以下のリクエストが実行できることを確認します。
```
@server=http://localhost:8080
@auth=Basic authenticated:

###
GET {{server}}/odata/v4/BooksService/LocalBooks
Authorization: {{auth}}

###
POST {{server}}/odata/v4/BooksService/createBook
Content-Type: application/json
Authorization: {{auth}}

{
    "book": {
        "ID": 11,
        "title": "My Test",
        "stock": 99
    }
}

### 
POST {{server}}/odata/v4/BooksService/LocalBooks(1)/BooksService.updateStock
Content-Type: application/json
Authorization: {{auth}}

{
    "stock": 7
}
```

## おまけ
プロジェクトルートのpackage.jsonに以下のスクリプトを追加すると便利です。

```json
  "scripts": {
    "build": "mbt build -t gen --mtar mtar.tar",
    "deploy": "cf deploy gen/mtar.tar",
    "bind": "cds bind -2 java-consumer-auth,java-consumer-destination",
    "run:mock": "mvn spring-boot:run",
    "run:local-odata": "mvn spring-boot:run -Dspring-boot.run.profiles=default,mocked",
    "run:hybrid": "cds bind --exec -- mvn spring-boot:run -Dspring-boot.run.profiles=default,hybrid"
  }
```

## 参考にしたリポジトリ、および記事
https://github.com/SAP-samples/cloud-cap-samples-java/tree/main

https://bnheise.medium.com/custom-actions-in-cap-java-2-fd84b6b3720a