型安全なURL生成ライブラリ url-from を公開しました


こんにちは。ニコニコ生放送生放送フロントエンドシステムセクションのmisuken(GitHub/Twitter)です。

今回は最近公開したurl-fromというライブラリに関して、作成した動機、安全性、利便性、おすすめの使い方を紹介していこうと思います。

url-fromが一般的なURL生成ライブラリと比較して強みを持っているのは以下の点になります。

  • パス部分も含め、全体的にエンコードを意識せず使用できる
  • URLの定義と生成を分離できる
  • 細部まで型や警告で保護してくれる

実際の利用シーンに馴染む設計を心がけたため、安全性と利便性と書き味を兼ね備えたライブラリに仕上がっています。

使用してもらったメンバーから「型がサクサク当たるので書いてて楽しい」といった声もあがるくらい、使っていて楽しくなるライブラリでもあります。

url-fromを作った動機

発端

以前からチーム内でURLの生成方法が時期や実装者によってまちまちで、書き方が安定しなかったり、エンコード漏れが発生したりと、度々問題に上がっていました。

JavaScript標準のnew URL()URLSearchParamsを使うことで合意したものの、インタフェースがイマイチで書き味が悪く、どうしても手続き的なコードになりがちです。 Query生成に関しては、代表的なquery-stringqsがあるものの、どのライブラリもパス部分は自力でエンコードしないといけないものがほとんどです。

パスにスラッシュを含む値を埋め込む場合

例えば https://example.com/tags/<tag> のようなURLで、タグ名に"/“を含むゲームタイトルが使用された場合、”/“がエンコードされた https://example.com/tags/foo%3Fbar という結果を期待します。

ユーザー入力のデータを埋め込む場合にencodeURIComponentを使用することになるわけですが、ユーザー入力のデータを埋め込む場合だけencodeURIComponentを使用しましょうという決まりは、抜け漏れが発生する典型的なパターンです。

抜け漏れを防ぐには、全ての動的な埋め込みにencodeURIComponentが必要であり、それは人間の注意力に頼るしかありません。

さすがにこれは厳しいので、以下の要件を満たすものを作れないものか?と試行錯誤し始めました。(HTMLやSQLで長年エスケープ周りのセオリーは把握している前提での話です)

  • クエリだけでなくパスの部分も自動でエンコードされること
  • 誰が使っても安全になること(これ使っておけば大丈夫的な)
  • 書き味と使い勝手が良いこと

参考にした情報

主に以下のサイトと、URL処理系ライブラリを参考にしながら作成しました。

色々と調べて見てみたところ、JavaScript標準のURLURLSearchParamsはQuery部分から値を得るなど、URLを構成する値の参照系APIには問題が無さそうでした。 その一方でURLの生成や更新系のAPIには空白の扱いで問題が生じることがなきにしもあらずといった感じです。

URL (or URLSearchParams) は encodeURIComponent の代替にはならない URLSearchParams で作ったものを decodeURIComponent でデコードしてはならない

2つ目の記事にあるように、API同士の関係性や、通信先の実装を理解していないとトラブルになる可能性がある点にも注意が必要です。

URLURLSearchParamsだけを使用していればある程度は問題なさそうですが、パス部分のエンコードを考慮するとencodeURIComponentも必要になります。

これらを全員に意識して実装やレビューをしようとしてもなかなか大変なので、なおさらQueryもパスもRFC3986でエンコードしてくれるものが欲しくなりました。

url-fromの仕組み

技術的には以下とTypeScriptの型システムの機能を駆使して実現しています。

タグ付きテンプレートは値を埋め込むプレースホルダ${}の中身が型推論可能であるため、可変長引数の...placeholdersをタプル型で推論しつつ、プレースホルダでリテラル型になる"tag?:string"のような文字列型をTemplate Literal Typesでパースし、戻り値になるURL生成関数の引数型に反映しています。

export default function urlFrom<T extends PlaceholderArg = never, U extends keyof NativePlaceholderValueTable = never>(
  rawLiterals: TemplateStringsArray,
  ...placeholders: [...Array<ExtractValidPlaceholderSyntax<T, keyof ResolvePlaceholders<T> & string> | U | [Value]>]
): BindUrl<T> {
  // 省略
}

安全性

エンコード漏れを起こせない仕組み

url-fromはRFC3986のルールに従ってエンコードを行います。

パスやクエリの動的に埋め込む値はもちろん、リテラル部分に含まれるエンコード対象文字列もコンポーネントを考慮して処理するため、全体を通してエンコード漏れが発生する余地がありません。

コンポーネントというのは、URLの各部位(scheme authority path query fragment)のことです。

   foo://example.com:8042/over/there?name=ferret#nose
   \_/   \______________/\_________/ \_________/ \__/
    |           |            |            |        |
 scheme     authority       path        query   fragment

schemeやportの “:” はエンコードしてはいけませんが、path query fragment では非予約語(unreserved)に定義されている ALPHA / DIGIT / "-" / "." / "_" / "~" 以外がエンコード対象になります。

その上で以下も考慮して結果に反映されます。

  • リテラル部分のpathの"/" はエンコード対象外(実装者が書く静的な部分なので意図的)
  • リテラル部分のqueryの "&" / "=" はエンコード対象外
  • リテラル部分のqueryの "=" 後の "&" / "#" 前にもう一度 "=" が現れたら警告を発してエンコード(queryの値部分のエンコード漏れか誤りの可能性が高いため)
// ❗ Warn: The literal part contains an unencoded query string "=". Received: `https://example.com/path/to?foo=bar=baz`
urlFrom`https://example.com/path/to?foo=bar=baz`(); // "https://example.com/path/to?foo=bar%3Dbaz"

// ✅ Direct Placeholderで意図的にエンコード対象文字列を埋め込めば警告は出ません
urlFrom`https://example.com/path/to?foo=bar${["="]}baz`(); // "https://example.com/path/to?foo=bar%3Dbaz"

これ以外にも、schemeやauthorityの各コンポーネントごとに適切な処理を行っています。

型による安全の担保

url-fromはプレースホルダの工夫により、型を通すことで大部分の安全が担保される仕組みになっています。

TypeScript風の型指定で動的な値が必須であるか、期待する値はstringnumberであるか、といった要素を静的な型で解決することにより安全を実現します。型が通っていれば値の渡し忘れや、不適切な型が渡ってくるといった問題も起きません。

urlFrom`https://example.com/users/${"userId:number"}`({ userId: 1234 }); // 必須&数値で number を渡す ✅OK
urlFrom`https://example.com/users/${"userId:number"}`({ userId: "1234" }); // 必須&数値で string を渡す ❌型エラー

urlFrom`https://example.com/users/${"userId?:number"}`({ userId: 1234 }); // 任意&数値で number を渡す ✅OK
urlFrom`https://example.com/users/${"userId?:number"}`({ userId: "1234" }); // 任意&数値で string を渡す ❌型エラー

urlFrom`https://example.com/users/${"userId:number"}`(); // 必須&数値で省略 ❌型エラー
urlFrom`https://example.com/users/${"userId?:number"}`(); // 任意&数値で省略 ✅OK

誤りに気付かせる方法

url-fromは実装者が誤っている可能性や、より良い書き方がある場合は警告を発して改善を促します。

例えば、リテラル部分にエンコード対象文字列が含まれると適切にエンコードしますが、実装者の意図ではなく誤りの可能性があるので、同時に警告を発します。

以下のように末尾に余計な “}” が残ってしまっている場合、警告を発することなくエンコードしてしまうと、実装者が気付くことが困難なためです。

// Warn: The literal part contains an unencoded path string "}". Received: `https://example.com/tags${"/tag?:string"}}`
urlFrom`https://example.com/tags/${"tag"}}`;

もしも意図的にこのようなURLを生成したい場合は、Direct Placeholderを使用することで実現できます。

// 意図が明確なので警告は出ず、"}" は適切にエンコードされます
urlFrom`https://example.com/tags/${"tag"}${["}"]}`;

現実の運用ではちょっとした修正の際にたった1文字の消し忘れでトラブルに繋がってしまうことがあり得るため、利用シーンを想定して運用レベルでトラブルが発生しにくくなるよう、いくつもの細かい配慮を施してあります。

利便性

型の絞り込み

url-fromにはType Narrowingという面白く強力な機能があります。

例えば以下のようなURLが必要だったとしましょう。

  • パス部分には “string” “number” “boolean” のいずれかを許可する
  • パスの “string” “number” “boolean” に応じてクエリの “value” の値の型も連動する

この場合、narrowingを使って以下のように定義すれば、型レベルで意図した状態を実現でき、型を通せば安全が保証されます。

const bindUrl = urlFrom`/${"type:string"}`.narrowing<
  | { type: "string"; "?query": { value: string } }
  | { type: "number"; "?query": { value: number } }
  | { type: "boolean"; "?query": { value: boolean } }
>;

bindUrl({ type: "string", "?query": { value: "str" }}); // "/string?value=str"
bindUrl({ type: "number", "?query": { value: 1 }});     // "/number?value=1"
bindUrl({ type: "boolean", "?query": { value: true }}); // "/boolean?value=true"

narrowingはTypeScriptのInstantiation Expressionsによって、URL生成関数の型をより明確なものにできる機能です。narrowingを使うと次のようなメリットを得られます。

  • stringnumberをより狭いリテラル型に制限できます
  • URL生成時に何を渡すべきかが明確になります
  • reducerのActionの型のように複数のパターンを定義できます

このように、url-fromはURLの定義側を充実されればさせるほど、URLの生成側が安全で楽になっていくのが大きな特徴です。

柔軟且つ明示的なスラッシュ管理

URL生成ライブラリで気になるのがスラッシュの管理方法です。

特に動的に埋め込む値の前後のスラッシュがどのように扱われるのかが明確でないと、実装していても本当に意図通りになるか不安になります。

url-fromではConditional Slashという機能によって柔軟且つ明示的なスラッシュ管理を実現しています。プレースホルダ内の先頭と最後に “/” を記述できるようになっているため、値が有効なときだけ適用されるスラッシュであることが一目瞭然です。

const bindUrl1 = urlFrom`https://example.com/users${"/userId?"}`;
console.log(bindUrl1()); // => "https://example.com/users"
console.log(bindUrl1({ userId: "279642" })); // => "https://example.com/users/279642"

const bindUrl2 = urlFrom`https://example.com/users${"/userId?/"}`;
console.log(bindUrl2()); // => "https://example.com/users"
console.log(bindUrl2({ userId: "279642" })); // => "https://example.com/users/279642/"

理想的なサジェスト

url-fromはプレースホルダや引数部分のサジェストもしっかり効くように作られています。

コーディング中にサジェストが効いている様子
  • 「あのUtility Placeholderはどう書くんだっけ?」
  • 「このURL生成時の引数は何を渡せばいいんだっけ?」

といったときに、適切にサジェストで教えてくれます。

このあたり、型推論周りで理想的なサジェストを出すためにだいぶ苦労したポイントでもありました。

おすすめの使い方

url-fromのメリットが活かせるおすすめの使い方も紹介します。

  1. システム内で使用するURLを1つのファイルにまとめて定義
  2. 期待するURL生成パターンの簡単なテストを書く
  3. 各所で定義済みのURL生成関数を利用

外部サイトのURL含め、システム内で使用するURLを1つのファイルにまとめておくと、システム内では常に定義済みのURL生成関数を使う習慣が生まれ、その場その場でURLを組み立てようとしなくなります。

レビューの際にも、URLを生成する際に必ず定義済みの関数を使用しているかをチェックすれば良いことになります。

テストを書くのは、期待するURLが生成できているかに加えて、警告や例外をPullRequestやそれ以前で察知できるようにするためです。 このテストを通っていれば、各所で定義済みのURL生成関数を利用する際の信頼度は非常に高いものになります。

また、URLの定義と生成が分離できる特性上、URLの管理を行いたいエンジニアが定義を担当し、他のエンジニアは定義済みの関数を使ってURLの生成を行えば、メンバー間にスキル差があってもシステム全体のURLを安定した制御下に置くことが可能です。

システム内で使用するURLの仕様が変更された場合でも、URLの定義側を修正すれば、各所で型エラーになってくれるため、仕様変更に対する追従も安全且つ簡単に行えます。

試してみたい方へ

将来的にはplayground的なものがあると試しやすいとは思っているのですが、現状で手軽に試せる方法を用意しています。

git clone git@github.com:misuken-now/url-from.git
cd url-from
yarn install
yarn test --watch ./src/__tests__/example.test.ts

これで https://github.com/misuken-now/url-from/blob/main/src/__tests__/example.test.ts のサンプルコードを触って挙動を確認できるようになっているので、興味を持った方は是非使用感を体験してみてください。

今後

url-fromの今後について、まずは以下のようなことを検討しています。

  • デフォルト値の指定
    • URL定義段階でデフォルト値を指定したい要求は必ず出てくるので、インタフェースを考慮して機能追加したい
  • 警告や例外メッセージの最適化
    • もっとわかりやすいメッセージにできる箇所が色々あるので改善したい
  • コードの改善
    • パフォーマンス的にもっと効率の良い処理にできそうな部分がありそう
    • 一部処理が複雑になっている部分を単純化したい
    • 大量のテストケースをわかりやすく整理したい
  • 型推論とサジェストの強化
    • scheme:系のプレースホルダは、先頭のプレースホルダのみで使用するべきなので型で守れるようにしたい

デフォルト値の指定は個人的にも欲しいので、追加することは決めているのですが、デフォルト値を指定した上で上書き可能にしたい場合と、上書き不可にしたい場合がありそうなので適切なインタフェースを検討中です。

また、警告や例外のメッセージはこうなっていたほうが適切であるといった提案や、セキュリティ面の懸念なども、お気軽にissueからご意見いただけると助かります。

まとめ

今回は型安全なURL生成ライブラリurl-fromを紹介しました。

これまでもテンプレート式のURL生成ライブラリはありましたが、文字列を直接置換するタイプが主流で、あまり筋の良いものではありませんでした。

url-fromはTypeScriptの型システムとうまく連携し、URLの定義と生成を分離するなどのアプローチにより、テンプレート式の短所を無くすだけでなく、新しい価値を生み出せたのではないかと感じています。

書いてて楽しくなるurl-fromを一度体験してみてはいかがでしょうか。

株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。