ゴール
当記事にて、Shopifyアプリケーションを開発する手順を整理します。
アプリケーションはSPA(React.js) + Serverlessの構成でAWS上に構築します。
フロントエンドはReact.jsを使用して実装します。
ビルドされたReactのモジュールをAWS S3上に配置し、CloudFrontを経由してブラウザよりHTTPSでアクセスします。
バックエンドは、@vendia/serverless-expressを使用して実装します。
LambdaアプリケーションよりShopify REST API / GraphQL APIを使用して、必要な情報を取り出し画面に表示します。
通常、Shopifyアプリケーションを作成する時は、Shopify App CLIを使用することが多いと思います。
対話形式でコマンドを入力するだけでアプリケーションの雛形を作成できる非序に便利なツールですが、HTTPセッションの管理にメモリーまたはDataBaseが必要となり、Lambdaアプリケーションとして実装するにはいろいろと工夫が必要となりそうです。
従って、今回はShopify App CLIを使用せずにアプリケーションの雛形を作る事をゴールにしようと思います。
永続化が必要な情報の保存先はRDSを使用する事と思います。
今回はサンプルのためS3上のファイルをデータストレージとしています。
事前にやっておくこと
- node.jsのインストール
- Shopify Partnerアカウントの作成
- Shopify テスト用ECサイトの作成
- AWSアカウントの登録
- AWS CLIのインストール
- 独自ドメインの取得(AWS Route53)
AWS CLIの準備
1. IAMユーザの登録
バックエンドのLambdaアプリケーションのデプロイ、実行時に必要となります。
以下の権限を付与します。
- AmazonS3FullAccess
- CloudWatchLogsFullAccess
- AmazonAPIGatewayAdministrator
- AWSCloudFormationFullAccess
- AWSLambda_FullAccess
※ Serverlessを使用したデプロイ時にCreateRollの権限が必要となります。CreateRoll権限を持った独自のポリシーを作成し、作成したユーザを許可ポリシーに追加する必要があります。
2. AWS config
AWS CLIにIAMユーザーの認証情報を登録します。
1.で作成したIAMユーザのアクセスキーを登録します。
$ aws configure
AWS Access Key ID [*******************]:
AWS Secret Access Key [*******************]:
Default region name [ap-northeast-1]:
Default output format [None]:
バックエンドの実装(準備)
まずは、Lambda用のアプリケーションの雛形を作成します。
ローカル環境での動作確認から、AWSへのデプロイまでを行います。
プロジェクトの初期化
npm init
必要なパッケージのインストール
npm i --save-dev @vendia/serverless-express express
サンプルプログラムの作成
Expressの設定
const express = require('express');
const app = express();
const router = require('./routes/router');
app.use('/', router);
module.exports = app;
Lambda用のファイル
const serverlessExpress = require('@vendia/serverless-express')
const app = require('./app')
const server = serverlessExpress.createServer(app)
exports.handler = (event, context) => serverlessExpress.proxy(server, event, context)
ローカルテスト用の設定
const app = require('./app')
app.listen(5001, () => {
console.log('local app Start!');
})
レスポンスを生成する処理
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json({message : 'Hello World!'});
});
router.get('/orders', (req, res) => {
res.json(
[
{ID: '12345' , name : 'Tom' , amount : '1000'},
{ID: '67890' , name : 'John' , amount : '2000'}
]
);
});
module.exports = router;
ローカル環境で動作確認
ローカル環境でExpressを起動します。
$ npm start
> api@1.0.0 start
> node local
local app Start!
APIをコールして正しい結果が取得出来ることを確認。
$ curl http://localhost:5001
{"message":"Hello World!"}%
$ % curl http://localhost:5001/orders
[{"ID":"12345","name":"Tom","amount":"1000"},{"ID":"67890","name":"John","amount":"2000"}]%
serverlessを使ってLambdaへデプロイ
ローカルでの動作確認が完了したので、いよいよAWSへデプロイします。
最初にデプロイ用のymlファイルを用意します。
service: ShopifyApiSample
provider:
name: aws
runtime: nodejs14.x
region: ap-northeast-1
functions:
serverlessTest:
handler: lambda.handler
events:
- http: ANY /
- http: 'ANY /{proxy+}'
Serverless Framework コマンドを使用してデプロイ
$ sls deploy
Running "serverless" from node_modules
Deploying ShopifyApiSample to stage dev (ap-northeast-1)
✔ Service deployed to stack ShopifyApiSample-dev (48s)
endpoints:
ANY - https://XXXXXX/dev
ANY - https://XXXXXX/dev/{proxy+}
functions:
serverlessTest: ShopifyApiSample-dev-serverlessTest (862 kB)
デプロイコマンド実行時に出力されたエンドポイントへアクセスし、正しく結果が受信できる事を確認します。
$ curl https://XXXXXX/dev
{"message":"Hello World!"}
$ curl https://XXXXXX/dev/orders
[{"ID":"12345","name":"Tom","amount":"1000"},{"ID":"67890","name":"John","amount":"2000"}]
以上で、バックエンドの雛形の作成が完了しました。
後半で、ShopifyGraphQLの実装を行います。
フロントエンドの実装(準備)
フロントエンドモジュールの雛形を用意します。
ローカル環境での動作確認からS3へのデプロイまでを行います。
ShopifyアプリはHTTPS通信が必要なため、AWS CloudFrontを利用します。
当記事では、create-react-appは使用しません。
Reactアプリの作成
必要なパッケージをインストール
WebPack
npm install -D webpack webpack-cli webpack-dev-server
Babel
npm install -D @babel/core @babel/preset-env babel-loader
npm install -D @babel/preset-react
React
npm install react react-dom
設定ファイルを用意
package.json
"scripts": {
"test" : "echo \"Error: no test specified\" && exit 1",
"start" : "webpack-dev-server --mode development",
"build" : "webpack --mode production"
},
WebPack.config
const path = require("path");
module.exports = {
mode: "development",
entry: [path.resolve(__dirname, "./src/index.js")],
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist")
},
module: {
rules: [
{
test: /\.js$/,
loader: "babel-loader",
exclude: /node_modules/
}
],
},
resolve: {
extensions: [".js",".json"],
},
devtool: "source-map",
devServer: {
static: {
directory: path.resolve(__dirname, 'dist'),
},
}
};
Babel
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
サンプルプログラムを用意
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shopify-APP Sample</title>
</head>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>
index.js
import React from 'react';
import ReactDom from 'react-dom';
import App from './components/App';
ReactDom.render(<App />, document.getElementById("root"));
App.js
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<React.Fragment>
<div>
<h1>Hello World!!!</h1>
</div>
</React.Fragment>
)
}
}
export default App;
動作確認(ローカル)
npm run start
ブラウザより localhost:8080 へアクセスし、サンプルが表示される事を確認します。
S3へのデプロイ
バゲット名を入力します。
バケット名は、ドメイン名と同じ文字列を指定して下さい。
パプリックアクセスを全てブロックのチェックをOFFにします。
静的ウェブホスティング設定より、インデックスドキュメントに「index.html」を指定します。
ポリシーを指定します。
"Resource": "arn:aws:s3:::[バゲット名]/*"
[バゲット名]には、バゲット作成時に指定した値を指定します。
動作確認(S3) HTTP
ReactのモジュールをS3へ配置し、ブラウザより表示できる事を確認します。
Reactアプリケーションをビルド
npm run build
distフォルダ以下に、ビルドされたファイルが生成されます。
これらのファイルを、作成したS3バケットにアップロードします。
aws s3 sync . s3://[バケット名]/
distフォルダへ移動し、AWS CLIコマンドを実行し、ビルドされたファイルをS3へアップロードします。
動作確認
S3バケットのプロパティ → 静的ウェブサイトホスティング 欄のエンドポイントにブラウザよりアクセスしてサンプルが表示される事を確認します。
HTTPS接続設定
ShopifyのAPPはHTTPSのみ許可されているため、S3上のファイルをHTTPSでアクセス出来るよう、CloudFrontを利用します。
AWS Certification Manager より証明書を作成
証明書は、米国東部 (バージニア北部) リージョン (us-east-1) に作成する必要があります。
ACM管理画面より新しい証明書をリクエストします。
「パブリック証明書をリクエスト」を選択し、「次へ」を押下します。
ドメイン名を指定し、リクエストボタンを押下します。
ここで指定したドメイン名は、S3のバケット名と同じ値とする必要があります。
ACM管理画面 証明書一覧より、証明書が発行されている事を確認します。
AWS CloudFront の設定
ディストリビューションの作成を行います。
オリジナルドメイン -- S3 バケットウェブサイトエンドポイント(http://は不要)
代替ドメイン名(CNAME) オプション -- S3のバケット名と同じ値を指定します。
画面最下部の、「ディストリビューションを作成」ボタンを押下します。
作成したディストリビューションの詳細より、「ディストリビューションドメイン名」を確認します。
Route53管理画面より、対象のドメイン名をクリックし、「レコードを作成」ボタンを押下します。
- レコード名 -- S3のバケット名と同じ値
- レコードタイプ -- CNAME
- 値 -- ディストリビューションドメイン名(http://は不要)
以上でHTTPS設定は完了です。
https://<ドメイン名>/ にアクセスし、サンプル画面が表示される事を確認して下さい。
ここまででフロントエンド、バックエンドの雛形の作成が完了しました。
バックエンド
- AWS Lambdaにexpress.jsモジュールを配置
- Serverless Frameworkを使用して、モジュールをデプロイ
フロントエンド
- AWS S3にReact.jsのモジュールを配置
- HTTPSプロトコルでフロントエンドアプリケーションにアクセス可能
- AWS CLIを使用して、モジュールをデプロイ
Shopifyアプリの登録
Shopifyパートナー管理画面メニューより「アプリ管理」を選択し、『アプリを作成』ボタンを押下。
以下の画面にて「アプリを手動で作成する」ボタンを押下します。
今回、Shopify CLIは使用しません。
Shopify CLIから作成されるアプリはWebサーバ上にデプロイする必要があります。
今回はServeless構成を前提としているため、手動で作成を行う事とします。
今回は受注情報を検索するアプリを作ります。
アプリ名に「OrderSearchApp」と入力して、『作成』ボタンを押下。
クライアントID、クライアントシークレットをメモします。
この値は今から作成するアプリケーションからShopifyAPIをコールする際のパラメータにセットします。
アプリ設定 -> URLに以下の値をセットします。
- アプリURL
アプリインストール処理を行うAPIのエンドポイント
以下のサンプルコードの左記に相当 → https://xxxx.xx.xx/api/install - 許可されたリダイレクトURL
Shopifyによりアプリ認証が成功した後にリダイレクトされるURL
以下のサンプルコードの左記に相当 → https://xxxx.xx.xx/api/auth
バックエンドの実装
パッケージのインストール
以下のパッケージを使用します。
パッケージ | 用途 |
---|---|
@aws-sdk/client-s3 | S3操作を行う際に使用 |
@dazn/lambda-powertools-logger | ログ出力用パッケージ |
cors | CORS(Cross-Origin Resource Sharing)対応 |
request | バックエンドよりHTTP通信を行う際に使用 |
shopify-api-node | ShopifyAPIをNodeプログラムより実行するライブラリ https://www.npmjs.com/package/shopify-api-node |
LambdaアプリケーションよりS3へのアクセスを許可
AWS管理コンソール
バックエンドのソースコード
import形式のモジュール参照を可能とするため、ファイルを全てESMに変更しています。
app.mjs
import express from 'express';
import cors from 'cors';
import router from './routes/router.mjs';
const app = express();
app.use(cors())
app.use(express.json())
app.use('/', router);
export default app;
lambda.mjs
import serverlessExpress from "@vendia/serverless-express"
import app from './app.mjs'
const server = serverlessExpress.createServer(app)
export const handler = (event, context) => serverlessExpress.proxy(server, event, context)
routes/router.mjs
import express from 'express';
import Log from '@dazn/lambda-powertools-logger';
import url from 'url';
import request from 'request';
import Shopify from 'shopify-api-node';
import { S3Client, GetObjectCommand , PutObjectCommand } from '@aws-sdk/client-s3'
const router = express.Router();
// Shop情報を保持するJSONファイル(初期値)
const fileContentsDefault = {
shopName : '',
shopAddress : '',
adminStaffName : '',
adminStaffEmail : ''
};
// API KEY
const shopifyAppApiKey = 'XXXXXX';
const shopifyAppApiSecretKey = 'XXXXXX';
const client = new S3Client({
region: 'ap-northeast-1'
})
router.get('/', (req, res) => {
res.json({message : 'Hello World!'});
});
/**
* APPインストール用URL
* Shop管理画面よりインストール時にCALLされる
* Shopify OAuth認証用のURLへリダイレクトする
* OAuth認証完了後に、バックエンドの認証APIエンドポイントへリダイレクトされる
* 永続化情報を保持するためのファイルをS3上に作成する
*/
router.get('/api/install', async (req, res) => {
Log.info('/api/install :: start');
var query = url.parse(req.url,true).query;
const shopId = query.shop;
// フォルダ存在チェック
const params = {
Bucket : "XXXXXXXXXX",
Key : `${shopId}/`,
};
const isExists = await existFile(params);
if ( !isExists ) {
// フォルダ作成
await createObject(params);
}
// 固有情報保持ファイル存在チェック
const paramsFile = {
Bucket : "XXXXXXXXXX",
Key : `${shopId}/shopInfo.json`,
Body : JSON.stringify(fileContentsDefault)
};
const isExistsFile = await existFile(paramsFile);
if ( !isExistsFile ) {
// ファイル作成
await createObject(paramsFile);
}
// バックエンド認証APIへリダイレクト
// scope : 作成するアプリケーションが必要とする権限を指定
// edirect_uri : 認証完了後に遷移するバックエンドのエンドポイントを指定
const URL = 'https://' + shopId + '/admin/oauth/authorize?client_id=' + shopifyAppApiKey + '&scope=read_orders,write_orders,write_products,write_merchant_managed_fulfillment_orders,read_customers,write_customers&redirect_uri=https://n2rinohvyj.execute-api.ap-northeast-1.amazonaws.com/dev/api/auth&state=12345&grant_options[]=';
res.redirect(URL);
})
/**
* S3 ファイル フォルダ存在チェック
*/
async function existFile(params){
let result = true;
const command = new GetObjectCommand(params);
await client.send(command).catch( e => {
Log.info('existFile :: S3 Check folder/file failed.' + e.toString());
result = false;
})
return result;
}
/**
* S3 ファイル フォルダ作成
*/
async function createObject(params){
let result = true;
const command = new PutObjectCommand(params);
await client.send(command).catch( e => {
Log.info('existFile :: S3 Crete folder/file failed.' + e.toString());
result = false;
})
return result;
}
/**
* Shopify認証後の処理
* 以下のパラメータが伝搬される(Shopifyより初期画面呼び出し時に伝搬されるパラメータ)
* code , APIKey , APISecretKeyを使ってTokenを取得する
*/
router.get('/api/auth', (req, res) => {
Log.info('/api/auth :: start');
const code = req.query.code;
const shopId = req.query.shop;
res.set(
"Content-Security-Policy",
`frame-ancestors https://${shopId} https://admin.shopify.com`
);
// Token取得
const options = {
method : 'POST',
json : true,
url : "https://" + shopId + "/admin/oauth/access_token",
form : {
client_id : shopifyAppApiKey , // APP固有の値
client_secret : shopifyAppApiSecretKey, // APP固有の値
code : code,
},
}
let accessToken = '';
request(options, async function(error , response, body ) {
// Token取得
accessToken = body.access_token
// APPホーム画面へリダイレクト
const URL = 'https://xxx.xx.xx/?token=' + accessToken + '&shop=' + shopId;
res.redirect(URL);
});
})
/**
* shopify-api-node を使用する例
*/
router.post('/api/getOrderCount', async (req, res) => {
Log.info('/api/getOrderCount :: start');
const shopId = req.body.shopId;
const token = req.body.token;
res.set(
"Content-Security-Policy",
`frame-ancestors https://${shopId} https://admin.shopify.com`
);
const shopify = new Shopify({
shopName : shopId,
accessToken : token,
});
shopify.order.list({ limit: 50 })
.then((orders) => {
res.json(orders)
})
.catch((err) => {
res.json(err)
});
});
/**
* shopify-api-node を使ってGraphQL APIを実行する例
*/
router.post('/api/getCustomerInfo', async (req, res) => {
Log.info('/api/getCustomerInfo :: start');
const shopId = req.body.shopId;
const token = req.body.token;
res.set(
"Content-Security-Policy",
`frame-ancestors https://${shopId} https://admin.shopify.com`
);
const shopify = new Shopify({
shopName : shopId,
accessToken : token,
});
const param = 'xxx@example.com';
const query = `{
customers(first: 5,query : "email:${param}") {
edges {
node {
firstName
lastName
email
tags
}
}
}
}`;
shopify
.graphql(query)
.then((customers) => {
res.json(customers)
})
.catch((err) => {
console.error(err)
});
});
/**
* GraphQL APIを直接呼び出す例
* requestを使ってGraphQL APIのエンドポイントに向けてPOSTする
*/
router.post('/api/getCustomreInfoGraph', async (req,res) => {
Log.info('/api/getCustomreInfoGraph :: start');
const shopId = req.body.shopId;
const token = req.body.token;
res.set(
"Content-Security-Policy",
`frame-ancestors https://${shopId} https://admin.shopify.com`
);
const param = 'xxx@example.com';
const query = `{
customers(first: 5,query : "email:${param}") {
edges {
node {
firstName
lastName
email
tags
}
}
}
}`;
const options = {
method : 'POST',
body : query,
url : "https://" + shopId + "/admin/api/2023-04/graphql.json",
headers : {
'Content-Type' : 'application/graphql',
'X-Shopify-Access-Token': token,
},
}
await request(options, async function(error , response, body ) {
res.json(body);
});
})
export default router;
当サンプルでは認証プロセスにて取得したShop名、Tokenをブラウザ上に保持し、API呼び出しの度にパラメータとしてバックエンドへ伝搬し、ShopifyAPI実行時の認証に使用しています。
当然ですが、これらの情報は隠蔽する必要があります。
フロントエンドの実装
import React , { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import queryString from 'query-string';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';
import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles((theme) => ({
button: {
margin : '5px !important' ,
},
}));
function App() {
const classes = useStyles();
const search = useLocation().search;
const [token,setToken] = useState('');
const [shop,setShop] = useState('');
useEffect(() => {
const pQueryParams = queryString.parse(search);
setToken(pQueryParams.token);
setShop(pQueryParams.shop);
}, []);
const getOrderCount = () => {
fetch('https://xxx.xx.xx/dev/api/getOrderCount', {
method : "POST",
body : JSON.stringify({
token : token,
shop : shop
}),
headers : {
'Content-Type': 'application/json'
}
})
.then((responseOrder) => responseOrder.json())
.then((responseJsonOrder) => {
console.log(JSON.stringify(responseJsonOrder));
alert(responseJsonOrder.length);
})
.catch((error) => {
console.log(error);
});
}
const getCustomerInfo = () => {
fetch('https://xxx.xxx.xx/dev/api/getCustomerInfo', {
method : "POST",
body : JSON.stringify({
token : token,
shop : shop
}),
headers : {
'Content-Type': 'application/json'
}
})
.then((responseCustomer) => responseCustomer.json())
.then((responseJsonCustomer) => {
console.log(JSON.stringify(responseJsonCustomer));
alert(JSON.stringify(responseJsonCustomer));
})
.catch((error) => {
console.log(error);
});
}
const getCustomerInfoGraph = () => {
fetch('https://xxx.xxx.xx/dev/api/getCustomreInfoGraph', {
method : "POST",
body : JSON.stringify({
token : token,
shop : shop
}),
headers : {
'Content-Type': 'application/json'
}
})
.then((responseCustomer) => responseCustomer.json())
.then((responseJsonCustomer) => {
console.log(JSON.stringify(responseJsonCustomer));
alert(JSON.stringify(responseJsonCustomer));
})
.catch((error) => {
console.log(error);
});
}
return (
<React.Fragment>
<Grid container justifyContent="center">
<Grid item xs={4} align='left'>
<Button
variant="contained"
color="primary"
className={classes.button}
startIcon={<CloudDownloadIcon />}
onClick={getOrderCount}
>
受注件数取得
</Button>
</Grid>
<Grid item xs={4} align='left'>
<Button
variant="contained"
color="primary"
className={classes.button}
startIcon={<CloudDownloadIcon />}
onClick={getCustomerInfo}
>
顧客情報取得
</Button>
</Grid>
<Grid item xs={4} align='left'>
<Button
variant="contained"
color="primary"
className={classes.button}
startIcon={<CloudDownloadIcon />}
onClick={getCustomerInfoGraph}
>
顧客情報取得(Graph)
</Button>
</Grid>
</Grid>
</React.Fragment>
);
}
export default App;
アプリケーションのインストール
Shopifyパートナー管理画面
作成したアプリケーションを選択し、「ストアを選択する」ボタンを押下。
アプリケーションのホーム画面が表示されます。
それぞれのボタン押下時に作成した3つのバックエンドAPIを実行します。
まとめ
アプリのインストールから初期画面表示までのシーケンスを以下に整理します。
アプリインストール時のシーケンス
- マーチャントがアプリのインストールを行った際は、Shopifyパートナ管理画面で指定したアプリURLが呼ばれる。この時、マーチャントを識別する値としてshop(例:XXXX.myshopify.com)が、パラメータとして伝搬される。
- インストールAPIは、shopとAPIキーをパラメータとし、Shopify認証APIへリダイレクトする。
- Shopify認証APIは認証を行い、アプリインストール確認画面を表示する。
- マーチャントがアプリインストールを許可した際は、バックエンドの認証APIエンドポインが呼ばれる。
- バックエンド認証APIは、ShopifyToken取得APIよりTokenを取得し、アプリのホーム画面へリダイレクトする。
アプリ開始時のシーケンス(インストールが完了している状態でアプリ起動)
- バックエンドの認証APIエンドポインが呼ばれる。
- バックエンド認証APIは、ShopifyToken取得APIよりTokenを取得し、アプリのホーム画面へリダイレクトする。