1つのAWS Lambda関数でSPAフレームワーク作成 の別バージョン。
違いは、Lambda の言語が Python、クライアント側が Vue.js である事と、Cloudformationテンプレート化した事ぐらい。
小さなアプリケーションであれば、恐らくこれで十分だと思う。
[補足]
/aws_build.sh
/client-project
/.env.development
/.env.production
/dist
/node_modules
/package-lock.json
/package.json
/public
/src
/App.vue
/EventHub.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
・・・ ビルド用シェル
・・・ Vue.jsプロジェクト
・・・ 環境変数(Vue.jsローカル動作確認用)
・・・ 環境変数(本番用)
・・・ Lambda用ソース
・・・ この配下にVue.js ビルド後のファイルをコピー (後述)
・・・ Cloudformation テンプレート
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
"""共通処理(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"
"""メイン処理.""" 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 }
"""サンプル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, } ) }
"""サンプル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) }
vue.js のビルド済みファイルをこの配下にコピーする。(後述)
vue create client-project
NODE_ENV="development" VUE_APP_API_ROOT="http://127.0.0.1:3000/api/"
※Vue.js をローカル起動している場合は、SAMローカルで動作中のLambdaにアクセスさせる。
NODE_ENV='production' VUE_APP_API_ROOT='./api/'
※ API Gateway & Lambda のデプロイ後は、ルート直下の api でアクセス。
※ ただし、API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。( Vue Router を使用する場合は historyモード はOFFにする事 )
module.exports = { publicPath: './' }
※ API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。
import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false new Vue({ render: h => h(App), }).$mount('#app')
import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false new Vue({ render: h => h(App), }).$mount('#app')
<template> <div id="app"> <Header /> <component v-bind:is="currentView"></component> <Footer /> </div> </template> <script> import EventHub from './EventHub.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(){ EventHub.$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>
import Vue from 'vue' const EventHub = new Vue() export default EventHub
<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 EventHub from '../EventHub.js' export default { name: 'Header', methods: { "menuClicked" : function(page){ EventHub.$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>
<template> <div id="footer"> <div>Footer Content</div> </div> </template> <script> export default { name: 'Footer', } </script>
<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>{{ result }}</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>
<template> <div id="main"> <div> サンプル1<br /> <hr/> <button v-on:click="callApi()">API呼び出し</button> <div style="margin: 20px;" v-if="status != ''">{{ status }}</div> <div style="margin:20px;">{{ message }}</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>
<template> <div id="main"> <div> サンプル2<br /> <hr/> <button v-on:click="callApi()">API呼び出し</button> <div style="margin: 20px;" v-if="status != ''">{{ status }}</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>{{ item.name }}</td><td>{{ item.price }}</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>
sam local start-api --template template.yml
cd client-project npm run serve
http://localhost:8080/ からアクセス確認
Vue.js ビルド
# ビルド cd client-project npm run build # ビルド済みファイルを Lambda 用の src 配下にコピー rm -rf ../src/client && cp -R dist ../src/client
Lambdaデプロイ
./awsbuild.sh deploy
※ awsbuild.sh の内容は CloudFormation実行用のシェル を参照。