Skip to content

Commit

Permalink
Replace undici's multipart/form-data parser with custom implementatio…
Browse files Browse the repository at this point in the history
…n in tests (#872)

Undici's multipart/form-data parser cannot handle Axios'
multipart/form-data requests correctly. For example, see this failure:
https://github.com/line/line-bot-sdk-nodejs/actions/runs/9362094041/job/25770215937.

The root cause of this issue is unclear, but it exists in the legacy
client code, which we plan to deprecate by the end of this year.

As a temporary workaround, we are replacing undici's parser with our own
implementation to ensure the tests pass.

Changes:
- Added a custom multipart/form-data parser function in
`test/client.spec.ts`.
- Updated tests to use the custom parser instead of undici's.
  • Loading branch information
tokuhirom authored Jun 5, 2024
1 parent f324445 commit a775036
Show file tree
Hide file tree
Showing 14 changed files with 877 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,55 @@ import { describe, it } from "vitest";

const channel_access_token = "test_channel_access_token";

// This is not a perfect multipart/form-data parser,
// but it works for the purpose of this test.
function parseForm(
arrayBuffer: ArrayBuffer,
): Record<string, string | Blob> {
const uint8Array = new Uint8Array(arrayBuffer);
const text = new TextDecoder().decode(uint8Array);

const boundary = text.match(/^--[^\r\n]+/)![0];

// split to parts, and drop first and last empty parts
const parts = text.split(new RegExp(boundary + "(?:\\r\\n|--)")).slice(1, -1);

const result: Record<string, string | Blob> = {};

for (const part of parts) {
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd === -1) continue;

const headers = part.slice(0, headerEnd);
const content = part.slice(headerEnd + 4);

const nameMatch = headers.match(/name="([^"]+)"/);
const fileNameMatch = headers.match(/filename="([^"]+)"/);

if (nameMatch) {
const name = nameMatch[1];

if (fileNameMatch) {
// it's a file
const contentTypeMatch = headers.match(/Content-Type:\s*(\S+)/i);
const contentType = contentTypeMatch
? contentTypeMatch[1]
: "application/octet-stream";

result[name] = new Blob([content.replace(/\r\n$/, "")], {
type: contentType,
});
} else {
// basic field
const value = content.trim();
result[name] = value;
}
}
}

return result;
}

{% macro paramDummyValue(param) %}
{# @pebvariable name="param" type="org.openapitools.codegen.CodegenParameter" #}
// {{ param.paramName }}: {{ param.dataType }}
Expand Down Expand Up @@ -82,8 +131,33 @@ const channel_access_token = "test_channel_access_token";
);
{% endif %}

res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({}));
{% if op.hasFormParams and op.isMultipart %}
let data: Buffer[] = [];

req.on('data', chunk => {
data.push(chunk);
});

req.on('end', () => {
// Combine the data chunks into a single Buffer
const buffer = Buffer.concat(data);

// Convert Buffer to ArrayBuffer
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);

// Form parameters
const formData = parseForm(arrayBuffer);
{% for param in op.formParams -%}
equal(formData["{{param.paramName}}"], String({{ paramDummyValue(param) }}));
{% endfor %}

res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({}));
});
{% else %}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({}));
{% endif %}
});
await new Promise((resolve) => {
server.listen(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,55 @@ import { describe, it } from "vitest";

const channel_access_token = "test_channel_access_token";

// This is not a perfect multipart/form-data parser,
// but it works for the purpose of this test.
export function parseForm(
arrayBuffer: ArrayBuffer,
): Record<string, string | Blob> {
const uint8Array = new Uint8Array(arrayBuffer);
const text = new TextDecoder().decode(uint8Array);

const boundary = text.match(/^--[^\r\n]+/)![0];

// split to parts, and drop first and last empty parts
const parts = text.split(new RegExp(boundary + "(?:\\r\\n|--)")).slice(1, -1);

const result: Record<string, string | Blob> = {};

for (const part of parts) {
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd === -1) continue;

const headers = part.slice(0, headerEnd);
const content = part.slice(headerEnd + 4);

const nameMatch = headers.match(/name="([^"]+)"/);
const fileNameMatch = headers.match(/filename="([^"]+)"/);

if (nameMatch) {
const name = nameMatch[1];

if (fileNameMatch) {
// it's a file
const contentTypeMatch = headers.match(/Content-Type:\s*(\S+)/i);
const contentType = contentTypeMatch
? contentTypeMatch[1]
: "application/octet-stream";

result[name] = new Blob([content.replace(/\r\n$/, "")], {
type: contentType,
});
} else {
// basic field
const value = content.trim();
result[name] = value;
}
}
}

return result;
}

describe("ChannelAccessTokenClient", () => {
it("getsAllValidChannelAccessTokenKeyIdsWithHttpInfo", async () => {
let requestCount = 0;
Expand Down
49 changes: 49 additions & 0 deletions lib/insight/tests/api/InsightClientTest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,55 @@ import { describe, it } from "vitest";

const channel_access_token = "test_channel_access_token";

// This is not a perfect multipart/form-data parser,
// but it works for the purpose of this test.
export function parseForm(
arrayBuffer: ArrayBuffer,
): Record<string, string | Blob> {
const uint8Array = new Uint8Array(arrayBuffer);
const text = new TextDecoder().decode(uint8Array);

const boundary = text.match(/^--[^\r\n]+/)![0];

// split to parts, and drop first and last empty parts
const parts = text.split(new RegExp(boundary + "(?:\\r\\n|--)")).slice(1, -1);

const result: Record<string, string | Blob> = {};

for (const part of parts) {
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd === -1) continue;

const headers = part.slice(0, headerEnd);
const content = part.slice(headerEnd + 4);

const nameMatch = headers.match(/name="([^"]+)"/);
const fileNameMatch = headers.match(/filename="([^"]+)"/);

if (nameMatch) {
const name = nameMatch[1];

if (fileNameMatch) {
// it's a file
const contentTypeMatch = headers.match(/Content-Type:\s*(\S+)/i);
const contentType = contentTypeMatch
? contentTypeMatch[1]
: "application/octet-stream";

result[name] = new Blob([content.replace(/\r\n$/, "")], {
type: contentType,
});
} else {
// basic field
const value = content.trim();
result[name] = value;
}
}
}

return result;
}

describe("InsightClient", () => {
it("getFriendsDemographicsWithHttpInfo", async () => {
let requestCount = 0;
Expand Down
49 changes: 49 additions & 0 deletions lib/liff/tests/api/LiffClientTest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,55 @@ import { describe, it } from "vitest";

const channel_access_token = "test_channel_access_token";

// This is not a perfect multipart/form-data parser,
// but it works for the purpose of this test.
export function parseForm(
arrayBuffer: ArrayBuffer,
): Record<string, string | Blob> {
const uint8Array = new Uint8Array(arrayBuffer);
const text = new TextDecoder().decode(uint8Array);

const boundary = text.match(/^--[^\r\n]+/)![0];

// split to parts, and drop first and last empty parts
const parts = text.split(new RegExp(boundary + "(?:\\r\\n|--)")).slice(1, -1);

const result: Record<string, string | Blob> = {};

for (const part of parts) {
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd === -1) continue;

const headers = part.slice(0, headerEnd);
const content = part.slice(headerEnd + 4);

const nameMatch = headers.match(/name="([^"]+)"/);
const fileNameMatch = headers.match(/filename="([^"]+)"/);

if (nameMatch) {
const name = nameMatch[1];

if (fileNameMatch) {
// it's a file
const contentTypeMatch = headers.match(/Content-Type:\s*(\S+)/i);
const contentType = contentTypeMatch
? contentTypeMatch[1]
: "application/octet-stream";

result[name] = new Blob([content.replace(/\r\n$/, "")], {
type: contentType,
});
} else {
// basic field
const value = content.trim();
result[name] = value;
}
}
}

return result;
}

describe("LiffClient", () => {
it("addLIFFAppWithHttpInfo", async () => {
let requestCount = 0;
Expand Down
Loading

0 comments on commit a775036

Please sign in to comment.