対象者
- Flutter/DartでURL(Deep Link、API、WebView遷移など)を安全に扱いたい方
- Uriのプロパティやエンコード規則を「挙動ベース」で理解したい方
- チームに共有できる形で、Uriまわりの仕様と落とし穴を整理したい方
はじめに
本記事は、Dart標準の Uri をテストコードベースで体系立てて整理します。
URLのパース、クエリの扱い、相対パス解決、replace、エンコード/デコード、file・mailto・独自スキームといった周辺仕様は、実装時に曖昧な理解のまま進むと不具合が出やすい領域です。
テストで挙動を固定しておくことで、仕様変更やOS差異、開発者間の認識ズレを未然に防げます。
Uri.parseで「分解」してプロパティを読む
最初のテストは、「文字列 → Uri → 各プロパティ」の確認です。重要なのは、URIを文字列で直接いじらず、必ず Uri.parse で分解して扱うことです。
/// テスト用のURI文字列をパース
final uri = Uri.parse('https://example.com/api/data?foo=bar&flag#frag');
// 主要コンポーネントの確認
expect(uri.scheme, equals('https'));
expect(uri.host, equals('example.com'));
expect(uri.path, equals('/api/data'));
expect(uri.queryParameters, equals({'foo': 'bar', 'flag': ''}));
expect(uri.fragment, equals('frag'));
final pathSegments = uri.pathSegments;
expect(pathSegments.length, 2);
expect(pathSegments[0], 'api');
expect(pathSegments[1], 'data');
// 文字列へ戻して同一性を確認
expect(uri.toString(), equals('https://example.com/api/data?foo=bar&flag#frag'));
ポイント:
pathは 先頭/を含む(/api/data)flagのように&flag(値なし)で書かれたものは、queryParameters上は 空文字''として扱われるpathSegmentsを使うと、/api/dataを['api', 'data']のように分割して扱える(ルーティングや権限制御で有用)
Uriを「組み立てる」:クエリのエンコードを任せる
次のテストは、Uri(...) コンストラクタで安全にURLを組み立てる例です。自前で?や&を連結しないのが基本方針になります。
final uri = Uri(
scheme: 'https',
host: 'example.com',
path: '/search',
queryParameters: {
'q': 'Dart&Flutter',
'lang': 'en',
'empty': '',
},
);
final expectedUrl =
'https://example.com/search?q=Dart%26Flutter&lang=en&empty';
expect(uri.toString(), equals(expectedUrl));
expect(uri.queryParameters['q'], equals('Dart&Flutter'));
expect(uri.queryParameters['empty'], equals(''));
ポイント:
queryParametersを渡すと、&などの危険な文字は自動でエンコードされる(例:Dart&Flutter→Dart%26Flutter)- 生成後に
queryParametersを読むと、デコード済みの値が取れる(=アプリ内部では人間が読む値で扱える)
同一キーを複数回出す(id=1&id=2&id=3)
final multiValUri = Uri(
scheme: 'https',
host: 'example.com',
path: '/multi',
queryParameters: {
'id': ['1', '2', '3'],
},
);
expect(multiValUri.toString().contains('id=1&id=2&id=3'), isTrue);
expect(multiValUri.queryParameters['id'], equals('3'));
expect(multiValUri.queryParametersAll['id'], equals(['1', '2', '3']));
ポイント:
queryParameters['id']は 最後の値(この例では'3')になる- 全件取りたい場合は
queryParametersAllを使う(Deep Linkの複数指定やフィルタなどで重要)
相対パス解決:Uri.resolveの「末尾スラッシュ」ルール
URL合成で頻出の落とし穴が resolve の挙動です。テストでは、末尾が / かどうかで結果が変わる点を押さえています。
final base = Uri.parse('https://example.com/path/to/page/');
final resolved1 = base.resolve('subpage.html');
expect(resolved1.toString(),
equals('https://example.com/path/to/page/subpage.html'),
);
final resolved2 = base.resolve('../image.png');
expect(resolved2.toString(),
equals('https://example.com/path/to/image.png'),
);
final baseFile = Uri.parse('https://example.com/path/to/page');
final resolved3 = baseFile.resolve('other.html');
expect(resolved3.toString(),
equals('https://example.com/path/to/other.html'),
);
ポイント:
.../page/(末尾スラッシュ)= ディレクトリ扱い.../page(末尾スラッシュなし)= ファイル扱い(最後の要素が置き換わる)
replaceで部分更新する(ただし query と queryParameters は同時指定不可)
replace は、「元のUriをベースに一部だけ差し替える」用途に便利です。特に、環境別にhostだけ差し替える・pathを差し替えるなどで重宝します。
final base = Uri.parse('https://example.com:8080/path/to/page?key=value&empty');
expect(base.replace(scheme: 'http').toString(),
'http://example.com:8080/path/to/page?key=value&empty',
);
expect(base.replace(host: 'example2').toString(),
'https://example2:8080/path/to/page?key=value&empty',
);
expect(base.replace(scheme: 'http', port: 80).toString(),
'http://example.com/path/to/page?key=value&empty',
);
query と queryParameters を同時に使うことはできません。
expect(
() => uri.replace(query: '', queryParameters: {}),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'check message',
'Both query and queryParameters specified',
),
),
);
ポイント:
replaceで元のUriをベースに一部だけ差し替えられるquery(生文字列)とqueryParameters(Map)は 同時に指定できない- チーム開発では、どちらの流派で統一するか決めておくと事故が減ります(推奨:
queryParameters)
エンコード/デコード:目的別に関数を使い分ける
テストは encodeFull / encodeComponent / encodeQueryComponent を比較しています。ここを間違えると、URLが壊れる・二重エンコードになる、といった問題が起きます。
const full = 'https://example.org/api?foo=some message';
final fullEncoded = Uri.encodeFull(full);
expect(fullEncoded, equals('https://example.org/api?foo=some%20message'));
expect(Uri.decodeFull(fullEncoded), equals(full));
const component = 'https://example.org/api?foo=some message';
final compEncoded = Uri.encodeComponent(component);
expect(compEncoded,
equals('https%3A%2F%2Fexample.org%2Fapi%3Ffoo%3Dsome%20message'),
);
expect(Uri.decodeComponent(compEncoded), equals(component));
const queryValue = 'Dart & Flutter';
final queryEncoded = Uri.encodeQueryComponent(queryValue);
expect(queryEncoded, equals('Dart+%26+Flutter'));
expect(Uri.decodeQueryComponent(queryEncoded), equals(queryValue));
使い分けの目安:
encodeFull:URL全体をできるだけ保ったまま必要箇所だけエンコード(ただしクエリ値の安全化には不十分な場合あり)encodeComponent:URLを丸ごと「部品」として埋め込みたいとき(例:リダイレクト先URLを別のURLのパラメータに入れる)encodeQueryComponent:クエリ値に最適(スペースが+になるのが特徴)
判定やプロパティを押さえる
後半は、判定やプロパティを網羅的にテストしています。Deep Link実装やセキュリティレビューで役立つ領域です。
fileスキーム
final fileUri = Uri.file('/home/user/data/sample.txt');
expect(fileUri.toString(), equals('file:///home/user/data/sample.txt'));
expect(fileUri.toFilePath(), equals('/home/user/data/sample.txt'));
final winFileUri = Uri.file(r'C:\Users\user\file.txt', windows: true);
expect(winFileUri.toString(), equals('file:///C:/Users/user/file.txt'));
fragment(#以降)
final uriWithFragment = Uri.parse('/api/data#frag');
expect(uriWithFragment.hasFragment, true);
expect(uriWithFragment.fragment, 'frag');
final uriWithoutFragment = Uri.parse('/api/data');
expect(uriWithoutFragment.hasFragment, false);
expect(uriWithoutFragment.fragment, '');
isAbsolute / hasAbsolutePath
expect(Uri.parse('https://example.com/path/to/page/').isAbsolute, true);
expect(Uri.parse('/path/to/page/').isAbsolute, false);
expect(Uri.parse('/path/to/page/').hasAbsolutePath, true); // 先頭/がある
expect(Uri.parse('path/to/page/').hasAbsolutePath, false); // 相対
Authority / UserInfo / origin
final uriWithAuthority = Uri.parse('https://example.com:80/path/to/page/');
expect(uriWithAuthority.hasAuthority, true);
expect(uriWithAuthority.authority, 'example.com:80');
final uri = Uri.parse('https://alice:secret@example.com:8443/api');
expect(uri.userInfo, 'alice:secret');
expect(uri.origin, 'https://example.com:80');
失敗例:pathに「?」や「#」を混ぜると、意図通りにならない
最後に、実務でやらかしたありがちな事故を列挙します。
path内のクエリーパラメータとフラグの区切り文字は、toStringでエンコードされる
final host = 'http://test.com';
expect(
Uri.parse(host).replace(path: '/test?key=value').toString(),
'http://test.com/test%3Fkey=value',
);
expect(
Uri.parse(host).replace(path: '/test#frag').toString(),
'http://test.com/test%23frag',
);
final url = 'http://hostname/path?query=value';
expect(Uri.parse(url).path, '/path');
ポイント:
pathは あくまでパスであり、?や#は「区切り文字」ではなく パス文字としてエンコードされる- クエリやフラグメントを変えたい場合は、
replace(query: ...)やreplace(fragment: ...)を使うべきです
pathに、クエリーパラメータとクエリやフラグメントはふくめない
final url = 'http://hostname/path?query=value#frag';
expect(Uri.parse(url).path, '/path');
ポイント:
pathは あくまでパスであり、クエリーパラメータやクエリやフラグメントは含まれない。
Q&A
Q1. queryParameters と queryParametersAll はどう使い分けますか?
queryParameters は「同一キーが複数ある場合は最後の値だけ返す」ため、単一値前提のAPI向けです。複数値(id=1&id=2...)を正しく扱う必要がある場合は queryParametersAll を使用します。
Q2. 相対パスの resolve が意図と違うのはなぜですか?
基底URIがディレクトリ扱い(末尾 /)か、ファイル扱い(末尾なし)かで結果が変わります。.../page/ に対して other.html を解決すると配下に追加されますが、.../page.html に対して解決すると最後の要素が置換されます。テストでこの差を固定しておくのが有効です。
Q3. replace(path: '/x?y=1') でクエリを変えたつもりが反映されません
path に ? や # を入れても、Uri はそれをクエリ区切りとは解釈せず、パス文字としてエンコードします。クエリを変えるなら replace(query: ...) または replace(queryParameters: ...) を使ってください。
まとめ
Uriは、パース・組み立て・更新・解決(resolve)・エンコードを 安全に行うための標準機能で、URL操作を文字列連結でやるより堅牢です- 本ソースは、
Uri.parse/Uri(...)/resolve/replace/encode/decode/判定系プロパティをテストで網羅し、挙動と落とし穴を仕様として固定しています - 実務では特に、
queryParametersAll、resolveの末尾スラッシュ、replaceのqueryとqueryParameters排他、pathに?や#を混ぜない、の4点を押さえると不具合が激減します
-
Next
記事がありません