フューチャー技術ブログ

構造化テキスト(URL)を文字列結合で作らないようにするライブラリを作ってみた

SQL、ファイルパスなどの構造化テキストを文字列結合で作ると、不正な文字列が入ってきた時に困るよ、というのはプログラミングの基本原則ですが、URLはついついやってしまいがちな部分です。

だいたいの言語にはURLクラスとかURIクラスとかその手のものがあり、それを使うことで安全にパースしたり組み立てたりできるのですが、いかんせんコードが長くなりがち、ということがあります。

TypeScriptをビルドしてnpmパッケージを作るのに便利なtsupというツールを使ってみたかったので、その題材としてURLを簡単かつ安全に組み立てるユーティリティを作ってみました。Node.js、Deno、Bunで動作確認しています。

テンプレートリテラルの前に関数をつける記法、タグ付きテンプレートリテラルというのがあります。文字列にする代わりに、テンプレートの文字列と間の値がこの関数の入力値になり、関数の返り値が実際のリテラルの評価値になる、というものです。litのHTMLテンプレートとして使われているやつですね。

作ったユーティリティの紹介

それを使ってURLを組み立てます。 urlというのがこの変換関数です。

import { url } from "url-tidy";

こんな感じで、文字列テンプレートとあまり変わらない感じですが、固定の文字列部分もきちんとURLの要素(プロトコルとかホストとかパスとかクエリーとか)にパースして要素分解しますし、固定部分もプレースホルダーで渡されるパス部分はencodeURI()を通すし、最後のURLの組み立てはURLクラスとかURLSearchParamsを裏で使うので、不正な文字が入って不正なURLになるということは防げているかと思います。まああまり遅いことはなさそうですが、固定文字列部分は一度パースしたらその状態をキャッシュするようにしています。

プレースホルダーはパス、クエリーの値、フラグメントなど、1つの要素に対してのみしか使えないようにしています。パスの末尾とクエリーをまるごと文字列で渡す、みたいなことはできません。

一番基本的な使い方はパスの一部の置き換えでしょう。

const id = 1000;
url`https://example.com/api/users/${id}/profile`
// => 'https://example.com/api/users/1000/profile'

配列を渡すと/区切りで繋いだURLにするので階層が可変なURLでも安心ですね。

const areaList = ["japan", "tokyo", "shinjuku"];
url`https://example.com/menu/${areaList}`
// => 'https://example.com/menu/japan/tokyo/shinjuku'

プロトコル、ポート、クエリーの値を設定する場合にnullを渡すと、前後の記号やクエリーならキー部分も出力からは消去します。検索条件のクエリーの入った文字列を作るけど、無駄に長くはしたくない時はこういうの欲しくなりますよね?こういうのをきちんとやろうとすると、URLSearchParamsを使うことになりますが、直接扱うとコードがかなりやりたいことのわりに増えちゃうな、という痒いところに届くようにしてみました。

const word = "spicy food";
const page = 10;
const perPage = null; // デフォルト値を使うので設定しない
const limit = null; // デフォルト値を使うので設定しない

url`https://example.com/api/search?word=${word}&page=${page}&perPage=${perPage}&limit=${limit}`
// => 'https://example.com/api/search?word=spicy+food&page=10'

逆にクエリー部分はZodやReact Hook Formでバリデーションした結果をオブジェクト形式で渡すよ、という場合も多いと思うので、オブジェクトやURLSearchParamsでまとめて渡せるようにしています。固定のクエリーや他のクエリーのプレースホルダーとマージした結果を作ります。

const searchParams = {
word: "spicy food",
safeSearch: false,
spicyLevel: Infinity,
}

url`https://example.com/api/search?${searchParams}`
// => 'https://example.com/api/search?word=spicy+food&safeSearch=false&spicyLevel=Infinity'

URL周りでよくあるユースケースだと、開発環境や本番などで、接続先のホスト部分が変わるよ、というのもあります。あとは、ユーザー名とパスワードはソースコード中にハードコーディングしたくないはずなのでテンプレートリテラルの中には存在することはなさそうということで、この方法でしか設定できないようになっています。

import { customFormatter } from 'url-tidy';

const apiUrl = customFormatter({
hostname: process.env.API_SERVER_HOST, // 'https://localhost:8080'
username: process.env.API_USER, // 'user'
password: process.env.API_PASSWORD, // 'pAssw0rd'

})

const id = 1000;

apiUrl`https://api-server/api/users/${id}/profile`
// => 'https://user:pAssw0rd@localhost:8080/api/users/1000/profile'

開発環境

TypeScriptでライブラリを作るのは、tscを駆使すれば可能ではありますが、配布するならバンドルしたいし、モジュール形式も複数対応しないと、など考えることはたくさんあります。いろんなゼロコンフィグとか設定が少ない便利ツールは雨後の筍のごとくたくさん登場しますが、それらを活用して「設定のメンテには手間をかけない」「新しいことをやりくなったら、すぐに捨てて、別のツールに乗り換え」がフロントエンド周りではベストかな、と思っています。式年遷宮し続ける方式。

今回は、tsdxViteのライブラリモードも試してみましたが、前者は依存のツール類がちょっと古くて、最近の高速ツールの恩恵がなさそう、後者は開発サーバー付きでReactとかVueのコンポーネントライブラリ開発なら便利そうだが、今回のような純粋なロジックの開発だと余計なものが多いな、と思い、tsupを選びました。

設定はpackage.jsonに直接書く方式で書きましたがこのぐらいで済みました。

package.json
{
"tsup": {
"target": "es2020",
"format": [
"cjs",
"esm"
],
"entry": [
"src/index.ts",
"!src/*.spec.ts"
],
"splitting": false,
"sourcemap": true,
"clean": true,
"dts": true
}
}

tsup固有要素以外のパッケージ化に必要だった設定はこれぐらいですかね。あとはリポジトリのURLを書いたり、ライセンスを書いたり、バンドルするファイル一覧を書いたり、private: falseにしたり。

package.json
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js"
}

今回はテストランナーはVitestを使いました。Node.jsもDenoもBunも内蔵のテストランナーを押す流れで、そちらを使うと高速という話も見ますが、Deno、BunのNode.js互換性も高くなり、Vitestで書いたテストを3つの環境で実行できました。GitHub Actionsで3つのテストを実行するようにしています。

コードチェックとフォーマッターは最近はBiomeを押す声が多いです。高速ではあるものの、ESLint+Prettierの方が個人的には好きかも。ESLintとPrettierの共存設定も以前よりもだいぶシンプルですし、Prettierが何もしなくても対応するEditorConfig対応はBiomeでは明示的に有効にしないといけないとかまああまり手間は変わらないかな、と。

おまけ

Go版も作りました。Goにはタグ付きテンプレートリテラル構文がないので、PrintfスタイルのAPIで実装しました。

import (
"github.com/shibukawa/urlf"
)

urlf.Urlf(`https://example.com/api/users/{}?key1={}&key2={}`, userCode, value1, value2)

まとめ

新しいツールの使い方を知るついでに前々から気になっていた、構造化文字列なのについ文字列結合で作ってしまいがちなURLの組み立てのユーティリティを作ってみました、というお話でした。