📱

OpenAPI定義を使ってGASからSlackへのリクエストを簡単に実装する

2023/03/08に公開

こんにちは、エンジニアの籏野です。
フォルシアでは社内のコミュニケーションツールに Slack を利用しています。
その用途は単純なチャットツールにとどまらず、監視アラートのような各種通知やリマインダー、勤怠管理など様々な目的のために利用されています。
そのような社内からの要望に応えるために、Slack API を利用した様々なアプリケーションが社内で誕生しています。

Slack API は公式ドキュメントもあるので、基本的にこちらに沿えば利用できますが繰り返し使っているともう少しイケてる仕組みで利用したくなってきます。
そこで今回はアプリ開発の方で知見が溜まりつつある「OpenAPI 定義によるコードの自動生成」を Slack API に適用してみましたので紹介します。

今回の記事で扱う内容は全て GitHub に置いてます。
https://github.com/taku-hatano/gas_slack

※過去の Slack API を利用した記事もぜひご覧ください。

Slack API × GAS

Slack API を利用したアプリケーションの実行環境は色々考えられますが、個人的によく使っているのはGoogle Apps Script(GAS)になります。
スプレッドシートなど google が提供するサービスと連携して何かを実現したい場合に限らず、定期実行設定なども手軽にできる点も実行環境としては大変魅力的です。

※Event API のような Slack のイベントをトリガーにリクエストを受ける必要があるアプリケーションの場合、GAS を公開できる範囲によっては利用できません。

GAS 実装のためにブラウザ上のエディタも用意されていますが、claspを利用することでローカルでの開発や TypeScript の導入など Web エンジニアとしては嬉しい開発環境を利用できるのも良い点です。
TypeScript を利用するのであれば、Slack API のリクエストやレスポンスに型を付与して開発できると大変助かります。
ありがたいことにSlack API は公式の OpenAPI 定義を公開しています。
今回はこの OpenAPI 定義をもとに型定義などのソースコードを自動生成してみました。

※ 実行環境に Node.js などを採用する場合には、@slack/web-api という npm モジュールが公開されているのでこちらを利用するのがよいと思います。
※ 上記 npm モジュールではリポジトリ内で独自にリクエスト/レスポンスの型定義をしており、OpenAPI 定義リポジトリの最終更新も 2021 年であるため、既にメンテナンスされていないかもしれません。
2023/2/1時点では実際にリクエストしてみると定義と異なる部分も確認されていますので、ご利用の際はご注意ください。

プロジェクトの作成

プロジェクトの作成と簡単な初期設定をしていきましょう。

$ mkdir gas_slack
$ cd gas_slack
$ npm init

開発用に TypeScript や eslint をインストールしていきます。
GAS 用の型や eslint 設定も公開されているので利用していきます。

# 型関連のモジュール
$ npm install --save-dev typescript @types/google-apps-script

# eslint
$ npm install --save-dev eslint eslint-config-prettier eslint-plugin-googleappsscript @typescript-eslint/eslint-plugin @typescript-eslint/parser

これにより GAS 上で使える独自の関数に対しても型が付いた状態で開発ができるようになります。
eslint の設定は以下のようにしました。

{
  "env": {
    "googleappsscript/googleappsscript": true,
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest"
  },
  "plugins": ["@typescript-eslint", "googleappsscript"]
}

その他フォーマッタ等はお好みで入れていきます。

Slack API 型定義の自動生成

過去の記事でも紹介したことがあるのですが、フォルシアでは API 開発時に OpenAPI で定義を作成・利用しており、OpenAPI 定義から必要なコードを自動生成するノウハウもかなり蓄積されてきています。
型定義の自動生成にはopenapi-typescriptを主に利用していますので紹介します。

$ npm install --save-dev openapi-typescript@5

※ Slack API の定義は OpenAPI V2 で書かれていますが、openapi-typescript は 5 系までしか V2 をサポートしていないためバージョンを指定します。

型定義の生成は以下のコマンド一発です。

$ npx openapi-typescript https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json slackapi.d.ts

出力された型定義は以下のようになっています。

...
export interface operations {
  /** Approve an app for installation on a workspace. */
  admin_apps_approve: {
    parameters: {
      header: {
        /** Authentication token. Requires scope: `admin.apps:write` */
        token: string;
      };
      formData: {
        /** The id of the app to approve. */
        app_id?: string;
        /** The id of the request to approve. */
        request_id?: string;
        team_id?: string;
      };
    };
    responses: {
      /** Typical success response */
      200: {
        schema: {
          ok: definitions["defs_ok_true"];
        } & { [key: string]: unknown };
      };
      /** Typical error response */
      default: {
        schema: {
          ok: definitions["defs_ok_false"];
        } & { [key: string]: unknown };
      };
    };
  };
...

型を整える

上記コマンドで型定義は生成されるのですが、そのまま利用するには少し使いづらいです。
以下のような型を実装することで、operationId と呼ばれる API を一意に特定するための ID を指定すれば必要な型が取れるようにしています。

/* eslint-disable @typescript-eslint/ban-types */
import { operations } from "./gen/slackapi";

type Parameter<T> = T extends { parameters: infer P } ? P : void;

type Response<T> = T extends {
  responses: { 200: { schema: infer R } };
}
  ? R
  : void;

export type SlackOperation = keyof operations;
export type SlackParameter<T extends SlackOperation> = Parameter<operations[T]>;
export type SlackResponse<T extends SlackOperation> = Response<operations[T]>;

ジェネリクスで operationId を指定すると対応するパラメータの型が取得できています。

※ GAS でのリクエスト時には header/query/formData を区別せず、payload という変数にまとめて利用します。
そのためここでインターセクション型を使った型を作りたかったのですが、Slack API の数が多いため以下のようなエラーが出て破綻しました。
必要な API にのみ絞るなど工夫してもいいかもしれません。

Expression produces a union type that is too complex to represent

Slack API リクエスト URL を自動生成

OpenAPI 定義にはリクエストするべき URL ももちろん定義されています。
先ほどの型定義は operationId をキーとして取得しましたので、URL も同じように取得できると扱いやすいです。
こちらも OpenAPI 定義からソースコードを自動生成しました。

自動生成スクリプト

※必要な node_modules のインストールは省略します。

import fs from "fs";
import fetch from "node-fetch";

const main = async () => {
  const response = await fetch(
    "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json"
  );
  const slackSpecJSON = await response.json();
  const { host, basePath, paths } = slackSpecJSON;
  const baseURL = `https://${host}${basePath}`;

  const operations: {
    operationId: string;
    url: string;
    method: string;
  }[] = [];
  Object.keys(paths).forEach((path) => {
    Object.keys(paths[path]).forEach((method) => {
      paths[path][method].operationId &&
        operations.push({
          operationId: paths[path][method].operationId,
          url: `${baseURL}${path}`,
          method,
        });
    });
  });

  const sourceCode = `
/**
 * !!!IMPORTANT!!!
 * Do not edit this file manually.
 * This is auto generated source code from swagger definitions.
 */

export const slackUrlDefinitions = {
	${operations
    .map((operation) => {
      return `${operation.operationId}: {url: "${operation.url}", method: "${operation.method}" as const}`;
    })
    .join(",")}
}
	`;

  fs.writeFileSync("出力先のパス", sourceCode);
};

main();

生成されるのは以下のようなオブジェクトになります。

export const slackUrlDefinitions = {
	...
	chat_postMessage: {
		url: "https://slack.com/api/chat.postMessage",
		method: "post" as const
	},
	...
}

Slack API へリクエストする関数

ここまで生成した成果物を利用して、GAS 上で Slack API にリクエストする関数を実装すると以下のようになります。

import { SlackResponse, SlackOperation, SlackParameter } from "../types/slack";
import { slackUrlDefinitions } from "./gen/slack_api";

export const fetchSlack = <T extends SlackOperation>(
  operation: T,
  params: SlackParameter<T>
) => {
  const payload = {};
  Object.values(params).forEach((val) => {
    Object.assign(payload, val);
  });
  const { url, method } = slackUrlDefinitions[operation];
  Logger.log(`Request to Slack...
	URL: ${url}
	PAYLOAD: ${JSON.stringify(payload, null, "\t")}`);
  const response: SlackResponse<T> = JSON.parse(
    UrlFetchApp.fetch(url, {
      method,
      payload,
    }).getContentText("UTF-8")
  );
  return response;
};

なんと約 20 行程のコードを実装するだけで、すべての SlackAPI にリクエストができる & リクエスト/レスポンスに型が付いているイケてる関数ができました!
実際に利用する場合には以下のような形になります。

fetchSlack("chat_postMessage", {
  header: {
    token: "XXXXXXXXXXXX",
  },
  formData: {
    channel: "YYYYY",
    text: "Slack APIでポストしたメッセージです",
  },
});

エディタ上で上記コードを書いてみると、第一引数に指定する operation の値を変えることでリクエストパラメータやレスポンスの型が変わっていくのが爽快です。
Slack API のアップデートが行われた場合でも、参照している OpenAPI 定義が更新されてくれれば型定義・ソースコードが新たに作成できるので追従も容易になりました。

最後に

OpenAPI 定義のおかげで、Slack API にリクエストするための仕組みが簡単に実装できました。
フォルシアでは OpenAPI を利用した API 定義の作成が広まってきているのですが、API の利用者目線でその利便性を再確認できて大変勉強になりました。
API とクライアントアプリの整合性を保つためにも、今後も OpenAPI 定義を上手く利用していきたいです。

FORCIA Tech Blog

Discussion