フォームの入力を API に登録する、アプリの設定をサーバーに保存するなど、JSON を POST で送る場面はよくあります。HTTP プラグインの fetch なら method と body を指定するだけですが、Content-Type を付け忘れると JSON として扱われず、エラーにもならないまま項目が捨てられることがあります。このレシピでは JS からの送り方と応答(201・204・422 など)の扱い、Rust の reqwest で構造体を送る方法を説明します。
前提条件
HTTP プラグインの追加、capability の http:default に送り先を allow で書く方法、ブラウザの fetch との使い分けは HTTP GET リクエストを送る(Rust経由) のとおりです。ここでは練習用の API の JSONPlaceholder を許可します。JSONPlaceholder は POST された内容を保存せず、id: 101 を付けて返すだけなので、何度試しても害はありません。
{
"$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://jsonplaceholder.typicode.com" }]
}
]
}
スコープは URL だけを見てメソッドを区別しないので、GET のために許可した URL へは POST も DELETE も送れます。書き込み系の API を持つホストを許可するときは、パスまで絞っておきます。
1. フロントエンドから実装する (TypeScript)
Content-Type を必ず付ける
HTTP プラグインの fetch は、渡された body から標準の Request を組み立て、そこで決まる Content-Type を(自分で指定していなければ)そのまま送ります。文字列は text/plain;charset=UTF-8 になるので、JSON.stringify() しただけでは JSON として扱われません。
| body に渡すもの | 自動で付く Content-Type |
|---|---|
JSON.stringify() した文字列 | text/plain;charset=UTF-8 |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 |
FormData | multipart/form-data(net-006) |
type 付きの Blob | その type |
オブジェクトをそのまま body に渡すと、TypeScript では型エラーになり、JS では [object Object] という文字列が送られます。次の関数は、送信と応答の確認をまとめたものです。
import { fetch } from '@tauri-apps/plugin-http';
export async function sendJson<T>(
url: string,
method: 'POST' | 'PUT' | 'PATCH',
data: unknown,
): Promise<T | null> {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json', // 省略すると text/plain で送られる
Accept: 'application/json',
},
body: JSON.stringify(data),
});
if (!res.ok) {
// 400 や 422 は、本文に入力エラーの詳細が入っていることが多い
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
if (res.status === 204) return null; // 本文が無いので json() を呼ぶと失敗する
return (await res.json()) as T;
}
// (続き)
type Post = { id: number; title: string; body: string; userId: number };
const created = await sendJson<Post>('https://jsonplaceholder.typicode.com/posts', 'POST', {
title: '買い物メモ',
body: '牛乳と卵',
userId: 1,
});
console.log(created?.id); // 101
更新は同じ関数で PUT(全体の置き換え)や PATCH(一部の変更)にします。JSON.stringify() は値が undefined の項目を省き、null は残すので、PATCH では「変えない項目は undefined、消す項目は null」と使い分けられます。Date は ISO 形式の文字列になり、Map や Set は {} になる点にも注意します。
二重送信を防ぐ
POST は同じ内容を 2 回送ると 2 件登録されることがあります。応答が返るまでボタンを無効にしておきます。タイムアウトした POST を自動で送り直すのも危険で、サーバーには届いていた可能性があります(通信のタイムアウト時間を設定する)。
2. バックエンドから実装する (Rust)
reqwest の json() は、serde で本文を JSON にし、Content-Type: application/json も付けます(json 機能が必要です。追加方法と Client の共有は net-001)。Rust のフィールド名と API の項目名の変換は rename_all で行います(Serde で構造体と JSON を変換する)。
use serde::{Deserialize, Serialize};
use tauri::Manager;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NewPost {
title: String,
body: String,
user_id: u32,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<String>>, // None なら項目ごと送らない(null を送らない)
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreatedPost {
id: u32,
title: String,
user_id: u32,
}
#[tauri::command]
async fn create_post(
client: tauri::State<'_, reqwest::Client>,
title: String,
body: String,
) -> Result<CreatedPost, String> {
let payload = NewPost { title, body, user_id: 1, tags: None };
let res = client
.post("https://jsonplaceholder.typicode.com/posts")
.json(&payload) // 本文を JSON にし、Content-Type: application/json を付ける
.send()
.await
.map_err(|e| e.to_string())?;
let status = res.status();
if !status.is_success() {
// error_for_status() は本文を捨てるので、入力エラーの詳細を読んでから返す
let detail = res.text().await.unwrap_or_default();
return Err(format!("HTTP {}: {}", status.as_u16(), detail));
}
res.json::<CreatedPost>().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| {
app.manage(reqwest::Client::new());
Ok(())
})
.invoke_handler(tauri::generate_handler![create_post])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
応答の構造体に無い項目(ここでは body)は読み飛ばされます。逆に、構造体にある項目が応答に無いと失敗するので、省略されうる項目は Option にします。
import { invoke } from '@tauri-apps/api/core';
const post = await invoke<{ id: number; title: string; userId: number }>('create_post', {
title: '買い物メモ',
body: '牛乳と卵',
});
console.log(post.id); // 101
動作確認
npm run tauri dev で起動し、Content-Type の有無で結果を比べます。
import { fetch } from '@tauri-apps/plugin-http';
const url = 'https://jsonplaceholder.typicode.com/posts';
const data = JSON.stringify({ title: 'foo', body: 'bar', userId: 1 });
const withType = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: data });
console.log(withType.status, await withType.json());
const withoutType = await fetch(url, { method: 'POST', body: data });
console.log(withoutType.status, await withoutType.json());
どちらも 201 ですが、Content-Type が無い方は送った項目が消えています。
201 {title: 'foo', body: 'bar', userId: 1, id: 101}
201 {id: 101}
よくあるエラーと対処法
- 201 や 200 が返るのに、送った項目がサーバーに入っていない: Content-Type が
text/plainになっています。サーバーによっては 400 や 415(Unsupported Media Type)になります。'Content-Type': 'application/json'を付けます。 res.json()が JSON の解析に失敗した趣旨の SyntaxError になる: 204 No Content などで本文が空か、HTML のエラーページが返っています。statusを確かめてから読みます。- 400・422 が返る: サーバーの入力チェックで弾かれています。
res.text()で本文を読むと、どの項目が悪いかが書かれていることが多いです。 - Rust で「error decoding response body」: 応答の JSON が構造体と合いません。型の違いや欠けている項目を確かめ、分からなければ
serde_json::Valueで受けて中身を見ます。 - 「url not allowed on the configured scope: …」: 送り先が
allowにありません(net-001)。
OS ごとの違いと注意点
- OS による違い: 送信は Rust が行うので、本文や Content-Type は OS で変わりません。
- 大きな本文:
bodyは一度すべてメモリに読み込んでから Rust に渡されます。ファイルの送信は サーバーにファイルをアップロードする を参照します。 - 認証: トークンなどの付け方と、送れないヘッダーは HTTP ヘッダーをカスタマイズして送る で扱います。
- 検索条件: 取得の条件を送るだけなら、POST ではなく GET のクエリにします(URL クエリパラメータを付与して送る)。
