dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

kintoneのAPIスキーマ情報からREST APIドキュメントを自動生成する

この記事は kintone Advent Calendar 2019 6日目の記事です。
kintone のアドベントカレンダーは初参加です、よろしくお願いします。


はじめに

kintone にはさまざまなAPIがありますが、その中でもちょっと特殊なのがAPIスキーマ情報を取得するためのAPIです。

これは、各APIのリクエスト・レスポンスがどういったパラメータで構成されているかを JSON Schema というフォーマットで返してくれるAPIです。

このリクエスト・レスポンスのデータ構造の情報を応用するといろんなツールに使えそうだと思い、今回はここからOpenAPI(旧Swagger)という規格のファイルを生成することで、REST APIドキュメントをいい感じに作成できないかな〜というのを試してみた話です。


JSON SchemaとOpenAPIとは何か

JSON Schema とは、ざっくりいうとJSONのデータ構造をJSONで定義するための言語です。

たとえば、firstName, lastName, age というプロパティを持つ Person というJSONデータをJSON Schemaで表現すると以下のようになります。

{
  "$id": "https://example.com/person.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Person",
  "type": "object",
  "properties": {
    "firstName": {
      "type": "string",
      "description": "The person's first name."
    },
    "lastName": {
      "type": "string",
      "description": "The person's last name."
    },
    "age": {
      "description": "Age in years which must be equal to or greater than zero.",
      "type": "integer",
      "minimum": 0
    }
  }
}

https://json-schema.org/learn/miscellaneous-examples.html より引用)

一方のOpenAPIですが、こちらはREST APIの仕様を記述するためのフォーマットです。
Swagger という言葉をご存知の方は多いかもしれません。2015年にOpenAPI Initiativeという団体が発足したのに伴い、Swaggerは現在OpenAPIに名前が変わっているようです。

OpenAPIやSwaggerでREST APIの仕様を記述するメリットとして、その仕様を利用した便利なツールが充実していることが挙げられます。
今回使用するAPIドキュメント生成のためのツールもその1つです。

Swagger ◯◯と名のつくツール群については、以下の記事がわかりやすいです。
【連載】Swagger入門 - 初めてのAPI仕様管理講座 [1] Swaggerとは|開発ソフトウェア|IT製品の事例・解説記事

また、OpenAPIがどのようなフォーマットなのか、またそこからどのようなAPIドキュメントができるのかについては、Swagger Editorというオンラインエディタのデモサイト https://editor.swagger.io/ を見てみるのが良いかと思います。

f:id:dackdive:20191209021924p:plain

成果物

やってみる

以下、記事中のコードはすべてJavaScriptです。(↑のリポジトリではTypeScriptで書いています)


1. APIスキーマ情報を取得する

はじめに、APIスキーマ情報を取得するAPIを実行し、結果をJSONで保存します。
流れとしては

  1. API 一覧の取得API (/k/v1/apis.json) を叩き、全APIスキーマ情報取得用のlinkを得る
  2. 取得したlinkからスキーマ情報の取得API (/k/v1/apis/*.json) を順番に実行し、各APIスキーマ情報を得る

という2ステップです。

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

const subdomain = process.env.KINTONE_SUBDOMAIN;

export async function fetchKintoneAPISchemas() {
  const baseUrl = `https://${subdomain}.cybozu.com/k/v1`;

  // TODO: fetch all of kintone REST apis
  // const resp = await fetch(`${baseUrl}/apis.json`);
  // const apis: Apis = (await resp.json()).apis;

  // const fetchSchemasPromises = Object.values(apis).map(async api => {
  //   const resp: any = await fetch(`${baseUrl}/${api.link}`);
  //   return resp.json();
  // });
  //
  // const schemas = await Promise.all(fetchSchemasPromises);

  const schemas = [await (await fetch(`${baseUrl}/apis/app/acl/get.json`)).json()]; // (*)
  fs.writeFileSync(
    path.resolve(__dirname, "generated", "kintone-api-schemas.json"),
    prettier.format(JSON.stringify(schemas), { parser: "json" })
  );
}

ただし、今回はとりあえず適当なAPI1つを例にうまくいくか試したかったので、アプリのアクセス権の取得API (/k/v1/app/acl.json)だけにしています。
APIスキーマ情報を取得したい場合は、(*) 行のかわりにコメントアウトしている部分を使うとうまくいきます。

生成した generated/kintone-api-schemas.json はこんな中身になっています。

[
  {
    "id": "app/acl/get",
    "baseUrl": "https://zaki-yama.cybozu.com/k/v1/",
    "path": "app/acl.json",
    "httpMethod": "GET",
    "request": {
      "properties": { "app": { "format": "long", "type": "string" } },
      "required": ["app"],
      "type": "object"
    },
    "response": {
      "properties": {
        "rights": { "items": { "$ref": "Right" }, "type": "array" },
        "revision": { "format": "long", "type": "string" }
      },
      "type": "object"
    },
    "schemas": {
      "Right": {
        "properties": {
          "recordImportable": { "type": "boolean" },
          "appEditable": { "type": "boolean" },
          "recordExportable": { "type": "boolean" },
          "recordAddable": { "type": "boolean" },
          "recordViewable": { "type": "boolean" },
          "recordEditable": { "type": "boolean" },
          "includeSubs": { "type": "boolean" },
          "recordDeletable": { "type": "boolean" },
          "entity": {
            "properties": {
              "code": { "type": "string" },
              "type": {
                "enum": ["USER", "ORGANIZATION", "GROUP", "CREATOR"],
                "type": "string"
              }
            },
            "type": "object"
          }
        },
        "type": "object"
      }
    }
  }
]


2. APIスキーマ情報をOpenAPI Specificationのファイルに変換する

続いて、先ほど保存したJSONファイルからOpenAPI Specificationという仕様に準拠したファイル(以下、OpenAPI Specファイルと呼びます)に変換します。
OpenAPIのドキュメントは、以下のようなフォーマットで記述します。
(YAMLでもJSONでもOKですが、読みやすさからYAMLで記述しています)

openapi: 3.0.1
info:
  description: Kintone REST API
  version: 1.0.0
  title: Kintone REST API
paths:
  /app/acl.json:
    get:
      parameters:
        - in: query
          name: app
          schema:
            type: string
          required: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                properties:
                  rights:
                    items:
                      $ref: '#/components/schemas/Right'
                    type: array
                  revision:
                    format: long
                    type: string
                type: object
components:
  schemas:
    Right:
      properties:
        recordImportable:
          type: boolean
        appEditable:
          type: boolean
        recordExportable:
          type: boolean
          ...(略)...

paths の下に各APIエンドポイントのパスを書き、リクエストを parameters に、レスポンスを responses に記述します。

一方、kintoneのAPIスキーマ情報のレスポンスとしては、request, responseにそれぞれリクエスト、レスポンスのパラメータ情報が含まれているので、これらをうまくマッピングしてあげれば良さそうです。

import fs from "fs";
import path from "path";
import yaml from "js-yaml";
import { convertRequestToParameters } from "./request-converter";

export function generateOpenAPISchema() {
  const kintoneAPISchemas = JSON.parse(
    fs.readFileSync(
      path.resolve(__dirname, "generated", "kintone-api-schemas.json"),
      "utf8"
    ),
    (key, value) => {
      if (key === "$ref") {
        return `#/components/schemas/${value}`;
      }
      return value;
    }
  );

  const json = {
    openapi: "3.0.1",
    info: {
      description: "Kintone REST API",
      version: "1.0.0",
      title: "Kintone REST API"
    }
  };
  const paths = generatePaths(kintoneAPISchemas);
  const components = generateComponents(kintoneAPISchemas);
  json.paths = paths;
  json.components = components;

  fs.writeFileSync(
    path.resolve(__dirname, "generated", "openapi.yaml"),
    yaml.safeDump(json)
  );
}

function generatePaths(kintoneAPISchemas) {
  const paths = {};
  kintoneAPISchemas.forEach(schema => {
    const key = `/${schema.path}`;
    paths[key] = {
      [schema.httpMethod.toLowerCase()]: {
        parameters: convertRequestToParameters(schema.request),
        responses: {
          "200": {
            description: "OK",
            content: {
              "application/json": {
                schema: schema.response
              }
            }
          }
        }
      }
    };
  });
  return paths;
}

function generateComponents(kintoneAPISchemas) {
  let schemas = {};
  kintoneAPISchemas.forEach((schema) => {
    schemas = {
      ...schema.schemas,
      ...schemas
    };
  });
  return { schemas };
}
// request-converter.js
export function convertRequestToParameters(request) {
  const parameters = [];
  Object.keys(request.properties).forEach(fieldCode => {
    const required = request.required.includes(fieldCode);
    parameters.push({
      in: "query",
      name: fieldCode,
      schema: {
        type: request.properties[fieldCode].type
      },
      required
    });
  });
  return parameters;
}

https://github.com/zaki-yama/kintone-openapi-generator/blob/master/src/generate-openapi-schema.ts


3. OpenAPI SpecファイルからREST APIドキュメントを生成する

スキーマ情報からOpenAPI Specのファイルが生成できたら、ここから周辺ツールを使ってREST APIドキュメントを生成します。
Swaggerのツール群でいうと Swagger UI がこれに該当します。

関連しそうなライブラリがいくつもあって迷ったんですが、今回は swagger-ui-express を使いました。

先ほど生成した OpenAPI Spec ファイルを読み込んで、以下のように渡してあげます。

// doc-server.js
import fs from "fs";
import path from "path";
import jsyaml from "js-yaml";
import swaggerUi from "swagger-ui-express";
import express from "express";

const spec = fs.readFileSync(
  path.resolve(__dirname, "generated", "openapi.yaml"),
  "utf8"
);
const doc = jsyaml.safeLoad(spec);
const app = express();

app.use("/", swaggerUi.serve, swaggerUi.setup(doc));

app.listen(3000, () => {
  console.log("Listen on port 3000");
});

あとは、ターミナルで

$ node doc-server.js

でローカルサーバーを起動し、 http://localhost:3000 にアクセスすると...

f:id:dackdive:20191206220248p:plain

無事APIドキュメントが表示できました。

なお余談ですが、VSCode というエディタを使っている場合は Swagger Viewer というextensionを入れると、エディタ上でこのようなAPIドキュメントを表示できます。

OpenAPI Specファイルを開いているときにコマンドパレットで

> Preview Swagger

というコマンドを実行してみてください。

f:id:dackdive:20191206220513p:plain

ドキュメントをどこかのサイトにホスティングする必要がないのであれば、これでも十分ですね。


4. [未完成😤] 認証機能を追加し、ドキュメントからAPIを実行できるようにする

先ほど生成したAPIドキュメントには「Try it out」というボタンが表示されています。
ここから自社のkintone環境にリクエストを送り、実際のデータのレスポンスを確認できたら便利ですね。

というわけでトライしてみます。

実際のkintone環境にアクセスするには認証が必要ですが、調べてみるとOpenAPI Specファイルにいくつかの項目を追加すればいいことがわかります。

これによると、必要なのは以下の2点です。

  1. servers > url にkintone環境のURLを指定する (例: https://hoge.cybozu.com)
  2. components > securitySchemes を指定する

後者について、OpenAPIではベーシック認証やAPIキー(トークン)による認証、OAuth2といった認証認可のスキームをサポートしています。
一方kintone REST APIがサポートしている認証方式は kintone REST APIの共通仕様 > ユーザー認証 の項に記載があります。
残念ながらユーザー名・パスワードによる認証は一般的なベーシック認証とは異なるため、ここではAPIトークン認証を使います。

APIトークン認証はトークンを X-Cybozu-Authorization というヘッダーに乗せて送ります。これはOpenAPIでは以下のように書きます。

components:
  securitySchemes:
    ApiTokenAuth:
      type: apiKey
      in: header
      name: X-Cybozu-API-Token
...
# 最後にこれも必要っぽい
security:
  - ApiTokenAuth: []

servers > url と合わせて、ステップ2で作成したスクリプトに処理を追加しておきます。

詳しくはリポジトリgenerate-openapi-schema.ts を参照ください。

結果

さて、この状態で再度APIドキュメントを立ち上げると、期待通りAuthorizeというボタンが追加されています。

f:id:dackdive:20191206233220p:plain:w320

kintoneで発行したAPIトークンを入力してAPIを実行してみましたが、エラーになってしまいました。

f:id:dackdive:20191206233232p:plain

localhostだからいけない(httpsならいける?)のか、それとも結局同一ドメインじゃないとうまくいかない...?
というのを調査しようと思いましたが、残念ながら時間切れです。


余談: モックサーバーを立てる

OpenAPI を利用したツールは今回紹介したAPIドキュメント生成以外にもさまざまなものがあります。
別の活用例として、たとえば prism というツールを使うと、モックサーバーを生成することもできます。

$ npm install @stoplight/prism-cli

でインストールし、

$ ./node_modules/.bin/prism mock <OpenAPI Specファイルのパス>

というコマンドを実行するだけです。

# モックサーバー起動
$ ./node_modules/.bin/prism mock <OpenAPI Specファイルのパス>
[01:13:41] › [CLI] …  awaiting  Starting Prism…
[01:13:41] › [HTTP SERVER] ℹ  info      Server listening at http://127.0.0.1:4010
[01:13:41] › [CLI] ℹ  info      GET        http://127.0.0.1:4010/app/acl.json?app=magni
[01:13:41] › [CLI] ▶  start     Prism is listening on http://127.0.0.1:4010

# 別ウィンドウで
$ curl "http://localhost:4010/app/acl.json?app=1"
{"rights":[{"recordImportable":true,"appEditable":true,"recordExportable":true,"recordAddable":true,"recordViewable":true,"recordEditable":true,"includeSubs":true,"recordDeletable":true,"entity":{"code":"string","type":"USER"}}],"revision":"string"}% 


TODO

ここまでやって、一通り完成したように見えましたが、このまま全APIスキーマをOpenAPI Specファイルに変換しようとしたところ、盛大にエラーが出ます。

f:id:dackdive:20191209013154p:plain
生成したSpecファイルを https://editor.swagger.io/ に貼り付けたら大量にエラーが出た様子

エラーメッセージを少し見た様子だと、OpenAPIとして期待している型が違う(integerを期待しているところが文字列)だったり、patternPropertiesなどOpenAPIがサポートしていないJSON Schemaのキーワードを使用しているのが問題のようです。

前者はまだいいとして、後者にちゃんと対応するのはなかなか大変そうですね。。。(そもそも回避できるのかもわかってない)


所感

JSON SchemaもOpenAPIも今回初めてまともに使用したのでうまくいくか全くわかりませんでしたが、それっぽいものを作ることはできました。
とはいえ、TODOに書いたように完全なREST APIドキュメントを生成できるようにするのはだいぶハードルが高そうです。

また、調べていて苦労した点は、どのツールがデファクトスタンダードなのかあまりよくわからなかったというところです。
今回はGitHubのStar数やコミットの最終更新日時などを判断材料に選定しましたが、適切な選択になっていたかはわかりません。

また、そもそもツールを探すのも一苦労でした。
一応、今回の知見としてOpenAPI.Toolsというツール一覧サイトがあるようです。

OpenAPI関連でやりたいことがあれば、まずはこのサイトを見るのがいいのかなーと思いました。
このサイトの信頼性もよくわかりませんが。。。

Dreamforce'19 Developer Keynoteまとめ

ブログ

動画

ブログ記事を読みながら動画をとばしとばし観た感じです。


Lightning Base Componentsがオープンソース

f:id:dackdive:20191121120137p:plain

動画09:30あたり。

先日のLWCのオープンソース化に続き、Lightning Base Componentsという基本コンポーネント群もオープンソース化したそうです。

リポジトリ

最初、リポジトリ名からBase Compoentsそのものでなくこれらを使ったサンプル集でしかないのかな、と思いましたが
このディレクトリ下がBase Componentsにあたりそうですね。 https://github.com/salesforce/base-components-recipes/tree/master/force-app/main/default/lwc


Evergreen: Salesforceでサーバーレスを実現する新機能

f:id:dackdive:20191121123751p:plain 動画10:15あたり。

かなりインパクトのある発表だったんじゃないでしょうか。
英語だけでなくほぼ同時に日本語でも紹介ブログが投稿されたあたり、本気度が伺えます。

ざっくり

  • Apex, Java, Node.jsでサーバーサイドの処理が書けるようになる
  • Platform Eventsなどを起点としたイベント駆動
    • (いや、Apexから直接実行もできるとあるので、完全にイベント駆動というわけでもないか)
  • 2月のSpring'20でリリース予定

ということは把握しましたが、まだブログもデモもちゃんと見れてないので別途詳細を書く。


Salesforce API Portal

f:id:dackdive:20191121125543p:plain

動画24:45あたり。

利用可能なAPIが一箇所にまとまったサイトができた、かな。おそらくまだリリースはされてない。

この後のBlockchain関連のところ(33:00あたり)でデモされてます。

f:id:dackdive:20191121190249p:plain

URLが https://**********.for.com/kjcapi/... となっているので、各Salesforce組織内に含まれるイメージ?

Select API Instanceというのはおそらくモックを叩くか実際の組織のデータを扱うかを選択できるってことで、これはAPIドキュメントとしてはかなり便利そう。


Einstein 機能強化

f:id:dackdive:20191121185842p:plain

動画24:45ぐらい。

元々のVision & Languageに加えMulti-Language、OCR、Voiceなどの新機能がリリース。


LWCのローカル開発

前に書いたので割愛。


Lightning Full Sandbox & Mask Data

f:id:dackdive:20191121191351p:plain

f:id:dackdive:20191121191358p:plain

数分で構築が完了する新しいSandbox(速度以外は従来のFull Sandboxと何が違うの?)と、そのSandboxで使えるデータのマスク機能。
運用環境のデータでテストしたいけどセンシティブなデータはマスクしたい...!っていうの実際あるので便利。

マスク方法はフィールドごとに以下の3種類から選べる。

  • Anonymization: ランダムな文字列に変換。例: BlakegB1ff95-$
  • Pseudonymization: 仮名化、と呼ぶらしい。元の値とは無関係だが読める文字列に変換。例:KelseyAmber
    • フィールドのコンテキストとか考慮されるんだろか
  • Deletion: フィールドの値をクリア

動画43:54あたりにデモあり。


リアルタイムイベントモニタリング

f:id:dackdive:20191121191731p:plain

Winter'20でGAになった機能らしいです。知らんかった。
イベントデータのほぼリアルタイムのストリーミングおよび保存 (正式リリース)

ヘルプもある:リアルタイムイベントモニタリング

リアルタイムイベントモニタリングを使用すると、次の点についてより貴重なインサイトを得ることができます。

  • 誰がいつどのデータを表示したか
  • どこでデータがアクセスされたか
  • いつユーザが UI を使用してレコードを変更するか
  • 誰がどこからログインしているか
  • 組織の誰がプラットフォームの暗号化管理に関連するアクションを実行しているか
  • どのシステム管理者が別のユーザとしてログインし、そのユーザとしてどのアクションを実行したか
  • Lightning ページの読み込みにどのくらいの時間がかかるか

Transaction Security Policyについては動画47:45あたりにデモあり。

f:id:dackdive:20191121193149p:plain

ユーザーがこのオブジェクトのレコードを閲覧しようとしたら、操作をブロックしつつ管理者に通知する、みたいなことがGUIでできるようになったのかな。


ロードマップ

f:id:dackdive:20191121194728p:plain

動画一番さいご。

"Lightning Web Runtime Beta"と"GitHub App Support Beta"が気になります。
前者はこないだLWCローカル開発機能を調べてたときに見つけた @webruntime/common とかだろうな。

@octokit/restのアーキテクチャについて調べた

良いREST APIクライアントの設計というものに関心があり、GitHub社の公式REST APIクライアントである @octokit/rest のコードを読んでみたメモです。

(ドキュメントは https://octokit.github.io/rest.js/)

知りたかったこと

漠然と、こういう疑問に対してなんらか知見が得られればいいなーと思っていました。

  • 全体的なディレクトリ構成やレイヤー。どういうふうに責務を分けているか
  • APIエンドポイントに対応するメソッドはどのように実装している?命名ルールやnamespaceなどの分割基準は?
  • Node.js環境とブラウザ環境を両方サポートするために、環境の差異をどのように吸収しているのか
  • エラーハンドリングはどうしてる?
  • テストはどうしてる?
    • 何に対してテストを書いているか。リクエスト部分のモックはどうしているか

調べてわかったことメモ

以下、特にまとまりなく書いてます。


@octokit/restのアーキテクチャ全体像

ライブラリ利用者目線での全体像は HOW_IT_WORKS.md がわかりやすいです。

f:id:dackdive:20191108220724p:plain

(画像はリンク先から引用)

図および本文から

などがわかりました。


@octokit/restと@octokit/request, @octokit/endpoint

依存関係は

@octokit/rest --(require)--> @octokit/request --(require)--> @octokit/endpoint

の順になっています。
それぞれの役割については以下のとおりです。


@octokit/endpoint

GitHub REST APIのエンドポイント(例:GET /gists/:gist_id) と必要なパラメータを受け取り、fetch APIaxiosに渡せる汎用的なリクエストオブジェクトに変換する。

(例)

const { endpoint } = require('@octokit/endpoint');

const options = endpoint('POST /repos/:owner/:repo/branches/:branch/protection/required_status_checks/contexts', {
  owner: 'zaki-yama',
  repo: 'my-repo',
  branch: 'my-branch',
});

console.log(options);

↑の結果

{ method: 'POST',
  url:
   'https://api.github.com/repos/zaki-yama/my-repo/branches/my-branch/protection/required_status_checks/contexts',
  headers:
   { accept: 'application/vnd.github.v3+json',
     'user-agent':
      'octokit-endpoint.js/5.4.1 Node.js/10.15.3 (macOS Mojave; x64)',
     'content-length': 0 } }

これ単体で使うことはなさそうですが、引数に補完が効くあたりは面白いというか便利だなと思いました。

f:id:dackdive:20191108225534p:plain


@octokit/request

先ほどの @octokit/endpoint を使い、実際にAPIを叩くところを行う。
APIリクエスト時のNode.js/ブラウザ環境の差分をどうしてるかとかはここを見ると良さそうです。


@octokit/rest

@octokit/request に認証まわりの機能や Hooks などの機構を追加して、最終的なAPIクライアントの形にしたもの。


(@octokit/request) Node.js環境とブラウザ環境の考慮は?

APIリクエスト部分は@octokit/requestがやっているということがわかったので、こっちを見てみます。
READMEには

It uses @octokit/endpoint to parse the passed options and sends the request using fetch (node-fetch in Node).

とあるので、ブラウザ環境ではfetch API、Node.js環境ではnode-fetchというライブラリを利用しているようです。
ところが、fetchを行っている https://github.com/octokit/request.js/blob/v5.3.1/src/fetch-wrapper.ts を見ても、動作環境の判定らしき処理はありません。

(https://github.com/octokit/request.js/blob/v5.3.1/src/fetch-wrapper.ts#L22-L36 を抜粋)

const fetch: typeof nodeFetch =
  (requestOptions.request && requestOptions.request.fetch) || nodeFetch;

return fetch(
  requestOptions.url,
  Object.assign(
    {
      method: requestOptions.method,
      body: requestOptions.body,
      headers: requestOptions.headers,
      redirect: requestOptions.redirect
    },
    requestOptions.request
  )
)

注)1, 2行目の requestOptions.request.fetch はオプションとして渡すとライブラリ利用者が任意のfetch関数に差し替えることができるもの

そこで node-fetch を見てみると、package.jsonで browser フィールドを指定している (そしてその中身は window.fetch) ことがわかります。

また webpack のドキュメントを読むと
https://webpack.js.org/configuration/resolve/#resolvemainfields
モジュールをimportする際、package.jsonbrowser -> module -> main フィールドの順に探しにいくようです。(target未指定の場合)

そのため、webpackなどのモジュールバンドラーを使ってバンドルしたJSはwindow.fetchを使うようになりそうです。
よって動作環境の違いはnode-fetch側でケアしており、@octokit/requestは特に何もしてませんでした。


(@octokit/rest) 各APIに対応するメソッドはどのように生成しているのか

@octokit/rest 内のコードを検索しても、octokit.repos.listForOrg()のようなメソッドはヒットしません。
で、調べてみたところ、以下のようなしくみで自動生成しているようでした。

生成されるroutes.jsonの中身はこんな感じになっている。

(https://github.com/octokit/rest.js/blob/master/plugins/rest-api-endpoints/routes.json#L4527-L4544 あたり)

{
  ...
  "repos": {
    "listForOrg": {
      "method": "GET",
      "params": {
        "direction": { "enum": ["asc", "desc"], "type": "string" },
        "org": { "required": true, "type": "string" },
        "page": { "type": "integer" },
        "per_page": { "type": "integer" },
        "sort": {
          "enum": ["created", "updated", "pushed", "full_name"],
          "type": "string"
        },
        "type": {
          "enum": ["all", "public", "private", "forks", "sources", "member"],
          "type": "string"
        }
      },
      "url": "/orgs/:org/repos"
    },
    ...
  },
  ...
}

JSONの第一階層(repos)がGraphQL APIscope、第二階層(listForOrg)がidに対応している。

  • このroutes.jsonを処理しているのはoctokit.registerEndpoints()という関数で、ここに定義されている
  • やっていることはひたすら octokit.<scope>.<id> で呼び出せる関数を生やす作業
  • code.jsと同じようなノリで、scripts/update-endpoints/typescript.jsはendpoints.jsonからTypeScript型定義を生成している
    • Mustacheを使っている
    • さらに、似たようなスクリプトが @octokit/endpoint にもある(↑で書いた補完が効くのはそのおかげ)

余談ですが、このoctokit.registerEndpoints()はライブラリ利用者が任意のエンドポイントを生やす関数として提供されているものでもあります。
参考:https://octokit.github.io/rest.js/#custom-endpoint-methods


(@octokit/request) エラーハンドリングはどのように行っている?

https://github.com/octokit/request.js/blob/master/src/fetch-wrapper.ts

RequestErrorという独自のエラークラスをthrowしています。エラークラスの実体は @octokit/request-errorという別のライブラリになっています。
APIリクエスト時に発生したエラーはすべてこのカスタムエラーとしてthrowする方針のようですね。
https://github.com/octokit/request.js/blob/master/src/fetch-wrapper.ts#L121-L124


Hooksのしくみはどうやって実現している?

https://octokit.github.io/rest.js/#hooks
よくわかってませんが、https://github.com/gr2m/before-after-hook というライブラリを使うとさくっと実現できる?


感想 & まだ調べられてないこと

パッケージレベルでかなり細かく分割してそれぞれをシンプルに保っているなあという点と、スキーマ情報から各エンドポイントに対応するメソッドを自動生成してるところなどはなるほどなあと思いました。

まだ調べられてないところとしてはテスト周りでしょうか。
どうやら一部のテスト(test/scenarios/以下)のために @octokit/fixtures-server というモックサーバーライブラリを使用しているらしく、このあたりも調べてみるとまた新たな学びがありそうです。

Salesforce: LWCのローカル開発機能(ベータ)を試す

先日、LWCのローカル開発機能のベータ版がリリースされました。

ブログ記事を読みつつ、手元で動かしてみたメモです。


LWC ローカル開発機能の特徴(ねらい)

ブログ記事では、以下の3点に言及しています。

  1. 可能な限り本番環境に近い環境で、コンポーネントをローカルでレンダリングする
    • ローカルで動けば実際の環境でも動く、を実現したい
  2. エラーの発見と解決を行いやすくする
  3. 実際のデータとのインテグレーション
    • Lightning Data ServiceやApexを使ったリクエストはスクラッチ組織にプロキシされるので、ローカル環境でも実データを使ったコンポーネントの描画ができる


インストール & セットアップ

Salesforce CLIプラグインとしてインストールします。

$ sfdx plugins:install @salesforce/lwc-dev-server

実データのやり取りにはスクラッチ組織を使用するため、DevHubの作成と認証、スクラッチ組織の作成とコードのpushまでは済ませておきます。

$ sfdx force:auth:web:login -d -a <myhuborg>
$ sfdx force:org:create -s -f config/project-scratch-def.json -a "LWC"
$ sfdx force:source:push

また、LWCは自分で作ったことほぼないので、今回は公式のlwc-recipesリポジトリで試します。

$ git clone https://github.com/trailheadapps/lwc-recipes
$ cd lwc-recipes


ローカルサーバーの起動

force:lightning:lwc:start というコマンドが追加されているので、プロジェクトルートで実行します。

$ sfdx force:lightning:lwc:start
Use of this plugin is subject to the Salesforce.com Program Agreement.
By installing this plugin, you agree to the Salesforce.com Program Agreement<https://trailblazer.me/terms>
and acknowledge the Salesforce Privacy Policy<https://www.salesforce.com/company/privacy.jsp>.

Starting LWC Local Development.
    Dev Hub Org: admin@yama.salesforce.com
    Scratch Org: test-lka7jajxzlec@example.com
    Api Version: 46.0
Template version key d2aed51205
[HPM] Proxy created: /  ->  https://power-computing-182-dev-ed.cs6.my.salesforce.com
[HPM] Subscribed to http-proxy events:  [ 'proxyReq', 'error', 'close' ]
Server up on http://localhost:3333

http://localhost:3333 にアクセスすると、このような画面が開きます。

f:id:dackdive:20191007041926p:plain

force-app/main/default/lwc 以下のLWCが一覧で表示されています。
ここから各コンポーネント名をクリックすると、該当のLWCがレンダリングされます。

f:id:dackdive:20191007042243p:plain


特徴

ブログ記事で言及されていた特徴をいくつか。

  • 該当のLWCをVSCodeで開くことができる

f:id:dackdive:20191007042659g:plain

  • エラー表示

f:id:dackdive:20191007043658p:plain

キャプチャは ldsDeleteRecord.js@track デコレータをtypoしたときです。
エラーが発生している行、スタックトレースが表示されています。

  • サーバーサイドとの通信

f:id:dackdive:20191007044531p:plain

実データを扱うコンポーネントについても問題なく表示されます。
Chromeの開発者ツールのNetworkタブより、実際に通信が発生しているのも確認できます。

また、recordIdプロパティを使っているコンポーネントについては、IDをハードコードすることで実データを取得することができます。

例:wireGetRecordStaticContact.js

f:id:dackdive:20191007045049p:plain

このへんの体験は将来的にもうちょっと良くなる予定、って書かれてました。

In coming releases, there will be tools in the Local Development Server to set component attributes so that it won’t be necessary to hard code development values when running and debugging locally.


所感

ちょっと試した限りではLWC版Storybookという印象でした。
サーバーとの通信はモックではなく実際にスクラッチ組織とやり取りするという点は賛否ありそうだなーという感想。

個人的には、そのせいでオブジェクトのメタデータやApexをpushしないといけないのでモックの方がいいかな(もしくは選べる)。

Proxyのしくみとかは気になるのでコード読みたいなと思ったんですが、リポジトリは公開されていない様子。
(おそらく https://github.com/forcedotcom/lwc-dev-server というリポジトリ)

インストールしたプラグイン

~/.local/share/sfdx/node_modules/@salesforce/lwc-dev-server

にあるので、何か手がかりはつかめるかもしれませんがそこまでの気力は湧かず。

複数のモジュール形式(CommonJS, ES Modules, UMD)をサポートしたnpmパッケージの作り方 in TypeScript

はじめに

npmパッケージを開発するとき、パッケージ利用者の実行環境に合わせて適切なモジュール形式のファイルをパッケージに含め、提供する必要があります。
具体的には、たとえば以下のようなバリエーションが考えられます。

  • Node.js環境であれば CommonJS 形式 (module.exports / require() )
  • ブラウザ環境で、webpackやRollupなどのモジュールバンドラーを前提とするならば CommonJSES Modules 形式 (export / import )
  • ブラウザ環境で、モジュールバンドラーなどは使わず<script>タグでファイルを読み込んで利用するならば UMD 形式

このとき、パッケージ提供側はどういったファイルをパッケージに含めるべきなのか、またそれを TypeScript でどのように実現できるのかがあまりよくわかっていなかったので、整理しました。

注:今回想定していない環境

目次



最初にまとめ

最終的なディレクトリ構成は以下のようになりました。

f:id:dackdive:20190923032928p:plain

また、package.jsonには以下main, module, types, filesフィールドを指定します。

// package.json
{
  "main": "lib/index.js",
  "module": "esm/index.js",
  "types": "lib/index.d.js",
  "files": [
    "lib",
    "esm",
    "umd"
  ],
  // ...
}

詳しくはGitHubにサンプルリポジトリを作りました。


1. CommonJS 形式

CommonJSはNode.js環境で採用されているモジュール形式であることと、ブラウザ環境でもwebpackなどのモジュールバンドラーやTypeScriptがこの形式をサポートしていることから、
とりあえずCommonJS形式でパッケージを提供しておけばNode.js環境とブラウザ環境を両方サポートできます。


tsconfig.json

TypeScriptでCommonJS形式でコンパイルする場合、tsconfig.jsonmoduleプロパティにcommonjsを指定します。
また、出力先のディレクトリ名は任意ですが、ここではlibとしてoutDirプロパティに指定しておきます。

// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "./lib"
    // ...
  }
}

こうすると、ソースファイルを格納しているディレクトリ(ここではsrc)と同じ構成のまま、
TypeScript → JavaScriptコンパイルした結果がlibディレクトリに出力されます。

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/
  - index.js
  - moduleA.js
  - moduleB.js


package.json

package.jsonでは、mainフィールドにパッケージのエントリーポイントとなるファイルのパスを、filesに出力先ディレクトlibを記載します。

// package.json
{
  "main": "lib/index.js",
  "files": [
    "lib"
  ]
}


2. ES Modules 形式

webpack(v2以降)やRollupなどのモジュールバンドラーには、Tree Shakingという機能が備わっています。
これは、exportしているがどこからもimportされていない不要なモジュールをバンドル時に削除する機能です。

参考:webpackの公式ドキュメント内でTree Shakingについて説明されているページ

パッケージ提供側が多数のモジュールを提供している場合、ES Modules形式つまり import/export で書かれたファイルをCommonJS形式に変換せずそのまま提供することで、利用者側はTree Shakingの恩恵を受けることができます。


tsconfig.json

TypeScriptでこれを実現する場合、CommonJSと同様にmoduleプロパティを使用します。
先ほどはcommonjsという値を指定しましたが、今度はESNextを指定します。

また、先ほどのCommonJS形式のファイルと出力先のディレクトリを分けるため、outDir: "./esm"も指定します。同じくディレクトリ名は任意です。

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./esm"
    // ...
  }
}

こうすることで、CommonJS形式と同じくsrc下と同じ構成のファイル群のJSがesmに生成されます。

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/  # 中身はCommonJS( module.exports / require )
  - index.js
  - moduleA.js
  - moduleB.js
- esm/  # 中身はES Modules( export / import )
  - index.js
  - moduleA.js
  - moduleB.js


NOTE: tsconfig.jsonの構成をどのようにすべきか

このように、同じTypeScriptのソースファイルから複数の設定でコンパイルする場合、プロパティの異なる複数のtsconfig.jsonが必要になります。
tsconfig.jsonextendsというプロパティがあり、これを使うとある設定ファイルの内容を継承して一部だけ上書きできます。
モジュール形式によらないプロパティはベースの設定ファイルに記述し、モジュール形式によって差が出るプロパティはベースを継承した別の設定ファイルを定義するのが良いかと思います。

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "es5",
    "strict": true,
    // ...
  }
}
// tsconfig.esm.json
{
  "extends": "./tsconfig.base",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./esm"
  }
}

また、コンパイル時は-pまたは--projectオプションで使用する設定ファイルを指定できるので、これをpackage.jsonのscriptsに記載しておくと良いでしょう。

// package.json
{
  "scripts": {
    "build": "npm run build:cjs && npm run build:esm",
    // CommonJS用
    "build:cjs": "tsc -p tsconfig.cjs.json",
    // ES Modules 用
    "build:esm": "tsc -p tsconfig.esm.json"
  }
}

詳しくはサンプルリポジトリを参考にしてください。


package.json

package.jsonには、CommonJS形式のときと同じくfilesフィールドに出力したディレクトリ(esm)を追加するほか、moduleフィールドでesm内のエントリーポイントを指定します。

// package.json
{
  "main": "lib/index.js",
  "module": "esm/index.js",
  "files": [
    "lib",
    "esm"
  ]
}

moduleフィールドについて、公式ドキュメントには載っていませんでしたが、以下のStack Overflowが見つかりました。

Node.jsでES Modulesを扱うためのProposalということでしょうか...(あんまりわかってない)
また、Rollupのドキュメントには以下の記述があります。

https://rollupjs.org/guide/en/#publishing-es-modules

If your package.json file also has a module field, ESM-aware tools like Rollup and webpack 2+ will import the ES module version directly.

つまり、モジュールバンドラーはこのフィールドで指定したパスのファイルをES Modules形式で扱うようです。


3. UMD

Reactなどのメジャーなライブラリでは、インストール方法として npm install だけではなく以下のようにCDNで利用できる形式をサポートしています。

https://reactjs.org/docs/cdn-links.html

<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

これをHTMLに書くとReactReactDOMグローバル変数に定義され、使えるようになるため、利用者側はモジュールバンドラーを使った環境を構築する必要がありません。
オンラインエディターなどでも簡単に試すことができます。

これを実現するためのモジュール形式がUMDです。


webpack.config.js

パッケージをUMD形式で配布するためには最終的にファイルを1つにバンドルする必要があり、これはTypeScript単体では完結しません。何らかのモジュールバンドラーを利用する必要があります。
ここではwebpackを例にします。

webpackでUMD形式のファイルを作成するには、output.libraryおよびoutput.libraryTargetフィールドを使用します。

https://webpack.js.org/configuration/output/#outputlibrary
https://webpack.js.org/configuration/output/#outputlibrarytarget

// webpack.config.js
const path = require("path");

module.exports = {
  mode: "production",
  entry: "./src/index.ts",
  output: {
    path: path.resolve(__dirname, "umd"),
    filename: "my-typescript-package.js",
    library: "MyTsPackage",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.ts(x*)?$/,
        exclude: /node_modules/,
        use: {
          loader: "ts-loader",
          options: {
            configFile: "tsconfig.umd.json"
          }
        }
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"]
  }
};

libraryTargetumdを指定することでUMD形式でのバンドルを行います。またバンドルしたファイルを読み込んだときにlibraryに指定したグローバル変数名でアクセスできるようになります。

なお、webpackを使用する場合、TypeScriptもwebpack内で使用するためts-loaderが必要となります。
options.configFileは設定ファイルがtsconfig.jsonという名前の場合は不要です。


tsconfig.json

tsconfig.json側は特別な設定は必要ありません。出力先のディレクトリも今回はwebpack.config.js側で指定しているため不要です。

ただ、
最新版TypeScript+webpack 4の環境構築まとめ(React, Vue.js, Three.jsのサンプル付き) - ICS MEDIA
にもあるように、moduleESNextを指定していないと、ソースファイル内に使用していないexportが混ざっていてもTree Shakingが機能せず、バンドルファイルに含まれることになります。
(これはアプリケーションを開発する際は重要ですが、npmパッケージとして提供したいようなライブラリの場合は使っていないexportは基本ないはず...)

また、ベースの設定ファイルでdeclaration: trueにして型定義ファイルを出力するようにしている場合、UMD用の設定では出力をOFF(declaration: false)にしておくのが良いと思います。


package.json

filesumdディレクトリを追加することと、UMDビルド用のスクリプトを追加すること以外は特にありません。

   "scripts": {
-    "build": "npm run build:cjs && npm run build:esm",
+    "build": "npm run build:cjs && npm run build:esm && npm run build:umd",
     "build:cjs": "tsc -p tsconfig.cjs.json",
     "build:esm": "tsc -p tsconfig.esm.json",
+    "build:umd": "webpack",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "files": [
     "lib",
-    "esm"
+    "esm",
+    "umd"
   ],

ここまでで、CommonJS、ES Modules、UMDのファイルが以下のような構成で出力されるようになっています。

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/  # 中身はCommonJS( module.exports / require )
  - index.js
  - moduleA.js
  - moduleB.js
- esm/  # 中身はES Modules( export / import )
  - index.js
  - moduleA.js
  - moduleB.js
- umd/  # UMD
  - my-typescript-package.js


NOTE: development用とproduction用の2つのバンドルを用意する場合は?

先ほど例として挙げたReactもそうですが、UMD形式に対応しているライブラリはdevelopment用 (foo.development.js)とproduction用(foo.production.js)の2種類を提供していることがあります。

https://reactjs.org/docs/cdn-links.html
f:id:dackdive:20190918030405p:plain:w400

(試していませんが) これを実現したい場合、webpackの
Configuration Types > Exporting multiple configurations
によると、webpack.config.jsで配列を返すようにすれば複数の設定でバンドルした結果を出力してくれます。
modeoutput.filenameだけ異なる2つの設定を用意し、まとめてumd/以下に出力すれば良いでしょう。


NOTE: 出力先のディレクトリ名について

UMD形式についてもディレクトリ名は任意です。
が、umdまたはdistという名前にしておくと、unpkg.comというCDNがnpm publish時に自動的に認識してくれ、publishしたパッケージが

https://unpkg.com/<package name>@<version>/<`umd` or `dist`>/<file name>

というURLで利用できるようになるので、この慣習にならっておくのがおすすめです。

参考:https://unpkg.com/#workflow

f:id:dackdive:20190918030605p:plain


4. (おまけ) 型定義ファイル(.d.ts)

モジュール形式とは別の話ですが、TypeScriptで開発したパッケージの場合、本体のスクリプトと一緒に型定義ファイルもパッケージに含めて提供してあげる必要があります。

必要なことは

  • tsconfig.jsondeclaration: trueを追加
  • package.jsontypesフィールドに出力した型定義ファイルのエントリーポイントを指定

の2点です。

後者は、たとえばlib/ディレクトリ以下に

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/
  - index.js
  - index.d.ts
  - moduleA.js
  - moduleA.d.ts
  - moduleB.js
  - moduleB.d.ts

というように型定義ファイルを出力するようにしている場合、

// package.json
{
  "main": "lib/index.js",
  "types": "lib/index.d.js"
}

となります。

参考:https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#including-declarations-in-your-npm-package

※サンプルリポジトリではlib/にもesm/にも型定義ファイルを出力するようになっていますが、どちらかだけで十分のはず


余談

上に書きませんでしたが、調べている過程で見つけたTips的な情報を。


package.jsonfilesフィールドは必ず必要なのか

filesはnpm publish時にパッケージに含めるファイルやディレクトリを指定するためのフィールドで、指定しなかった場合は全てのファイルがパッケージに含められます。
そのため、余計なファイルが含まれることを許容すればこのフィールドは指定しなくても良さそうに思えます。

が、 https://docs.npmjs.com/files/package.json#files を読むと

You can also provide a .npmignore file in the root of your package or in subdirectories, which will keep files from being included. (中略) If there is a .gitignore file, and .npmignore is missing, .gitignore’s contents will be used instead.

とあります。つまり

  • .npmignore に列挙したファイルやディレクトリはパッケージから除外される
  • .npmignore を置いていない場合、.gitignoreの内容が使われる

ということです。
今回のlibのようにコンパイルしたファイルの出力先はgit commitしないよう.gitignoreに記載するのが一般的だと思われるので、filesの指定がないと意図せずパッケージにも含まれなくなってしまいます。
このような事故を避けるため、またパッケージの内容を明示的にするためにもfilesは記載しておくのがおすすめです。


package.jsonunpkgフィールドとは

UMD形式をサポートしているパッケージをいくつか調べたところ、package.jsonunpkgというフィールドを指定しているパッケージがありました。

これについては unpkg.com のサイトを確認したところ

https://unpkg.com/#examples

If you omit the file path (i.e. use a “bare” URL), unpkg will serve the file specified by the unpkg field in package.json, or fall back to main.

とあるので、UMD形式で出力したファイルのパスを指定しておけば、利用者は

https://unpkg.com/<package name>@<version>

というURLでパッケージを読み込めるというわけですね。
(あんましなさそうですが)


webpack.config.jsのexternalsオプションとは

これもUMD形式をサポートしているパッケージでよく見かけました。

参考:

// webpack.config.js
externals: {
  react: 'React',
  'react-dom': 'ReactDOM',
}

// または
externals: {
  react: {
    amd: 'react',
    commonjs: 'react',
    commonjs2: 'react',
    root: 'React',
  },
  // ...
}

これについてはwebpackの公式ドキュメント
https://webpack.js.org/configuration/externals/

によると、このオプションで指定したモジュールはバンドルしたファイルに含まれなくなり、パッケージ利用者側が別途モジュールを読み込む必要があります。


リファレンス

モジュール形式については以下が参考になりました。

参考にしたライブラリ

TypeScript: 配列の値をString Literal Typesとして使う

メモ。
こんな感じで、ある配列として定義されている値に対し、「その値のいずれか」を意図した String Literal Types を作りたい。

const size = ['small', 'medium', 'large'];

type Size = 'small' | 'medium' | 'large';

でも、配列と型定義とで値を二度書きたくない。


こうする

TypeScript 3.4 で導入された const assertion を使うと、次のように書ける。

const size = ['small', 'medium', 'large'] as const; // readonly ["small", "medium", "large"]

type Size = typeof size[number]; // 'small' | 'medium' | 'large'

(Playground)

こちらの Stack Overflow を参考にした。


リファレンス

TypeScript: ReactNode型とReactElement型とReactChild型

メモ。

  • ReactNode
  • ReactElement
  • ReactChild

の関係性、何回か調べている気がするので整理しておく。

f:id:dackdive:20190807012948p:plain

@types/react の型定義
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts
を参照した。

図で、ReactNodeArrayArray<ReactNode> 以外の線は Union Types を表している。
たとえば

type ReactChild = ReactElement | ReactText

である。


メモ

  • JSX が受け付けるすべてのものをまとめた type として ReactNode があり、そこから string や null などを除いた純粋な React コンポーネントを意味するのが ReactElement
  • ほとんどが type alias だが、ReactNodeArrayReactPortal だけは interface だった
    • ReactNodeArrayArray<ReactNode> を継承してるだけ