JSON データを POST 送信する

HTTP プラグインの fetch で JSON を POST する。Content-Type を忘れると text/plain で送られ項目が無視される落とし穴、204・422 の扱い、reqwest の json() も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約8分 net-002
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. Content-Type を必ず付ける
  4. 二重送信を防ぐ
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

フォームの入力を 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
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8
FormDatamultipart/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 ごとの違いと注意点

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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