Skip to content

Commit ff313a2

Browse files
authored
Merge pull request #1607 from Teyik0/fix-nested-formdata
Fix nested formdata
2 parents 6bd09aa + 1273bbd commit ff313a2

File tree

9 files changed

+2146
-501
lines changed

9 files changed

+2146
-501
lines changed

src/compose.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import {
4040
import { ELYSIA_TRACE, type TraceHandler } from './trace'
4141

4242
import {
43-
coercePrimitiveRoot,
4443
ElysiaTypeCheck,
4544
getCookieValidator,
4645
getSchemaValidator,
@@ -63,6 +62,7 @@ import type {
6362
SchemaValidator
6463
} from './types'
6564
import { tee } from './adapter/utils'
65+
import { coercePrimitiveRoot } from './replace-schema'
6666

6767
const allocateIf = (value: string, condition: unknown) =>
6868
condition ? value : ''
@@ -1473,9 +1473,21 @@ export const composeHandler = ({
14731473

14741474
if (candidate) {
14751475
const isFirst = fileUnions.length === 0
1476+
// Handle case where schema is wrapped in a Union (e.g., ObjectString coercion)
1477+
let properties = candidate.schema?.properties ?? type.properties
1478+
1479+
// If no properties but schema is a Union, try to find the Object in anyOf
1480+
if (!properties && candidate.schema?.anyOf) {
1481+
const objectSchema = candidate.schema.anyOf.find((s: any) => s.type === 'object')
1482+
if (objectSchema) {
1483+
properties = objectSchema.properties
1484+
}
1485+
}
1486+
1487+
if (!properties) continue
14761488

14771489
const iterator = Object.entries(
1478-
type.properties
1490+
properties
14791491
) as [string, TSchema][]
14801492

14811493
let validator = isFirst ? '\n' : ' else '

src/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,12 @@ import {
4343
} from './utils'
4444

4545
import {
46-
coercePrimitiveRoot,
47-
stringToStructureCoercions,
4846
getSchemaValidator,
4947
getResponseSchemaValidator,
5048
getCookieValidator,
5149
ElysiaTypeCheck,
52-
queryCoercions
50+
hasType,
51+
resolveSchema,
5352
} from './schema'
5453
import {
5554
composeHandler,
@@ -165,6 +164,7 @@ import type {
165164
InlineHandlerNonMacro,
166165
Router
167166
} from './types'
167+
import { coercePrimitiveRoot, coerceFormData, queryCoercions, stringToStructureCoercions } from './replace-schema'
168168

169169
export type AnyElysia = Elysia<any, any, any, any, any, any, any>
170170

@@ -588,7 +588,13 @@ export default class Elysia<
588588
dynamic,
589589
models,
590590
normalize,
591-
additionalCoerce: coercePrimitiveRoot(),
591+
additionalCoerce: (() => {
592+
const resolved = resolveSchema(cloned.body, models, modules)
593+
// Only check for Files if resolved schema is a TypeBox schema (has Kind symbol)
594+
return (resolved && Kind in resolved && (hasType('File', resolved) || hasType('Files', resolved)))
595+
? coerceFormData()
596+
: coercePrimitiveRoot()
597+
})(),
592598
validators: standaloneValidators.map((x) => x.body),
593599
sanitize
594600
}),
@@ -650,7 +656,13 @@ export default class Elysia<
650656
dynamic,
651657
models,
652658
normalize,
653-
additionalCoerce: coercePrimitiveRoot(),
659+
additionalCoerce: (() => {
660+
const resolved = resolveSchema(cloned.body, models, modules)
661+
// Only check for Files if resolved schema is a TypeBox schema (has Kind symbol)
662+
return (resolved && Kind in resolved && (hasType('File', resolved) || hasType('Files', resolved)))
663+
? coerceFormData()
664+
: coercePrimitiveRoot()
665+
})(),
654666
validators: standaloneValidators.map(
655667
(x) => x.body
656668
),
@@ -8144,8 +8156,10 @@ export {
81448156
export {
81458157
getSchemaValidator,
81468158
getResponseSchemaValidator,
8147-
replaceSchemaType
81488159
} from './schema'
8160+
export {
8161+
replaceSchemaTypeFromManyOptions as replaceSchemaType
8162+
} from './replace-schema'
81498163

81508164
export {
81518165
mergeHook,

src/replace-schema.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { Kind, type TAnySchema, type TSchema } from "@sinclair/typebox";
2+
import { t } from "./type-system";
3+
import type { MaybeArray } from "./types";
4+
5+
export interface ReplaceSchemaTypeOptions {
6+
from: TSchema;
7+
to(schema: TSchema): TSchema | null;
8+
excludeRoot?: boolean;
9+
rootOnly?: boolean;
10+
original?: TAnySchema;
11+
/**
12+
* Traverse until object is found except root object
13+
**/
14+
untilObjectFound?: boolean;
15+
/**
16+
* Only replace first object type, can be paired with excludeRoot
17+
**/
18+
onlyFirst?: "object" | "array" | (string & {});
19+
}
20+
21+
/**
22+
* Replace schema types with custom transformation
23+
*
24+
* @param schema - The schema to transform
25+
* @param options - Transformation options (single or array)
26+
* @returns Transformed schema
27+
*
28+
* @example
29+
* // Transform Object to ObjectString
30+
* replaceSchemaType(schema, {
31+
* from: t.Object({}),
32+
* to: (s) => t.ObjectString(s.properties || {}, s),
33+
* excludeRoot: true,
34+
* onlyFirst: 'object'
35+
* })
36+
*/
37+
export const replaceSchemaTypeFromManyOptions = (
38+
schema: TSchema,
39+
options: MaybeArray<ReplaceSchemaTypeOptions>,
40+
): TSchema => {
41+
if (Array.isArray(options)) {
42+
let result = schema;
43+
for (const option of options) {
44+
result = replaceSchemaTypeFromOption(result, option);
45+
}
46+
return result;
47+
}
48+
49+
return replaceSchemaTypeFromOption(schema, options);
50+
};
51+
52+
const replaceSchemaTypeFromOption = (
53+
schema: TSchema,
54+
option: ReplaceSchemaTypeOptions,
55+
): TSchema => {
56+
if (option.rootOnly && option.excludeRoot) {
57+
throw new Error("Can't set both rootOnly and excludeRoot");
58+
}
59+
if (option.rootOnly && option.onlyFirst) {
60+
throw new Error("Can't set both rootOnly and onlyFirst");
61+
}
62+
if (option.rootOnly && option.untilObjectFound) {
63+
throw new Error("Can't set both rootOnly and untilObjectFound");
64+
}
65+
66+
type WalkProps = { s: TSchema; isRoot: boolean; treeLvl: number };
67+
const walk = ({ s, isRoot, treeLvl }: WalkProps): TSchema => {
68+
if (!s) return s;
69+
// console.log("walk iteration", { s, isRoot, treeLvl, transformTo: option.to.toString() })
70+
71+
const skipRoot = isRoot && option.excludeRoot;
72+
const fromKind = option.from[Kind];
73+
74+
// Double-wrapping check
75+
if (s.elysiaMeta) {
76+
const fromElysiaMeta = option.from.elysiaMeta;
77+
if (fromElysiaMeta === s.elysiaMeta && !skipRoot) {
78+
return option.to(s) as TSchema;
79+
}
80+
return s;
81+
}
82+
83+
const shouldTransform = fromKind && s[Kind] === fromKind;
84+
if (!skipRoot && option.onlyFirst && s.type === option.onlyFirst) {
85+
if (shouldTransform) {
86+
return option.to(s) as TSchema;
87+
}
88+
return s;
89+
}
90+
91+
if (isRoot && option.rootOnly) {
92+
if (shouldTransform) {
93+
return option.to(s) as TSchema;
94+
}
95+
return s;
96+
}
97+
98+
if (!isRoot && option.untilObjectFound && s.type === "object") {
99+
return s;
100+
}
101+
102+
const newWalkInput = { isRoot: false, treeLvl: treeLvl + 1 };
103+
const withTransformedChildren = { ...s };
104+
105+
if (s.oneOf) {
106+
withTransformedChildren.oneOf = s.oneOf.map((x: TSchema) =>
107+
walk({ ...newWalkInput, s: x }),
108+
);
109+
}
110+
if (s.anyOf) {
111+
withTransformedChildren.anyOf = s.anyOf.map((x: TSchema) =>
112+
walk({ ...newWalkInput, s: x }),
113+
);
114+
}
115+
if (s.allOf) {
116+
withTransformedChildren.allOf = s.allOf.map((x: TSchema) =>
117+
walk({ ...newWalkInput, s: x }),
118+
);
119+
}
120+
if (s.not) {
121+
withTransformedChildren.not = walk({ ...newWalkInput, s: s.not });
122+
}
123+
124+
if (s.properties) {
125+
withTransformedChildren.properties = {};
126+
for (const [k, v] of Object.entries(s.properties)) {
127+
withTransformedChildren.properties[k] = walk({
128+
...newWalkInput,
129+
s: v as TSchema,
130+
});
131+
}
132+
}
133+
134+
if (s.items) {
135+
const items = s.items;
136+
withTransformedChildren.items = Array.isArray(items)
137+
? items.map((x: TSchema) => walk({ ...newWalkInput, s: x }))
138+
: walk({ ...newWalkInput, s: items as TSchema });
139+
}
140+
141+
// Transform THIS node (with children already transformed)
142+
const shouldTransformThis =
143+
!skipRoot && fromKind && withTransformedChildren[Kind] === fromKind;
144+
if (shouldTransformThis) {
145+
return option.to(withTransformedChildren) as TSchema;
146+
}
147+
148+
return withTransformedChildren;
149+
};
150+
151+
return walk({ s: schema, isRoot: true, treeLvl: 0 });
152+
};
153+
154+
/**
155+
* Helper: Extract plain Object from ObjectString
156+
*
157+
* @example
158+
* ObjectString structure:
159+
* {
160+
* elysiaMeta: "ObjectString",
161+
* anyOf: [
162+
* { type: "string", format: "ObjectString" }, // ← String branch
163+
* { type: "object", properties: {...} } // ← Object branch (we want this)
164+
* ]
165+
* }
166+
* ArrayString structure:
167+
* {
168+
* elysiaMeta: "ArrayString",
169+
* anyOf: [
170+
* { type: "string", format: "ArrayString" }, // ← String branch
171+
* { type: "array", items: {...} } // ← Array branch (we want this)
172+
* ]
173+
* }
174+
*/
175+
export const revertObjAndArrStr = (schema: TSchema): TSchema => {
176+
if (schema.elysiaMeta !== "ObjectString" && schema.elysiaMeta !== "ArrayString")
177+
return schema;
178+
179+
const anyOf = schema.anyOf;
180+
if (!anyOf?.[1]) return schema;
181+
182+
// anyOf[1] is the object branch (already clean, no elysiaMeta)
183+
return anyOf[1];
184+
};
185+
186+
let _stringToStructureCoercions: ReplaceSchemaTypeOptions[];
187+
188+
export const stringToStructureCoercions = () => {
189+
if (!_stringToStructureCoercions) {
190+
_stringToStructureCoercions = [
191+
{
192+
from: t.Object({}),
193+
to: (schema) => t.ObjectString(schema.properties || {}, schema),
194+
excludeRoot: true,
195+
},
196+
{
197+
from: t.Array(t.Any()),
198+
to: (schema) => t.ArrayString(schema.items || t.Any(), schema),
199+
},
200+
] satisfies ReplaceSchemaTypeOptions[];
201+
}
202+
203+
return _stringToStructureCoercions;
204+
};
205+
206+
let _queryCoercions: ReplaceSchemaTypeOptions[];
207+
208+
export const queryCoercions = () => {
209+
if (!_queryCoercions) {
210+
_queryCoercions = [
211+
{
212+
from: t.Object({}),
213+
to: (schema) => t.ObjectString(schema.properties ?? {}, schema),
214+
excludeRoot: true,
215+
},
216+
{
217+
from: t.Array(t.Any()),
218+
to: (schema) => t.ArrayQuery(schema.items ?? t.Any(), schema),
219+
},
220+
] satisfies ReplaceSchemaTypeOptions[];
221+
}
222+
223+
return _queryCoercions;
224+
};
225+
226+
let _coercePrimitiveRoot: ReplaceSchemaTypeOptions[];
227+
228+
export const coercePrimitiveRoot = () => {
229+
if (!_coercePrimitiveRoot)
230+
_coercePrimitiveRoot = [
231+
{
232+
from: t.Number(),
233+
to: (schema) => t.Numeric(schema),
234+
rootOnly: true,
235+
},
236+
{
237+
from: t.Boolean(),
238+
to: (schema) => t.BooleanString(schema),
239+
rootOnly: true,
240+
},
241+
] satisfies ReplaceSchemaTypeOptions[];
242+
243+
return _coercePrimitiveRoot;
244+
};
245+
246+
let _coerceFormData: ReplaceSchemaTypeOptions[];
247+
248+
export const coerceFormData = () => {
249+
if (!_coerceFormData)
250+
_coerceFormData = [
251+
{
252+
from: t.Object({}),
253+
to: (schema) => t.ObjectString(schema.properties ?? {}, schema),
254+
onlyFirst: 'object',
255+
excludeRoot: true
256+
},
257+
{
258+
from: t.Array(t.Any()),
259+
to: (schema) => t.ArrayString(schema.items ?? t.Any(), schema),
260+
onlyFirst: 'array',
261+
excludeRoot: true
262+
},
263+
] satisfies ReplaceSchemaTypeOptions[];
264+
265+
return _coerceFormData;
266+
};

0 commit comments

Comments
 (0)