目的:ブラウザ(Wijmo FlexGrid)で編集したデータを安全かつ確実に Spring
@RestController
に送信・適用するための実務チェックリストとサンプル。
1. 高レベルフロー(要点)
- クライアントで差分を取得(
view.trackChanges = true
→itemsAdded/itemsEdited/itemsRemoved
) - 送信前に正規化(Date → ISO、不要キー除去、キーソートは任意)+軽いスキーマ検証
-
fetch
でContent-Type: application/json
を付けて送信 - サーバーは
@RequestBody
を受け、Jacksonで DTO に変換 -
PUT
はフル更新(必須チェック)、PATCH
は差分マージ(readerForUpdating
) - バリデーション、楽観ロック、ID生成、結果返却、UI反映
2. クライアント(Wijmo + プレーンJS)の最低限の実装
A. 差分を集めて送る(簡潔)
view.trackChanges = true;
function makePayload(view) {
return {
added: view.itemsAdded || [],
edited: view.itemsEdited || [],
removed: view.itemsRemoved || []
};
}
async function saveChanges(view) {
const payload = makePayload(view);
// 正規化を推奨(下記 normalize を参照)
const res = await fetch('/api/items/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
B. 軽い正規化ユーティリティ(送る前に)
function normalize(obj) {
if (obj == null) return obj;
if (obj instanceof Date) return obj.toISOString();
if (Array.isArray(obj)) return obj.map(normalize);
if (typeof obj !== 'object') return obj;
return Object.keys(obj).sort().reduce((acc,k)=>{
acc[k] = normalize(obj[k]);
return acc;
}, {});
}
// 使い方: body = JSON.stringify(normalize(payload))
C. 送信前検証(推奨)
- 軽量:手書きチェック(必須フィールドの有無)
- 中量:
zod
/io-ts
等でスキーマを作りparse()
して弾く - 重量:OpenAPI から生成した型で静的保障 + AJV でランタイム検証
3. サーバー側(Spring)での安全実装パターン
A. ObjectMapper 設定(推奨初期設定)
@Bean
public ObjectMapper objectMapper() {
var om = new ObjectMapper();
om.registerModule(new JavaTimeModule()); // JSR310
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
om.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true);
return om;
}
-
FAIL_ON_UNKNOWN_PROPERTIES = true
でクライアントの余分なキーを拒否 -
JavaTimeModule
でLocalDateTime
等を扱う
B. DTO の基本方針
- プリミティブ型は使わずラッパーを使う(
Integer
/Long
/Boolean
)→ 欠損をnull
として扱う - 必須は
@NotNull
等で明示 - フィールド名は JSON と一致(
@JsonProperty
でマッピング可能)
C. バッチ受け取り(例)
public class ItemDto { /* id, title, completed, ... */ }
public class BatchItemsDto { List<ItemDto> added, edited, removed; }
@PostMapping("/api/items/batch")
public ResponseEntity<?> batch(@RequestBody BatchItemsDto batch) {
// サービスに任せる
var result = service.applyBatch(batch);
return ResponseEntity.ok(result);
}
D. PATCH(差分マージ)の安全な実装
@Autowired ObjectMapper mapper;
@Autowired Validator validator; // javax.validation
@PatchMapping("/api/items/{id}")
public ResponseEntity<?> patch(@PathVariable Long id, @RequestBody JsonNode patchNode) throws Exception {
Item entity = service.findById(id);
ItemDto dto = service.toDto(entity);
// 送られたキーだけ既存 DTO にマージ
mapper.readerForUpdating(dto).readValue(patchNode);
// バリデーション
var violations = validator.validate(dto);
if (!violations.isEmpty()) return ResponseEntity.badRequest().body(violations);
service.updateFromDto(dto);
return ResponseEntity.ok(...);
}
-
readerForUpdating
は送られてないフィールドをそのままにしてくれる -
@Valid
を PATCH にそのまま使うと誤検出しやすいのでマージ後に明示的に検証する
4. 受信 JSON と DTO の「同型性検査」について
文字列比較ではなく、パースしてツリー比較(JsonNode.equals)を使う。
@PostMapping("/api/debug/check")
public ResponseEntity<?> check(@RequestBody JsonNode incoming) throws Exception {
JsonNode original = incoming;
ItemDto dto = mapper.treeToValue(incoming, ItemDto.class);
JsonNode dtoNode = mapper.valueToTree(dto);
boolean same = dtoNode.equals(original);
return ResponseEntity.ok(Map.of("same", same));
}
- ただし
FAIL_ON_UNKNOWN_PROPERTIES
の設定に依存する動作に注意 -
dtoNode.equals(original)
が false の場合、差分をログに吐いて原因を追う(フォーマットの違い、欠損、型の違い、日付形式など)
5. 日付・形式の問題回避(実務Tips)
- 送信は
date.toISOString()
(UTC ISO8601)で統一 - Spring は
JavaTimeModule
を有効化 - 入力で
"2025-08-21T07:00:00Z"
を受け取る想定にする
6. ID生成・新規行の扱い
- 新規行(クライアントで ID が無い)は
added
として送る - サーバーで ID を発行し、更新後の DTO を返してクライアントで差し替える
例: サーバー返却
{ "added": [{"tempId":"t-1","id":123,...}], "edited": [...], "removed": [...] }
- tempId を付けておくと置換が楽
7. 競合(並行更新)対策
- 楽観ロック(JPA の
@Version
)を導入 - 衝突検出時は
409 Conflict
を返し、クライアントに差分解決を促す
8. エラー設計
- バリデーションエラー →
400 Bad Request
と詳細 JSON - 権限/認可 →
403
/401
- 楽観ロック競合 →
409 Conflict
とサーバー側最新データ - サーバー障害 →
5xx