#author("2019-10-23T06:53:23+00:00","","")
#author("2019-10-25T03:01:35+00:00","","")
#mynavi(AWSメモ)
#setlinebreak(on);

* 概要 [#y70c87af]
#html(<div style="padding-left: 10px;">)
[[1つのAWS Lambda関数でSPAフレームワーク作成]] の別バージョン。
違いは、Lambda の言語が Python、クライアント側が Vue.js である事と、Cloudformationテンプレート化した事ぐらい。
小さなアプリケーションであれば、恐らくこれで十分だと思う。
(Lambdaは 100万リクエスト/月 は無料なので、リクエスト数が少なければS3ホスティングするより安くなる筈)


[補足]
- css や js をLambdaに内包しておいて、リクエストURLに応じてファイルを返却する。(S3からの配信はしない)
- /api/xxx へのリクエストは対象の api の処理を読み込んで実行する。
- いちおう画像も返却できるように API Gateway の BinaryMediaTypes を指定。

#html(</div>)

* 目次 [#z4fb8e28]
#contents
- 関連
-- [[AWSメモ]]
-- [[1つのAWS Lambda関数でSPAフレームワーク作成]]
-- [[1つのAWS LambdaでSPA(CloudFront編)]]
-- [[API Gateway&Lambda で画像データを返却する]]
-- [[Vue.js]]

* フォルダ構成 [#ce245159]
#html(<div style="margin-left: 10px; padding: 5px; border: 1px solid #333;">)

#html(<div style="display: inline-block">)
/aws_build.sh
/client-project
 /.env.development
 /.env.production
 /dist
 /node_modules
 /package-lock.json
 /package.json
 /public
 /src
  /App.vue
  /EventBus.js
  /assets
  /components
   /Footer.vue
   /Header.vue
   /PageSample1.vue
   /PageSample2.vue
   /PageTop.vue
  /main.js
/src
 /api
  sample1.py
  sample2.py
 /index.py
 /libs
 /myenv.py
 /client
  /css
   /xxxxx.css
  /js
   /app.xxxxxxxx.js
   /app.xxxxxxxx.js.map
  /index.html
/template.yml
#html(</div>)
#html(<div style="display: inline-block; padding-left: 20px; vertical-align: top;">)
・・・ ビルド用シェル ( [[CloudFormation実行用のシェル]] )
・・・ Vue.jsプロジェクト
・・・ 環境変数(Vue.jsローカル動作確認用)
・・・ 環境変数(本番用)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
・・・ Lambda用ソース
 
 
 
 
 
 
・・・ この配下にVue.js ビルド後のファイルをコピー ([[後述>#v3eb8199]])
 
 
 
 
 
 
・・・ Cloudformation テンプレート
#html(</div>)

#html(</div>)

* サーバ側 [#t9f8a647]
#html(<div style="padding-left: 10px;">)
#html(</div>)

** Cloudformationテンプレート [#b02b96c2]
#html(<div style="padding-left: 10px;">)

#mycode2(){{
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: "1LambdaでSPAのサンプル"

Globals:
  Api:
    Cors:
      AllowMethods: "'PUT,GET,POST,DELETE,OPTIONS'"
      AllowHeaders: "'Content-Type'"
      AllowOrigin: "'*'"

Resources:
  ImageSupportSpaApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: ImageSupportSpaApi
      StageName: Prod
      BinaryMediaTypes: 
        - "image/*"

  ImageSupportSpaFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: ImageSupportSpaFunction
      CodeUri: ./src
      Handler: index.handler
      Runtime: python3.7
      Timeout: 60
      MemorySize: 128
      Role: !GetAtt ImageSupportSpaFunctionRole.Arn
      Events:
        Root:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /
            Method: get
        Favicon:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /favicon.ico
            Method: get 
        StaticCss:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /css/{filename}
            Method: get
        StaticJs:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /js/{filename}
            Method: get
        StaticImage:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /img/{filename}
            Method: get
        ListActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}
            Method: get
        GetActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}/{id}
            Method: get
        PostActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}
            Method: post
        PutActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}/{id}
            Method: put
        DeleteActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}/{id}
            Method: delete

  ImageSupportSpaFunctionRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: ImageSupportSpaFunctionRole
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "lambda.amazonaws.com"
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "ImageSupportSpaFunctionPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:                                         # 必要に応じて権限を付与
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - s3:GetObject
                Resource: "*"

Outputs:
  ImageSupportSpaApiUrl:
    Value: !Sub "https://${ImageSupportSpaApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
    Export:
      Name: ImageSupportSpaApiUrl
}}
#html(</div>)

** src/myenv.py [#oe07cc72]
#html(<div style="padding-left: 10px;">)

#mycode2(){{
"""共通処理(PATHのセットなど)."""
import sys
import os


def add_path(path):
    """PATH追加."""
    sys.path.append(os.path.abspath(os.path.dirname(__file__)) + path)


def is_local(event):
    """ローカルでの動作確認用かどうかの判断."""
    return event["requestContext"]["identity"]["sourceIp"] == "127.0.0.1"
}}
#html(</div>)

** src/index.py [#d930bf49]
#html(<div style="padding-left: 10px;">)

#mycode2(){{
"""メイン処理."""
import base64
import importlib
import json
import os
import re
import myenv

# PATH追加
myenv.add_path("/libs")


def handler(event, context):
    """メインハンドラ."""
    print(json.dumps(event, indent=4))
    status_code = 200
    response_body = ""
    headers = {"Content-Type": "text/html"}
    if myenv.is_local(event):
        headers["Access-Control-Allow-Origin"] = "*"
        headers["Access-Control-Allow-Methods"] = "PUT,POST,DELETE,OPTIONS"

    is_base64_encoded = False
    if event["path"] == "/":
        with open("client/index.html") as f:
            response_body = f.read()
    elif event["path"] == "/favicon.ico":
        file_path = "client/favicon.ico"
        if os.path.exists(file_path):
            headers["Content-Type"] = "image/x-icon"
            with open(file_path, "rb") as f:
                response_body = base64.b64encode(f.read()).decode("utf-8")
                is_base64_encoded = True
    elif re.match(r"^/(css|js)/.+", event["path"]):
        file_path = "client/" + event["path"][1:]
        if re.match(r"^/css/.+", event["path"]):
            headers["Content-Type"] = "text/css"
        else:
            headers["Content-Type"] = "application/javascript"
        if os.path.exists(file_path):
            with open(file_path) as f:
                response_body = f.read()
    elif re.match(r"^/img/.+", event["path"]):
        file_path = "client/" + event["path"][1:]
        headers["Content-Type"] = "image/png"
        if os.path.exists(file_path):
            with open(file_path, "rb") as f:
                response_body = base64.b64encode(f.read()).decode("utf-8")
                is_base64_encoded = True
    elif re.match(r"^/api/.+", event["path"]):
        headers["Content-Type"] = "application/json"
        resource = event["pathParameters"]["resource"]
        # print("api call " + event["path"])
        # print("./api/" + resource + ".py")
        if os.path.exists("./api/" + resource + ".py"):
            m = importlib.import_module("api." + resource)
            result = m.handler(event, context)
            if isinstance(result, dict):
                if "statusCode" in result:
                    status_code = result["statusCode"]
                if "headers" in result:
                    headers = {**headers, **result["headers"]}
                if "body" in result:
                    response_body = result["body"]
                if "isBase64Encoded" in result:
                    is_base64_encoded = result["isBase64Encoded"]
            else:
                status_code = 500
                response_body = {"message": "Invalid API Response Format!"}
        if response_body == "":
            status_code = 501
            response_body = {"message": "API Not Found!"}
    else:
        status_code = 501
        response_body = {"message": "Not Implemented!"}

    if isinstance(response_body, dict):
        response_body = json.dumps(response_body)

    return {
        "statusCode": status_code,
        "headers": headers,
        "isBase64Encoded": is_base64_encoded,
        "body": response_body
    }
}}

#html(</div>)

** src/api/sample1.py [#za201d5e]
#html(<div style="padding-left: 10px;">)

#mycode2(){{
"""サンプル1."""
import json


def handler(event, context):
    """メインハンドラ."""
    request_body = {}
    if event["body"] and isinstance(event["body"], str):
        request_body = json.loads(event["body"])
    elif event["body"] and isinstance(event["body"], dict):
        request_body = event["body"]

    return {
        "body": json.dumps(
            {
                "message": "This is sample1 api message!",
                "httpMethod": event["httpMethod"],
                "path": event["path"],
                "resource": event["resource"],
                "pathParameters": event["pathParameters"],
                "queryStringParameters": event["queryStringParameters"],
                "request_body": request_body,
            }
        )
    }
}}

#html(</div>)

** src/api/sample2.py [#h3f8846f]
#html(<div style="padding-left: 10px;">)

#mycode2(){{
"""サンプル2."""
import json


def handler(event, context):
    """メインハンドラ."""
    response_body = {
        "items": [{"name": "item1", "price": 1000},
                  {"name": "item2", "price": 2000},
                  {"name": "item3", "price": 3000},
                  {"name": "item4", "price": 4000}]
    }

    return {
        "body": json.dumps(response_body)
    }
}}
#html(</div>)


** src/client/* [#sd6ecad8]
#html(<div style="padding-left: 10px;">)
vue.js のビルド済みファイルをこの配下にコピーする。(後述)
#html(</div>)

* クライアント周りの作成 [#p47adea4]
#html(<div style="padding-left: 10px;">)

** プロジェクトの作成 [#r959dd7a]
#myterm2(){{
vue create client-project
}}

** 設定ファイル [#o9bf2529]

** client-project/.env.development [#a024c309]
#mycode2(){{
NODE_ENV="development"
VUE_APP_API_ROOT="http://127.0.0.1:3000/api/"
}}
※Vue.js をローカル起動している場合は、SAMローカルで動作中のLambdaにアクセスさせる。

** client-project/.env.production [#kb182de2]
#mycode2(){{
NODE_ENV='production'
VUE_APP_API_ROOT='./api/'
}}
※ API Gateway & Lambda のデプロイ後は、ルート直下の api でアクセス。
※ ただし、API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。( &color(red){Vue Router を使用する場合は historyモード はOFFにする事}; )

** client-project/vue.config.js [#o086254c]
#mycode2(){{
module.exports = {
      publicPath: './'
}
}}
※ API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。

** client-project/src/main.js [#h7b83b75]
#mycode2(){{
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')
}}

** client-project/src/main.js [#qa90542d]
#mycode2(){{
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')
}}

** client-project/src/App.vue [#i111bad6]
#mycode2(){{
<template>
  <div id="app">
    <Header />
    <component v-bind:is="currentView"></component>
    <Footer />
  </div>
</template>

<script>
import EventBus from './EventBus.js'
import Header from './components/Header.vue'
import PageTop from './components/PageTop.vue'
import PageSample1 from './components/PageSample1.vue'
import PageSample2 from './components/PageSample2.vue'
import Footer from './components/Footer.vue'

export default {
  name: 'app',
  components: {
    Header,
    Footer,
    PageTop,
    PageSample1,
    PageSample2,
  },
  data: function(){
    return {
      currentView: "PageTop"
    }
  },
  methods: {
    switchView(viewName) {
      this.currentView = viewName;
    }   
  },  
  created: function(){
    EventBus.$on('switchView', this.switchView)
  }
}
</script>

<style>
html, body {
  margin: 0;
}
h1 {
  margin: 0;
  font-size: 120%;
}
#header {
  padding: 2px 0;
}
#main {
  padding: 5px 10px;
}
#footer {
  position: fixed;
  padding: 5px;
  bottom: 0;
  width: 100%;
  background: #eee;
}
</style>
}}

** client-project/src/EventBus.js [#e9c2b819]
#mycode2(){{
import Vue from 'vue'
const EventBus = new Vue()
export default EventBus
}}

** client-project/src/Header.vue [#vd8484dd]
#mycode2(){{
<template>
  <div id="header">
    <h1>Header Content</h1>
    <ul id="header-menu">
      <li v-on:click="menuClicked('PageTop')">TOP</li>
      <li v-on:click="menuClicked('PageSample1')">サンプル1</li>
      <li v-on:click="menuClicked('PageSample2')">サンプル2</li>
    </ul>
  </div>
</template>

<script>
import EventBus from '../EventBus.js'
export default {
  name: 'Header',
  methods: {
    "menuClicked" : function(page){
      EventBus.$emit('switchView', page)
    },  
  }
}
</script>

<style scoped>
#header h1 {
  padding: 0 5px;
}
#header-menu {
  list-style-type: none;
  margin: 0;
  padding: 0;
  background: #333;
  color: #fff;
}
#header-menu.ul,#header-menu li {
  display: inline-block;
}
#header-menu li {
  padding: 10px;
  border-right: 1px dotted #fff;
  cursor: pointer;
}
#header-menu li:hover {
  opacity: 0.8;
  text-decoration: underline;
}
</style>
}}

** client-project/src/Footer.vue [#o791a366]
#mycode2(){{
<template>
  <div id="footer">
    <div>Footer Content</div>
  </div>
</template>

<script>
export default {
  name: 'Footer',
}
</script>
}}

** client-project/src/PageTop.vue [#b93e3001]
#mycode2(){{
<template>
  <div id="main">
    <div>
      Top Content<br />
      <button v-on:click="listAction()">get</button>
      <button v-on:click="postAction()">post</button>
      <button v-on:click="putAction(1)">put</button>
      <button v-on:click="deleteAction(1)">delete</button>
      <hr/>
      <img src="img/sample.png" width="500"/>
      <hr/>
      <pre>&#123;&#123; result &#125;&#125;</pre>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'PageTop',
  data: function(){
    return {
      msg: process.env.VUE_APP_API_ROOT,
      result: ""
    }
  },
  created: function(){
  },
  methods: {
    listAction: function(){
      this.result = "loading.."
      axios
        .get(process.env.VUE_APP_API_ROOT + "sample1" + "?param1=123")
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
    postAction: function(){
      this.result = "loading.."
      axios
        .post(process.env.VUE_APP_API_ROOT + "sample1", {"var1": "abc"})
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
    putAction: function(){
      this.result = "loading.."
      axios
        .put(process.env.VUE_APP_API_ROOT + "sample1/1", {"var2": "def"})
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
    deleteAction: function(){
      this.result = "loading.."
      axios
        .delete(process.env.VUE_APP_API_ROOT + "sample1/1")
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
  }
}
</script>
}}

** client-project/src/PageSample1.vue [#d2b54f02]
#mycode2(){{
<template>
  <div id="main">
    <div>
      サンプル1<br />
      <hr/>
      <button v-on:click="callApi()">API呼び出し</button>
      <div style="margin: 20px;" v-if="status != ''">&#123;&#123; status &#125;&#125;</div>
      <div style="margin:20px;">&#123;&#123; message &#125;&#125;</div>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'Sample1',
  data: function(){
    return {
      status: "",
      message: "",
    }
  },
  methods: {
    callApi: function(){
      this.status = "loading.."
      this.message = ""
      let url = process.env.VUE_APP_API_ROOT + "sample1"
      let self = this
      axios
        .get(url)
        .then(response => {
          console.log(response)
          self.status = ""
          self.message = response.data["message"]
        }).catch(error => {
          console.log(error)
          self.status = "api call error!"
        })
    },
  }
}
</script>
}}

** client-project/src/PageSample2.vue [#sa5a98f2]
#mycode2(){{
<template>
  <div id="main">
    <div>
      サンプル2<br />
      <hr/>
      <button v-on:click="callApi()">API呼び出し</button>
      <div style="margin: 20px;" v-if="status != ''">&#123;&#123; status &#125;&#125;</div>
      <table id="sample2_table" v-if="items.length > 0">
      <thead>
      <tr>
          <th>Name</th><th>Price</th>
      </tr>
      </thead>
      <tbody>
      <tr v-for="item in items">
        <td>&#123;&#123; item.name &#125;&#125;</td><td>&#123;&#123; item.price &#125;&#125;</td>
      </tr>
      </tbody>
      </table>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'Sample2',
  data: function(){
    return {
      status: "",
      items: [],
    }
  },
  methods: {
    callApi: function(){
      this.status = "loading.."
      this.items = []
      let url = process.env.VUE_APP_API_ROOT + "sample2"
      let self = this
      axios
        .get(url)
        .then(response => {
          console.log(response)
          self.status = ""
          self.items = response.data["items"]
        }).catch(function (error) {
          self.status = "api call error!"
        })
    },
  }
}
</script>

<style>
#sample2_table {
  margin-top: 20px;
  border-collapse: collapse;
}
#sample2_table th {
  background: #ccc;
  color: #333;
}
#sample2_table th,
#sample2_table td{
  padding: 5px;
  border: 1px solid #333;
}
#sample2_table tbody tr:nth-child(even) td{
  background: #efe;
}
</style>
}}

#html(</div>)

* 動作確認 [#od9c1e95]
#html(<div style="padding-left: 10px;">)

** SAMローカル起動 [#ua42f476]
#myterm2(){{
sam local start-api --template template.yml
}}

** Vue.js をローカル起動 [#p9deaab9]
#myterm2(){{
cd client-project
npm run serve
}}

http://localhost:8080/ からアクセス確認

#html(</div>)

* デプロイ [#v3eb8199]
#html(<div style="padding-left: 10px;">)

Vue.js ビルド
#myterm2(){{
# ビルド
cd client-project
npm run build

# ビルド済みファイルを Lambda 用の src 配下にコピー
rm -rf ../src/client && cp -R dist ../src/client
}}

Lambdaデプロイ
#myterm2(){{
./awsbuild.sh deploy
}}
※ awsbuild.sh の内容は [[CloudFormation実行用のシェル]] を参照。

#html(</div>)

#html(<div style="height:400px;"></div>)



トップ   一覧 単語検索 最終更新   ヘルプ   最終更新のRSS