• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Zajno / common-utils / 23173420138

17 Mar 2026 01:02AM UTC coverage: 70.499% (+0.3%) from 70.225%
23173420138

Pull #136

github

web-flow
Merge ba59d0faf into 250f9fc69
Pull Request #136: [common/api] FormData support

783 of 1057 branches covered (74.08%)

Branch coverage included in aggregate %.

41 of 42 new or added lines in 3 files covered. (97.62%)

3399 of 4875 relevant lines covered (69.72%)

25.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.87
/packages/common/src/api/extensions/formData.ts
1
import type { AnyObject } from '../../types/misc.js';
2
import type { ApiEndpoint } from '../endpoint.js';
3
import type { IEndpointInfo } from '../endpoint.types.js';
4
import type { CallerHooks } from '../hooks.js';
5
import type { IRequestRawConfig } from '../call.types.js';
6

7
/**
8
 * Serializer function that converts a plain object into a FormData instance (or similar body).
9
 */
10
export type IFormDataSerializer = (data: Record<string, unknown>) => unknown;
11

12
/**
13
 * Options for the default FormData serializer factory.
14
 */
15
export interface FormDataSerializerOptions {
16
    /**
17
     * FormData constructor to use. Defaults to `globalThis.FormData`.
18
     *
19
     * Useful for:
20
     * - Older Node.js versions using `form-data` npm package
21
     * - Testing with mocks
22
     */
23
    FormData?: new () => FormData;
24

25
    /**
26
     * How to serialize non-primitive, non-Blob values (e.g., nested objects, arrays).
27
     * - `'json'` (default): `JSON.stringify(value)`
28
     * - Custom function: `(key, value) => string | Blob`
29
     */
30
    serializeValue?: 'json' | ((key: string, value: unknown) => string | Blob);
31
}
32

33
/**
34
 * Creates a default FormData serializer.
35
 *
36
 * Iterates over own enumerable properties of the data object and appends them to a new FormData instance.
37
 *
38
 * - `Blob`/`File` values are appended as-is
39
 * - `null`/`undefined` values are skipped
40
 * - Primitive values are converted to strings
41
 * - Objects/arrays are JSON-stringified by default (configurable via `serializeValue`)
42
 */
43
export function createFormDataSerializer(options?: FormDataSerializerOptions): IFormDataSerializer {
44
    const Ctor = options?.FormData ?? globalThis.FormData;
11✔
45
    const serializeValue = options?.serializeValue ?? 'json';
11✔
46

47
    return (data: Record<string, unknown>): FormData => {
11✔
48
        const fd = new Ctor();
8✔
49

50
        for (const [key, value] of Object.entries(data)) {
8✔
51
            if (value == null) {
13✔
52
                continue;
2✔
53
            }
54

55
            if (typeof Blob !== 'undefined' && value instanceof Blob) {
11✔
56
                fd.append(key, value);
1✔
57
                continue;
1✔
58
            }
59

60
            if (typeof value !== 'object') {
10✔
61
                // eslint-disable-next-line @typescript-eslint/no-base-to-string
62
                fd.append(key, String(value));
7✔
63
                continue;
7✔
64
            }
65

66
            if (typeof serializeValue === 'function') {
3✔
67
                const serialized = serializeValue(key, value);
2✔
68
                if (typeof Blob !== 'undefined' && serialized instanceof Blob) {
2✔
NEW
69
                    fd.append(key, serialized);
×
70
                } else {
71
                    fd.append(key, serialized as string);
2✔
72
                }
73
            } else {
74
                fd.append(key, JSON.stringify(value));
1✔
75
            }
76
        }
77

78
        return fd;
8✔
79
    };
80
}
81

82
/**
83
 * FormData serialization extension for endpoint.
84
 *
85
 * Marks an endpoint to have its request body serialized as FormData before sending.
86
 * Works with any HTTP transport (fetch, axios, etc.) — the library converts the plain object
87
 * to FormData via a configurable serializer, so the transport receives a ready-to-send FormData body.
88
 *
89
 * @example
90
 * ```typescript
91
 * const Endpoint = ApiEndpoint.create.extend(IEndpointFormData.extender);
92
 *
93
 * const upload = Endpoint('Upload')
94
 *     .post<{ name: string, file: File }, { url: string }>()
95
 *     .asFormData();
96
 * ```
97
 */
98
export interface IEndpointFormData {
99
    /** Whether this endpoint should serialize body data as FormData. `true` uses the default serializer; a function uses a custom one. */
100
    readonly formData?: boolean | IFormDataSerializer;
101

102
    /** Marks this endpoint to serialize request body as FormData using the provided (or default) serializer. */
103
    asFormData(serializer?: IFormDataSerializer): this;
104
}
105

106
export namespace IEndpointFormData {
1✔
107
    export const extender: ApiEndpoint.IBuilderExtender<IEndpointFormData> = <T extends ApiEndpoint>(base: T) => {
1✔
108
        const ext = {
7✔
109
            formData: undefined,
110
            asFormData(this: { formData: boolean | IFormDataSerializer }, serializer?: IFormDataSerializer) {
111
                this.formData = serializer ?? true;
7✔
112
                return this;
7✔
113
            },
114
        } as IEndpointFormData;
115
        return Object.assign(base, ext);
7✔
116
    };
117

118
    export function guard(api: IEndpointInfo): api is (IEndpointInfo & IEndpointFormData) {
1✔
119
        return 'formData' in api && !!(api as AnyObject).formData;
7✔
120
    }
121

122
    /**
123
     * Creates caller hooks for FormData serialization.
124
     *
125
     * The hook converts `config.data` (plain object) into a FormData instance using the provided serializer.
126
     *
127
     * @param serializer - Default serializer used when endpoint has `.asFormData()` without a custom serializer.
128
     *   Use {@link createFormDataSerializer} for the built-in one, or provide a fully custom function.
129
     *
130
     * @example
131
     * ```typescript
132
     * // Simplest setup (global FormData, JSON-stringify objects)
133
     * IEndpointFormData.createHooks(createFormDataSerializer())
134
     *
135
     * // Custom FormData constructor
136
     * import FormDataNode from 'form-data';
137
     * IEndpointFormData.createHooks(createFormDataSerializer({ FormData: FormDataNode as any }))
138
     *
139
     * // Fully custom serializer
140
     * IEndpointFormData.createHooks((data) => {
141
     *     const fd = new FormData();
142
     *     // custom logic...
143
     *     return fd;
144
     * })
145
     * ```
146
     */
147
    export function createHooks(serializer: IFormDataSerializer): CallerHooks<object> {
1✔
148
        return {
5✔
149
            beforeRequest: (config) => {
150
                if (!guard(config._meta.api) || !config.data) {
5✔
151
                    return;
2✔
152
                }
153

154
                const endpoint = config._meta.api;
3✔
155
                const ser = typeof endpoint.formData === 'function'
3✔
156
                    ? endpoint.formData
157
                    : serializer;
158

159
                (config as IRequestRawConfig<unknown>).data = ser(config.data as Record<string, unknown>);
3✔
160
            },
161
        };
162
    }
163
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc