GASのdoPostでJSONを受け取り、指定したシートに記録します。
この例は練習用の記録先を明示し、入力検査とリクエストIDによる再追記防止を行います。
練習コード・入力JSON・文字変換ブックをダウンロード(ZIP)できます。
記事ごとに別のApps Scriptプロジェクトを使い、新規の練習データを対象にしてください。
最終確認は2026年10月2日です。
GASの51項目はローカルの模擬検証、curlの3項目はローカルHTTP環境、数式の8項目はArtifact Toolでの検証です。
Googleへの認可・実シートの操作・カレンダーの列挙・配備後のHTTP通信・Google Sheetsでの数式計算は未検証です。
練習シートとスクリプトプロパティ
新規の練習ブックに「practice-log」シートを作り、A1:D1にrequest_id、script、name、dateの順で見出しを入れます。
シートの共有範囲は広げず、実行するアカウントから編集できることを確認します。
| プロパティ | 設定値 |
|---|---|
| SPREADSHEET_ID | 練習ブックのID |
| SHEET_NAME | practice-log |
| POST_TOKEN | 自分で用意した練習専用の長いランダムな文字列。公開記事や共有ファイルには載せない |
「拡張機能」→「Apps Script」を開き、プロジェクトの設定からスクリプトプロパティを指定します。
以下のコードを保存します。
doGetは動作確認用の応答だけを返し、シートの行やトークンを公開しません。
// この例は、新規の練習シートだけで使います。
function fail_(code) {
const error = new Error(code);
error.publicCode = code;
throw error;
}
function property_(key) {
const value = PropertiesService.getScriptProperties().getProperty(key);
if (!value) fail_('missing_configuration');
return value;
}
function json_(value) {
return ContentService.createTextOutput(JSON.stringify(value))
.setMimeType(ContentService.MimeType.JSON);
}
function payload_(event) {
if (!event || !event.postData || typeof event.postData.contents !== 'string') fail_('missing_body');
if (!/^application\/json(?:\s*;|$)/i.test(event.postData.type || '')) fail_('content_type');
const text = event.postData.contents;
if (!text || text.length > 4096) fail_('body_size');
let data;
try { data = JSON.parse(text); } catch (_) { fail_('invalid_json'); }
const keys = ['token', 'request_id', 'script', 'name', 'date'];
if (!data || Array.isArray(data) || typeof data !== 'object' ||
Object.keys(data).length !== keys.length || !keys.every(k => typeof data[k] === 'string')) fail_('schema');
if (data.token !== property_('POST_TOKEN')) fail_('unauthorized');
if (!/^[a-zA-Z0-9_-]{8,80}$/.test(data.request_id)) fail_('request_id');
for (const key of ['script', 'name']) {
data[key] = data[key].trim();
if (!data[key] || data[key].length > 120 || /^[=+@-]/.test(data[key]) || /[\r\n\t]/.test(data[key])) fail_('unsafe_text');
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date)) fail_('date');
const date = new Date(data.date + 'T00:00:00Z');
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== data.date) fail_('date');
return {request_id: data.request_id, script: data.script, name: data.name, date: data.date};
}
function logSheet_() {
const sheet = SpreadsheetApp.openById(property_('SPREADSHEET_ID')).getSheetByName(property_('SHEET_NAME'));
if (!sheet) fail_('missing_sheet');
const expected = ['request_id', 'script', 'name', 'date'];
const headers = sheet.getRange(1, 1, 1, 4).getValues()[0];
if (!expected.every((x, i) => headers[i] === x)) fail_('headers');
return sheet;
}
function appendOnce_(data) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) fail_('busy');
try {
const sheet = logSheet_();
const last = sheet.getLastRow();
if (last > 1001) fail_('practice_limit');
const rows = last > 1 ? sheet.getRange(2, 1, last - 1, 4).getValues() : [];
const record = [data.request_id, data.script, data.name, data.date];
const matches = rows.filter(row => row[0] === data.request_id);
if (matches.length) {
if (matches.length !== 1 || !record.every((x, i) => matches[0][i] === x)) fail_('request_id_conflict');
return {ok: true, status: 'already_recorded', request_id: data.request_id};
}
if (last >= 1001) fail_('practice_limit');
sheet.getRange(last + 1, 1, 1, 4).setNumberFormat('@').setValues([record]);
SpreadsheetApp.flush();
return {ok: true, status: 'recorded', request_id: data.request_id};
} finally { lock.releaseLock(); }
}
function doPost(event) {
try { return json_(appendOnce_(payload_(event))); }
catch (error) { return json_({ok: false, error: error.publicCode || 'service_error'}); }
}
function doGet() {
// トークンやシートの行はGETで返しません。
return json_({ok: true, service: 'practice-log', read_enabled: false});
}
function demoPost() {
const data = {token: property_('POST_TOKEN'), request_id: 'practice-20261002-01',
script: 'demo.jsx', name: 'practice-user', date: '2026-10-02'};
const response = doPost({postData: {type: 'application/json', contents: JSON.stringify(data)}});
console.log(response.getContent());
}
エディタで先に1件を記録する
demoPostを選んで実行します。
練習ブックへのアクセス権限を確認して認可すると、request_idがpractice-20261002-01の行を記録します。
再実行時はalready_recordedを返し、同じ内容を増やしません。
失敗した場合はログのerrorと見出し・設定値を照合します。
curlから送る入力とリダイレクト
HTTPで試す場合は、「デプロイ」→「新しいデプロイ」でウェブアプリを選び、実行するアカウントとアクセスできるユーザーを確認します。
通常のcurlはブラウザーのGoogleログインを引き継ぎません。
サインイン必須の配備ではログイン画面になる場合があり、このコマンドだけでは認証できません。
匿名アクセスが必要な練習でも、共有トークンは必須です。
本番データを含むブックを匿名公開するための設計ではありません。
アクセス条件が合わない場合は設定を広げず、先のエディタ実行までで確認してください。
HTTPによる練習が終わった配備は管理画面から停止します。
request.jsonはUTF-8で保存します。
tokenは自分のPOST_TOKENへ置き換え、実際のトークンを公開したり、URLの引数に入れたりしないでください。
日付はYYYY-MM-DD形式です。
{
"token": "REPLACE_WITH_YOUR_PRACTICE_TOKEN",
"request_id": "practice-20261002-01",
"script": "demo.jsx",
"name": "practice-user",
"date": "2026-10-02"
}Windowsではcurl.exe –versionで利用できるか確認し、request.jsonと同じフォルダで次のコマンドを実行します。
YOUR_PRACTICE_EXEC_URLを、自分の練習用配備の /exec URLへ置き換えます。
macOS・Linuxではcurl.exeをcurlに読み替えます。
curl.exe --location --header "Content-Type: application/json" --data-binary "@request.json" "YOUR_PRACTICE_EXEC_URL"Content Serviceの応答は別のURLへリダイレクトされるため、–locationを指定します。
–data-binaryで最初のPOSTを指定し、–request POSTは併用しません。
ローカルの302・303応答では、最初のPOSTの後にGETで応答を受け取ることを確認しました。
Googleへの実通信は未検証です。
確認結果と切り分け
| 条件 | 結果 |
|---|---|
| 最初の正しい入力 | ok:true、status:recorded。データが1行増える |
| 同じIDと同じ内容 | already_recorded。行は増えない |
| 同じIDで異なる内容 | request_id_conflict。書き込まない |
| 本文なし・不正JSON・項目違い | missing_body、invalid_json、schema |
| トークン違い | unauthorized。書き込まない |
| 数式のような文字・改行・長すぎる値 | unsafe_text。練習例の入力条件外として拒否 |
| GETの応答 | 接続確認のみ。シート設定や書き込みの成功は示さない |
| HTTP200でもok:false | 失敗。応答本文のerrorを確認する |
| 1000件を超える練習データ | practice_limit。部分的な走査で続行しない |
LockServiceで同じプロジェクトの同時実行を制御します。
別プロジェクトや手動編集までは防げないため、練習中は他の処理から同じシートを変更しないでください。
通信が途中で切れた場合は、シートのrequest_idを確認してから同じIDで再確認します。
IDを変えると別件として記録されます。
停止と戻し方
この例は定期トリガーを作りません。
HTTPの受付を止めるときは配備を停止し、練習トークンの使用も終了します。
データを戻す必要がある場合は、変更前のコピーと記録したrequest_idを照合します。
本番の行を一括削除する操作は含めていません。
公式資料
ウェブアプリのイベントと配備、Content Serviceのリダイレクト、curlのリダイレクトとリクエスト方式を参照してください。
関連記事・旧実装の参考資料
旧GitHubコードや外部の参考例は、現在の記事のZIPと異なる場合があります。
今回の練習は本文のセル番地・プロパティ・ZIPをそろえて実行してください。





