Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 46 additions & 23 deletions src/internal/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,30 @@ export function makeFile(
return new File(fileBits as any, fileName ?? 'unknown_file', options);
}

export function getName(value: any): string | undefined {
return (
(
(typeof value === 'object' &&
value !== null &&
(('name' in value && value.name && String(value.name)) ||
('url' in value && value.url && String(value.url)) ||
('filename' in value && value.filename && String(value.filename)) ||
('path' in value && value.path && String(value.path)))) ||
''
)
.split(/[\\/]/)
.pop() || undefined
);
type CreateFormOptions = {
preserveFilePaths?: boolean;
};

export function getName(value: any, options: CreateFormOptions = {}): string | undefined {
let name = '';
let isURL = false;

if (typeof value === 'object' && value !== null) {
if ('name' in value && value.name) {
name = String(value.name);
} else if ('url' in value && value.url) {
name = String(value.url);
isURL = true;
} else if ('filename' in value && value.filename) {
name = String(value.filename);
} else if ('path' in value && value.path) {
name = String(value.path);
}
}

if (options.preserveFilePaths && !isURL) return name || undefined;

return name.split(/[\\/]/).pop() || undefined;
}

export const isAsyncIterable = (value: any): value is AsyncIterable<any> =>
Expand All @@ -75,19 +85,21 @@ export const isAsyncIterable = (value: any): value is AsyncIterable<any> =>
export const maybeMultipartFormRequestOptions = async (
opts: RequestOptions,
fetch: OpenAI | Fetch,
options?: CreateFormOptions,
): Promise<RequestOptions> => {
if (!hasUploadableValue(opts.body)) return opts;

return { ...opts, body: await createForm(opts.body, fetch) };
return { ...opts, body: await createForm(opts.body, fetch, options) };
};

type MultipartFormRequestOptions = Omit<RequestOptions, 'body'> & { body: unknown };

export const multipartFormRequestOptions = async (
opts: MultipartFormRequestOptions,
fetch: OpenAI | Fetch,
options?: CreateFormOptions,
): Promise<RequestOptions> => {
return { ...opts, body: await createForm(opts.body, fetch) };
return { ...opts, body: await createForm(opts.body, fetch, options) };
};

const supportsFormDataMap = /* @__PURE__ */ new WeakMap<Fetch, Promise<boolean>>();
Expand Down Expand Up @@ -125,14 +137,17 @@ function supportsFormData(fetchObject: OpenAI | Fetch): Promise<boolean> {
export const createForm = async <T = Record<string, unknown>>(
body: T | undefined,
fetch: OpenAI | Fetch,
options?: CreateFormOptions,
): Promise<FormData> => {
if (!(await supportsFormData(fetch))) {
throw new TypeError(
'The provided fetch function does not support file uploads with the current global FormData class.',
);
}
const form = new FormData();
await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value)));
await Promise.all(
Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value, options)),
);
return form;
};

Expand All @@ -156,7 +171,12 @@ const hasUploadableValue = (value: unknown): boolean => {
return false;
};

const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => {
const addFormValue = async (
form: FormData,
key: string,
value: unknown,
options: CreateFormOptions = {},
): Promise<void> => {
if (value === undefined) return;
if (value == null) {
throw new TypeError(
Expand All @@ -168,16 +188,19 @@ const addFormValue = async (form: FormData, key: string, value: unknown): Promis
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
form.append(key, String(value));
} else if (value instanceof Response) {
form.append(key, makeFile([await value.blob()], getName(value)));
form.append(key, makeFile([await value.blob()], getName(value, options)));
} else if (isAsyncIterable(value)) {
form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value)));
form.append(
key,
makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value, options)),
);
} else if (isNamedBlob(value)) {
form.append(key, value, getName(value));
form.append(key, value, getName(value, options));
} else if (Array.isArray(value)) {
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry)));
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry, options)));
} else if (typeof value === 'object') {
await Promise.all(
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop, options)),
);
} else {
throw new TypeError(
Expand Down
4 changes: 3 additions & 1 deletion src/resources/skills/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export class Skills extends APIResource {
create(body: SkillCreateParams | null | undefined = {}, options?: RequestOptions): APIPromise<Skill> {
return this._client.post(
'/skills',
maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client),
maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client, {
preserveFilePaths: true,
}),
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/resources/skills/versions/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export class Versions extends APIResource {
): APIPromise<SkillVersion> {
return this._client.post(
path`/skills/${skillID}/versions`,
maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client),
maybeMultipartFormRequestOptions({ body, ...options, __security: { bearerAuth: true } }, this._client, {
preserveFilePaths: true,
}),
);
}

Expand Down
38 changes: 38 additions & 0 deletions tests/api-resources/skills/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import OpenAI, { toFile } from 'openai';
import { mockFetch } from '../../utils/mock-fetch';

const client = new OpenAI({
apiKey: 'My API Key',
Expand Down Expand Up @@ -30,6 +31,43 @@ describe('resource skills', () => {
).rejects.toThrow(OpenAI.NotFoundError);
});

test('create: preserves directory paths in uploaded filenames', async () => {
const { fetch, handleRequest } = mockFetch();
const client = new OpenAI({
apiKey: 'My API Key',
adminAPIKey: 'My Admin API Key',
baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010',
fetch,
});

const formDataProbe = handleRequest(async () => new Response('ok'));
const request = handleRequest(async (_req, init) => {
const form = init!.body as FormData;
expect((form.get('files[]') as File).name).toBe('my-skill/SKILL.md');

return new Response(
JSON.stringify({
id: 'skill_123',
created_at: 0,
default_version: 'v1',
description: 'Test skill',
latest_version: 'v1',
name: 'my-skill',
object: 'skill',
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});

const response = await client.skills.create({
files: [new File(['# Skill'], 'my-skill/SKILL.md')],
});
await formDataProbe;
await request;

expect(response.id).toBe('skill_123');
});

test('retrieve', async () => {
const responsePromise = client.skills.retrieve('skill_123');
const rawResponse = await responsePromise.asResponse();
Expand Down
38 changes: 38 additions & 0 deletions tests/form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ describe('form data validation', () => {
expect(form.get('bar')).toBe('baz');
});

test('strips file paths by default', async () => {
const form = await createForm(
{
files: [new File(['Example data'], 'my-skill/SKILL.md')],
},
fetch,
);

expect((form.get('files[]') as File).name).toBe('SKILL.md');
});

test('preserves file paths when requested', async () => {
const form = await createForm(
{
files: [new File(['Example data'], 'my-skill/SKILL.md')],
},
fetch,
{ preserveFilePaths: true },
);

expect((form.get('files[]') as File).name).toBe('my-skill/SKILL.md');
});

test('strips response URLs when preserving file paths', async () => {
const response = new Response('Example data');
Object.defineProperty(response, 'url', { value: 'https://cdn.example.com/my-skill/SKILL.md' });

const form = await createForm(
{
files: [response],
},
fetch,
{ preserveFilePaths: true },
);

expect((form.get('files[]') as File).name).toBe('SKILL.md');
});

test('nested undefined property is stripped', async () => {
const form = await createForm(
{
Expand Down