URL クエリパラメータを付与して送る

URL と URLSearchParams でクエリを組み立て、HTTP プラグインの fetch で送る。スペースが + になる符号化、配列や未指定値の扱い、スコープとクエリの関係、reqwest の query() も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約8分 net-004
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. URL と URLSearchParams で組み立てる
  4. スペースは + になる
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

検索語やページ番号、並び順などを ?q=tauri&page=2 の形で URL に付けて GET する場面はよくあります。文字列をつなげて作ると、値に & や #、スペース、日本語が入ったときに壊れます。JS では URL と URLSearchParams、Rust では reqwest の query() を使えば、エスケープ(URL エンコード)は自動で行われます。このレシピでは組み立て方と符号化の違い、HTTP プラグインのスコープとクエリの関係を説明します。

前提条件

HTTP プラグインの追加と、capability の http:default に送り先を allow で書く方法は HTTP GET リクエストを送る(Rust経由) のとおりです。ここでは受け取ったクエリを JSON で返してくれる httpbin.org の /get だけを許可します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [{ "url": "https://httpbin.org/get" }]
    }
  ]
}

パターンに ? 以降を書かなければ、クエリは何が付いても一致します。書いた場合はクエリ文字列全体がその形に一致する必要があり、順番も区別されます。たとえば https://api.example.com/search?q=* は ?q=a&limit=10 には一致しますが、?limit=10&q=a には一致しません。組み立ての順番で許可・拒否が変わってしまうので、スコープはパスで絞り、クエリは書かないようにします。

1. フロントエンドから実装する (TypeScript)

URL と URLSearchParams で組み立てる

url.searchParams の set() は同じキーを置き換え、append() は追加します。HTTP プラグインの fetch には URL オブジェクトをそのまま渡せます。

import { fetch } from '@tauri-apps/plugin-http';

export type SearchFilters = { q: string; page?: number; tags?: string[]; archived?: boolean };

export function buildSearchUrl(base: string, f: SearchFilters): URL {
  const url = new URL(base); // base に既にクエリがあっても壊れない
  url.searchParams.set('q', f.q); // スペース・&・日本語も自動でエスケープされる
  if (f.page !== undefined) url.searchParams.set('page', String(f.page));
  for (const tag of f.tags ?? []) url.searchParams.append('tag', tag); // tag=a&tag=b
  if (f.archived) url.searchParams.set('archived', 'true');
  return url;
}

export async function search(f: SearchFilters): Promise<unknown> {
  const res = await fetch(buildSearchUrl('https://httpbin.org/get', f));
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

未指定の値は付けないようにします。String(undefined) は「undefined」という文字列になり、そのままサーバーに届きます。配列の渡し方は API によって tag=a&tag=b、tags=a,b、tags[]=a などと決まっているので、仕様書に合わせます。

new URL('items', base) でパスをつなぐときは、base の末尾の / に注意します。https://api.example.com/v1 を基準にすると v1 が置き換わって https://api.example.com/items になり、末尾が /v1/ なら https://api.example.com/v1/items になります。パスに入れる ID などは searchParams の対象外なので、encodeURIComponent() でエスケープします。

スペースは + になる

URLSearchParams はフォームと同じ規則で符号化するので、スペースが %20 ではなく + になります。

値URLSearchParamsencodeURIComponent()
Tauri 入門Tauri+%E5%85%A5%E9%96%80Tauri%20%E5%85%A5%E9%96%80
C++C%2B%2BC%2B%2B
a&b=ca%26b%3Dca%26b%3Dc

多くのサーバーは + をスペースとして読みますが、URL に署名を付ける API などでは %20 を求められることがあります。値の中の + は %2B になっているので、url.search = url.searchParams.toString().replace(/\+/g, '%20') で置き換えても安全です。逆に、encodeURIComponent() 済みの値を searchParams に入れると %2520 のように二重にエンコードされます。

2. バックエンドから実装する (Rust)

reqwest の query() は、組の配列や Serialize した構造体をクエリにして URL に追記します。符号化は URLSearchParams と同じで、スペースは + です。構造体の None の項目は自動で省かれ、bool は true / false になります。Vec の項目は扱えないので、同じキーを繰り返す値は組の配列で渡します。reqwest の追加と Client の共有は net-001 のとおりです。

use serde::Serialize;

/// クエリにする構造体。None の項目は付かない
#[derive(Serialize)]
struct SearchQuery<'a> {
    q: &'a str,
    page: Option<u32>,
    archived: Option<bool>,
}

#[tauri::command]
async fn search_items(
    client: tauri::State<'_, reqwest::Client>,
    keyword: String,
    page: Option<u32>,
    tags: Vec<String>,
) -> Result<serde_json::Value, String> {
    let query = SearchQuery { q: &keyword, page, archived: None };
    // 同じキーを繰り返す値は構造体に入れられないので、(キー, 値) の組で渡す
    let tag_pairs: Vec<(&str, &str)> = tags.iter().map(|t| ("tag", t.as_str())).collect();

    let res = client
        .get("https://httpbin.org/get")
        .query(&query) // ?q=...&page=...
        .query(&tag_pairs) // 上書きではなく追記: &tag=a&tag=b
        .send()
        .await
        .map_err(|e| e.to_string())?;
    res.json::<serde_json::Value>().await.map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_http::init()) // 1 章の fetch 用
        .manage(reqwest::Client::new())
        .invoke_handler(tauri::generate_handler![search_items])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

通信系のレシピは reqwest 0.12 系で書いています。0.13 系では query() が query 機能に分かれたので、cargo add reqwest --features json,query のように有効にします。

import { invoke } from '@tauri-apps/api/core';

const result = await invoke<{ url: string }>('search_items', {
  keyword: 'Tauri 入門',
  page: 2,
  tags: ['rust', 'js'],
});
console.log(result.url);

動作確認

npm run tauri dev で起動し、組み立てた URL と、httpbin.org が受け取ったクエリを表示します。

import { fetch } from '@tauri-apps/plugin-http';

const url = new URL('https://httpbin.org/get');
url.searchParams.set('q', 'Tauri 入門');
url.searchParams.set('page', '2');
for (const tag of ['rust', 'js']) url.searchParams.append('tag', tag);

const res = await fetch(url);
const body = (await res.json()) as { args: Record<string, string | string[]> };
console.log(url.href);
console.log(JSON.stringify(body.args));
https://httpbin.org/get?q=Tauri+%E5%85%A5%E9%96%80&page=2&tag=rust&tag=js
{"page":"2","q":"Tauri 入門","tag":["rust","js"]}

+ がスペースに戻り、同じキーの値が配列として届いています。https://httpbin.org/anything?q=1 に変えると、パスが allow と違うので「url not allowed on the configured scope: https://httpbin.org/anything?q=1」で拒否されます。

よくあるエラーと対処法

  • クエリを付けたときだけ「url not allowed on the configured scope」: allow のパターンに ? 以降が書かれています。クエリの部分を消し、パスで絞ります。
  • サーバーに「undefined」「null」という値が届く: 未指定の値も String() で文字列にしています。付ける前に取り除きます。
  • %2520 のように二重にエンコードされる: エスケープ済みの値を searchParams に入れています。元の値を渡します。
  • スペースを含む検索だけ結果が合わない・署名エラーになる: サーバーが + をスペースとして読んでいません。上の方法で %20 にします。
  • Rust で「builder error」になる: クエリの構造体に Vec などの項目があります。source() をたどると「unsupported value」と出ます。組の配列で渡します。
  • Rust で query というメソッドが無い趣旨のコンパイルエラー: reqwest 0.13 系で query 機能を有効にしていません。
  • 414 URI Too Long: クエリが長すぎます。条件が多いなら本文に入れて POST します(JSON データを POST 送信する)。

OS ごとの違いと注意点

  • OS による違い: 組み立ては WebView の標準機能、送信は Rust が行うので、符号化の結果は OS で変わりません。
  • 秘密を入れない: クエリはサーバーやプロキシのアクセスログに残ります。API キーやトークンはヘッダーで送ります(HTTP ヘッダーをカスタマイズして送る)。
  • # 以降は送られない: フラグメントはサーバーに届きません。値に # が入るなら、必ず searchParams を通します。
  • 構造体の変換: Rust 側で項目名を変えるなら #[serde(rename = "...")] を使います(Serde で構造体と JSON を変換する)。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する