WordPress REST APIで下書きの上書きを防ぐ方法|更新日時による競合検知と保存後照合




はじめに

WordPress REST APIを使うと、外部ツールから投稿を更新できます。しかし、WordPressの管理画面で編集した後に、外部ツールが古い本文を送信すると、新しい修正を上書きしてしまう可能性があります。

対策の基本は、取得時の更新日時を覚えておき、保存直前の更新日時と一致するときだけ更新することです。さらに、更新対象を下書きに限定し、保存後の値を再取得して照合します。

この記事では、WordPress REST APIのmodifiedを使ったクライアント側の競合検知を実装します。WordPressが更新を自動的にロックしてくれる設定ではありません。

下書きを安全に更新する4段階

今回の更新処理は、次の4段階に分けます。

段階 確認する内容 防げる問題
1. 最新版の取得 状態、modified、本文、分類 古い情報を前提にした更新
2. 更新前の比較 取得時と現在のmodified 別画面で行われた編集の上書き
3. 下書き限定で更新 statusがdraftか 公開済み記事の誤更新
4. 保存後の照合 本文、スラッグ、カテゴリーなど 保存漏れや想定外の変換

この考え方は、データを取得した時点と更新する時点の状態を比較する「楽観的な競合検知」に近い方法です。

実行前の準備

コード例は、組み込みのfetchが使えるNode.js 18以降を前提にします。WordPress側では、投稿を編集できるユーザーのアプリケーションパスワードを発行してください。

認証情報はコードへ直接書かず、環境変数から読み込みます。

WP_SITE_URL=https://example.com
WP_USERNAME=api-user
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx

アプリケーションパスワードはHTTPS経由で使います。通信を暗号化する理由は、HTTPとHTTPSの違いとHTTPSが必要な理由で確認できます。

通常のログインパスワードやアプリケーションパスワードを、ソースコード、Git、記事本文へ保存しないでください。

最新の下書きと更新日時を取得する

まず、更新対象の投稿をcontext=editで取得します。_fieldsを指定すると、必要な項目だけにレスポンスを絞れます。

const siteUrl = process.env.WP_SITE_URL;
const username = process.env.WP_USERNAME;
const appPassword = process.env.WP_APP_PASSWORD;

const authorization = `Basic ${Buffer.from(
  `${username}:${appPassword}`,
).toString("base64")}`;

async function getDraft(postId) {
  const url = new URL(`/wp-json/wp/v2/posts/${postId}`, siteUrl);
  url.searchParams.set("context", "edit");
  url.searchParams.set(
    "_fields",
    "id,status,modified,slug,title,content,categories,tags",
  );

  const response = await fetch(url, {
    headers: {
      Accept: "application/json",
      Authorization: authorization,
    },
  });

  if (!response.ok) {
    throw new Error(`下書きの取得に失敗しました: ${response.status}`);
  }

  const post = await response.json();
  if (post.status !== "draft") {
    throw new Error(`更新できるのは下書きだけです: ${post.status}`);
  }

  return post;
}

編集を始めるときに取得したmodifiedを、expectedModifiedとして保存しておきます。更新処理へ渡すのは本文だけでなく、この更新日時も必要です。

更新直前に競合を検知する

更新ボタンを押した時点で、同じ投稿をもう一度取得します。取得時からmodifiedが変わっていたら、管理画面や別ツールで編集された可能性があるため処理を中止します。

async function updateDraftSafely(postId, expectedModified, input) {
  const current = await getDraft(postId);

  if (current.modified !== expectedModified) {
    throw new Error(
      `更新競合を検出しました。取得時=${expectedModified}、現在=${current.modified}`,
    );
  }

  const response = await fetch(
    new URL(`/wp-json/wp/v2/posts/${postId}`, siteUrl),
    {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: authorization,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify({
        title: input.title,
        content: input.content,
        slug: input.slug,
        categories: input.categoryIds,
        tags: input.tagIds,
        status: "draft",
      }),
    },
  );

  if (!response.ok) {
    throw new Error(`下書きの更新に失敗しました: ${response.status}`);
  }

  return verifySavedDraft(postId, input);
}

ポイントは、入力値から公開状態を受け取らず、コード側でstatus: "draft"に固定することです。公開操作が必要になっても、下書き更新とは別の権限と処理に分けた方が安全です。

保存後にWordPressの値を照合する

HTTPリクエストが成功しても、意図した値がすべて保存されたとは限りません。更新後の投稿を再取得し、重要な項目を比較します。

function normalizeHtml(value) {
  return String(value ?? "")
    .replace(/\r\n?/g, "\n")
    .replace(/&#x0*([0-9a-f]+);/gi, (_, code) =>
      String.fromCodePoint(Number.parseInt(code, 16)),
    )
    .replace(/&#0*([0-9]+);/g, (_, code) =>
      String.fromCodePoint(Number.parseInt(code, 10)),
    )
    .trim();
}

function sameNumberSet(left = [], right = []) {
  const a = [...new Set(left.map(Number))].sort((x, y) => x - y);
  const b = [...new Set(right.map(Number))].sort((x, y) => x - y);
  return a.length === b.length && a.every((value, index) => value === b[index]);
}

async function verifySavedDraft(postId, input) {
  const saved = await getDraft(postId);
  const mismatches = [];

  if (saved.title.raw !== input.title) mismatches.push("title");
  if (normalizeHtml(saved.content.raw) !== normalizeHtml(input.content)) {
    mismatches.push("content");
  }
  if (saved.slug !== input.slug) mismatches.push("slug");
  if (!sameNumberSet(saved.categories, input.categoryIds)) {
    mismatches.push("categories");
  }
  if (!sameNumberSet(saved.tags, input.tagIds)) mismatches.push("tags");

  if (mismatches.length > 0) {
    throw new Error(`保存後の照合に失敗しました: ${mismatches.join(", ")}`);
  }

  return {
    postId: saved.id,
    modified: saved.modified,
    verified: true,
  };
}

カテゴリーとタグは順番ではなく、IDの集合として比較しています。本文は改行コードと数値文字参照を正規化し、意味が同じなのに表記だけが違うケースを減らしています。

この方法だけでは完全な排他制御にならない

modifiedの比較は、外部ツールが古い下書きを送る事故を減らすために有効です。ただし、「最後の確認」と「更新リクエスト」の間へ別の更新が入る可能性は残ります。また、更新日時の精度だけでは、非常に短い間隔の変更を区別できない場合があります。

複数人が頻繁に同じ投稿を編集する環境や、更新の取りこぼしを許容できない用途では、期待する更新日時やバージョン番号を受け取り、サーバー側で検証してから保存する専用エンドポイントを検討してください。

クライアント側だけで実装する場合も、次の対策を組み合わせると事故の範囲を小さくできます。

  • 更新対象を下書きだけに限定する
  • 更新直前に最新版を再取得する
  • 競合時は自動で再試行せず、人が差分を確認する
  • 保存後にWordPress側の値を再取得する
  • 更新前の本文を復元できる形で残す
  • 公開と削除は別の処理に分ける

よくあるエラーの確認ポイント

状況 主な確認項目
401 Unauthorized ユーザー名、アプリケーションパスワード、HTTPS、Authorizationヘッダー
403 Forbidden ユーザーに対象投稿を編集する権限があるか
title.rawなどがない context=editを指定し、認証できているか
更新直後に照合が失敗する WordPressやプラグインによるHTML変換、カテゴリー・タグID、スラッグの自動変更
競合を検出した 最新版を取得し直し、ローカルの変更との差分を人が確認する

照合エラーを無視して公開処理へ進むのではなく、どの項目が一致しなかったかを記録して止める設計が重要です。

まとめ

WordPress REST APIで既存の下書きを更新するときは、更新リクエストを送るだけでなく、取得時のmodifiedと現在の値を比較します。さらに、対象を下書きへ限定し、保存後の本文や設定を再取得して照合すると、誤上書きや保存漏れに気づきやすくなります。

この方法はクライアント側の安全策であり、完全な排他制御ではありません。まずは下書きだけの小さな範囲から導入し、競合時に自動更新しない運用と組み合わせてください。

参考情報

コメント

タイトルとURLをコピーしました