動機
私は普段 Line API を利用したサービスの開発をしています。
不具合調査や新しい機能の検討などの際に、API を実際に呼び出して挙動を確認したいということがちょこちょこあります。
サービスは Ruby on Rails で実装しているので、そういうときは大抵 rails のコンソールでちょっとしたコードを実行して確認しているのですが、もっと手軽に試せると便利だなとおもい実現してみました。
やること
Line API は https://github.com/line/line-openapi に OpenAPI の定義が公開されているため、これを Swagger UI などで表示してやればお手軽に API を試すことができそうです。
ただし、API 呼び出し時のチャネルアクセストークンをどうするか?という点が問題になります。
今回はチャネルアクセストークンの発行を自動化して Swagger UI に組み込んでやることで、使いたいAPIをすぐに試せるようにしてみようと思います。
方針
TypeScript + node.js で作成したWebアプリケーションを Google App Engine 上にデプロイします。
アクセストークンは Datastore に保存し、期限内であれば使い回せるようにします。
鍵などは SecretManager で管理します。
プロジェクト作成
以下でプロジェクトを作成していきます。
mkdir line-api-viewer
cd line-api-viewer
curl -o .gitignore https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
npm init -y
npm pkg set main="dist/index.js"
npm install typescript @types/node --save-dev
npx tsc --init
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/index.js"
mkdir src
npm install express
npm install @types/express --save-dev
tsconfig.json を下記に修正。
{
"compilerOptions": {
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
/* Emit */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
/* Interop Constraints */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
/* Completeness */
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
下記の内容で src/index.ts を作成。
import express from 'express';
const app = express();
const port = 8080;
app.get('/', (req, res) => {
res.send('Hello, World!');
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
一旦下記で動作確認。
npm run build
npm run start
http://localhost:8080/ にアクセスすると"Hello, World!"と表示されたと思います。
次に、下記の内容で app.yaml を作成。
runtime: nodejs20
service: line-api-viewer
handlers:
- url: /.*
script: auto
package.json にデプロイ用のコマンドを追加。
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"start": "node dist/index.js",
"deploy": "npm run build && gcloud app deploy",
"gcp-build": "npm run build"
},
App Engine にデプロイします。
gcloud config set account [YOUR ACCOUNT]
gcloud config set project [YOUR PROJECT]
npm run deploy
これで App Engine 側でも"Hello, World!"が表示されると思います。
公式アカウントの作成
手順にしたがって、公式アカウントを作成します。
下記ページで、"LINE公式アカウントをはじめる"をクリック。
"LINEアカウントで登録" をクリック。
ログイン
LINEビジネスIDを作成 をクリック。
電話番号を入力し、"SMSを送信"をクリック。
送られてきた認証番号を入力。
サービスに戻るをクリック。
ログインをクリック。
LINE公式アカウントの作成画面に遷移するので、入力して"確認"をクリック
入力内容の確認画面。"完了"をクリック。
完了画面。"LINE Official Account Managerへ"をクリック。
情報利用に関する同意について。内容を確認し、"同意"をクリック。
"設定"をクリック。
左のメニューから"Messaging API"をクリック。
"Messaging APIを利用する"をクリック。
プロバイダーを選択または作成。
プライバシーポリシーと利用規約があれば入力。
OKをクリック。
LINE Developers へ登録
下記へアクセスし、"コンソール"をクリック。
登録画面が表示されるので、必要項目を入力し"アカウントを作成"をクリック。
チャネルアクセストークン
下記に従って、チャネルアクセストークンを発行する機能を作成していきます。
まずはキーペアを作成していきます。
ブラウザの開発ツールを開き、コンソールに下記を入力します。
(async () => {
const pair = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["sign", "verify"]
);
console.log("=== private key ===");
console.log(
JSON.stringify(
await crypto.subtle.exportKey("jwk", pair.privateKey),
null,
" "
)
);
console.log("=== public key ===");
console.log(
JSON.stringify(
await crypto.subtle.exportKey("jwk", pair.publicKey),
null,
" "
)
);
})();
すると、private key と public key が表示されます。どちらも利用するので必ずメモしておいてください。
LINE Developers の Messaging API チャネル > チャネル基本設定 > アサーション署名キー の "公開鍵を登録する" をクリック。
public key の内容を貼り付けて "登録" をクリック
kid が表示されるのでメモしておきます。
private key と kid はプログラムから利用しますが、ソースコード内に書くのは危ないので Secret Manager で管理します。
Google Cloud の Secret Manager コンソールから、シークレットの作成をクリック。
名前をそれぞれ line-priv-key, line-kid とし、シークレットの値にそれぞれの値を貼り付けてシークレットを作成しておきます。
下記内容で src/utils/secret.ts を作成し、Secret Manager で管理している機密情報をプログラムから取得できるようにします。
npm install @google-cloud/secret-manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();
export async function getSecret(secretName: string): Promise<string> {
try {
const projectId = process.env.GOOGLE_CLOUD_PROJECT;
const name = `projects/${projectId}/secrets/${secretName}/versions/latest`;
const [version] = await client.accessSecretVersion({ name });
const payload = version.payload?.data?.toString();
if (!payload) {
throw new Error(`Secret ${secretName} not found`);
}
return payload;
} catch (error) {
console.error(`Error fetching secret ${secretName}:`, error);
throw error;
}
}
チャネルアクセストークンの発行に必要なJWTの発行方法は下記に記載されています。
これに従ってJWTを発行する処理を src/line/token.ts に実装します。
チャネルIDは環境変数で渡す想定です。
npm install axios node-jose
import { getSecret } from "../utils/secret";
import jose from "node-jose";
import axios from "axios";
// https://developers.line.biz/ja/docs/messaging-api/generate-json-web-token/#jwt-use-nodejs
export const issueJWT = async () => {
const privateKey = await getSecret("line-priv-key");
const kid = await getSecret("line-kid");
const channelId = process.env.CHANNEL_ID;
const header = {
alg: "RS256",
typ: "JWT",
kid,
};
const payload = {
iss: channelId,
sub: channelId,
aud: "https://api.line.me/",
exp: Math.floor(new Date().getTime() / 1000) + 60 * 30,
token_exp: 60 * 60 * 24 * 30,
};
return await jose.JWS.createSign(
{ format: "compact", fields: header },
JSON.parse(privateKey)
)
.update(JSON.stringify(payload))
.final();
};
このJWTを利用して、チャネルアクセストークンを取得することができます。
type ChannelAccessToken = {
access_token: string;
expires_in: number;
};
// https://developers.line.biz/ja/docs/messaging-api/generate-json-web-token/#issue_a_channel_access_token_v2_1
export const issueChannelAccessToken = async (): Promise<ChannelAccessToken> => {
const jwt = await issueJWT();
const url = `https://api.line.me/oauth2/v2.1/token`;
const res = await axios.post(
url,
new URLSearchParams({
grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: jwt.toString(),
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return res.data as ChannelAccessToken;
};
チャネルアクセストークンを保存して使い回す
これでチャネルアクセストークンは発行できたのでAPI呼び出しが可能にはなるのですが、毎回チャネルアクセストークンを発行するのは無駄なので、どこかに保存しておいて使い回せるようにします。
今回は前述の通り Datastore へ保存することにします。
src/utils/datastore.ts を下記の内容で作成します。
import { Datastore } from "@google-cloud/datastore";
const NAMESPACE = "line-api-viewer";
const datastore: Datastore = new Datastore({
projectId: process.env.GOOGLE_CLOUD_PROJECT,
namespace: NAMESPACE,
});
export interface Entity {
[key: string]: any;
}
export const get = async (kind: string, id: string): Promise<Entity | null> => {
const key = datastore.key([kind, id]);
try {
const [entity] = await datastore.get(key);
return entity || null;
} catch (error) {
console.error(error);
throw error;
}
};
export const save = async (kind: string, id: string, data: Entity): Promise<void> => {
const key = datastore.key([kind, id]);
const entity = {
key,
data,
};
try {
await datastore.save(entity);
} catch (error) {
console.error(error);
throw error;
}
};
これを利用して、Datastoreに利用可能なチャネルアクセストークンがあればそれを、なければ発行する処理を作成します。
import { get, save } from '../utils/datastore';
...
const SAFETY_MARGIN = 5 * 60; // 5分の余裕を持たせる(秒)
const TOKEN_KIND = 'LineAccessToken';
const TOKEN_ID = 'current';
export const getAccessToken = async (): Promise<string> => {
try {
const token = await get(TOKEN_KIND, TOKEN_ID);
if (token && token.expires_at) {
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime < token.expires_at - SAFETY_MARGIN) {
return token.access_token;
}
}
const newToken = await issueChannelAccessToken();
const tokenData: TokenData = {
access_token: newToken.access_token,
expires_at: Math.floor(Date.now() / 1000) + newToken.expires_in
};
await save(TOKEN_KIND, TOKEN_ID, tokenData);
return newToken.access_token;
} catch (error) {
console.error('Error managing access token:', error);
throw error;
}
};
最後に、app.yaml を修正しておきます。
runtime: nodejs20
service: line-api-viewer
env_variables:
GOOGLE_CLOUD_PROJECT: "[YOUR PROJECT]"
CHANNEL_ID: "[YOUR CHANNEL ID]"
handlers:
- url: /.*
script: auto
これでチャネルアクセストークン発行まわりの処理ができました。
Swagger UI
下記で公開されている yml をダウンロードし、static フォルダ内においておきます。
static フォルダ内のファイルが dist にコピーされるように package.json を変更します。
"scripts": {
...
"build": "tsc && cp -r static dist/",
...
},
swagger-ui-express を利用すると、この yml から UI を表示することができるようなのでこちらを使っていきます。
npm install express swagger-ui-express yamljs
npm install --save-dev @types/express @types/swagger-ui-express @types/yamljs
src/index.ts を下記のように修正します。
import express from 'express';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
import path from 'path';
const app = express();
const port = process.env.PORT || 8080;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const openApiSpec = YAML.load(path.join(__dirname, '../static/line-messaging-api.yml'));
const swaggerOptions = {
swaggerOptions: {
displayRequestDuration: true,
tryItOutEnabled: true,
requestSnippetsEnabled: true,
defaultModelRendering: 'model',
defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 1,
},
explorer: true
};
// CORSの設定を追加
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
next();
});
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec, swaggerOptions));
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
この状態で /api-docs へアクセスすると API 一覧が表示され、"Authorize" からチャネルアクセストークンを適切に設定すれば Execute から実際に API を呼び出すことが可能です。
ただ、チャネルアクセストークンを手動でどこかで発行or取得して貼り付けて...が面倒なので先程作った getAccessToken を組み込みたい。
ちょっときれいなやり方が見つけられなかったので、今回は Swagger UI からのリクエストを一度受け取るエンドポイントを追加して、そこでチャネルアクセストークンを付与して Line API を呼び出しその結果を返す、という形にしました。
// プロキシエンドポイントの実装
app.use('/line-proxy/*', async (req, res) => {
try {
const accessToken = await getAccessToken();
const LINE_API_BASE = 'https://api.line.me';
const targetPath = req.originalUrl.replace('/line-proxy', '');
const response = await fetch(`${LINE_API_BASE}${targetPath}`, {
method: req.method,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body)
});
const data = await response.json();
res.status(response.status).json(data);
} catch (error: any) {
console.error('Proxy error:', error);
res.status(error.status || 500).json({
error: error.message
});
}
});
static/line-messaging-api.yml の servers にこのエンドポイントを追加します
servers:
- url: "https://xxxxxx.an.r.appspot.com/line-proxy"
- url: "https://api.line.me"
- url: "http://localhost:8080/line-proxy"
これでチャネルアクセストークンのことを気にせずに Swagger UI から LINE API を呼び出すことができるようになりました!
その他
メッセージの送信の際にユーザーのUIDが必要になることがあるので、下記のようなエンドポイントを追加してWebhookの内容をログから確認できるようにしておくと便利かもしれません。(Messaging APIチャネルでWebhookの設定が必要です。)
// Webhook エンドポイント
app.post('/webhook', (req, res) => {
console.log('Webhook received:');
console.log('Headers:', req.headers);
console.log('Body:', JSON.stringify(req.body, null, 2));
// LINE Webhookの仕様に従って200を返す
res.status(200).end();
});
App Engine のアプリにファイアウォール等を設定して、関係者以外がアクセスできないようにしておくと良いと思います。