Skip to content

Commit 4e7545c

Browse files
stainless-botRobertCraigie
authored andcommitted
chore(client): clean up file helpers
chore: unknown commit message
1 parent bb899c3 commit 4e7545c

File tree

11 files changed

+281
-282
lines changed

11 files changed

+281
-282
lines changed

src/client.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import * as Shims from './internal/shims';
1212
import * as Opts from './internal/request-options';
1313
import * as qs from './internal/qs';
1414
import { VERSION } from './version';
15-
import { isBlobLike } from './uploads';
1615
import { buildHeaders } from './internal/headers';
1716
import * as Errors from './error';
1817
import * as Pagination from './pagination';
@@ -443,14 +442,8 @@ export class OpenAI {
443442
opts?: PromiseOrValue<RequestOptions>,
444443
): APIPromise<Rsp> {
445444
return this.request(
446-
Promise.resolve(opts).then(async (opts) => {
447-
const body =
448-
opts && isBlobLike(opts?.body) ? new DataView(await opts.body.arrayBuffer())
449-
: opts?.body instanceof DataView ? opts.body
450-
: opts?.body instanceof ArrayBuffer ? new DataView(opts.body)
451-
: opts && ArrayBuffer.isView(opts?.body) ? new DataView(opts.body.buffer)
452-
: opts?.body;
453-
return { method, path, ...opts, body };
445+
Promise.resolve(opts).then((opts) => {
446+
return { method, path, ...opts };
454447
}),
455448
);
456449
}

src/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,7 @@
22

33
export { OpenAI as default } from './client';
44

5-
export {
6-
multipartFormRequestOptions,
7-
maybeMultipartFormRequestOptions,
8-
type Uploadable,
9-
createForm,
10-
toFile,
11-
} from './uploads';
5+
export { type Uploadable, toFile } from './uploads';
126
export { APIPromise } from './api-promise';
137
export { OpenAI, type ClientOptions } from './client';
148
export { PagePromise } from './pagination';

src/internal/uploads.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { type RequestOptions } from './request-options';
2+
import { type FilePropertyBag } from './builtin-types';
3+
import { isFsReadStreamLike, type FsReadStreamLike } from './shims';
4+
import './polyfill/file.node.js';
5+
6+
type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView;
7+
type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView;
8+
9+
/**
10+
* Typically, this is a native "File" class.
11+
*
12+
* We provide the {@link toFile} utility to convert a variety of objects
13+
* into the File class.
14+
*
15+
* For convenience, you can also pass a fetch Response, or in Node,
16+
* the result of fs.createReadStream().
17+
*/
18+
export type Uploadable = FileLike | ResponseLike | FsReadStreamLike;
19+
20+
/**
21+
* Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc.
22+
* Don't add arrayBuffer here, node-fetch doesn't have it
23+
*/
24+
interface BlobLike {
25+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */
26+
readonly size: number;
27+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */
28+
readonly type: string;
29+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */
30+
text(): Promise<string>;
31+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */
32+
slice(start?: number, end?: number): BlobLike;
33+
}
34+
35+
/**
36+
* This check adds the arrayBuffer() method type because it is available and used at runtime
37+
*/
38+
const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } =>
39+
value != null &&
40+
typeof value === 'object' &&
41+
typeof value.size === 'number' &&
42+
typeof value.type === 'string' &&
43+
typeof value.text === 'function' &&
44+
typeof value.slice === 'function' &&
45+
typeof value.arrayBuffer === 'function';
46+
47+
/**
48+
* Intended to match DOM File, node:buffer File, undici File, etc.
49+
*/
50+
interface FileLike extends BlobLike {
51+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */
52+
readonly lastModified: number;
53+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */
54+
readonly name: string;
55+
}
56+
declare var FileClass: {
57+
prototype: FileLike;
58+
new (fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): FileLike;
59+
};
60+
61+
/**
62+
* This check adds the arrayBuffer() method type because it is available and used at runtime
63+
*/
64+
const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise<ArrayBuffer> } =>
65+
value != null &&
66+
typeof value === 'object' &&
67+
typeof value.name === 'string' &&
68+
typeof value.lastModified === 'number' &&
69+
isBlobLike(value);
70+
71+
/**
72+
* Intended to match DOM Response, node-fetch Response, undici Response, etc.
73+
*/
74+
export interface ResponseLike {
75+
url: string;
76+
blob(): Promise<BlobLike>;
77+
}
78+
79+
const isResponseLike = (value: any): value is ResponseLike =>
80+
value != null &&
81+
typeof value === 'object' &&
82+
typeof value.url === 'string' &&
83+
typeof value.blob === 'function';
84+
85+
const isUploadable = (value: any): value is Uploadable => {
86+
return isFileLike(value) || isResponseLike(value) || isFsReadStreamLike(value);
87+
};
88+
89+
type ToFileInput = Uploadable | Exclude<BlobLikePart, string> | AsyncIterable<BlobLikePart>;
90+
91+
/**
92+
* Construct a `File` instance. This is used to ensure a helpful error is thrown
93+
* for environments that don't define a global `File` yet and so that we don't
94+
* accidentally rely on a global `File` type in our annotations.
95+
*/
96+
function makeFile(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): FileLike {
97+
const File = (globalThis as any).File as typeof FileClass | undefined;
98+
if (typeof File === 'undefined') {
99+
throw new Error('`File` is not defined as a global which is required for file uploads');
100+
}
101+
102+
return new File(fileBits, fileName, options);
103+
}
104+
105+
/**
106+
* Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats
107+
* @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s
108+
* @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible
109+
* @param {Object=} options additional properties
110+
* @param {string=} options.type the MIME type of the content
111+
* @param {number=} options.lastModified the last modified timestamp
112+
* @returns a {@link File} with the given properties
113+
*/
114+
export async function toFile(
115+
value: ToFileInput | PromiseLike<ToFileInput>,
116+
name?: string | null | undefined,
117+
options?: FilePropertyBag | undefined,
118+
): Promise<FileLike> {
119+
// If it's a promise, resolve it.
120+
value = await value;
121+
122+
// If we've been given a `File` we don't need to do anything
123+
if (isFileLike(value)) {
124+
const File = (globalThis as any).File as typeof FileClass | undefined;
125+
if (File && value instanceof File) {
126+
return value;
127+
}
128+
return makeFile([await value.arrayBuffer()], value.name);
129+
}
130+
131+
if (isResponseLike(value)) {
132+
const blob = await value.blob();
133+
name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file';
134+
135+
return makeFile(await getBytes(blob), name, options);
136+
}
137+
138+
const parts = await getBytes(value);
139+
140+
name ||= getName(value) ?? 'unknown_file';
141+
142+
if (!options?.type) {
143+
const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type);
144+
if (typeof type === 'string') {
145+
options = { ...options, type };
146+
}
147+
}
148+
149+
return makeFile(parts, name, options);
150+
}
151+
152+
export async function getBytes(
153+
value: Uploadable | BlobLikePart | AsyncIterable<BlobLikePart>,
154+
): Promise<Array<BlobPart>> {
155+
let parts: Array<BlobPart> = [];
156+
if (
157+
typeof value === 'string' ||
158+
ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc.
159+
value instanceof ArrayBuffer
160+
) {
161+
parts.push(value);
162+
} else if (isBlobLike(value)) {
163+
parts.push(value instanceof Blob ? value : await value.arrayBuffer());
164+
} else if (
165+
isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc.
166+
) {
167+
for await (const chunk of value) {
168+
parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating?
169+
}
170+
} else {
171+
const constructor = value?.constructor?.name;
172+
throw new Error(
173+
`Unexpected data type: ${typeof value}${
174+
constructor ? `; constructor: ${constructor}` : ''
175+
}${propsForError(value)}`,
176+
);
177+
}
178+
179+
return parts;
180+
}
181+
182+
function propsForError(value: unknown): string {
183+
if (typeof value !== 'object' || value === null) return '';
184+
const props = Object.getOwnPropertyNames(value);
185+
return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`;
186+
}
187+
188+
function getName(value: unknown): string | undefined {
189+
return (
190+
(typeof value === 'object' &&
191+
value !== null &&
192+
(('name' in value && String(value.name)) ||
193+
('filename' in value && String(value.filename)) ||
194+
('path' in value && String(value.path).split(/[\\/]/).pop()))) ||
195+
undefined
196+
);
197+
}
198+
199+
const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator<unknown> =>
200+
value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
201+
202+
/**
203+
* Returns a multipart/form-data request if any part of the given request body contains a File / Blob value.
204+
* Otherwise returns the request as is.
205+
*/
206+
export const maybeMultipartFormRequestOptions = async (opts: RequestOptions): Promise<RequestOptions> => {
207+
if (!hasUploadableValue(opts.body)) return opts;
208+
209+
return { ...opts, body: await createForm(opts.body) };
210+
};
211+
212+
type MultipartFormRequestOptions = Omit<RequestOptions, 'body'> & { body: unknown };
213+
214+
export const multipartFormRequestOptions = async (
215+
opts: MultipartFormRequestOptions,
216+
): Promise<RequestOptions> => {
217+
return { ...opts, body: await createForm(opts.body) };
218+
};
219+
220+
export const createForm = async <T = Record<string, unknown>>(body: T | undefined): Promise<FormData> => {
221+
const form = new FormData();
222+
await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value)));
223+
return form;
224+
};
225+
226+
const hasUploadableValue = (value: unknown): boolean => {
227+
if (isUploadable(value)) return true;
228+
if (Array.isArray(value)) return value.some(hasUploadableValue);
229+
if (value && typeof value === 'object') {
230+
for (const k in value) {
231+
if (hasUploadableValue((value as any)[k])) return true;
232+
}
233+
}
234+
return false;
235+
};
236+
237+
const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => {
238+
if (value === undefined) return;
239+
if (value == null) {
240+
throw new TypeError(
241+
`Received null for "${key}"; to pass null in FormData, you must use the string 'null'`,
242+
);
243+
}
244+
245+
// TODO: make nested formats configurable
246+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
247+
form.append(key, String(value));
248+
} else if (isUploadable(value)) {
249+
const file = await toFile(value);
250+
form.append(key, file as any);
251+
} else if (Array.isArray(value)) {
252+
await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry)));
253+
} else if (typeof value === 'object') {
254+
await Promise.all(
255+
Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
256+
);
257+
} else {
258+
throw new TypeError(
259+
`Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`,
260+
);
261+
}
262+
};

src/resources/audio/transcriptions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import { APIResource } from '../../resource';
44
import * as AudioAPI from './audio';
55
import { APIPromise } from '../../api-promise';
6-
import { type Uploadable, multipartFormRequestOptions } from '../../uploads';
6+
import { type Uploadable } from '../../uploads';
77
import { RequestOptions } from '../../internal/request-options';
8+
import { multipartFormRequestOptions } from '../../internal/uploads';
89

910
export class Transcriptions extends APIResource {
1011
/**

src/resources/audio/translations.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { APIResource } from '../../resource';
44
import * as AudioAPI from './audio';
55
import * as TranscriptionsAPI from './transcriptions';
66
import { APIPromise } from '../../api-promise';
7-
import { type Uploadable, multipartFormRequestOptions } from '../../uploads';
7+
import { type Uploadable } from '../../uploads';
88
import { RequestOptions } from '../../internal/request-options';
9+
import { multipartFormRequestOptions } from '../../internal/uploads';
910

1011
export class Translations extends APIResource {
1112
/**

src/resources/files.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import { APIResource } from '../resource';
44
import { APIPromise } from '../api-promise';
55
import { CursorPage, type CursorPageParams, PagePromise } from '../pagination';
6-
import { type Uploadable, multipartFormRequestOptions } from '../uploads';
6+
import { type Uploadable } from '../uploads';
77
import { RequestOptions } from '../internal/request-options';
88
import { sleep } from '../internal/utils/sleep';
99
import { APIConnectionTimeoutError } from '../error';
10+
import { multipartFormRequestOptions } from '../internal/uploads';
1011

1112
export class Files extends APIResource {
1213
/**

src/resources/images.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { APIResource } from '../resource';
44
import { APIPromise } from '../api-promise';
5-
import { type Uploadable, multipartFormRequestOptions } from '../uploads';
5+
import { type Uploadable } from '../uploads';
66
import { RequestOptions } from '../internal/request-options';
7+
import { multipartFormRequestOptions } from '../internal/uploads';
78

89
export class Images extends APIResource {
910
/**

src/resources/uploads/parts.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { APIResource } from '../../resource';
44
import { APIPromise } from '../../api-promise';
5-
import { type Uploadable, multipartFormRequestOptions } from '../../uploads';
5+
import { type Uploadable } from '../../uploads';
66
import { RequestOptions } from '../../internal/request-options';
7+
import { multipartFormRequestOptions } from '../../internal/uploads';
78

89
export class Parts extends APIResource {
910
/**

0 commit comments

Comments
 (0)