【Dart】Uriの挙動詳細

  • 2026年1月26日
  • 2026年1月26日
  • Dart

対象者

  • 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&FlutterDart%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',
);

queryqueryParameters を同時に使うことはできません。

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));

使い分けの目安:

  • encodeFullURL全体をできるだけ保ったまま必要箇所だけエンコード(ただしクエリ値の安全化には不十分な場合あり)
  • encodeComponentURLを丸ごと「部品」として埋め込みたいとき(例:リダイレクト先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. queryParametersqueryParametersAll はどう使い分けますか?

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.parseUri(...)resolvereplace/encode/decode/判定系プロパティをテストで網羅し、挙動と落とし穴を仕様として固定しています
  • 実務では特に、queryParametersAllresolveの末尾スラッシュ、replacequeryqueryParameters排他、path?#を混ぜない、の4点を押さえると不具合が激減します