API開発チュートリアル

このチュートリアルでは、APIの開発手順について説明します。

はじめに

このチュートリアルでは、Qmonus SDKを使って実際にアプリケーションを作成してみます。 Qmonus SDKのユースケースの一つである「クラウドのリソース管理」を題材に取り上げ、実際にGoogle CloudのComputeEngineのインスタンスを管理するAPI(Sample CRUD API)を作成します。

前提

Sample CRUD APIでは、Google CloudのCompute Engine APIを用います。 そのため以下ができることを前提としています。

  • Google CloudでComputeEngineの権限を持つサービスアカウントを作成している。
  • Google CloudのCompute Engine APIを用いてCompute EngineインスタンスをCRUDすることができる。

開発物

Google CloudのCompute Engine APIを用いて、Compute Engineインスタンスを管理するクラウドリソース管理APIを作成します。

Sample CRUD API
CREATE : インスタンスを作成する
READ   : インスタンスの情報を取得する
UPDATE : インスタンスのマシンタイプを更新する
DELETE : インスタンスを削除する

Sample CRUD APIのAPI仕様

今から作るAPIの仕様は以下になります。

CRUD memo endpoint request method request body response
CREATE Google CloudにComputeEngineインスタンスを新規作成する /sample_crud_api/instances POST {“instance_name”: “xxxxxxx”} 200 OK
READ 指定したインスタンス情報を取得する /sample_crud_api/instances/{instance_name} GET - 200 OK, {“instances”: [*]}
UPDATE 該当のComputeEngineインスタンスのマシンタイプを更新する /sample_crud_api/instances/{instance_name} PUT {“machineType”: “xxxxxxx”} 200 OK
DELETE 該当のComputeEngineインスタンスを削除する /sample_crud_api/instances/{instance_name} DELETE - 200 OK

Sample CRUD APIの実装

ここからSample CRUD APIの実装に入ります。 まず今から作るSample CRUD APIの全体を把握するため、アーキテクチャを確認します

Sample CRUD APIのアーキテクチャ

Sample CRUD APIのアーキテクチャは以下になります。

ATOM
まず、Google CloudのComputeEngineインスタンスをQmonus SDK上で管理するためにATOMを用意します。 ATOMには、ComputeEngineのインスタンスが持つ情報(projectId, zone, machineType, sourceImage, network)とインスタンスのステータスを保存するようにし、Qmonus SDK上でリソースの管理を行います。

Config
続いて、Configを用意します。
Google CloudのCompute Engine APIを実行するためにはGoogle Cloud側でのサービスアカウントが必要です。
こちらのサービスアカウントのキー情報をConfigに保管することで、Qmonus SDK上でCompute Engine APIを実行できるようにします。

Routing
次にルーティングの設定を行います。(図のAPI GW箇所)
ルーティングを行うことで、各シナリオでCompute Engine APIを実行した際に、一度APIGWを経由してGoogle Cloudに飛んでいくようにします。
これにより各Compute Engine APIの実行状況を把握できるようになります。

Scenario
次に各シナリオを作成します。
Compute Engine APIを実行する際に、まずアクセストークンを取得する必要があるため、アクセストークンを取得するシナリオを作成します。
各CRUD APIはCompute Engine APIを叩くために、まずこのアクセストークンを取得するシナリオを呼び出すことになります。

最後に、CRUDの各シナリオを作成します。

--
上記がSample CRUD APIの実装の流れになります。

ATOMの実装

ATOMとは

まずATOMとはQmonus SDKではClassと表現されています。 イメージとしては、Qmonus SDKのModelとPythonのClassが一緒になった概念になります。 Modelのようにカラムの定義が可能で、PythonのClassのようにクラスメソッド、インスタンスメソッドの定義も可能となっています。

ATOMの定義

さっそくATOMを定義してみます。 以下の手順で、SDKポータル上でATOMを定義します。

クラスの作成
[Workflow Scenario as a Service]>[Class]>[Create New Class]からクラスを作成します。

入力は以下を参考にしてください。

項番 項目 入力値 説明
1 category SampleCrudAPI ATOMが属する分類を指定してください。この情報はクラスエディタでの階層表示のためのタグとしてのみ使用されます。
2 workspace 未指定 ワークスペースを設定します。
3 name VmInstance ATOMの名前を指定します(クラス名と同義)。ユニークである必要があります。

メタ情報の定義
Class Definitionsの設定値を以下の表を参考に設定してください。

項番 項目 入力値 説明
1 persistence 選択(default) 永続化モードを指定します。選択した場合、ATOM定義を保存するとクラス構造に対応するデータベーステーブルが自動的に生成され、データベースに対するCRUDの組込みメソッドが実装されます。
2 abstract 未選択(default) 抽象クラスモードを指定します。
3 api_generation 選択(default) API自動生成モードを指定します。API自動生成モードでは、ATOMをCRUDするためのRestful APIが自動的に生成されます。

Note

SDK Portalを触っていると、画面上に該当の項目が表示されていないことがあります。
その場合は↓の鉛筆マークを押下し、表示する項目を編集することで該当の項目を表示させてあげることができます。

identifier fieldの定義
[attributes]>[identifier]に以下を入力します。

項番 項目 入力値 説明
1 title instanceId フィールド名を指定します
2 field_type integer フィールドの型を指定します。
3 field_persistence 選択(default) フィールドの永続化モードを指定します。選択しない場合は本フィールドはデータベースカラムとして生成されません。
4 field_immutable 選択(default) フィールドの値が不変かを指定します。選択した場合、本フィールドの値変更は許可されません。
5 field_metadata* { "POST": false } フィールドに任意のメタ情報を定義します。メタ情報はdict型のデータを設定してください。

local fieldsの定義
以下の表を参考に設定してください。

項番 項目 入力値 説明
1 field_name instanceName identifier field参照
2 field_type [String]を選択(default) identifier field参照
3 field_persistence 選択(default) identifier field参照
4 field_nullable 選択(default) フィールドの値にNullを許容するかを指定します。
5 field_immutable 未選択(default) identifier field参照
6 field_unique 未選択(default) フィールドが複合ユニークインデックスの対象かを指定します。
7 field_format* '[a-zA-Z0-9]' フィールドのフォーマットをjsonschemaもしくは正規表現で指定します。
8 field_metadata* { "POST": true, "PUT": true } identifier field参照

同様に以下のlocal fieldsの定義も行います。

  • projectId
  • zone
  • machineType
  • sourceImage
  • network
  • status
  • gceId

local fieldsfield_name以外は変更する必要はありません。

また、local fieldsはデフォルト値を設定することができます。
以下からデフォルト値を設定しておきます。

以下のlocal fieldsへ、デフォルト値を設定してください。(設定の際にはサービスアカウントやGoogle Cloudのコンソールを確認してください)

  • projectId
  • zone
  • machineType

カウンタの作成

[Transaction as a Service]>[Transaction Settings]の画面右上にある[+]ボタンを押下すると、カウンタ作成画面が開きます。
以下の項目を入力してください。

項番 項目 入力値 説明
1 counter_type Number カウンタの種類を指定します。Number、UUID、Inventoryの3つのタイプがあります。
2 workspace 未入力 ワークスペースを設定します。
3 counter_name instanceId カウンタを一意に識別する名前を指定します。
4 counter_format $ カウンタから返される値の形式を指定します。値は$で表されます。
5 min_num 1 カウンタの最小値を指定します。
6 max_num 9999 カウンタの最大値を指定します。
7 padding 選択しない ゼロパディングモードを指定します。

入力後、画面右下部にある[Create Counter]ボタンを押下します。

メソッド(instance_methods)の定義

以下のコードを先ほど定義したVmInstanceのinstance_methods>method_bodyに入力する。

async def initialize(self, *args, **kwargs):
    if not self.instanceId:
        self.instanceId = int(await Counter.allocate("instanceId"))

ATOMの確認

上記でATOMの用意ができました。   Interactive shellから、以下の入力をしてみて動作を確認してみましょう。

# instanceの作成
>>> test_instance = await atom.VmInstance(instanceName="test-instance01")↵
... ↵
↵
>>> print(test_instance.instanceId)↵
... ↵
↵
1
# 作成したinstanceの保存
>>> await test_instance.save()↵
... ↵
↵
# 作成された全てのinstanceの取得
>>> instances = await atom.VmInstance.retrieve()↵
... ↵
↵
>>> print(instances)↵
... ↵
↵
[VmInstance(instance='Vm1JbnN0YW5jZTo0MWRkMjM4ZTg4OGMxMWYwOWMyM2Q2MzdmZDljNjEzYw==', xid=None, xname=None, instanceId=1, instanceName='test-instance01', projectId='*********', zone='*********', machineType='*********', sourceImage=None, network=None, status=None, gceId=None)]
# instanceNameの変更
>>> await instances[0].save(instanceName="test-instance02")↵
... ↵
↵
# instanceIdでフィルタをかけてinstanceを取得
>>> instance01 = await atom.VmInstance.load(1)↵
... ↵
↵
>>> print(instance01)↵
... ↵
↵
# instancenameが変更されていることを確認
VmInstance(instance='Vm1JbnN0YW5jZTo0MWRkMjM4ZTg4OGMxMWYwOWMyM2Q2MzdmZDljNjEzYw==', xid=None, xname=None, instanceId=1, instanceName='test-instance02', projectId='*********', zone='*********', machineType='*********', sourceImage=None, network=None, status=None, gceId=None)
# instanceの削除
>>> await instance01.destroy()↵
... ↵
↵
>>> instances = await atom.VmInstance.retrieve()↵
... ↵
↵
>>> print(instances)↵
... ↵
↵
# instanceが削除されていることを確認
[]

Configの設定

Google CloudのAPIを呼び出すためにはサービスアカウントを用意し、サービスアカウントキーをもとにアクセストークンを取得する必要があります。 そのため、今回のSample CRUD APIでもGoogle Cloud APIを呼び出すためにConfigにサービスアカウントキー情報を置く必要があります。

まずGoogle Cloud上でサービスアカウントを作成し、サービスアカウントキーを取得してください。 次に以下のように[Workflow Scenario as a Service]>[Config]からCreate New Configをクリックします。

serviceはSampleCrudApiと入力し、 workspaceは未入力で右下のCreate Configをクリックします。

以下のようにConfigにSampleCrudApiが作成されているのが確認できたら、右上のオレンジ色の編集マークをクリックします。

Service Configの項目にJSON形式のサービスアカウント情報を入力し、右上の保存ボタンを押してconfigを保存します。
これでConfigの設定は完了です。

Routingの設定

続いてルーティングを設定します。
[API Gateway as a Service]>[Routing]を開きます。

Create New Routingsからひとつずつ設定します。
以下は/GoogleCloud/SampleCrudAPI/Create/{projectId}/{zone}への接続をhttps://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{zone}/instancesに振り替えたい場合のルーティング設定例になります。

Note

proxy: 外部サービスやAPIへのリクエストを中継するためのプロキシです。 ここではシナリオからGoogle Cloud APIへリクエストする際に、API GWを中継させるようにするため、このプロキシをかませるようにします。

authorities: リクエストを送信する際の認証情報や権限を定義する項目です。 ここではGoogle Cloud APIのURIにおけるドメインを入力しています。

target: リクエストを転送する先のURLやエンドポイントを設定します。リクエストの送信先であるGoogle Cloud APIのURLを入力しています。 またGoogle Cloud APIはHTTPS通信のためschemeはhttpsとしています。

Caution

Routingのpathをコピペによって入力すると、最後尾に半角や全角の空白文字が付随してしまうことがあります。
空白文字が存在すると正しくルーティングできないので、以下の設定でも気をつけましょう。

これに倣って以下の表からルーティング設定を行います。

項番 workspace domain scheme(proxy) path(proxy) authority scheme(target) path(target)
1 未設定 default http /GoogleCloud/SampleCrudAPI/token oauth2.googleapis.com https /token
2 未設定 default http /GoogleCloud/SampleCrudAPI/{projectId}/{zone} compute.googleapis.com https /compute/v1/projects/{projectId}/zones/{zone}/instances
3 未設定 default http /GoogleCloud/SampleCrudAPI/{projectId}/{zone}/{instanceName} compute.googleapis.com https /compute/v1/projects/{projectId}/zones/{zone}/instances/{instanceName}
4 未設定 default http /GoogleCloud/SampleCrudAPI/{projectId}/{zone}/{instanceName}/stop compute.googleapis.com https /compute/v1/projects/{projectId}/zones/{zone}/instances/{instanceName}/stop
5 未設定 default http /GoogleCloud/SampleCrudAPI/{projectId}/{zone}/{instanceName}/start compute.googleapis.com https /compute/v1/projects/{projectId}/zones/{zone}/instances/{instanceName}/start
6 未設定 default http /GoogleCloud/SampleCrudAPI/{projectId}/{zone}/{instanceName}/setMachineType compute.googleapis.com https /compute/v1/projects/{projectId}/zones/{zone}/instances/{instanceName}/setMachineType

以上でルーティングの設定は完了です。

アクセストークンの取得APIの実装

続いてアクセストークンを取得するシナリオを作成します。   以下から作成画面に遷移します。([Workflow Scenario as a Service]>[Scenario]>[Create New Scenario]

作成画面を開いたのち、以下のシナリオ定義を設定し、シナリオを作成します。

項番 項目 入力値 説明
1 category SampleCrudApi シナリオが属する分類を指定してください。この情報はシナリオエディタでの階層表示のためのタグとしてのみ使用されます
2 workspace 未入力 シナリオのワークスペースを設定します
3 name GetAccessToken シナリオの名前を指定します。ユニークである必要があります。
4 method GET APIとして待機するHTTPメソッドを指定します。
5 uri /sample_crud_api/get_access_token APIとして待機するHTTPパスを指定します。
6 transaction 未選択 トランザクションサービスと連携します。今回は利用しません。
7 routing_auto_generation_mode 選択 API Gatewayにルーティングを自動登録するか選択します。

Caution

Scenarioのuriに関してもRoutingのpath同様に、最後尾に空白文字があると正しくuriとして設定できません。
Scenarioの設定の際には注意しましょう。

ワークフローの設定

シナリオを作成したのち、以下のように[Scenario]>[script]をクリックし、scriptブロックを作成します。

scriptの設定をします。 設定項目は以下になります。(Display Labelはデフォルトでは表示されないため、鉛筆マークから選択する)

Display Label: create jwt
Python code:

import jwt
import json
from datetime import datetime, timedelta

# -- JWTの生成

# Configに設定したサービスアカウントの秘密鍵とメールアドレスをセットする
private_key = __CONFIG__["private_key"]
client_email = __CONFIG__["client_email"]
current_time = datetime.now()
expiration_time = current_time + timedelta(hours=1)

payload = {
    "iss": client_email,
    "scope": "https://www.googleapis.com/auth/compute",
    "aud": "https://oauth2.googleapis.com/token",
    "exp": int(expiration_time.timestamp()),
    "iat": int(current_time.timestamp()),
}
jwt_token = jwt.encode(payload, private_key, algorithm="RS256")

同様にもうひとつscriptブロックを作成し以下を設定します。

Display Label: get access token
Python code:

# -- アクセストークンの取得

r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/token",
    method="POST",
    headers={
        "Content-Type": "application/x-www-form-urlencoded"
    },
    body=f"grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={jwt_token}"
    )

if r.error:
    raise Error(r.code, reason="POST failed")

access_token = json.loads(r.body)["access_token"]
context.session.finish({"access_token": access_token})

Variablesの設定

script間で変数を共有するために、workflow全体で利用できるglobal変数を定義する必要がある。 以下のようにVariablesタブを開き、右上の➕マークをクリックし変数の設定欄を追加する。

設定項目は以下である。
name:jwt_token

動作確認:APIの呼び出し

これで実装は完了です。 「Try API Call」>「Execute Debug」から動作確認をしてみます。

access_tokenが返却されれば成功です。
※失敗する場合はサービスアカウントキーの情報に不備がある可能性が高いです。

CREATE APIの実装

続いて、CREATE APIを作成します。
シナリオの作成方法はアクセストークンの取得APIで説明したため割愛します。
設定するシナリオ定義とScriptブロックは以下になります。

シナリオ定義

項番 項目 入力値 説明
1 category SampleCrudApi シナリオが属する分類を指定してください。この情報はシナリオエディタでの階層表示のためのタグとしてのみ使用されます
2 workspace 未入力 シナリオのワークスペースを設定します
3 name InstanceCreate シナリオの名前を指定します。ユニークである必要があります。
4 method POST APIとして待機するHTTPメソッドを指定します。
5 uri /sample_crud_api/instances APIとして待機するHTTPパスを指定します。
6 transaction 未選択 トランザクションサービスと連携します。今回は利用しません。
7 routing_auto_generation_mode 選択 API Gatewayにルーティングを自動登録するか選択します。

scriptブロック

Display Label: get access token
Python code:

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

if r.error:
    raise Error(r.code, reason="POST failed")

access_token = json.loads(r.body)["access_token"]

Display Label: insert atom
Python code:

new_instances = await atom.VmInstance.retrieve(instanceName=instance_name)

# インスタンスが存在しなければ新しく作成
if not new_instances:
    # VmInstanceクラスのインスタンスを作成する
    new_instance = await atom.VmInstance(
        instanceName=instance_name,
        status="CREATING"
    )
    # データベースに保存する
    await new_instance.save()
else:
    new_instance = new_instances[0]

Display Label: create instance
Python code:

faker("postInstance")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    },
    body={
        "machineType": f"zones/{new_instance.zone}/machineTypes/{new_instance.machineType}",
        "name": new_instance.instanceName,
        "disks": [
            {
                "initializeParams": {
                    "sourceImage": "projects/debian-cloud/global/images/family/debian-12"
                },
                "boot": True
            }
        ],
        "networkInterfaces": [
            {
                "network": "global/networks/default"
            }
        ],
        "shieldedInstanceConfig": {
            "enableSecureBoot": False
        }
    }
    )

if r.error:
    raise Error(r.code, reason=f"POST failed {new_instance.instanceName}")

Display Label: check status & update status
Python code:

# ポーリング処理
CHECK_INTERVAL = 5

while True:

    faker("getInstanceRunning")
    r = await callout(
        path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}/{new_instance.instanceName}",
        method="GET",
        headers={
            "Authorization": f"Bearer {access_token}"
        },
        )

    if r.error:
        raise Error(r.code, reason=f"Check Status failed {new_instance.instanceName}")

    if r.code == 200:
        if json.loads(r.body.decode("utf-8"))["status"] in ["RUNNING"]:
            gce_id = json.loads(r.body.decode("utf-8"))["id"]
            await new_instance.save(status="RUNNING", gceId=gce_id)
            break

    await asyncio.sleep(CHECK_INTERVAL)

Variablesの設定

Variablesの設定の仕方はアクセストークンの取得の際に記載したので割愛します。 以下をVariablesに設定してください。
name: new_instance
name: access_token

動作確認:APIの呼び出し

Try API Callを行い、動作確認をしてみましょう。(Content Bodyの設定欄はデフォルトでは表示されないので、鉛筆マークから選択してください)

Content Body:

{
    "instance_name": "test-instance"
}

Google Cloudのコンソールを確認すると、ComputeEngineにインスタンスが新規作成されているはずです。

READ APIの実装

続いて、READ APIを作成します。
シナリオの作成方法はアクセストークンの取得APIで説明したため割愛します。
設定するシナリオ定義とScriptブロックは以下になります。

シナリオ定義

項番 項目 入力値 説明
1 category SampleCrudApi シナリオが属する分類を指定してください。この情報はシナリオエディタでの階層表示のためのタグとしてのみ使用されます
2 workspace 未入力 シナリオのワークスペースを設定します
3 name InstanceGet シナリオの名前を指定します。ユニークである必要があります。
4 method GET APIとして待機するHTTPメソッドを指定します。
5 uri /sample_crud_api/instances/{instance_name} APIとして待機するHTTPパスを指定します。
6 transaction 未選択 トランザクションサービスと連携します。今回は利用しません。
7 routing_auto_generation_mode 選択 API Gatewayにルーティングを自動登録するか選択します。

scriptブロック

Display Label: get instances from atom
Python code:

instance_name = context.request.resources.instance_name

if instance_name:
    instances = await atom.VmInstance.retrieve(instanceName=instance_name)
    if instances == []:
        raise Error(404, reason=f"Instances not exist")
else:
    instances = await atom.VmInstance.retrieve()

response = []
for instance in instances:
    instance_json = {
        "instanceId": instance.instanceId,
        "instanceName": instance.instanceName,
        "projectId": instance.projectId,
        "zone": instance.zone,
        "machineType": instance.machineType,
        "sourceImage": instance.sourceImage,
        "network": instance.network,
        "status": instance.status

    }
    response.append(instance_json)

response_json = json.dumps({"instances": response})
context.session.finish(response_json)

動作確認:APIの呼び出し

Try API Callを行い、動作確認をしてみましょう。
url resourcesの設定欄はデフォルトでは表示されないので、左のCustom API Requestをクリックしてください。
instance_name: test-instance
ATOMのインスタンス情報が取得できるはずです。

UPDATE APIの実装

続いて、UPDATE APIを作成します。
シナリオの作成方法はアクセストークンの取得APIで説明したため割愛します。
設定するシナリオ定義とScriptブロックは以下になります。

シナリオ定義

項番 項目 入力値 説明
1 category SampleCrudApi シナリオが属する分類を指定してください。この情報はシナリオエディタでの階層表示のためのタグとしてのみ使用されます
2 workspace 未入力 シナリオのワークスペースを設定します
3 name InstanceUpdate シナリオの名前を指定します。ユニークである必要があります。
4 method PUT APIとして待機するHTTPメソッドを指定します。
5 uri /sample_crud_api/instances/{instance_name} APIとして待機するHTTPパスを指定します。
6 transaction 未選択 トランザクションサービスと連携します。今回は利用しません。
7 routing_auto_generation_mode 選択 API Gatewayにルーティングを自動登録するか選択します。

scriptブロック

Display Label: get access token
Python code:

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

if r.error:
    raise Error(r.code, reason="POST failed")

access_token = json.loads(r.body)["access_token"]

Display Label: target machineType
Python code:

instance_name = context.request.resources.instance_name

# Update対象のインスタンスを取得する
update_instances = await atom.VmInstance.retrieve(instanceName=instance_name)
update_instance = update_instances[0]

Display Label: instance stop
Python code:

faker("postInstanceStop")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/stop",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"stop failed")

await update_instance.save(status="STOPPING")

Display Label: check status & update status
Python code:

# ポーリング処理
CHECK_INTERVAL = 5

while True:

    faker("getInstanceTerminated")
    r = await callout(
        path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
        method="GET",
        headers={
            "Authorization": f"Bearer {access_token}"
        },
        )

    if r.error:
        raise Error(r.code, reason=f"get failed")

    if r.code == 200:

        if json.loads(r.body.decode("utf-8"))["status"] in ["TERMINATED"]:
            await update_instance.save(status="STOPPED")
            break

    else:
        await update_instance.save(status="FAILED")
        break

    # 同期のsleepは不可
    await asyncio.sleep(CHECK_INTERVAL)

Display Label: post machineType
Python code:

faker("postInstanceMachineType")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/setMachineType",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    body={
        "machineType": f"zones/{update_instance.zone}/machineTypes/{machine_type}"
    }
    )

if r.error:
    raise Error(r.code, reason=f"post failed")

Display Label: check changing machineType
Python code:

import os

# ポーリング処理
CHECK_INTERVAL = 5

while True:

    faker("getInstanceMachineType")
    r = await callout(
        path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
        method="GET",
        headers={
            "Authorization": f"Bearer {access_token}"
        },
        )

    if r.error:
        raise Error(r.code, reason=f"get failed {update_instance.instanceName}")

    if r.code == 200:
        # インスタンスのマシンタイプを取得する
        instance_machine_type = os.path.basename(json.loads(r.body.decode("utf-8"))["machineType"])

        # インスタンスのマシンタイプと今回変更したいマシンタイプが一致した場合、更新は終わっているためポーリングを終了する
        if instance_machine_type == machine_type:
            break

    await asyncio.sleep(CHECK_INTERVAL)

Display Label: instance start
Python code:

faker("postInstanceStart")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/start",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"start failed")

await update_instance.save(status="STARTING")

Display Label: check status & update status
Python code:

# ポーリング処理
CHECK_INTERVAL = 5

while True:

    faker("getInstanceRunning")
    r = await callout(
        path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
        method="GET",
        headers={
            "Authorization": f"Bearer {access_token}"
        },
        )

    if r.error:
        raise Error(r.code, reason=f"get failed")

    if r.code == 200:

        if json.loads(r.body.decode("utf-8"))["status"] in ["RUNNING"]:
            await update_instance.save(status="RUNNING")
            break

    else:
        await update_instance.save(status="FAILED")
        break

    # 同期のsleepは不可
    await asyncio.sleep(CHECK_INTERVAL)

Display Label: update machineType
Python code:

# machineTypeを置き換える
await update_instance.save(machineType=machine_type)

Variablesの設定

name: update_instance
name: access_token

Endpointの設定

ScenarioにはTimeout設定があり、Scenario内の処理に時間をようしている場合、指定時間経過した時に自動でタイムアウトします。 デフォルト値は60sですが、GoogleCloudのVMのMachineTypeの変更が反映されるまでに時間がかかるため、ポーリング処理中にタイムアウトしてしまいます。 そこで以下のようにEndpointのタブからconnect_timeoutrequest_timeout600sに変更してください。

動作確認:APIの呼び出し

Try API Callを行い、動作確認をしてみましょう。
url resoursesにはtest-instanceを入力し、Content Bodyには以下を入力しましょう。

{
    "machine_type": "e2-small"
}

Google Cloudのコンソールを開いて、ComputeEngineのインスタンスを確認するとMachineTypeが更新されているはずです。

DELETE APIの実装

続いて、DELETE APIを作成します。
シナリオの作成方法はアクセストークンの取得APIで説明したため割愛します。
設定するシナリオ定義とScriptブロックは以下になります。

シナリオ定義

項番 項目 入力値 説明
1 category SampleCrudApi シナリオが属する分類を指定してください。この情報はシナリオエディタでの階層表示のためのタグとしてのみ使用されます
2 workspace 未入力 シナリオのワークスペースを設定します
3 name InstanceDelete シナリオの名前を指定します。ユニークである必要があります。
4 method DELETE APIとして待機するHTTPメソッドを指定します。
5 uri /sample_crud_api/instances/{instance_name} APIとして待機するHTTPパスを指定します。
6 transaction 未選択 トランザクションサービスと連携します。今回は利用しません。
7 routing_auto_generation_mode 選択 API Gatewayにルーティングを自動登録するか選択します。

scriptブロック

Display Label: get access token
Python code:

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

if r.error:
    raise Error(r.code, reason="POST failed")

access_token = json.loads(r.body)["access_token"]

Display Label: target atom
Python code:

instance_name = context.request.resources.instance_name

delete_instances = await atom.VmInstance.retrieve(instanceName=instance_name)
delete_instance = delete_instances[0]

Display Label: delete instance
Python code:

faker("deleteInstance")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{delete_instance.projectId}/{delete_instance.zone}/{delete_instance.instanceName}",
    method="DELETE",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"DELETE failed {delete_instance.instanceName}")

# DELETINGステータスに変更
await delete_instance.save(status="DELETING")

Display Label: check status
Python code:

# ポーリング処理
CHECK_INTERVAL = 5

while True:

    faker("getInstance")
    r = await callout(
        path=f"/GoogleCloud/SampleCrudAPI/{delete_instance.projectId}/{delete_instance.zone}/{delete_instance.instanceName}",
        method="GET",
        headers={
            "Authorization": f"Bearer {access_token}"
        },
        )

    if r.code == 404:
        await delete_instance.save(status="DELETED")
        break
    elif r.code == 200:
        pass
    else:
        await delete_instance.save(status="FAILED")
        break

    await asyncio.sleep(CHECK_INTERVAL)

Variableの設定

name: delete_instance
name: access_token

Endpointの設定

UPDATE API同様にGoogle Cloudのインスタンスを削除する処理は時間を要するため、Endpointのタブからconnect_timeoutrequest_timeout600sに変更してください。

動作確認:APIの呼び出し

Try API Callを行い、動作確認をしてみましょう。 Google Cloudのコンソールにて確認すると、ComputeEngineにインスタンスが新規作成されているはずです。

トランザクションの実装

より安全なリソース管理のために

これまでのSample CRUD APIの実装では、基本的なリソースの作成、読み取り、更新、削除が可能になりました。しかし、実際の運用環境では、APIの実行中に予期せぬエラーが発生する可能性があります。特に、複数の処理が連携する更新処理(Update API)では、一部の処理が失敗した場合にシステムの状態が不整合になるリスクがあります。

例えば、Update APIではCompute Engineインスタンスの設定を変更する際に、以下の手順を実行していました。

  1. インスタンスを停止
  2. マシンタイプを変更
  3. インスタンスを再起動

ここで、2番目の「インスタンスのマシンタイプを変更する」処理で、Google Cloudから応答が返ってこなかったらどうなるでしょうか?
応答がないため、Qmonus SDK側では、マシンタイプの変更が成功したのか、失敗したのかを判断できません。
そうなると、Google Cloud上のインスタンスの状態と、Qmonus SDKが管理しているインスタンスの状態が食い違ってしまう可能性があります。
これでは、安全にリソース管理ができているとは言えません。

このような問題を解決し、システムの状態を常に一貫性のある状態に保つために、トランザクションの概念が重要になります。
Qmonus SDKでは、冪等性を担保するためのトランザクション機能が組み込まれています。

Note

トランザクションとは、一連の処理を「ひとまとまりの処理」として扱う仕組みです。
トランザクションを使うと、一連の処理は、すべて成功するか、またはすべて失敗するかのどちらかになります。

これにより、Sample CRUD APIにトランザクションを導入することで、冪等性(同じ操作を何度実行しても、結果が常に同じになる)を担保することができます。
Google Cloudのレスポンスが帰ってこない場合であっても、トランザクション機能によって安全にロールバックし、インスタンスの設定を更新前の状態に戻すことで、システムの状態を矛盾なく保つことができます。

この章では、より安全なリソース管理を目指して、1章で作成したSample CRUD APIにトランザクション機能を実装していきます。

トランザクションの実装方針

Sample CRUD APIでは以下のようにトランザクションを実装することにします。

CREATE:
リソースの作成中にエラーが発生した場合、システムの状態を矛盾なく保つために、処理全体をロールバックします。

READ:
読み取り操作はシステムの状態を変更しないため、トランザクションは実装しません。

UPDATE:
リソースの更新中にエラーが発生した場合、システムの状態を矛盾なく保つために、処理全体をロールバックします。

DELETE:
リソースの削除は、一度実行すると元に戻せない操作です。そのため、トランザクションは実装しますが、エラーが発生した場合でもロールバックは行わず、ロールフォワードのみを許可します。

それではまずは、CREATE APIからトランザクション機能を実装していきます。

CREATE APIのトランザクション実装

トランザクションのステータス

トランザクションを実装する前に、Qmonus SDKのトランザクションのステータス管理を理解しておく必要があります。
Qmonus SDKではトランザクションは、次の状態マシンによって管理されています。

トランザクション -Qmonus SDK

これを今回実装したCREATE APIに当てはめてみます。
まずCREATE APIが呼ばれるとステータスはProcessingとなります。
処理が正常に終了するとComplete、途中で処理が失敗するとAbortedになります。
例えば、CREATE APIの途中でGoogle Cloud APIからレスポンスステータスコード500が返却されるなどしてraise Errorとなった場合などはAbortedとなります。

Abortedになった際に、キャンセル処理を有効にしていた場合はCancellingとなり、キャンセル処理が実行されます。 キャンセル処理はシナリオの各トイブロックに対して設定でき、そのトイブロックの状態を実行前に戻すように実装します。 Abortedになったトイブロックから、実行した順番をさかのぼるようにキャンセル処理を実行していき、最終的にトランザクションの実行前の状態に戻ります。 こうして正常にCancellingが終了した場合はCancelledとなり、トランザクションは終了します。(いわゆるロールバックの処理です) なお、Cancellingの途中でふたたびエラーになる場合はAbortedに戻ります。

トランザクションのステータスはポータルから適宜参照できるため、Abortedになったトランザクションは必要に応じてユーザーが個別に対応することになります。

以上がロールバックの場合のトランザクションのステータス遷移になります。 これを踏まえたうえで、実際にCREATE APIにロールバック処理を実装してみます。

CREATE APIでトランザクションを有効にする

まず、CREATE APIのTransactionタブを開き、以下のように設定します。 (項目が見当たらない場合は適宜鉛筆マークから項目を表示させたうえで設定してください)

なお、それぞれ以下の設定となります。

Transaction Mode

項目 設定値 説明
enable True トランザクション機能を有効にする。

Transaction Properties

項目 設定値 説明
async True トランザクションが終了したタイミングでトランザクションからシナリオにcallbackを返すようになります。
auto_rollback True トランザクション内でエラーが発生した場合、自動的にロールバックを実行するようになります。
auto_begin True トランザクションが自動的に開始されるようになります。トランザクション開始時にレスポンスステータスコード202を返します。
auto_response True トランザクションの結果が自動的にレスポンスのBodyとして返されるようになります。

各トイブロックに対してCancellationを実装する

続いて、キャンセル処理を実装していきます。
キャンセル処理は各トイブロックごとに許可するかしないかを設定します。

CREATE APIでは、以下のように設定します。

トイブロック名 Cancellable
get access token 未設定
insert atom True
create instance True
check status & update status True

上記のトイブロックを選択し、ParametersからCancellationをチェックし、CancellableTrueにします。

また、CancellationRollback settingをチェックし、Python codeを入力します。

Python codeはそれぞれ以下を入力します。

トイブロック: get access token
Python code(Rollback setting):

# VmInstanceクラスのインスタンスを削除
instances = await atom.VmInstance.retrieve(instanceName=instance_name)
instance = instances[0]
await instance.destroy()

<方針>
このトイブロックでは、新規のインスタンスをデータベースに保存しています。
そのため、キャンセル処理ではこのトイブロックの実行前の状態に戻したいので、作成したインスタンスを削除する処理となっています。

<処理の流れ>
まず新規作成したインスタンス名で検索し、該当のインスタンスを取得します。
そのあと、このインスタンスを destroyにより削除して、このトイブロック実行前の状態に戻します。

トイブロック: create instance
Python code(Rollback setting):

r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}/{new_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

# VMが作成されていない場合はcallbackで行うことはないためreturnする
if r.code == 404:
    return

# VMが作成されている場合はcallbackでこのVMを削除する必要がある
if r.code == 200:

    # ステータスをDELETINGにする
    await new_instance.save(status="DELETING")

    # DELETE
    r = await callout(
        path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}/{new_instance.instanceName}",
        method="DELETE",
        headers={
            "Authorization": f"Bearer {access_token}"
        },
        )

    if r.error:
        raise Error(r.code, reason=f"callback delete failed {new_instance.instanceName}")

<方針>
このトイブロックでは、Google Cloud APIを呼び出してインスタンスを新規に作成しています。
そのため、キャンセル処理ではこのトイブロックの実行前の状態に戻したいので、インスタンスが新規に作成されている場合はそれを削除するようにします。

<処理の流れ>
まずGoogle Cloud側にインスタンスが新規に作成されているかどうかがわからないため、Google Cloudのインスタンスの状態をGETにより確認します。
その際に、ステータス404が返る場合は、インスタンスが作成されていないということなので何もせず終了します。
200が返る場合は、インスタンスが作成されているということなので、DELETEにより該当のインスタンスを削除します。

トイブロック: check status & update status
Python code(Rollback setting):

# GET
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}/{new_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"callback get failed {new_instance.instanceName}")

# 200 かつ RUNNINGでない場合はタイムアウトエラーを返す
if r.code == 200:
    if json.loads(r.body.decode("utf-8"))["status"] != "RUNNING":
        raise Error(r.code, reason=f"callback get failed {new_instance.instanceName}")

# 200 以外の場合はエラーを返す
if r.code != 200:
    raise Error(r.code, reason=f"callback get failed {new_instance.instanceName}")

# ステータスをCREATINGに戻す
await new_instance.save(status="CREATING")

<方針>
このトイブロックでは、GETをポーリングしてインスタンスが無事に作成されたかを確認し、作成されている場合はSDK側のインスタンスのステータスをRUNNINGに変更しています。
そのため、キャンセル処理ではこのトイブロックの実行前の状態に戻したいので、インスタンスがGoogle Cloud側に作成されている場合はSDK側のインスタンスのステータスをCREATINGに戻します。

<処理の流れ>
まずGoogle Cloud側にインスタンスが新規に作成されているかどうかがわからないため、Google Cloudのインスタンスの状態をGETにより確認します。
その際に、ステータス200かつRUNNINGでない場合、タイムアウトエラーしていたということなのでタイムアウトエラーとします。
200以外が返る場合は、予期しないことが起こっているためエラーとします。
200が返る場合は、インスタンスが作成されているということなので、SDK側のインスタンスのステータスをCREATINGに戻します。

以上により、CREATE APIにおけるトランザクションを実装できました。   トランザクションの動作確認は「3.ユニットテストの実装」で行います。

READ APIのトランザクション実装

READ APIは読み取り操作はシステムの状態を変更しないため、トランザクションは実装しません。

UPDATE APIのトランザクション実装

UPDATE APIでトランザクションを有効にする

CREATE API同様、以下のように設定します。

各トイブロックに対してCancellationを実装する

続いて、キャンセル処理を実装していきます。
キャンセル処理は各トイブロックごとに許可するかしないかを設定します。

UPDATE APIでは、以下のように設定します。

トイブロック名 Cancellable
get access token 未設定
target machineType True
instance stop True
check status & update status True
post machineType True
check changing machineType True
instance start True
check status & update status True
update machineType False

CREATE APIを参考に、以下のようにそれぞれのトイブロックに対してCancellationPython codeを入力します。

トイブロック: get access token
Python code(Rollback setting): 未設定

トイブロック: target machineType
Python code(Rollback setting):

# 未設定

<方針>
このトイブロックでは、Updateの対象となるインスタンスをインスタンス名から取得しています。
そのため、このトイブロックでは参照しかしていないため、CancellableはTrueとしてロールバック可にしていますが、キャンセル処理についてはなしとしています。  

トイブロック: instance stop
Python code(Rollback setting):

# GET: インスタンスの状態を確認する
faker("getInstanceTerminated")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

if r.code == 200:
    # RUNNING:何もせずreturn
    if json.loads(r.body.decode("utf-8"))["status"] in ["RUNNING"]:
        return

    # TERMINATED:このVMを再起動する
    if json.loads(r.body.decode("utf-8"))["status"] in ["TERMINATED"]:

        # ステータスをSTARTINGにする
        await update_instance.save(status="STARTING")

        # STARTする
        faker("postInstanceStart")
        r = await callout(
            path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/start",
            method="POST",
            headers={
                "Authorization": f"Bearer {access_token}"
            },
            )

        # STARTできていることをポーリングで確認する
        CHECK_INTERVAL = 5
        while True:
            faker("getInstanceRunning")
            r = await callout(
                path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
                method="GET",
                headers={
                    "Authorization": f"Bearer {access_token}"
                },
                )
            if r.error:
                raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

            # 200かつステータスがRUNNINGの場合:return
            if (r.code == 200) and (json.loads(r.body.decode("utf-8"))["status"] in ["RUNNING"]):
                await update_instance.save(status="RUNNING")
                return

            await asyncio.sleep(CHECK_INTERVAL)

<方針>
このトイブロックでは、インスタンスをアップデートするため、対象のインスタンスを停止して、SDK側のインスタンスのステータスをRUNNINGからSTOPPINGに変更しています。
そのため、キャンセル処理では停止前の状態に戻したいので、インスタンスの状態を確認し、停止していた場合は再起動してSDK側のインスタンスのステータスをRUNNINGに変更します。

<処理の流れ>
まずGoogle Cloud側のインスタンスのステータスがわからないのでGETで確認します。
その際に、ステータス200かつRUNNINGが返る場合は、すでに起動状態にあるので何もせず終了します。
ステータス200かつTERMINATEDが返る場合は、停止状態にあるのでGoogle Cloud APIにより再起動をし、ちゃんと再起動できているかをポーリングで確認しています。起動できていた場合、SDK側のインスタンスのステータスをRUNNINGに戻します。

トイブロック: check status & update status
Python code(Rollback setting):

# GET: インスタンスの状態を確認する
faker("getInstanceTerminated")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

if r.code == 200:
    if json.loads(r.body.decode("utf-8"))["status"] in ["TERMINATED"]:
        # 200かつTERMINATEDの場合:ステータスをSTOPPINGに変更
        await update_instance.save(status="STOPPING")
        return
    else:
        # 200かつTERMINATEDでない場合:タイムアウトエラー
        raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

# 上記以外の場合:エラー
raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

<方針>
このトイブロックでは、GETをポーリングしてインスタンスが無事に停止したかを確認し、停止している場合はSDK側のインスタンスのステータスをSTOPPINGからSTOPPEDに変更しています。
そのため、キャンセル処理ではこのトイブロックの実行前の状態に戻したいので、GETでインスタンスの状態を確認し、インスタンスが停止している場合はSDK側のインスタンスのステータスをSTOPPINGに戻します。

<処理の流れ>
まずGoogle Cloud側のインスタンスのステータスがわからないのでGETで確認します。
200かつTERMINATEDの場合は、インスタンスが停止されているため、ステータスをSTOPPINGに変更します。
200かつTERMINATEDでない場合はタイムアウトエラーが考えられるのでタイムアウトエラーとします。
それ以外の場合は、予期しないことが起こっているためエラーとします。

トイブロック: post machineType
Python code(Rollback setting):

import os
# GET: インスタンスのマシンタイプを確認する
faker("getInstanceMachineType")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

if r.code == 200:
    # インスタンスのマシンタイプを取得する
    instance_machine_type = os.path.basename(json.loads(r.body.decode("utf-8"))["machineType"])

    # マシンタイプが更新されていない場合:何もせずreturn
    if instance_machine_type == update_instance.machineType:
        return

    # マシンタイプが更新されていた場合:POSTでマシンタイプを元に戻す
    elif instance_machine_type == machine_type:

        # POST
        r = await callout(
            path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/setMachineType",
            method="POST",
            headers={
                "Authorization": f"Bearer {access_token}"
            },
            body={
                "machineType": f"zones/{update_instance.zone}/machineTypes/{update_instance.machineType}"
            }
            )

        if r.error:
            raise Error(r.code, reason=f"post failed")

        # GET:ポーリングでマシンタイプの更新を確認する
        CHECK_INTERVAL = 5
        while True:
            r = await callout(
                path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
                method="GET",
                headers={
                    "Authorization": f"Bearer {access_token}"
                },
                )
            if r.error:
                raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

            # 200かつマシンタイプが更新されていた:終了
            if r.code == 200:
                if os.path.basename(json.loads(r.body.decode("utf-8"))["machineType"]) == update_instance.machineType:
                    return
            else:
                raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

            await asyncio.sleep(CHECK_INTERVAL)

raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

<方針>
このトイブロックでは、Google Cloud APIを呼び出してマシンタイプを変更しています。
そのため、キャンセル処理ではこのトイブロックの実行前の状態に戻したいので、GETでインスタンスの状態を確認し、インスタンスのマシンタイプが変更されている場合はそれを戻すようにGoogle Cloud APIを呼び出します。

<処理の流れ>
まずGoogle Cloud側のインスタンスのマシンタイプがわからないのでGETで確認します。
その結果、マシンタイプが更新されていなかったら何もせず終了します。
マシンタイプが更新されていた場合は、POSTでマシンタイプを元に戻します。
その後、マシンタイプが無事に元に戻ったかをGETをポーリングして確認します。
200かつマシンタイプが更新されていた場合は終了し、それ以外の場合はエラーを返します。

トイブロック: check changing machineType
Python code(Rollback setting):

# 未設定

<方針>
このトイブロックでは、マシンタイプが無事に更新されたかどうかをGETでポーリングで確認します。
そのため、このトイブロックでは参照しかしていないため、CancellableはTrueとしてロールバック可にしていますが、キャンセル処理についてはなしとしています。

トイブロック: instance start
Python code(Rollback setting):

# GET: インスタンスの状態を確認する
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

if r.code == 200:
    # TERMINATED:何もせずreturn
    if json.loads(r.body.decode("utf-8"))["status"] in ["TERMINATED"]:
        return

    # RUNNING:このVMを停止する
    if json.loads(r.body.decode("utf-8"))["status"] in ["RUNNING"]:

        # ステータスをSTOPPINGにする
        await update_instance.save(status="STOPPING")

        # STOPする
        r = await callout(
            path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/stop",
            method="POST",
            headers={
                "Authorization": f"Bearer {access_token}"
            },
            )

        # STOPできていることをポーリングで確認する
        CHECK_INTERVAL = 5
        while True:
            r = await callout(
                path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
                method="GET",
                headers={
                    "Authorization": f"Bearer {access_token}"
                },
                )
            if r.error:
                raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

            # 200かつステータスがTERMINATEDの場合:return
            if (r.code == 200) and (json.loads(r.body.decode("utf-8"))["status"] in ["TERMINATED"]):
                await update_instance.save(status="STOPPED")
                return

            await asyncio.sleep(CHECK_INTERVAL)

<方針>
このトイブロックでは、停止していた対象のインスタンスを再起動して、SDK側のインスタンスのステータスをSTOPPEDからSTARTINGに変更しています。
そのため、キャンセル処理では停止前の状態に戻したいので、インスタンスの状態を確認し、再起動していた場合は停止してSDK側のインスタンスのステータスをSTOPPEDに変更します。

<処理の流れ>
まずGoogle Cloud側のインスタンスのステータスがわからないのでGETで確認します。
その際に、ステータス200かつTERMINATEDが返る場合は、すでに停止状態にあるので何もせず終了します。
ステータス200かつRUNNINGが返る場合は、起動状態にあるのでGoogle Cloud APIにより停止をし、ちゃんと停止できているかをポーリングで確認しています。停止できていた場合、SDK側のインスタンスのステータスをSTOPPEDに戻します。

トイブロック: check status & update status
Python code(Rollback setting):

# GET: インスタンスの状態を確認する
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

if r.error:
    raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

if r.code == 200:
    if json.loads(r.body.decode("utf-8"))["status"] in ["RUNNING"]:
        # 200かつRUNNINGの場合:ステータスをSTARTINGに変更
        await update_instance.save(status="STARTING")
        return
    else:
        # 200かつRUNNINGでない場合:タイムアウトエラー
        raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

# 上記以外の場合:エラー
raise Error(r.code, reason=f"callback get failed {update_instance.instanceName}")

<方針>
このトイブロックでは、GETをポーリングしてインスタンスが無事に起動したかを確認し、起動している場合はSDK側のインスタンスのステータスをSTARTINGからRUNNINGに変更しています。
そのため、キャンセル処理ではこのトイブロックの実行前の状態に戻したいので、GETでインスタンスの状態を確認し、インスタンスが起動している場合はSDK側のインスタンスのステータスをSTARTINGに戻します。

<処理の流れ>
まずGoogle Cloud側のインスタンスのステータスがわからないのでGETで確認します。
200かつRUNNINGの場合は、インスタンスが起動されているため、ステータスをSTATINGに変更します。
200かつRUNNINGでない場合はタイムアウトエラーが考えられるのでタイムアウトエラーとします。
それ以外の場合は、予期しないことが起こっているためエラーとします。

トイブロック: update machineType
Python code(Rollback setting): False

<方針>
これより前のトイブロックでGoogle Cloud側のインスタンスのマシンタイプを変更したため、このトイブロックでは、SDKのインスタンスのマシンタイプを更新する処理を行っている。
この段階から、ロールバックでマシンタイプを元に戻すのは許可しないとし、CancellableをFalseとしている。

以上により、UPDATE APIにおけるトランザクションを実装できました。
トランザクションの動作確認は「3.ユニットテストの実装」で行います。

DELETE APIのトランザクション実装

DELETE APIでトランザクションを有効にする

CREATE API同様に設定します。

各トイブロックに対してCancellationを実装する

DELETE APIでは、以下のように設定します。

トイブロック名 Cancellable
get access token False
target atom False
delete instance False
check status False

DELETE APIで扱うリソースの削除は、一度実行すると元に戻せない操作です。
そのため、トランザクションは実装しますが、エラーが発生した場合でもロールバックは行わず、ロールフォワードのみを許可します。
したがって、トランザクションは設定しますが、CancellableはすべてのトイブロックにおいてFalseとし、ロールバックを禁止しています。

以上により、DELETE APIにおけるトランザクションを実装できました。 トランザクションの動作確認は「3.ユニットテストの実装」で行います。

ユニットテストの実装

ここまでで、Sample CRUD APIをトランザクション含め実装することができました。
ここからは実装したSample CRUD APIのユニットテストを実装していこうと思います。

テストはこちらから作成することができます。

ユニットテストは以下の順に作成します。

  1. Faker
    テスト実行時に、外部サービスとの実際の通信を行わずに、あらかじめ定義した疑似動作(フェイク関数)を実行して応答を模擬する仕組みです。

  2. Illusion
    Fakerで定義されたフェイク関数群を、テストシーンに適用するための設定・割り当ての仕組みです。これにより、特定のテスト実行時に外部環境の動作を模擬できます。

  3. Test Case
    一つのテストシナリオを構成する単位で、入力条件や期待する出力、検証(assert)処理などを記述し、個別の機能やプラグインの動作をテストします。

  4. Test Suite
    複数のTest Caseをまとめた集合体で、関連するテストケースを一括して実行し、システム全体や複数機能間の連携動作を検証するために使用されます。

Fakerの実装

今回作成するFaker

今回のSample CRUD APIでは、以下のFakerを用意します。

faker名 使用するCRUD API 想定する用途
getAccessToken CREATE
UPDATE
DELETE
疑似的にGETしてGoogle Cloud APIのアクセストークンを取得した際の挙動をする
getInstance CREATE
DELETE
疑似的にGETしてインスタンスを取得した際の挙動をする
getInstanceRunning CREATE
UPDATE
疑似的にGETしてインスタンスを取得した際の挙動をする
成功時にRUNNINGステータスを返す
getInstanceTerminated UPDATE 疑似的にGETしてインスタンスを取得した際の挙動をする
成功時にTERMINATEDステータスを返す
getInstanceMachineType UPDATE 疑似的にGETしてインスタンスを取得した際の挙動をする
MachineTypeにe2-microもしくはe2-smallを返す
postInstance CREATE 疑似的にPOSTしてインスタンスを作成した際の挙動をする
postInstanceStop UPDATE 疑似的にPOSTしてインスタンスを停止した際の挙動をする
postInstanceStart UPDATE 疑似的にPOSTしてインスタンスを再起動した際の挙動をする
postInstanceMachineType UPDATE 疑似的にPOSTしてMachineTypeを更新した際の挙動をする
deleteInstance DELETE 疑似的にDELETEしてインスタンスを削除した際の挙動をする

Note

どうしてFakerが必要なのか:
今回のSample CRUD APIに対しテストコードを実装しようとすると、まず悩むところとしてGoogle Cloud APIの呼び出しをどうするかがあると思います。そのまま各APIをテストで実行してしまうと、テストのたびにGoogle Cloudにリクエストが飛んでしまいます。

この問題に対して、Fakerを使うとこれを解決することができます。
Fakerを定義しcallout関数に対しておいてあげることで、そのcalloutでは実際にAPIを呼び出すことなく、Fakerで定義した疑似動作を行うことになります。これにより、外部にリクエストを飛ばさずにテストの実行を実現することができます。

Fakerの新規作成

それでは一つずつFakerを作成していきます。
[Unit Test as a Service]>[Faker]>[Create New Faker]からFakerを作成することができます。

作成後、Fakerタブからふるまいを定義することができます。

以下のFakerを作成します。

name: getAccessToken
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(
        200,
        body=json.dumps({"access_token": "xxxxx"}).encode("utf-8")
        )
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: getInstance
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def Running(*args, **kwargs):
    return FakeHttpResponse(
        200,
        body=json.dumps({"status": "RUNNING", "id": "111"}).encode("utf-8")
        )
async def Terminated(*args, **kwargs):
    return FakeHttpResponse(
        200,
        body=json.dumps({"status": "TERMINATED", "id": "111"}).encode("utf-8")
        )
async def e2micro(*args, **kwargs):
    return FakeHttpResponse(
        200,
        # projectId, zoneには各自がデフォルトで設定している値に書き換えてください
        body=json.dumps({"machineType": "https://www.googleapis.com/compute/v1/projects/<projectId>/zones/<zone>/machineTypes/e2-micro"}).encode("utf-8")
        )
async def e2small(*args, **kwargs):
    return FakeHttpResponse(
        200,
        # projectId, zoneには各自がデフォルトで設定している値に書き換えてください
        body=json.dumps({"machineType": "https://www.googleapis.com/compute/v1/projects/<projectId>/zones/<zone>/machineTypes/e2-small"}).encode("utf-8")
        )
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: getInstanceRunning
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def Running(*args, **kwargs):
    return FakeHttpResponse(
        200,
        body=json.dumps({"status": "RUNNING", "id": "111"}).encode("utf-8")
        )
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: getInstanceTerminated
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def Terminated(*args, **kwargs):
    return FakeHttpResponse(
        200,
        body=json.dumps({"status": "TERMINATED", "id": "111"}).encode("utf-8")
        )
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: getInstanceMachineType
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def e2micro(*args, **kwargs):
    return FakeHttpResponse(
        200,
        # projectId, zoneには各自がデフォルトで設定している値に書き換えてください
        body=json.dumps({"machineType": "https://www.googleapis.com/compute/v1/projects/<projectId>/zones/<zone>/machineTypes/e2-micro"}).encode("utf-8")
        )
async def e2small(*args, **kwargs):
    return FakeHttpResponse(
        200,
        # projectId, zoneには各自がデフォルトで設定している値に書き換えてください
        body=json.dumps({"machineType": "https://www.googleapis.com/compute/v1/projects/<projectId>/zones/<zone>/machineTypes/e2-small"}).encode("utf-8")
        )
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: postInstance
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: postInstanceStop
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: postInstanceStart
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: postInstanceMachineType
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

name: deleteInstance
category: SampleCrudApi
fakes:

async def Success(*args, **kwargs):
    return FakeHttpResponse(200)
async def InternalError(*args, **kwargs):
    return FakeHttpResponse(500)
async def NotFound(*args, **kwargs):
    return FakeHttpResponse(404)

FakerをScenarioに実装する

FakerをScenarioに実装します。
最後に、設定したこれらのfakerをscenarioのcallout部分に実装していきます。

はじめにFakerの実装例を紹介します。

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

このように、scenarioのPython Scriptで外部APIを呼んでいるcallout関数の上にfaker(“faker name”)の形式で挿入します。 これにより、テスト実行時にこちらのcallout関数は指定したFakerの疑似動作をするようになります。

今回用意したFakerの挿入箇所をまとめておきます。
こちらを参考に、それぞれのscenarioに対してFakerを挿入してください。

faker: getAccessToken
scenario①: InstanceCreate
対象トイブロック: get access token
挿入するcallout関数:

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

scenario②: InstanceUpdate
対象トイブロック: get access token
挿入するcallout関数:

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

scenario③: InstanceDelete
対象トイブロック: get access token
挿入するcallout関数:

faker("getAccessToken")
r = await callout(
    path="/sample_crud_api/get_access_token",
    method="GET"
    )

faker: getInstance
scenario①: InstanceCreate
対象トイブロック: create instance (Cancellation)
挿入するcallout関数:

faker("getInstance")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}/{new_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario②: InstanceDelete
対象トイブロック: check status
挿入するcallout関数:

faker("getInstance")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{delete_instance.projectId}/{delete_instance.zone}/{delete_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

faker: getInstanceRunning
scenario①: InstanceCreate
対象トイブロック: check status & update status
挿入するcallout関数:

faker("getInstanceRunning")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}/{new_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario②: InstanceUpdate
対象トイブロック: instance stop (Cancellation)
挿入するcallout関数:

faker("getInstanceRunning")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario③: InstanceUpdate
対象トイブロック: check status & update status
挿入するcallout関数:

faker("getInstanceRunning")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

faker: getInstanceTerminated
scenario①: InstanceUpdate
対象トイブロック: instance stop (Cancellation)
挿入するcallout関数:

faker("getInstanceTerminated")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario②: InstanceUpdate
対象トイブロック: check status & update status
挿入するcallout関数:

faker("getInstanceTerminated")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario③: InstanceUpdate
対象トイブロック: check status & update status (Cancellation)
挿入するcallout関数:

faker("getInstanceTerminated")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

faker: getInstanceMachineType
scenario①: InstanceUpdate
対象トイブロック: post machineType (Cancellation)
挿入するcallout関数:

faker("getInstanceMachineType")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario②: InstanceUpdate
対象トイブロック: check changing machineType
挿入するcallout関数:

faker("getInstanceMachineType")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}",
    method="GET",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

faker: postInstance
scenario①: InstanceCreate
対象トイブロック: create instance
挿入するcallout関数:

faker("postInstance")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{new_instance.projectId}/{new_instance.zone}",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    },
    body={
        "machineType": f"zones/{new_instance.zone}/machineTypes/{new_instance.machineType}",
        "name": new_instance.instanceName,
        "disks": [
            {
                "initializeParams": {
                    "sourceImage": "projects/debian-cloud/global/images/family/debian-12"
                },
                "boot": True
            }
        ],
        "networkInterfaces": [
            {
                "network": "global/networks/default"
            }
        ],
        "shieldedInstanceConfig": {
            "enableSecureBoot": False
        }
    }
    )

faker: postInstanceStop
scenario①: InstanceUpdate
対象トイブロック: instance stop
挿入するcallout関数:

faker("postInstanceStop")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/stop",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

faker: postInstanceStart
scenario①: InstanceUpdate
対象トイブロック: instance stop (Cancellation)
挿入するcallout関数:

faker("postInstanceStart")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/start",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

scenario②: InstanceUpdate
対象トイブロック: instance start
挿入するcallout関数:

faker("postInstanceStart")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/start",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

faker: postInstanceMachineType
scenario①: InstanceUpdate
対象トイブロック: post machineType
挿入するcallout関数:

faker("postInstanceMachineType")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{update_instance.projectId}/{update_instance.zone}/{update_instance.instanceName}/setMachineType",
    method="POST",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    body={
        "machineType": f"zones/{update_instance.zone}/machineTypes/{machine_type}"
    }
    )

faker: deleteInstance
scenario①: InstanceDelete
対象トイブロック: delete instance
挿入するcallout関数:

faker("deleteInstance")
r = await callout(
    path=f"/GoogleCloud/SampleCrudAPI/{delete_instance.projectId}/{delete_instance.zone}/{delete_instance.instanceName}",
    method="DELETE",
    headers={
        "Authorization": f"Bearer {access_token}"
    },
    )

以上でFakerの実装が完了です。

Illusionの実装

今回作成するIllusion

今回のSample CRUD APIでは、以下のIllusionを用意します。

Illusion名 想定する場合
CreateSuccess CREATE APIが成功する場合
CreateInstanceFailed CREATE APIでインスタンスの作成に失敗する場合
UpdateSuccess UPDATE APIが成功する場合
UpdateMachineTypeFailed UPDATE APIでマシンタイプの変更に失敗する場合
DeleteSuccess DELETE APIが成功する場合
DeleteInstanceFailed DELETE APIでインスタンスの削除に失敗する場合

Note

IllusionとはFakerがどの挙動をするかをまとめたものになります。

例えば、上記のCreateSuccessはCREATE APIが成功する場合のIllusionになりますが、この場合のFakerは以下をセットします。

  • getAccessToken : Success
  • postInstance : Success
  • getInstanceRunning : Running

CREATE APIが成功する場合、CREATE APIが呼び出すGoogle Cloud APIはすべて成功することになるため、そのようにふるまうようにFakerを指定しています。

一方で上記のCreateInstanceFailedではCREATE APIのインスタンスの作成に失敗する場合を再現するため、以下のようにFakerをセットします。

  • getAccessToken : Success
  • postInstance : InternalError
  • getInstanceRunning : NotFound
  • getInstance : NotFound

これはインスタンス作成(postInstance)がInternalErrorになる場合を再現しています。
インスタンス作成に失敗すると、作成後にインスタンスをGETしてもNotFoundになることからそれもFakerで表現しています。

このように、テストケースを作成する際に、いちいちFakerを設定するのではなく、IllusionでFakerをまとめて設定をすることができるようになります。

Illusionの新規作成

それでは一つずつillusionを作成していきます。
[Unit Test as a Service]>[Illusion]>[Create New Illusion]からIllusionを作成することができます。

categorynameを入力してillusionを作成します。

作成したIllusionを開きます。
Fakers to useタブを開き、Fakersタブのうち、Illusionに含めたいFakerをドラッグアンドドロップでFakers to useに移動させます。
その際に、Fakerにどのふるまいをさせるかを選択します。

最終的に以下のようになります。

上記の例では、IllusionのCreateSuccessのFakerは

  • getAccessToken:Successの挙動をする
  • postInstance:Successの挙動をする
  • getInstanceRunning:Runningの挙動をする

となります。

上記を参考に、今回のSample CRUD APIでは以下のIllusionを作成します。

name category fakes
CreateSuccess SampleCrudApi getAccessToken - Success
postInstance - Success
getInstanceRunning - Running
CreateInstanceFailed SampleCrudApi getAccessToken - Success
postInstance - InternalError
getInstanceRunning - NotFound
getInstance - NotFound
UpdateSuccess SampleCrudApi getAccessToken - Success
postInstanceStop - Success
getInstanceTerminated - Terminated
postInstanceMachineType - Success
getInstanceMachineType - e2small
postInstanceStart - Success
getInstanceRunning - Running
UpdateMachineTypeFailed SampleCrudApi getAccessToken - Success
postInstanceStop - Success
getInstanceTerminated - Terminated
postInstanceMachineType - InternalError
getInstanceMachineType - e2micro
postInstanceStart - Success
getInstanceRunning - Running
DeleteSuccess SampleCrudApi getAccessToken - Success
deleteInstance - Success
getInstance - NotFound
DeleteInstanceFailed SampleCrudApi getAccessToken - Success
deleteInstance - InternalError

以上でIllusionの実装が完了です。

Testcaseの実装

今回作成するTestcase

今回のSample CRUD APIでは、以下のTestcaseを用意します。

Test Case 適用するIllusion 想定するケース
CreateSuccess CreateSuccess CREATE APIが成功するケース
CreateInstanceFailed CreateInstanceFailed CREATE APIでインスタンスの作成に失敗するケース
GetSuccess GetSuccess READ APIが成功するケース
UpdateSuccess UpdateSuccess UPDATE APIが成功するケース
UpdateMachineTypeFailed UpdateMachineTypeFailed UPDATE APIでマシンタイプの変更に失敗するケース
DeleteSuccess DeleteSuccess DELETE APIが成功するケース
DeleteInstanceFailed DeleteInstanceFailed DELETE APIでインスタンスの削除に失敗するケース

Note

今回はチュートリアルということで、各CRUD APIの成功ケースと、CREATE、UPDATE、DELETEにおける代表的な失敗ケースの計7ケースを用意しています。
しかし、実際の運用を考えると失敗ケースはほかにも考えられますので、自分の実装するアプリケーションをテストする場合は、十分にケースを考えるようにしましょう。

Testcaseの新規作成

それでは一つずつTestcaseを作成していきます。
例として、今回作成するTestcaseの一つである、CreateSuccessを作成してみます。
[Unit Test as a Service]>[Test Case]>[Create New TestCase]からTestcaseを作成することができます。

まず、何に対するテストケースかを選択します。今回はScenarioに対するテストのため、Scenarioを選択してください。

次にTestcaseのnamecategorytargetを入力します。 targetには、今回テストしたいScenarioを選択してください。

今回は以下を入力してください。

  • name : CreateSuccess
  • category : SampleCrudApi
  • target : InstanceCreate

Testcaseの設定

作成したTestcaseを選択し、Testcase Settingタブを開きます。
先ほど設定したnamecategorytargetに加えて、illusionをここで設定することができます。
今回はillusionに以下を設定してください。

  • illusion : CreateSuccess

Perspective SettingタブにはFakerの挙動が表示されます。今回は上で指定したillusionの内容が入っていることが確認できます。

Flow Settingの設定

続いて、具体的にこのTestcaseの中身を実装していきます。   Flow Settingタブを開いてください。

Note

それぞれの項目の説明は以下になります。 特にAPIの呼び出しはScenario Inputで行います。

  • Preparation(準備):
    テスト実行前の初期化や環境設定を行います。たとえば、テスト対象のデータやモックのセットアップを行います。

  • Assert Begin(検証開始):
    テストの開始を宣言し、後続の検証処理が始まることを示します。

  • Scenario Input(シナリオ入力):
    実際にテスト対象のAPI呼び出しや操作などの入力処理を記述します。

  • Scenario Output(シナリオ出力):
    テスト実行後の応答結果や状態変化を確認・記録します。出力内容が期待通りかをチェックするための処理です。

  • Assert End(検証終了):
    テストシナリオの終了を明示し、全体の結果がまとめられます。検証ロジックの完了を示します。

  • Cleanup(後処理):
    テスト実行後に環境を元に戻すための処理です。たとえば、作成したテストデータの削除やリソースの解放などを行います。

今回のTestcaseでは以下のように実装します。

Testcase①
name: CreateSuccess
target: InstanceCreate(CREATE API)
illusion: CreateSuccess
Preparation:

async def hoge(*args, **kwargs):
    pass

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: POST
  • path: /sample_crud_api/instances
  • headers:
{
    "Content-Type": "application/json"
}
  • body:
{
    "instance_name": "test-instance"
}

Note

APIの呼び出しはScenario Inputで行います。
このテストケースではCREATE APIのテストを行うため、Scenario Inputでは、CREATE APIの呼び出しを行います。

Scenario Input (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Scenario Output:

async def assertion():
    assert Response.code==202, "Invalid response code %r" % Response.code

Note

Scenario OutputではAPIを呼び出したあとのレスポンスを検証できます。
CERATE APIは202が返却されることが期待されるため、202が返却されたかどうかを確認します。

Scenario Output (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Assert End:

async def assertion():
    assert Transaction.status=="Complete", "Transaction is in an unexpected state %r" % Transaction.status

Note

Assert Endでは最終的な結果を検証できます。
このテストケースはCREATE APIが正常に完了した場合のテストになります。そのため、最終的にトランザクションのステータスがCompleteであるかどうかを確認します。

Cleanup:

async def cleanup():
    # テスト用のInstanceを削除する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    await test_instance.destroy()

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"

Note

Cleanupでは環境のクリーンアップを行います。このテストを実行したことで、CREATE APIが呼び出され、データベースにtest-instanceのレコードが追加されました。
そのため、作成されたtest-instanceのレコードをクリーンアップで削除し、テスト実行前の状態に戻しています。

Caution

Cleanupが正しく行われたかの判断は、本Testcaseで作成したtest-instanceという名前のVmInstanceが削除されたかどうかで判断します。
チュートリアルを実施する中でtest-instanceという名前のインスタンスを複数作成している場合、Cleanupでは正しく削除されず、Cleanup後にtest-instanceが残ってしまいます。
そのため、Testを実行する際には事前にtest-instanceという名前のVmInstanceは削除しましょう。

上記を参考に、そのほかのTestcaseについても実装してください。

Testcase②
name: CreateInstanceFailed
target: InstanceCreate(CREATE API)
illusion: CreateInstanceFailed
Preparation:

async def hoge(*args, **kwargs):
    pass

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: POST
  • path: /sample_crud_api/instances
  • headers:
{
    "Content-Type": "application/json"
}
  • body:
{
    "instance_name": "test-instance"
}

Scenario Input (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Scenario Output:

async def assertion():
    assert Response.code==202, "Invalid response code %r" % Response.code

Scenario Output (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Assert End:

async def assertion():
    assert Transaction.status=="Cancelled", "Transaction is in an unexpected state %r" % Transaction.status

Note

このテストケースはCREATE APIが途中で失敗し、ロールバックとなる場合のテストになります。そのため、最終的にトランザクションのステータスがロールバックが正常に完了した場合のステータスであるCancelledであるかどうかを確認します。

Cleanup:

async def cleanup():

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"  

Testcase③
name: GetSuccess
target: InstanceGet(READ API)
illusion: -
Preparation:

async def preparation():
    #テスト用のVmInstanceクラスのインスタンスを作成する
    test_instance = await atom.VmInstance(
        instanceName="test-instance",
        status="RUNNING",
        machineType="e2-micro"
        )
    # データベースに保存する
    await test_instance.save()

Note

Preparationはテスト開始前にテストデータのセットアップができます。
このテストはREAD APIに対するテストのため、GET対象のデータを事前に用意しておきます。

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: GET
  • path: /sample_crud_api/instances/test-instance

Scenario Input (script):

  • get instances from atom:-

Scenario Output:

async def assertion():
    assert Response.code==200, "Invalid response code %r" % Response.code

Scenario Output (script):

  • get instances from atom: -

Assert End:

async def hoge(*args, **kwargs):
    pass

Cleanup:

async def cleanup():
    # テスト用のInstanceを削除する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    await test_instance.destroy()

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"

Testcase④
name: UpdateSuccess
target: InstanceUpdate(UPDATE API)
illusion: UpdateSuccess
Preparation:

async def preparation():
    #テスト用のVmInstanceクラスのインスタンスを作成する
    test_instance = await atom.VmInstance(
        instanceName="test-instance",
        status="RUNNING",
        machineType="e2-micro"
        )
    # データベースに保存する
    await test_instance.save()

Note

このテストはUPDATE APIに対するテストのため、UPDATE対象のデータを事前に用意しておきます。

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: PUT
  • path: /sample_crud_api/instances/test-instance
  • headers:
{
    "Content-Type": "application/json"
}
  • body:
{
    "machine_type": "e2-small"
}

Scenario Input (script):

  • get access token:-
  • target machineType: -
  • instance stop: -
  • check status & update status: -
  • post machineType: -
  • check changing machineType: -
  • instance start: -
  • check status & update status: -
  • update machineType: -

Scenario Output:

async def assertion():
    assert Response.code==202, "Invalid response code %r" % Response.code

Scenario Output (script):

  • get access token:-
  • target machineType: -
  • instance stop: -
  • check status & update status: -
  • post machineType: -
  • check changing machineType: -
  • instance start: -
  • check status & update status: -
  • update machineType: -

Assert End:

async def assertion():
    assert Transaction.status=="Complete", "Transaction is in an unexpected state %r" % Transaction.status

Cleanup:

async def cleanup():
    # テスト用のInstanceを削除する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    await test_instance.destroy()

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"

Testcase⑤
name: UpdateMachineTypeFailed
target: InstanceUpdate(UPDATE API)
illusion: UpdateMachineTypeFailed
Preparation:

async def preparation():
    #テスト用のVmInstanceクラスのインスタンスを作成する
    test_instance = await atom.VmInstance(
        instanceName="test-instance",
        status="RUNNING",
        machineType="e2-micro"
        )
    # データベースに保存する
    await test_instance.save()

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: PUT
  • path: /sample_crud_api/instances/test-instance
  • headers:
{
    "Content-Type": "application/json"
}
  • body:
{
    "machine_type": "e2-small"
}

Scenario Input (script):

  • get access token:-
  • target machineType: -
  • instance stop: -
  • check status & update status: -
  • post machineType: -
  • check changing machineType: -
  • instance start: -
  • check status & update status: -
  • update machineType: -

Scenario Output:

async def assertion():
    assert Response.code==202, "Invalid response code %r" % Response.code

Scenario Output (script):

  • get access token:-
  • target machineType: -
  • instance stop: -
  • check status & update status: -
  • post machineType: -
  • check changing machineType: -
  • instance start: -
  • check status & update status: -
  • update machineType: -

Assert End:

async def assertion():
    assert Transaction.status=="Cancelled", "Transaction is in an unexpected state %r" % Transaction.status

    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    assert test_instance.machineType=="e2-micro", "MachineType is unexpected"
    assert test_instance.status=="RUNNING", "Status is unexpected"

Note

このテストケースはUPDATE APIが途中で失敗し、ロールバックとなる場合のテストになります。そのため、最終的にトランザクションのステータスがロールバックが正常に完了した場合のステータスであるCancelledであるかどうかを確認します。

また、SDK側のインスタンスのマシンタイプとステータスが、テスト実行前のe2-microとRUNNINGに戻っているかも確認しています。これにより、キャンセル処理が正常に行われたかどうかを確認しています。

Cleanup:

async def cleanup():
    # テスト用のInstanceを削除する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    await test_instance.destroy()

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"

Testcase⑥
name: DeleteSuccess
target: InstanceDelete(DELETE API)
illusion: DeleteSuccess
Preparation:

async def preparation():
    #テスト用のVmInstanceクラスのインスタンスを作成する
    test_instance = await atom.VmInstance(
        instanceName="test-instance",
        status="RUNNING",
        machineType="e2-micro"
        )
    # データベースに保存する
    await test_instance.save()

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: DELETE
  • path: /sample_crud_api/instances/test-instance

Scenario Input (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Scenario Output:

async def assertion():
    assert Response.code==202, "Invalid response code %r" % Response.code

Scenario Output (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Assert End:

async def assertion():
    assert Transaction.status=="Complete", "Transaction is in an unexpected state %r" % Transaction.status

Cleanup:

async def cleanup():
    # テスト用のInstanceを削除する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    await test_instance.destroy()

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"

Testcase⑦
name: DeleteInstanceFailed
target: InstanceDelete(DELETE API)
illusion: DeleteInstanceFailed
Preparation:

async def preparation():
    #テスト用のVmInstanceクラスのインスタンスを作成する
    test_instance = await atom.VmInstance(
        instanceName="test-instance",
        status="RUNNING",
        machineType="e2-micro"
        )
    # データベースに保存する
    await test_instance.save()

Assert Begin:

async def hoge(*args, **kwargs):
    pass

Scenario Input:

  • method: DELETE
  • path: /sample_crud_api/instances/test-instance

Scenario Input (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Scenario Output:

async def assertion():
    assert Response.code==202, "Invalid response code %r" % Response.code

Scenario Output (script):

  • get access token:-
  • insert atom: -
  • create instance: -
  • check status & update status: -

Assert End:

async def assertion():
    assert Transaction.status=="Aborted", "Transaction is in an unexpected state %r" % Transaction.status

Note

このテストケースはDELETE APIが途中で失敗した場合のテストになります。
DELETE APIはロールバックを許可していないため、トランザクションのステータスはAbortedのままになるはずです。
そのため、最終的にトランザクションのステータスがAbortedであるかどうかを確認します。

Cleanup:

async def cleanup():
    # テスト用のInstanceを削除する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    test_instance = test_instances[0]
    await test_instance.destroy()

    # テスト用のInstanceの削除を確認する
    test_instances = await atom.VmInstance.retrieve(instanceName="test-instance")
    assert test_instances==[], "Test instance exists"

以上でTest Caseの実装が完了です。

Testsuiteの実装

今回のSample CRUD APIでは、以下のTestsuiteを用意します。

Testsuite 説明
SampleCrudApiSuite Sample CRUD APIの全テストケースを集めたもの

Note

Testsuiteとはいくつかのテストケースをまとめたものになります。
ユニットテストでは、テストケースを一つずつ実行することもできますが、テストスイートを実行することもできます。
そのため、事前に複数のテストケースをテストスイートにまとめておくことでユニットテストの実行が簡単になります。
今回のSample CRUD APIでは、すべてのテストケースを一つのテストスイートにまとめています。

Testsuiteの新規作成

それではTest Suiteを作成していきます。
[Unit Test as a Service]>[Testsuites]>[Create New Testsuite]からTestsuiteを作成することができます。

categorynameを入力してTestsuiteを作成します。 今回は以下を入力します。

name: SampleCrudApiSuite
category: SampleCrudApi

作成したTestsuiteを開きます。 Testcase to executeタブを開き、右のTestcasesタブのうち、Testsuiteに含めたいTestcasesをドラッグアンドドロップでTestcase to executeに移動させます。 今回はすべてのテストケースをSampleCrudApiSuiteに移動させます。

以上でTestsuiteの実装が完了です。

Unit Testの実行

最後に作成したテストを実行してみます。

Caution

Testの実行前にtest-instanceという名前のVmInstanceは削除しておきましょう。
削除しないとTestcaseで正しくCleanupされません。

まずUnit Testを開きましょう。

Test Scenarioに先ほど作成したTestcaseTestsuiteがあると思います。 SampleCrudApiSuiteを選択し、実行ボタンを押下して実行してみましょう。

テストが順に実行されて以下のような結果が表示されると思います。
100%となればすべてのテストにPassしたことになります。

ーー
以上、Sample CRUD APIのチュートリアルになります。