From 2356a5a525db5f23746e1333cd1a748709505d08 Mon Sep 17 00:00:00 2001 From: Yoichi Fujimoto Date: Mon, 13 Apr 2026 15:54:40 +0900 Subject: [PATCH] feat: add support for nested metadata serialization in request bodies and query strings --- README.md | 7 +++ src/resource.ts | 31 ++++++++++- test/requestor.spec.js | 120 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e1ff0ae..f721930 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ payjp.charges.create({ currency: 'jpy', card: 'token_id_by_Checkout_or_payjp.js' }).then(console.log).catch(console.error); + +payjp.charges.create({ + amount: 1000, + currency: 'jpy', + customer: 'cus_xxx', + metadata: { user_id: 123 } +}).then(console.log).catch(console.error); ``` Typescript diff --git a/src/resource.ts b/src/resource.ts index 1a0d245..2246b00 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -38,6 +38,33 @@ export default class Resource { return Math.ceil((delay / 2) * (1 + Math.random())); } + private isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === "[object Object]"; + } + + private appendFormValue(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined) { + return; + } + + if (this.isPlainObject(value)) { + for (const [nestedKey, nestedValue] of Object.entries(value)) { + this.appendFormValue(params, `${key}[${nestedKey}]`, nestedValue); + } + return; + } + + params.append(key, String(value)); + } + + private serializeQuery(query: object): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + this.appendFormValue(params, key, value); + } + return params; + } + protected request( method: string, endpoint: string, @@ -56,14 +83,14 @@ export default class Resource { // Set query parameters or request body if (method === "GET" || method === "DELETE") { // For GET and DELETE, add query parameters to URL - const params = new URLSearchParams(query as Record); + const params = this.serializeQuery(query); const queryString = params.toString(); if (queryString) { url = `${url}?${queryString}`; } } else { // For POST and PUT, send as request body - const body = new URLSearchParams(query as Record); + const body = this.serializeQuery(query); fetchOptions.body = body.toString(); } diff --git a/test/requestor.spec.js b/test/requestor.spec.js index 8233a56..5f38a28 100644 --- a/test/requestor.spec.js +++ b/test/requestor.spec.js @@ -112,6 +112,74 @@ describe("HTTP Requestor", () => { }); }); }); + it("serializes nested object values for POST bodies", (done) => { + const dummy = { + amount: 100, + metadata: { user: 121899 }, + }; + const status = 200; + const server = http + .createServer((msg, res) => { + server.close(); + assert.strictEqual(msg.method, "POST"); + assert.strictEqual(msg.url, "/v1/charges"); + let rawData = ""; + msg.on("data", (chunk) => { + rawData += chunk; + }); + msg.on("end", () => { + assert.strictEqual(rawData, "amount=100&metadata%5Buser%5D=121899"); + const body = JSON.stringify(dummy); + res.writeHead(status, { + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json", + }); + res.end(body); + }); + }) + .listen(() => { + const apibase = `http://localhost:${server.address().port}/v1`; + const payjp = new Payjp(apikey, { apibase }); + payjp.charges.create(dummy).then((r) => { + assert.deepStrictEqual(r, dummy); + done(); + }); + }); + }); + it("keeps flat bracket notation keys for POST bodies", (done) => { + const dummy = { + amount: 100, + "metadata[user]": "test", + }; + const status = 200; + const server = http + .createServer((msg, res) => { + server.close(); + assert.strictEqual(msg.method, "POST"); + assert.strictEqual(msg.url, "/v1/charges"); + let rawData = ""; + msg.on("data", (chunk) => { + rawData += chunk; + }); + msg.on("end", () => { + assert.strictEqual(rawData, "amount=100&metadata%5Buser%5D=test"); + const body = JSON.stringify(dummy); + res.writeHead(status, { + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json", + }); + res.end(body); + }); + }) + .listen(() => { + const apibase = `http://localhost:${server.address().port}/v1`; + const payjp = new Payjp(apikey, { apibase }); + payjp.charges.create(dummy).then((r) => { + assert.deepStrictEqual(r, dummy); + done(); + }); + }); + }); it("return 400 by GET", (done) => { const dummy = { object: "payjp" }; const status = 400; @@ -145,6 +213,58 @@ describe("HTTP Requestor", () => { }); }); }); + it("serializes nested object values for GET query strings", (done) => { + const dummy = { + metadata: { user: 121899 }, + }; + const status = 200; + const server = http + .createServer((msg, res) => { + server.close(); + assert.strictEqual(msg.method, "GET"); + assert.strictEqual(msg.url, "/v1/charges?metadata%5Buser%5D=121899"); + const body = JSON.stringify({}); + res.writeHead(status, { + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json", + }); + res.end(body); + }) + .listen(() => { + const apibase = `http://localhost:${server.address().port}/v1`; + const payjp = new Payjp(apikey, { apibase }); + payjp.charges.request("GET", "charges", dummy).then((r) => { + assert.deepStrictEqual(r, {}); + done(); + }); + }); + }); + it("keeps flat bracket notation keys for GET query strings", (done) => { + const dummy = { + "metadata[user]": "test", + }; + const status = 200; + const server = http + .createServer((msg, res) => { + server.close(); + assert.strictEqual(msg.method, "GET"); + assert.strictEqual(msg.url, "/v1/charges?metadata%5Buser%5D=test"); + const body = JSON.stringify({}); + res.writeHead(status, { + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json", + }); + res.end(body); + }) + .listen(() => { + const apibase = `http://localhost:${server.address().port}/v1`; + const payjp = new Payjp(apikey, { apibase }); + payjp.charges.request("GET", "charges", dummy).then((r) => { + assert.deepStrictEqual(r, {}); + done(); + }); + }); + }); it("return 200 by DELETE, but not json", (done) => { const status = 200; const server = http