Skip to content

Commit a82e27c

Browse files
committed
feat: add support serialization to FormData
1 parent e9d734c commit a82e27c

File tree

8 files changed

+238
-31
lines changed

8 files changed

+238
-31
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,33 @@ JSON.stringify(user);
181181
// Result: {"firstName":"","familyName":""}
182182
```
183183

184+
Class to FormData
185+
------
186+
187+
Sometimes classes contain properties with the File type. Sending such classes via json is a heavy task. Converting a file property to json can freeze the interface for a few seconds if the file is large. A much better solution is to send an Ajax form. Example:
188+
189+
```typescript
190+
import { Serializable } from "ts-serializable";
191+
192+
export class User extends Serializable {
193+
194+
public firstName: string = '';
195+
196+
public familyName: File | null = null;
197+
198+
}
199+
200+
// ... send file function ...
201+
202+
await fetch("api/sendFile", {
203+
method: "POST",
204+
body: user.toFormData()
205+
});
206+
207+
```
208+
209+
Naming strategies, custom names, ignoring and other decorators are supported during conversion.
210+
184211
Bonus
185212
------
186213

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@
2323
"scripts": {
2424
"lint": "eslint --fix ./src/ ./tests/",
2525
"test": "node --import ./ts-loader.js --test --test-reporter=spec --test-reporter-destination=stdout \"tests/**/*.spec.ts\"",
26+
"test-watch": "node --watch --import ./ts-loader.js --test --test-reporter=spec --test-reporter-destination=stdout \"tests/**/*.spec.ts\"",
2627
"coverage": "node --import ./ts-loader.js --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \"tests/**/*.spec.ts\"",
2728
"build": "tsc --project tsconfig.build.json && node ./dist/index.js",
28-
"prepublishOnly": "npm run lint && npm run build && npm run test",
29+
"prepublishOnly": "npm run lint && npm run build && npm run test && node ./dist/index.js",
2930
"release": "cliff-jumper --name 'ts-serializable' --package-path '.' --no-skip-changelog --no-skip-tag",
3031
"prepare": "husky install"
3132
},

src/classes/Serializable.ts

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import type {AcceptedTypes} from "../models/AcceptedType.js";
1010
import {SerializationSettings} from "../models/SerializationSettings.js";
11+
import {classToFormData} from "../utils/ClassToFormData.js";
12+
import {getPropertyName} from "../utils/GetProperyName.js";
1113

1214
/**
1315
* Class how help you deserialize object to classes.
@@ -136,22 +138,43 @@ export class Serializable {
136138
* @memberof Serializable
137139
*/
138140
public toJSON (): Record<string, unknown> {
139-
const fromJson: this = {...this};
140141
const toJson: Record<string, unknown> = {};
142+
const keys = Reflect.ownKeys(this);
141143

142-
for (const prop in fromJson) {
143-
// Json.hasOwnProperty(prop) - preserve for deserialization for other classes with methods
144-
if (fromJson.hasOwnProperty(prop) && this.hasOwnProperty(prop)) {
145-
if (Reflect.getMetadata("ts-serializable:jsonIgnore", this.constructor.prototype, prop) !== true) {
146-
const toProp = this.getJsonPropertyName(prop);
147-
Reflect.set(toJson, toProp, Reflect.get(fromJson, prop));
144+
for (const key of keys) {
145+
if (typeof key === "symbol") {
146+
// eslint-disable-next-line no-continue
147+
continue;
148+
}
149+
150+
if (this.hasOwnProperty(key)) {
151+
if (Reflect.getMetadata("ts-serializable:jsonIgnore", this.constructor.prototype, key) !== true) {
152+
const toProp = this.getJsonPropertyName(key);
153+
Reflect.set(toJson, toProp, Reflect.get(this, key));
148154
}
149155
}
150156
}
151157

152158
return toJson;
153159
}
154160

161+
/**
162+
* Serialize class to FormData.
163+
*
164+
* Can be used for prepare ajax form with files.
165+
* Send files via ajax json its heavy task, because need convert file to base 64 format,
166+
* user interface can be freeze on many seconds on this operation if file is too big.
167+
* Ajax forms its lightweight alternative.
168+
*
169+
* @param {string} formPrefix Prefix for form property names
170+
* @param {FormData} formData Can be update an existing FormData
171+
* @returns {FormData}
172+
* @memberof Serializable
173+
*/
174+
public toFormData (formPrefix?: string, formData?: FormData): FormData {
175+
return classToFormData(this, formPrefix, formData);
176+
}
177+
155178
/**
156179
* Process serialization for @jsonIgnore decorator
157180
*
@@ -288,29 +311,16 @@ export class Serializable {
288311
return Reflect.get(this, prop);
289312
}
290313

291-
protected getJsonPropertyName (thisProperty: string, settings?: Partial<SerializationSettings>): string {
292-
if (Reflect.hasMetadata("ts-serializable:jsonName", this.constructor.prototype, thisProperty)) {
293-
return Reflect.getMetadata("ts-serializable:jsonName", this.constructor.prototype, thisProperty) as string;
294-
}
295-
296-
if (settings?.namingStrategy) {
297-
return settings.namingStrategy.toJsonName(thisProperty);
298-
}
299-
300-
if (Reflect.hasMetadata("ts-serializable:jsonObject", this.constructor)) {
301-
const objectSettings: Partial<SerializationSettings> = Reflect.getMetadata(
302-
"ts-serializable:jsonObject",
303-
this.constructor
304-
) as Partial<SerializationSettings>;
305-
return objectSettings.namingStrategy?.toJsonName(thisProperty) ?? thisProperty;
306-
}
307-
308-
if (Serializable.defaultSettings.namingStrategy) {
309-
const {namingStrategy} = Serializable.defaultSettings;
310-
return namingStrategy.toJsonName(thisProperty) ?? thisProperty;
311-
}
312-
313-
return thisProperty;
314+
/**
315+
* Extract correct name for property.
316+
* Considers decorators for transforming the property name.
317+
*
318+
* @param {string} property Source name of property
319+
* @param {Partial<SerializationSettings>} settings Serialization settings
320+
* @returns
321+
*/
322+
protected getJsonPropertyName (property: string, settings?: Partial<SerializationSettings>): string {
323+
return getPropertyName(this, property, settings);
314324
}
315325

316326
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ export {SnakeCaseNamingStrategy} from "./naming-strategies/SnakeCaseNamingStrate
2424
export {PascalCaseNamingStrategy} from "./naming-strategies/PascalCaseNamingStrategy.js";
2525
export {KebabCaseNamingStrategy} from "./naming-strategies/KebabCaseNamingStrategy.js";
2626
export {CamelCaseNamingStrategy} from "./naming-strategies/CamelCaseNamingStrategy.js";
27+
28+
// Utils
29+
export {classToFormData} from "./utils/ClassToFormData.js";
30+
export {getPropertyName} from "./utils/GetProperyName.js";

src/utils/ClassToFormData.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {getPropertyName} from "./GetProperyName.js";
2+
3+
// eslint-disable-next-line max-statements, max-lines-per-function
4+
export const classToFormData = (obj: object, formPrefix?: string, formData?: FormData) => {
5+
const newFormData = formData ?? new FormData();
6+
const keys = Reflect.ownKeys(obj);
7+
8+
for (const key of keys) {
9+
if (typeof key === "symbol") {
10+
// eslint-disable-next-line no-continue
11+
continue;
12+
}
13+
14+
// eslint-disable-next-line no-prototype-builtins
15+
if (obj.hasOwnProperty(key)) {
16+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
17+
if (Reflect.getMetadata("ts-serializable:jsonIgnore", obj.constructor.prototype, key) !== true) {
18+
const name = formPrefix ?
19+
`${formPrefix}.${getPropertyName(obj, key)}` :
20+
getPropertyName(obj, key);
21+
22+
/*
23+
* The function is defined inside the function to capture variables and
24+
* solve the problem of order of definition.
25+
*/
26+
const processValue = (value: unknown, index?: number): void => {
27+
if (Array.isArray(value)) {
28+
for (const [oneIndex, oneVal] of value.entries()) {
29+
processValue(oneVal, oneIndex);
30+
}
31+
} else if (value === null) {
32+
// Null is not sent in the form.
33+
} else if (value instanceof File) {
34+
newFormData.append(name, value);
35+
} else if (value instanceof Date) {
36+
newFormData.append(name, value.toISOString());
37+
} else if (typeof value === "object") {
38+
let prefix = name;
39+
40+
// For arrays of objects in form need add index
41+
if (typeof index === "number") {
42+
prefix += `[${index.toString()}]`;
43+
}
44+
45+
classToFormData(value, prefix, formData);
46+
} else {
47+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
48+
newFormData.append(name, String(value));
49+
}
50+
};
51+
52+
const uValue: unknown = Reflect.get(obj, key);
53+
processValue(uValue);
54+
}
55+
}
56+
}
57+
58+
return newFormData;
59+
};

src/utils/GetProperyName.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
2+
3+
import {Serializable} from "../classes/Serializable.js";
4+
import {SerializationSettings} from "../models/SerializationSettings.js";
5+
6+
// eslint-disable-next-line max-statements
7+
export const getPropertyName = (obj: object, property: string, settings?: Partial<SerializationSettings>) => {
8+
if (Reflect.hasMetadata("ts-serializable:jsonName", obj.constructor.prototype, property)) {
9+
return Reflect.getMetadata("ts-serializable:jsonName", obj.constructor.prototype, property) as string;
10+
}
11+
12+
if (settings?.namingStrategy) {
13+
return settings.namingStrategy.toJsonName(property);
14+
}
15+
16+
if (Reflect.hasMetadata("ts-serializable:jsonObject", obj.constructor)) {
17+
const objectSettings: Partial<SerializationSettings> = Reflect.getMetadata(
18+
"ts-serializable:jsonObject",
19+
obj.constructor
20+
) as Partial<SerializationSettings>;
21+
return objectSettings.namingStrategy?.toJsonName(property) ?? property;
22+
}
23+
24+
if (Serializable.defaultSettings.namingStrategy) {
25+
const {namingStrategy} = Serializable.defaultSettings;
26+
return namingStrategy.toJsonName(property);
27+
}
28+
29+
return property;
30+
};
31+
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/* eslint-disable max-lines-per-function */
2+
/* eslint-disable max-statements */
3+
4+
import("reflect-metadata"); // Polyfill
5+
import {assert} from "chai";
6+
import {describe, it} from "node:test";
7+
8+
// Import type {Friend as IFriend} from "./models/User";
9+
10+
11+
describe("FormData convertation", () => {
12+
it("class can be converted to FormData", async () => {
13+
const {User} = await import("./models/User");
14+
const json = await import("./jsons/json-generator.json", {with: {type: "json"}});
15+
16+
const [object] = Reflect.get(json, "default") as typeof json;
17+
18+
const user = new User().fromJSON(object);
19+
20+
const formData = user.toFormData();
21+
22+
assert.isTrue(user instanceof User);
23+
assert.isTrue(formData.has("id"), "form don't have field id");
24+
assert.strictEqual(user.id, formData.get("id"), "id is not equal");
25+
assert.strictEqual(String(user.index), formData.get("index"), "index is not equal");
26+
assert.strictEqual(user.guid, formData.get("guid"), "guid is not equal");
27+
assert.strictEqual(String(user.isActive), formData.get("isActive"), "isActive is not equal");
28+
assert.strictEqual(user.balance, formData.get("balance"), "balance is not equal");
29+
assert.strictEqual(user.picture, formData.get("picture"), "picture is not equal");
30+
assert.strictEqual(String(user.age), formData.get("age"), "age is not equal");
31+
assert.strictEqual(user.eyeColor, formData.get("eyeColor"), "eyeColor is not equal");
32+
assert.strictEqual(user.name, formData.get("name"), "name is not equal");
33+
assert.strictEqual(user.company, formData.get("company"), "company is not equal");
34+
assert.strictEqual(user.email, formData.get("email"), "email is not equal");
35+
assert.strictEqual(user.phone, formData.get("phone"), "phone is not equal");
36+
assert.strictEqual(user.address, formData.get("address"), "address is not equal");
37+
assert.strictEqual(user.about, formData.get("about"), "about is not equal");
38+
assert.strictEqual(String(user.latitude), formData.get("latitude"), "latitude is not equal");
39+
assert.strictEqual(String(user.longitude), formData.get("longitude"), "longitude is not equal");
40+
assert.deepEqual(user.tags, formData.getAll("tags"), "tags is not equal");
41+
assert.strictEqual(user.greeting, formData.get("greeting"), "greeting is not equal");
42+
assert.strictEqual(user.favoriteFruit, formData.get("favoriteFruit"), "favoriteFruit is not equal");
43+
44+
fetch("api/sendFile", {
45+
method: "POST",
46+
body: user.toFormData()
47+
});
48+
49+
/*
50+
* Nodejs version of FormData don't support arrays of objects, but C# and browser version of FormData support him.
51+
* Because test run under nodejs env, this next part disabled. Check this code in future versions of nodejs.
52+
*
53+
* user.friends.forEach((friend: IFriend, index: number) => {
54+
* assert.strictEqual(
55+
* String(friend.id),
56+
* formData.get(`friends[${index.toString()}].id`),
57+
* `friend ${String(index)} id is not equal`
58+
* );
59+
* assert.strictEqual(
60+
* friend.name,
61+
* formData.get(`friends[${index.toString()}].name`),
62+
* `friend ${String(index)} name is not equal`
63+
* );
64+
* });
65+
*/
66+
});
67+
});

tests/models/User.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,12 @@ export class User extends Serializable {
8484
@jsonIgnore()
8585
public isExpanded: boolean = false;
8686

87+
public getName (): string {
88+
return [
89+
this.email,
90+
this.company,
91+
this.address
92+
].join(" ");
93+
}
94+
8795
}

0 commit comments

Comments
 (0)