Skip to content

Commit

Permalink
fix: multipart form (#571)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Mar 25, 2024
1 parent 0392937 commit 13a0ddc
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 8 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Playwright Tests
on:
push:
branches: [main]

env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: "buildwithfern"
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
WORKOS_API_KEY: ${{ secrets.WORKOS_API_KEY }}
WORKOS_CLIENT_ID: ${{ secrets.WORKOS_CLIENT_ID }}
HUME_API_KEY: ${{ secrets.HUME_API_KEY }}

jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install
uses: ./.github/actions/install

- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps

- name: Run Playwright tests
run: pnpm exec playwright test --debug

- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ packages/**/generated

# turbo
.turbo
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@
"editor.defaultFormatter": "foxundermoon.shell-format"
},
"scss.validate": false,
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"test": "turbo test",
"test:playwright": "pnpm exec playwright test --debug",
"clean": "turbo clean",
"compile": "turbo compile",
"codegen": "turbo codegen",
Expand All @@ -31,6 +32,7 @@
"docs:dev": "pnpm --filter=@fern-ui/docs-bundle run dev:fern-prod",
"docs-dev:dev": "pnpm --filter=@fern-ui/docs-bundle run dev:fern-dev",
"docs:build": "pnpm --filter=@fern-ui/docs-bundle run build:fern-prod",
"docs:start": "pnpm --filter=@fern-ui/docs-bundle run start:fern-prod",
"fdr:generate": "fern generate --api fdr"
},
"devDependencies": {
Expand All @@ -39,6 +41,7 @@
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.3",
"@next/eslint-plugin-next": "^14.1.0",
"@playwright/test": "^1.42.1",
"@types/is-ci": "^3.0.4",
"@types/jest": "^29.5.11",
"@types/lodash-es": "4.17.12",
Expand All @@ -48,6 +51,7 @@
"@yarnpkg/sdks": "^3.1.0",
"chalk": "^5.3.0",
"depcheck": "^1.4.3",
"dotenv": "^16.4.5",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"eslint-config-prettier": "^9.1.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/app/src/api-playground/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export function stringifyFetch(
}
const headers = redacted ? buildRedactedHeaders(endpoint, formState) : buildUnredactedHeaders(endpoint, formState);

// TODO: ensure case insensitivity
if (headers["Content-Type"] === "multipart/form-data") {
delete headers["Content-Type"]; // fetch will set this automatically
}

function buildFetch(body: string | undefined) {
if (endpoint == null) {
return "";
Expand Down
46 changes: 39 additions & 7 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import { NextResponse, type NextRequest } from "next/server";
import { jsonResponse } from "../../../utils/serverResponse";

export const runtime = "edge";
export const dynamic = "force-dynamic";
export const maxDuration = 60 * 5; // 5 minutes

async function dataURLtoBlob(dataUrl: string): Promise<Blob> {
if (dataUrl.startsWith("http:") || dataUrl.startsWith("https:")) {
const response = await fetch(dataUrl);
return await response.blob();
}

const [header, base64String] = dataUrl.split(",");
if (header == null || base64String == null) {
throw new Error("Invalid data URL");
}

const mime = header.match(/:(.*?);/)?.[1];
const bstr = atob(base64String);
let n = bstr.length;
const u8arr = new Uint8Array(n);

while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}

return new Blob([u8arr], { type: mime });
}

async function buildRequestBody(body: ProxyRequest.SerializableBody | undefined): Promise<BodyInit | undefined> {
if (body == null) {
Expand All @@ -20,7 +45,7 @@ async function buildRequestBody(body: ProxyRequest.SerializableBody | undefined)
case "file":
if (value.value != null) {
const base64 = value.value.dataUrl;
const blob = await (await fetch(base64)).blob();
const blob = await dataURLtoBlob(base64);
const file = new File([blob], value.value.name, { type: value.value.type });
formData.append(key, file);
}
Expand All @@ -29,7 +54,7 @@ async function buildRequestBody(body: ProxyRequest.SerializableBody | undefined)
const files = await Promise.all(
value.value.map(async (serializedFile) => {
const base64 = serializedFile.dataUrl;
const blob = await (await fetch(base64)).blob();
const blob = await dataURLtoBlob(base64);
return new File([blob], serializedFile.name, { type: serializedFile.type });
}),
);
Expand All @@ -50,7 +75,7 @@ async function buildRequestBody(body: ProxyRequest.SerializableBody | undefined)
return undefined;
}
const base64 = body.value.dataUrl;
const blob = await (await fetch(base64)).blob();
const blob = await dataURLtoBlob(base64);
return new File([blob], body.value.name, { type: body.value.type });
}
default:
Expand All @@ -65,9 +90,16 @@ export default async function POST(req: NextRequest): Promise<NextResponse> {
const startTime = Date.now();
try {
const proxyRequest = (await req.json()) as ProxyRequest;
const headers = new Headers(proxyRequest.headers);

// omit content-type for multipart/form-data so that fetch can set it automatically with the boundary
if (headers.get("Content-Type")?.toLowerCase().includes("multipart/form-data")) {
headers.delete("Content-Type");
}

const response = await fetch(proxyRequest.url, {
method: proxyRequest.method,
headers: new Headers(proxyRequest.headers),
headers,
body: await buildRequestBody(proxyRequest.body),
});
let body = await response.text();
Expand All @@ -77,12 +109,12 @@ export default async function POST(req: NextRequest): Promise<NextResponse> {
// Ignore
}
const endTime = Date.now();
const headers = response.headers;
const responseHeaders = response.headers;

return jsonResponse<ProxyResponse>(200, {
error: false,
response: {
headers: Object.fromEntries(headers.entries()),
headers: Object.fromEntries(responseHeaders.entries()),
ok: response.ok,
redirected: response.redirected,
status: response.status,
Expand All @@ -92,7 +124,7 @@ export default async function POST(req: NextRequest): Promise<NextResponse> {
body,
},
time: endTime - startTime,
size: headers.get("Content-Length"),
size: responseHeaders.get("Content-Length"),
});
} catch (err) {
// eslint-disable-next-line no-console
Expand Down
79 changes: 79 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";

/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
dotenv.config({ path: path.resolve(__dirname, "playwright/.env.local") });

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./playwright",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},

/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},

{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},

{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},

/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },

/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],

/* Run your local dev server before starting the tests */
webServer: {
command: "pnpm docs:dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Binary file added playwright/assets/david_hume.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions playwright/proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
import fs from "fs";
import path from "path";

test("multipart-form upload", async ({ request }) => {
const bitmap = fs.readFileSync(path.join(__dirname, "assets", "david_hume.jpeg"));
const base64Image = bitmap.toString("base64");
const mimeType = "image/jpeg";
const dataUrl = `data:${mimeType};base64,${base64Image}`;
const r = await request.post("http://localhost:3000/api/fern-docs/proxy", {
data: {
url: "https://api.hume.ai/v0/batch/jobs",
method: "POST",
headers: {
"X-Hume-Api-Key": process.env.HUME_API_KEY ?? "",
"Content-Type": "multipart/form-data",
},
body: {
type: "form-data",
value: {
file: {
type: "file",
value: {
name: "david_hume.jpg",
lastModified: 1699633808160,
size: 42777,
type: mimeType,
dataUrl,
},
},
},
},
},
});

const response = await r.json();

expect(response.error).toEqual(false);
expect(response.response.status).toEqual(200);
});
30 changes: 30 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 13a0ddc

Please sign in to comment.