HTTP ヘッダーをカスタマイズして送る

HTTP プラグインと reqwest で Authorization などのヘッダーを送る。黙って捨てられる Cookie・Origin と unsafe-headers、既定の User-Agent、応答ヘッダーの読み方も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約9分 net-003
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 共通のヘッダーとトークンを付ける
  4. 送れないヘッダーと自動で付くヘッダー
  5. 応答ヘッダーを読む
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

API キーやアクセストークン(Authorization)、表示言語(Accept-Language)、アプリのバージョンなど、API に渡す情報の多くはヘッダーで送ります。HTTP プラグインの fetch でも標準と同じく headers に書けますが、黙って捨てられるヘッダーと自動で付くヘッダーがあり、書いたとおりには届きません。このレシピでは実際に届く内容と、Rust の reqwest でヘッダーを付ける方法を説明します。

前提条件

HTTP プラグインの追加と、capability の http:default に送り先を allow で書く方法は HTTP GET リクエストを送る(Rust経由) のとおりです。API との通信は DevTools の Network タブに出ないので、ここでは受け取ったヘッダーを JSON で返してくれる httpbin.org を許可して、届いた内容を確かめます。

{
  "$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" }]
    }
  ]
}

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

共通のヘッダーとトークンを付ける

毎回付けるヘッダーは 1 か所にまとめ、呼び出しごとの指定で上書きできるようにします。オブジェクトのスプレッドで合成すると、content-type と Content-Type のように綴りの違う同じヘッダーが両方残り、値が「a, b」と連結されて送られます。Headers は名前の大文字・小文字を区別しないので、set() で確実に上書きできます。

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

const COMMON_HEADERS: Record<string, string> = {
  Accept: 'application/json',
  'Accept-Language': 'ja',
  'User-Agent': 'MyApp/1.2.0', // 省略すると tauri-plugin-http/<バージョン> になる
};

export async function apiFetch(url: string, token: string | null, init: RequestInit = {}): Promise<Response> {
  const headers = new Headers(COMMON_HEADERS);
  new Headers(init.headers).forEach((value, name) => headers.set(name, value)); // 呼び出し側の指定を優先
  if (token) headers.set('Authorization', `Bearer ${token}`);
  return fetch(url, { ...init, headers });
}

トークンを保存する場所は 暗号化ストレージ (Stronghold) の導入と利用 を参照します。

送れないヘッダーと自動で付くヘッダー

次のヘッダーは Fetch の仕様で禁止されているため、Rust 側でエラーにならずに捨てられます。tauri dev のターミナルには「Skipping cookie header as it is a forbidden header per fetch spec」のような警告が出ますが、リリースビルドでは何も出ません。

  • Cookie、Origin、Referer、Host、Content-Length、Connection、Accept-Encoding、Accept-Charset、Date、DNT、Expect、TE、Trailer、Transfer-Encoding、Upgrade、Via、Set-Cookie、Access-Control-Request-Headers、Access-Control-Request-Method
  • 名前が Proxy- か Sec- で始まるもの

User-Agent と Authorization は禁止されていないので送れます。逆に、指定しなくても次のヘッダーは付きます。

ヘッダー付く値
User-Agenttauri-plugin-http/<バージョン>(指定すれば置き換わる)
OriginWebView のオリジン(tauri dev 中は開発サーバー)
Cookieプラグインが保存している Cookie

Cookie は、応答の Set-Cookie をプラグインが覚えて、以後の同じサイトへのリクエストに自動で付けます。有効期限付きのものはアプリの終了時にアプリのキャッシュフォルダの .cookies に保存され、次回の起動時に読み込まれます。WebView の Cookie とは別なので、ログインは HTTP プラグイン経由で行います。

禁止ヘッダーをどうしても送るなら、src-tauri/Cargo.toml でプラグインの unsafe-headers 機能を有効にします。Origin の上書きもでき、空文字列を渡せば Origin を付けずに送れます。ただし WebView 上のどのスクリプトからも送れるようになるので、必要な通信だけを 2 章の Rust 側で行う方が安全です。

[dependencies]
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }

応答ヘッダーを読む

レート制限の残り回数や ETag などは res.headers から読みます。名前の大文字・小文字は問いません。ブラウザの fetch と違い、set-cookie も読めます。

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

const res = await fetch('https://httpbin.org/response-headers?X-RateLimit-Remaining=42');
console.log(res.headers.get('x-ratelimit-remaining')); // "42"
for (const [name, value] of res.headers) console.log(`${name}: ${value}`);

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

reqwest には禁止ヘッダーの制限が無く、Origin も Cookie も自動では付きません(Cookie を保存するには reqwest の cookies 機能が要ります)。すべてのリクエストに付けるヘッダーは default_headers()、個別のものは header()、トークンは bearer_auth() で付けます。reqwest の追加と Client の共有は net-001 のとおりです。

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE};
use tauri::Manager;

/// JS には渡さず、Rust 側だけで持つトークン
struct ApiToken(Option<String>);

fn build_client() -> reqwest::Result<reqwest::Client> {
    let mut headers = HeaderMap::new();
    headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
    headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("ja"));
    reqwest::Client::builder()
        .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
        .default_headers(headers) // この Client のすべてのリクエストに付く
        .build()
}

#[tauri::command]
async fn echo_headers(
    client: tauri::State<'_, reqwest::Client>,
    token: tauri::State<'_, ApiToken>,
) -> Result<serde_json::Value, String> {
    let mut req = client
        .get("https://httpbin.org/headers")
        .header("X-Client-Version", env!("CARGO_PKG_VERSION"));
    if let Some(t) = &token.0 {
        req = req.bearer_auth(t); // Authorization: Bearer <t>
    }
    let res = req.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 用
        .setup(|app| {
            // 例: 設定フォルダの token.txt から読む。末尾の改行は trim() で落とす
            let path = app.path().app_config_dir()?.join("token.txt");
            let token = std::fs::read_to_string(path).ok().map(|s| s.trim().to_string());
            app.manage(ApiToken(token));
            app.manage(build_client()?);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![echo_headers])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ファイルや環境変数から読んだトークンは、末尾に改行が付いていることがよくあります。改行の入った値はヘッダーにできず、送信時にエラーになるので trim() しておきます。

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

const echoed = await invoke<{ headers: Record<string, string> }>('echo_headers');
console.log(echoed.headers['User-Agent'], echoed.headers['Authorization']);

動作確認

npm run tauri dev で起動し、httpbin.org にヘッダーを送って、届いた内容を表示します。Cookie はわざと付けています。

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

const res = await fetch('https://httpbin.org/headers', {
  headers: {
    Accept: 'application/json',
    Authorization: 'Bearer test-token',
    'User-Agent': 'MyApp/1.2.0',
    'X-Client-Version': '1.2.0',
    Cookie: 'theme=dark', // 禁止ヘッダーなので届かない
  },
});
console.log(JSON.stringify(await res.json(), null, 2));

次のように表示されます(一部省略)。Cookie は届かず、指定していない Origin が付いています。ターミナルには前述の警告が出ます。

{
  "headers": {
    "Accept": "application/json",
    "Authorization": "Bearer test-token",
    "Host": "httpbin.org",
    "Origin": "http://localhost:1420",
    "User-Agent": "MyApp/1.2.0",
    "X-Client-Version": "1.2.0"
  }
}

よくあるエラーと対処法

  • 付けたはずのヘッダーがサーバーに届かない: 上の禁止ヘッダーです。unsafe-headers を有効にするか、Rust 側で送ります。
  • 401 Unauthorized: Bearer とトークンの間の半角スペース、トークンの期限切れ、API が求める形式(X-Api-Key など)を確かめます。
  • Headers を作る時点で TypeError になる: 値に日本語などの文字か、途中に改行が入っています。サーバーと取り決めて encodeURIComponent() や Base64 で ASCII にします。
  • Rust で「builder error」になる: ヘッダーの値に改行などが入っています。source() をたどると「failed to parse header value」と出ます。
  • ブラウザの fetch でだけ CORS エラーになる: Authorization などを付けると事前確認(プリフライト)が行われ、サーバーがそのヘッダーを許可していないと失敗します。HTTP プラグインはこの影響を受けません。

OS ごとの違いと注意点

  • Origin の値: Windows・Android は http://tauri.localhost、macOS・Linux は tauri://localhost です。Origin を見て拒否するサーバーでは OS によって結果が変わります。プラグイン 2.5.6 より前は macOS・Linux で null が送られていました。
  • ヘッダー名の大文字・小文字: HTTP/2 では小文字で送られ、res.headers の名前も小文字になります。サーバー側で区別しない前提で扱います。
  • 秘密の置き場所: Authorization の値は WebView の JS から読めます。漏れて困る API キーは Rust 側に置き、2 章の方法で付けます。クエリに入れるとログに残るので避けます(URL クエリパラメータを付与して送る)。
  • Content-Type: 本文との組み合わせは JSON データを POST 送信する を参照します。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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