amplify+AppSyncで拠点から拠点への距離順でソートしたい

これをやりたい。

https://medium.com/@tobinc/aws-amplify-graphql-with-geo-point-and-custom-resources-free-elasticsearch-provider-d1742fbc4ceb

なんか難しそうだから体で覚えよう。

## Setup Amplify Backend

まずは初期設定。
project作ってadd apiとかしてって感じですね。

```
$ mkdir park_threes
$ cd park_threes
$ amplify init

Scanning for plugins...
Plugin scan successful
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project parkthrees
? Enter a name for the environment dev
? Choose your default editor: Vim (via Terminal, Mac OS only)
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using vue
? Source Directory Path:  src
? Distribution Directory Path: dist
? Build Command:  npm run-script build
? Start Command: npm run-script serve
...

$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: parkthrees
? Choose the default authorization type for the API API key
? Enter a description for the API key: aaaa
? After how many days from now the API key should expire (1-365): 7
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? Yes
? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? Yes

```

お試しだし、認証はsimpleにapi keyにしといた。

schema.graphqlはひとまずこれ

```graphql:amplify/backend/api/parkthrees/schema.graphql
type Tree @model {
 id: ID!
 species: String!
 location: Location!
 height: Float
}
type Location {
 lat: Float!
 lon: Float!
}
```

Location って型が設定されてますね。
そして`amplify push`

```
$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name | Operation | Provider plugin   |
| -------- | ------------- | --------- | ----------------- |
| Api      | parkthrees    | Create    | awscloudformation |
? Are you sure you want to continue? Yes

The following types do not have '@auth' enabled. Consider using @auth with @model
         - Tree
Learn more about @auth here: https://docs.amplify.aws/cli/graphql-transformer/directives#auth


GraphQL schema compiled successfully.

Edit your schema at /mnt/c/Users/masra/park_threes/amplify/backend/api/parkthrees/schema.graphql or place .graphql files in a directory at /mnt/c/Users/masra/park_threes/amplify/backend/api/parkthrees/schema
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
⠙ Updating resources in the cloud. This may take a few minutes...
```

そして`amplify codegen`。これって具体的に何が生成されるんだっけ？

```
$ amplify codegen
✔ Downloaded the schema
✔ Generated GraphQL operations successfully and saved at src/graphql
```

push されてるschemaをDLしてローカルのsrc/graphql関連のスクリプトを生成するわけか。
queryとかmutationとかの定義すね。

## amplify mock

ここでmockでも動かしてみよう。

```
$ amplify mock
```

ってやるとmockが起動するので
こんな感じでmutationを実行しておく

```graphql
mutation MyMutation {
  createTree(input: 
    {
      species: "tokyo tower", 
      location: {
        lat: 35.658584, 
        lon: 139.7454316}, 
      height: 1.5, 
      id: "100"}) {
    id
  }
}
```

![location.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/30db09f9-6db9-8e9e-442e-fa4f068db71d.png)

登録したレコードはこんなクエリーで取り出せる

```graphql
query MyQuery {
  getTree(id: "100") {
    id
    species
    updatedAt
    location {
      lat
      lon
    }
  }
}
```

## Elastic Search

お手本のサイトだとBonsaiっていうサービスを使ってる。 `free tier` って書いてあるし、試しに使ってみますか。

https://bonsai.io/　にアクセスしsign up。いくつかの情報を登録するだけでさっくりと登録できました。

で、登録と同時にclusterは作られていたようなので、credentialsをチェック。

こういう感じのやつをメモっておきます。

```
https://xxxxxxxx:yyyyyyyy@zzzzzzzz-3471393681.ap-southeast-2.bonsaisearch.net:443

Key        xxxxxxxx
Secret     yyyyyyyy
```

## Create the Resolvers

`parameters.json` とか `CustomResources.json` ってなんだろう？って思ったけど、`amplify/backend/api/parkthrees/stacks/` にあるやつかな。
CloudFormationのテンプレートっぽい。
ここにElasticSearchのリソースを書けばいいのか。
見本の通りに追記してみて、一回、`$ amplify push`してみる

```json:parameters.json
{
    "AppSyncApiName": "graphql",
    "BonsaiEndpoint": "https://park-trees-medium-7535634961.eu-west-1.bonsaisearch.net"
}
```

```json:CustomResources.json
"Parameters": {
  "AppSyncApiId": {
    "Type": "String",
  },
  "BonsaiEndpoint": {
    "Type": "String",
  }
},
"Resources": {
  "BonsaiDatasource":{
    "Type" : "AWS::AppSync::DataSource",
    "Properties" : {
      "ApiId" :{
        "Ref": "AppSyncApiId"
      },
      "HttpConfig" : {
        "Endpoint" : { 
          "Ref":"BonsaiEndpoint"
        }
      },
      "Name" : "BonsaiElasticsearch",
      "Type" : "HTTP"
    }
  }
}
```

すると、DataSourceが作られました。(一番上のBonsaiDataSourceってのは手動で作ってみたやつなので気にしないでください）

![location3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/8975183f-78d3-5233-ddd7-7e8479e17742.png)

## Add Custom Schema + Resolvers for a Search Query

次に `amplify/backend/api/parkthrees/schema.graphql` を編集してみる。

```graphql:schema.graphql
input LocationInput {
  lat: Float!
  lon: Float!
}
input NearbyTreesInput {
  location: LocationInput!
  km: Int!
  limit: Int!
}
type Query {
  nearbyTrees(input: NearbyTreesInput!): NearbyTreesConnection!
}
type NearbyTreesConnection {
 items: [NearbyTreeConnectionItem!]!
}
type NearbyTreeConnectionItem {
 tree: Tree!
 distance: Float!
}
```

すでに存在してるし、追記です。

その次は `amplify/backend/api/parkthrees/resolvers/` に `Query.nearbyTrees.re(q|s).vtl` とかを作成してみればいいのかな。
lsしてみるとこんな感じでファイルがあってnearbyTreesはまだない。

```bash
$ ls amplify/backend/api/parkthrees/build/resolvers/
Mutation.createTree.req.vtl  Mutation.deleteTree.res.vtl  Query.getTree.req.vtl    Query.listTrees.res.vtl
Mutation.createTree.res.vtl  Mutation.updateTree.req.vtl  Query.getTree.res.vtl
Mutation.deleteTree.req.vtl  Mutation.updateTree.res.vtl  Query.listTrees.req.vtl
```

reqがrequestでresがresponseか。keyとか以外は見本と一緒なのをかいておく

```vtl:Query.nearbyTrees.req.vtl
#set( $indexPath = "/tree/_doc/_search" )
#set( $input = $ctx.args.input)
#set( $distance = $util.defaultIfNull($input.km, 10) )
#set( $creds = "zx5dej31tt:5inkmy78lz")
{
  "version": "2018-05-29",
  "method": "POST",
  "resourcePath": "$indexPath",
  "params": {
    "headers" : {
      "Content-Type" : "application/json",
      "Authorization":"Basic $util.base64Encode($creds)"
    },
    "body": {
      "size": #if( $input.limit ) $input.limit #else 100 #end,
      "sort": [{
        "_geo_distance": {
          "location": $util.toJson($input.location),
          "order": "asc",
          "unit": "km", 
          "distance_type": "plane" 
        }
      }],
      "query": {
        "bool" : {
          "must" : {
              "match_all" : {}
          },
          "filter" : {
            "geo_distance" : {
                "distance" : "${distance}km",
                "location" : $util.toJson($input.location)
            }
          }
        }
      }
    }
  }
}
```

```vtl:Query.nearbyTrees.res.vtl
#set( $es_items = [] )
#set( $body = $util.parseJson($context.result.body))
#foreach( $entry in $body.hits.hits )
  #set ( $item = {
    "tree": $entry.get("_source"),
    "distance": $entry.get("sort").get(0)
  })
  $util.qr($es_items.add($item))
#end

#set( $es_response = {
  "items": $es_items
} )

$util.toJson($es_response)
```

何やってるのか理解は追いついてないが先に進もう。

## Create Bonsai Index

ElasticSearchのConsoleで下記をPUTするといいらしい。
`tree` っていう名前のindexができるってさ。

```json
{
  "mappings": {
    "properties": {
      "createdAt": {
        "type": "date"
      },
      "height": {
        "type": "float"
      },
      "id": {
        "type": "text"
      },
      "species": {
        "type": "text"
      },
      "updatedAt": {
        "type": "date"
      },
      "location": {
        "type": "geo_point"
      }
    }
  }
}
```

なるほど、`geo_point`ってtypeがあるわけか。

## Create the Lambda Function

最後の山場かな。DynamoDBのデータをElasticSearchに伝播するLambda Functionが必要っぽい。

とりあえず add function

```bash
$ amplify function add

? Select which capability you want to add: Lambda function (serverless function)
? Provide a friendly name for your resource to be used as a label for this category in the project: DDBToBonsai
? Provide the AWS Lambda function name: DDBToBonsai
? Choose the runtime that you want to use: Python
? Do you want to access other resources in this project from your Lambda function? No
? Do you want to invoke this function on a recurring schedule? No
? Do you want to configure Lambda layers for this function? No
? Do you want to edit the local lambda function now? No
Successfully added resource DDBToBonsai locally.

$ amplify push
```

ちなみにpython3.8以上が必要だっていわれたけど、ubuntuだったので `apt install python3.8` をして解決です。

push後、 `amplify/backend/awscloudformation/nested-cloudformation-stack.yml` をみるとFunctionの情報も追加されております。
`amplify/backend/function/DDBToBonsai/DDBToBonsai-cloudformation-template.json` も作られてる。

**Safely store the Connection String** もやらないといけないのか。ElasticSearchのEndpointをSecret Managerに登録しておきます。

![location4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/6e2e319e-c02d-5bda-74aa-e518f97acd2f.png)

そしてこれをLambdaにアタッチされてるRoleから読み込めるようにしておきます。
~わからんけどとりあえずコンソールから手動で設定しておいた。~
↑CustomResources.json内で定義してました。

`amplify/backend/api/parkthrees/parameters.json` に下記を追記

```json:parameters.json
    "DDBToBonsaiFunctionRole":{
        "Fn::GetAtt": [
            "functionDDBToBonsai",
            "Outputs.LambdaExecutionRole"
        ]
    }
```

`CustomResources.json` にもいろいろ追記する。`Parameters` と `Resources`を追加ですね。

```json:CusomResources.json
"Parameters": {
  ...,
  "DDBToBonsaiFunctionRole": {
   "Type": "String"
  }
},
"Resources": { 
  ..., 
  "BonsaiFunctionPolicy": {
   "Type": "AWS::IAM::Policy",
   "Properties": {
    "PolicyName": "main",
    "PolicyDocument": {
     "Version": "2012-10-17",
     "Statement": [
      {
       "Effect": "Allow",
       "Action": [
        "dynamodb:DescribeStream",
        "dynamodb:GetRecords",
        "dynamodb:GetShardIterator",
        "dynamodb:ListStreams"
       ],
       "Resource": "*"
      },
      {
       "Sid": "10",
       "Effect": "Allow",
       "Action": "secretsmanager:GetSecretValue",
       "Resource": {
        "Fn::Sub": "arn:aws:secretsmanager:eu-west-2:${AWS::AccountId}:secret:medium-bonsai"
       }
      }
     ]
    },
    "Roles": [
     {
      "Ref": "DDBToBonsaiFunctionRole"
     }
    ]
   }
  }
}
```

そして　Lambdaのコードだけど・・これだけ？

```python
import boto3

DEBUG = True 
secretsClient = boto3.session.Session().client(service_name='secretsmanager',region_name='eu-west-2')
get_secret_value_response = secretsClient.get_secret_value(
            SecretId='arn:aws:secretsmanager:eu-west-2:818304461904:secret:medium-bonsai-BHiuhN'
        )
ES_ENDPOINT = get_secret_value_response['SecretString']
DOC_TYPE = '_doc'
```

わからん。とりあえず、Hello Worldのreturn文の前くらいに入れておくか。
ファイルは`amplify/backend/function/DDBToBonsai/src/index.py`かと思います。

そしてまた`parameters.json`と`CustomResources.json`を編集しろとある。

```json:parameters.json
    "DDBToBonsaiFunction":{
        "Fn::GetAtt": [
            "functionDDBToBonsai",
            "Outputs.Name"
        ]
    }
```

```json:CustomResource.json
"Parameters:{ 
  "AppSyncApiId":{
    "Type":"String"
  },
  ...,
  "DDBToBonsaiFunction":{
    "Type":"String"
  },
},
"Resources":{
  ...,
  "ESMTreeDDBtoBonsai":{
    "Type" : "AWS::Lambda::EventSourceMapping",
    "Properties" : {
      "Enabled" : true,
      "EventSourceArn" : {
        "Fn::ImportValue": {
          "Fn::Join":[
            ":",
            [
              {
                "Ref":"AppSyncApiId"
              },
              "GetAtt",
              "TreeTable",
              "StreamArn"
            ]
          ]
        }
      },
      "FunctionName" : {
        "Ref":"DDBToBonsaiFunction"
      },
      "MaximumRetryAttempts" : 0,
      "StartingPosition" : "LATEST"
    }
  }
}
```

さて・・functionはこれじゃダメな気がするぞ。
DynamoDBStreamのあたりを何も書いてない。
しかしひとまずpushだ。

```bash
$ amplify push

✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name | Operation | Provider plugin   |
| -------- | ------------- | --------- | ----------------- |
| Api      | parkthrees    | Update    | awscloudformation |
| Function | DDBToBonsai   | Update    | awscloudformation |
? Are you sure you want to continue? Yes
```

deployは正常に終わった。

mutationとかqueryを実行してみよう。

```graphql:mutation
mutation make{
  createTree(input:{
    species:"Willow",
    height:15.5,
    location:{
      lat:50,
      lon:-1
    }
  }){
    id
    species
    height
    location{
      lat
      lon
    }
  }
}
```

```graphql:query
query{
  listTrees{
    items{
      id
    }
  }
}
```

とりあえず、普通には実行できるのを確認。

![location5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/d65e0fbc-0b85-c713-27f0-9b5560f19b7b.png)

そしてbonsaiの方は・・

![location6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/c41e1813-c135-40ff-4628-5ff6522c42f8.png)

やはりデータ入ってないかー。
lambdaのコードでしょうね。ログ見ると、dynamodbstreamは飛んできてた。

```
{'Records': [{'eventID': '9397a35657f2c07e8ddacee1fb5e3b25', 'eventName': 'INSERT', 'eventVersion': '1.1', 'eventSource': 'aws:dynamodb', 'awsRegion': 'ap-northeast-1', 'dynamodb': {'ApproximateCreationDateTime': 1598387040.0, 'Keys': {'id': {'S': '64ef921a-26e2-49a5-8e5a-5d5ce39977df'}}, 'NewImage': {'createdAt': {'S': '2020-08-25T20:24:00.458Z'}, 'species': {'S': 'Willow'}, '__typename': {'S': 'Tree'}, 'location': {'M': {'lon': {'N': '-1'}, 'lat': {'N': '50'}}}, 'id': {'S': '64ef921a-26e2-49a5-8e5a-5d5ce39977df'}, 'height': {'N': '15.5'}, 'updatedAt': {'S': '2020-08-25T20:24:00.458Z'}}, 'SequenceNumber': '30000700000000027252856258', 'SizeBytes': 202, 'StreamViewType': 'NEW_AND_OLD_IMAGES'}, 'eventSourceARN': 'arn:aws:dynamodb:ap-northeast-1:273074835969:table/Tree-wzt4cjcy5zgmhofbulk5i2pljm-dev/stream/2020-08-19T10:14:02.825'}]}
```

## DynamoDB to ES

コードは一部しか書かないけどあとは察しろって事だったのかな。

https://docs.aws.amazon.com/ja_jp/elasticsearch-service/latest/developerguide/es-aws-integrations.html#es-aws-integrations-dynamodb-es

このあたりのコードがあればいけるでしょう、たぶん。
indexが`tree`でtypeが`_doc`になるのかな。authは省いていいと思う。
requestsモジュールが必要なので `amplify/backend/function/DDBToBonsai/Pipfile` に書いておく。
amplifyの@searchble使うとこのへんもautoで作られちゃうみたいなので、今度試してみよう。
https://docs.amplify.aws/cli/graphql-transformer/resolvers

しかし、このへんを自前で実装してみて為にはなったかもしれん。

deployして再pushします


## そろそろ終わるよ

そんなわけで、mutationからのbonsaiでGET

・・とおもったけど、bonsaiにデータ入ってない。あれ？

もしやjsonがDynamoDB特有のやつになっちゃってる？
こういう型付きのやつ。

```
          "species": {
            "S": "Willow"
          },
```

ためしにこういう感じのテストデータぶん投げたら入った。

```
{
  "Records": [
    {
      "eventID": "9397a35657f2c07e8ddacee1fb5e3b25",
      "eventName": "INSERT",
      "eventVersion": "1.1",
      "eventSource": "aws:dynamodb",
      "awsRegion": "ap-northeast-1",
      "dynamodb": {
        "ApproximateCreationDateTime": 1598387040,
        "Keys": {
          "id": {
            "S": "64ef921a-26e2-49a5-8e5a-5d5ce39977df"
          }
        },
        "NewImage": {
          "createdAt": "2020-08-25T20:24:00.458Z",
          "species": "Willow",
          "__typename":"Tree",
          "location": {
              "lon": -1,
              "lat": 50
          },
          "id": "64ef921a-26e2-49a5-8e5a-5d5ce39977df",
          "height": 15.5,
          "updatedAt": "2020-08-25T20:24:00.458Z"
        },
        "SequenceNumber": "30000700000000027252856258",
        "SizeBytes": 202,
        "StreamViewType": "NEW_AND_OLD_IMAGES"
      },
      "eventSourceARN": "arn:aws:dynamodb:ap-northeast-1:273074835969:table/Tree-wzt4cjcy5zgmhofbulk5i2pljm-dev/stream/2020-08-19T10:14:02.825"
    }
  ]
}
```

NewImageの中だけ変えてある。

ここ、Deserializeすればいいのかなぁ。
お。まさしくこれだ。
https://qiita.com/segawa/items/5c6d418019e0ca931dff

ESに投げるとこだけ抜粋するとこんな感じになりました。めんどいのでやっつけコードです。

```python
            document = record['dynamodb']['NewImage']
            image = {'location':{}}
            for key in document:
                if key == "location":
                    image[key]['lat'] = float(deserializer.deserialize(document[key]['M']['lat']))
                    image[key]['lon'] = float(deserializer.deserialize(document[key]['M']['lon']))
                else:
                    image[key] = deserializer.deserialize(document[key])
            r = requests.put(url + id, data=simplejson.dumps(image), headers=headers)
```

Decimal to Jsonも問題もあったりして場当たり的なやっつけ感、ここに極まってます。

・・さて、あとはちゃんとResolver経由でESからデータを取れるかだ。

```graphql
query{
  nearbyTrees(input:{
    location:{
      lat:50.1,
      lon:-0.5
    },
    km:100,
    limit:10
  }){
    items{
      tree{
        species
        height
        location{
          lat
          lon
        }
      }
      distance
    }
  }
}
```

お、なんか返ってきた！

![location7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/2540513b-316e-5ed2-c94a-7bbb4962f7d2.png)

じゃあせっかくなので、こういうデータを入れつつ・・

```graphql
mutation make{
  createTree(input:{
    species:"Tokyo",
    height:1,
    location:{
      lat:35.68944,
      lon:139.69167
    }
  }){
    id
  }
},
mutation make2{
  createTree(input:{
    species:"Saitama",
    height:1,
    location:{
      lat:35.85694,
      lon:139.64889
    }
  }){
    id
  }
},
mutation make3{
  createTree(input:{
    species:"Ibaraki",
    height:1,
    location:{
      lat:36.34139,
      lon:140.44667
    }
  }){
    id
  }
},
mutation make4{
  createTree(input:{
    species:"Fukushima",
    height:1,
    location:{
      lat:37.75,
      lon:140.46778
    }
  }){
    id
  }
}
```

群馬あたりからクエリーを。

```graphql
query{
  nearbyTrees(input:{
    location:{
      lat:36.39111,
      lon:139.06083
    },
    km:300,
    limit:10
  }){
    items{
      tree{
        species
        height
        location{
          lat
          lon
        }
      }
      distance
    }
  }
}
```

群馬に近いのは埼玉、茨城の順。
なんか合ってる感じになりました！

![location8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/0dfa564c-786d-727c-190f-9e328f6a0d36.png)

むずいけども、AppSyncの裏側でElasticSearchを使えたらいろいろと幅が広がりそうだ。
今度、@searchbleもためしてみます。