検索語やページ番号、並び順などを ?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 ではなく + になります。
| 値 | URLSearchParams | encodeURIComponent() |
|---|---|---|
Tauri 入門 | Tauri+%E5%85%A5%E9%96%80 | Tauri%20%E5%85%A5%E9%96%80 |
C++ | C%2B%2B | C%2B%2B |
a&b=c | a%26b%3Dc | a%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 を変換する)。
