できない.dev

HTTP POSTでJSONを送るには

各言語で JSON を本文に載せた POST を送り、Content-Type を明示して、返ってきた 2xx のレスポンスを読むところまでの基本形を示す。
本文は原則としてその言語のシリアライザに任せ、標準ライブラリに JSON シリアライザを持たない言語だけを例外として扱う。

公開:

各言語見出しの横のバッジは検証状態を表す。実行確認済みはコードを実際に実行して確認したもの、静的確認は構文と公式 API ドキュメントで確認したものである。

Python 実行確認済み

import json
import urllib.request
 
url = "https://jsonplaceholder.typicode.com/posts"
payload = {"title": "hello", "body": "world", "userId": 1}
data = json.dumps(payload).encode("utf-8")
 
req = urllib.request.Request(
    url, data=data, method="POST", headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as res:
    created = json.loads(res.read().decode("utf-8"))
 
print(res.status, created["id"])

標準ライブラリの urllib.request で POST を送る。
data にバイト列を渡した時点でメソッドは POST になるが、意図を読み取れるように method を明示している。
Content-Type を自分で付けるのは、urllib が既定で application/x-www-form-urlencoded を仮定し、受け側が JSON として解釈しないためである。

うまくいかない時: Python で「json.decoder.JSONDecodeError: Expecting value」が解消できない

JavaScript 実行確認済み

const url = "https://jsonplaceholder.typicode.com/posts";
 
async function main() {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: "hello", body: "world", userId: 1 }),
    signal: AbortSignal.timeout(10000),
  });
  if (!res.ok) {
    throw new Error(`unexpected status: ${res.status}`);
  }
  const created = await res.json();
  console.log(res.status, created.id);
}
 
main();

Node.js 18 以降のグローバル fetch で送れるので外部パッケージは要らない。
body には JSON.stringify した文字列を渡し、Content-Type を明示する。
res.ok を先に見るのは、fetch が 4xx / 5xx でも reject せず解決するため、確認しないとエラー応答を作成結果として読み進めてしまうからである。

うまくいかない時: Node.js で「fetch is not defined」が解決できない(古い Node)Node.js で fetch が「self-signed certificate in certificate chain」で接続できない

TypeScript 実行確認済み

type NewPost = { title: string; body: string; userId: number };
type CreatedPost = NewPost & { id: number };
 
const url = "https://jsonplaceholder.typicode.com/posts";
 
async function createPost(input: NewPost): Promise<CreatedPost> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) {
    throw new Error(`unexpected status: ${res.status}`);
  }
  return (await res.json()) as CreatedPost;
}
 
async function main(): Promise<void> {
  const created = await createPost({ title: "hello", body: "world", userId: 1 });
  console.log(created.id, created.title);
}
 
main();

送る形と返る形を別々の型で宣言しておくと、リクエスト本文の作り間違いをコンパイル時に潰せる。
res.json() の戻り値は unknown 相当なので as で受けているが、実行時の検証にはならない。
外部 API が相手なら zod などのスキーマ検証を挟むほうが安全である。

うまくいかない時: Node.js で「fetch is not defined」が解決できない(古い Node)

Go 静的確認

package main
 
import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)
 
type NewPost struct {
	Title  string `json:"title"`
	Body   string `json:"body"`
	UserID int    `json:"userId"`
}
 
func main() {
	body, err := json.Marshal(NewPost{Title: "hello", Body: "world", UserID: 1})
	if err != nil {
		panic(err)
	}
 
	client := &http.Client{Timeout: 10 * time.Second}
	res, err := client.Post(
		"https://jsonplaceholder.typicode.com/posts",
		"application/json",
		bytes.NewReader(body),
	)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
 
	if res.StatusCode/100 != 2 {
		panic(fmt.Sprintf("unexpected status: %d", res.StatusCode))
	}
 
	var created struct{ ID int }
	if err := json.NewDecoder(res.Body).Decode(&created); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, created.ID)
}

json.Marshal した結果を bytes.NewReader で io.Reader に包んで渡す。
client.Post の第2引数が Content-Type なので、ヘッダを別に組み立てる必要はない。
既定の http.DefaultClient にはタイムアウトが無いため、応答しない相手で詰まらないよう http.Client を自分で組み立てている。

Rust 静的確認

// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//              serde_json = "1"
use serde_json::json;
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let payload = json!({ "title": "hello", "body": "world", "userId": 1 });
 
    let created: serde_json::Value = reqwest::blocking::Client::new()
        .post("https://jsonplaceholder.typicode.com/posts")
        .json(&payload)
        .send()?
        .error_for_status()?
        .json()?;
 
    println!("{}", created["id"]);
    Ok(())
}

reqwest の json() は本文のシリアライズと Content-Type の付与をまとめて行うため、ヘッダを手で書く必要がない。
error_for_status() を挟むのは、4xx / 5xx を Err に変換してエラー応答を作成結果として読み進めないようにするためである。
json 機能を有効にしないとこのメソッドは生えない。

Java 静的確認

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
 
public class Main {
    public static void main(String[] args) throws Exception {
        String json = """
                {"title":"hello","body":"world","userId":1}""";
 
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(10))
                .build();
        HttpRequest req = HttpRequest.newBuilder(URI.create("https://jsonplaceholder.typicode.com/posts"))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();
 
        HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() / 100 != 2) {
            throw new RuntimeException("unexpected status: " + res.statusCode());
        }
        System.out.println(res.statusCode());
        System.out.println(res.body());
    }
}

Java 11 以降の java.net.http.HttpClient で POST を送れる。
BodyPublishers.ofString に本文の文字列を渡し、Content-Type を明示する。
この例だけ JSON をテキストブロックで直接書いているのは、Java の標準ライブラリに JSON シリアライザが無いためである。
値が固定のうちはこれで足りるが、動的な値を埋めるなら Jackson などに任せる。
手で連結すると引用符や改行のエスケープで壊れる。

C# 実行確認済み

using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
 
record NewPost(string Title, string Body, int UserId);
record CreatedPost(int Id, string Title);
 
class Program
{
    static readonly HttpClient client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(10)
    };
 
    static async Task Main()
    {
        var input = new NewPost("hello", "world", 1);
 
        using var res = await client.PostAsJsonAsync(
            "https://jsonplaceholder.typicode.com/posts", input);
        res.EnsureSuccessStatusCode();
 
        var created = await res.Content.ReadFromJsonAsync<CreatedPost>();
        Console.WriteLine($"{(int)res.StatusCode} {created?.Id}");
    }
}

System.Net.Http.Json の PostAsJsonAsync を使うと、シリアライズと Content-Type の付与を任せられる。
HttpClient を static フィールドで使い回すのは、リクエストごとに生成して破棄すると TIME_WAIT のソケットが積み上がって SocketException に至るためである。
既定のプロパティ名は camelCase へ変換されるので、title / body / userId として送られる。

つまずき

POST が通らないときにまず疑うのは Content-Type である。
本文は JSON なのにヘッダを付け忘れると、受け側は既定の form 形式として解釈し、400 を返すか、全フィールドが空のまま 200 を返す。
後者は「エラーが出ないのにデータが入らない」という形で表面化するので厄介である。
次に多いのがステータスの確認漏れで、JavaScript の fetch は 4xx / 5xx でも Promise を解決するため、res.ok を見ないとエラー応答を作成結果として読み進めてしまう。
作成系の API は 200 ではなく 201 を返すことも多いので、200 との等値比較ではなく 2xx の範囲で判定するほうがよい。

本文は文字列連結で組み立てない

JSON の本文をテンプレート文字列で組み立てると、値に引用符・改行・バックスラッシュが混じった瞬間に壊れる。
ユーザー入力が入る場面では現実に起きるうえ、壊れた本文は受け側で 400 になるだけで、どの値が原因かは分からない。
各言語のシリアライザ(Python の json.dumps、Go の json.Marshal、C# の PostAsJsonAsync など)に任せれば、エスケープは処理系が担保する。
ここに載せたサンプルがどれも辞書やレコードから作っているのはそのためである。

リトライするなら冪等性を先に確認する

GET と違い POST は既定では冪等ではない。
タイムアウトしたリクエストをそのまま再送すると、サーバ側では 1 回目も届いていて二重登録になることがある。
応答が返らなかったときに何が起きるかは相手の API 次第なので、無条件のリトライを入れる前に仕様を確認したい。
API が Idempotency-Key のようなヘッダを受け付けるならそれを付ける。
受け付けないなら、リトライは接続確立前の失敗に限る、あるいは作成後に一覧を引いて重複を確認する、といった設計にする。

この記事は役立ちましたか?