Skip to content

Commit e61c020

Browse files
authored
Improve form parse (#34)
1 parent e097781 commit e61c020

4 files changed

Lines changed: 504 additions & 8 deletions

File tree

docs/server-generation.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,44 @@ type GetUserServiceRequestOptions struct {
222222
}
223223
```
224224

225+
### Form-Encoded Requests
226+
227+
When your OpenAPI spec defines `application/x-www-form-urlencoded` as the request content type,
228+
the generated adapter automatically parses form data into your typed request body.
229+
230+
The adapter supports **deepObject encoding** as defined in OpenAPI 3.0, which allows nested objects and arrays in form data:
231+
232+
```
233+
# Simple key-value pairs
234+
name=John&age=30&active=true
235+
236+
# Nested objects (deepObject style)
237+
address[city]=Berlin&address[country]=DE
238+
239+
# Arrays with indices
240+
items[0]=first&items[1]=second
241+
242+
# Complex nested structures (e.g., Stripe API style)
243+
flow_data[subscription][items][0][id]=si_123&flow_data[subscription][items][0][quantity]=2
244+
```
245+
246+
**Type conversion** is handled automatically:
247+
248+
| Form Value | Converted To |
249+
|------------|--------------|
250+
| `true`, `false` | `bool` |
251+
| `42`, `-10` | `int64` |
252+
| `3.14`, `0.05` | `float64` |
253+
| Other values | `string` |
254+
255+
**Conservative conversion** preserves string values that look like numbers but shouldn't be converted:
256+
257+
- Phone numbers starting with `+` (e.g., `+1234567890`)
258+
- Values with leading zeros (e.g., `00123`)
259+
- Values containing spaces or parentheses
260+
261+
This enables seamless integration with APIs like Stripe that use complex form-encoded request bodies.
262+
225263
### Response Data
226264

227265
Return a `*<Operation>ResponseData` from your service method:

integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const (
4040
showMaxErrors = 50
4141

4242
// Default maximum concurrency for parallel test execution
43-
defaultMaxConcurrency = 5
43+
defaultMaxConcurrency = 50
4444

4545
// Timeout for each spec's operations (generate, build, etc.)
4646
specTimeout = 5 * time.Minute

pkg/runtime/encode_body.go

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"fmt"
1717
"net/url"
1818
"sort"
19+
"strconv"
1920
"strings"
2021
)
2122

@@ -69,21 +70,173 @@ func EncodeFormFields(data any, encoding map[string]FieldEncoding) (string, erro
6970
return values.Encode(), nil
7071
}
7172

72-
// ConvertFormFields converts a raw form-encoded response body to JSON format.
73-
// It parses the form-encoded data, converts it to a map, and then marshals it to JSON.
73+
// ConvertFormFields converts a raw form-encoded request body to JSON format.
74+
// It handles deepObject encoding (e.g., "obj[key][nested]=value") and converts
75+
// string values to appropriate types (bool, number, etc.).
7476
func ConvertFormFields(resp []byte) ([]byte, error) {
7577
values, err := url.ParseQuery(string(resp))
7678
if err != nil {
7779
return nil, fmt.Errorf("error parsing form-encoded body: %w", err)
7880
}
7981

80-
data := make(map[string]any, len(values))
81-
for key := range values {
82-
data[key] = values.Get(key)
82+
data := decodeFormData(values)
83+
return json.Marshal(data)
84+
}
85+
86+
// decodeFormData decodes URL-encoded form data into a nested map structure.
87+
// It handles deepObject encoding (e.g., "obj[key][nested]=value") and converts
88+
// string values to appropriate types (bool, number, etc.).
89+
func decodeFormData(values url.Values) map[string]any {
90+
result := make(map[string]any)
91+
92+
for key, vals := range values {
93+
// Check if this is a deepObject encoded key (contains brackets)
94+
if strings.Contains(key, "[") {
95+
setNestedValue(result, key, vals)
96+
} else {
97+
// Simple key-value pair
98+
if len(vals) == 1 {
99+
result[key] = convertFormStringValue(vals[0])
100+
} else {
101+
// Multiple values for the same key (array)
102+
converted := make([]any, len(vals))
103+
for i, v := range vals {
104+
converted[i] = convertFormStringValue(v)
105+
}
106+
result[key] = converted
107+
}
108+
}
83109
}
84110

85-
// Convert back to JSON
86-
return json.Marshal(data)
111+
return result
112+
}
113+
114+
// setNestedValue sets a value in a nested map structure based on a deepObject encoded key.
115+
// Example: "obj[key][0][nested]=value" sets result["obj"]["key"][0]["nested"] = value
116+
func setNestedValue(result map[string]any, key string, values []string) {
117+
// Parse the key to extract the path
118+
// Example: "flow_data[subscription_update_confirm][items][0][id]"
119+
// becomes ["flow_data", "subscription_update_confirm", "items", "0", "id"]
120+
parts := parseDeepObjectKey(key)
121+
if len(parts) == 0 {
122+
return
123+
}
124+
125+
// Special case: if only 2 parts and second is numeric, it's a simple array
126+
// Example: "expand[0]" should become expand: ["value"]
127+
if len(parts) == 2 && isFormNumeric(parts[1]) {
128+
arr, ok := result[parts[0]].([]any)
129+
if !ok {
130+
arr = make([]any, 0)
131+
}
132+
133+
idx := mustFormAtoi(parts[1])
134+
arr = ensureArraySize(arr, idx, false)
135+
arr[idx] = convertFormValues(values)
136+
result[parts[0]] = arr
137+
return
138+
}
139+
140+
// Navigate/create the nested structure
141+
current := result
142+
for i := 0; i < len(parts)-1; i++ {
143+
part := parts[i]
144+
nextPart := parts[i+1]
145+
146+
// Check if next part is a number (array index)
147+
isNextArray := isFormNumeric(nextPart)
148+
149+
if isNextArray {
150+
// Current should be an array
151+
arr, ok := current[part].([]any)
152+
if !ok {
153+
arr = make([]any, 0)
154+
current[part] = arr
155+
}
156+
157+
// Get the index
158+
idx := mustFormAtoi(nextPart)
159+
160+
// Check if this is the last level (nextPart is the last part)
161+
if i+2 == len(parts) {
162+
// This is the final value - just set it in the array
163+
arr = ensureArraySize(arr, idx, false)
164+
arr[idx] = convertFormValues(values)
165+
current[part] = arr
166+
return
167+
}
168+
169+
// Not the final value - need to navigate deeper
170+
arr = ensureArraySize(arr, idx, true)
171+
current[part] = arr
172+
173+
// Move to the array element
174+
elem, ok := arr[idx].(map[string]any)
175+
if !ok {
176+
elem = make(map[string]any)
177+
arr[idx] = elem
178+
}
179+
current = elem
180+
i++ // Skip the index part
181+
} else {
182+
// Current should be an object
183+
next, ok := current[part].(map[string]any)
184+
if !ok {
185+
next = make(map[string]any)
186+
current[part] = next
187+
}
188+
current = next
189+
}
190+
}
191+
192+
// Set the final value
193+
lastPart := parts[len(parts)-1]
194+
current[lastPart] = convertFormValues(values)
195+
}
196+
197+
// ensureArraySize grows the array to accommodate the given index.
198+
// If useMap is true, fills new slots with fresh map[string]any instances.
199+
// Otherwise, fills with nil.
200+
func ensureArraySize(arr []any, idx int, useMap bool) []any {
201+
for len(arr) <= idx {
202+
if useMap {
203+
arr = append(arr, make(map[string]any))
204+
} else {
205+
arr = append(arr, nil)
206+
}
207+
}
208+
return arr
209+
}
210+
211+
// convertFormValues converts form values to appropriate types.
212+
// Returns a single value if there's only one, or a slice if there are multiple.
213+
func convertFormValues(values []string) any {
214+
if len(values) == 1 {
215+
return convertFormStringValue(values[0])
216+
}
217+
converted := make([]any, len(values))
218+
for i, v := range values {
219+
converted[i] = convertFormStringValue(v)
220+
}
221+
return converted
222+
}
223+
224+
// parseDeepObjectKey parses a deepObject encoded key into parts.
225+
// Example: "flow_data[subscription_update_confirm][items][0][id]"
226+
// Returns: ["flow_data", "subscription_update_confirm", "items", "0", "id"]
227+
func parseDeepObjectKey(key string) []string {
228+
var parts []string
229+
230+
// Split by '[' and clean up ']'
231+
segments := strings.Split(key, "[")
232+
for _, seg := range segments {
233+
cleaned := strings.TrimSuffix(seg, "]")
234+
if cleaned != "" {
235+
parts = append(parts, cleaned)
236+
}
237+
}
238+
239+
return parts
87240
}
88241

89242
func encodeForm(prefix string, value any, values url.Values, explode bool) {
@@ -134,3 +287,55 @@ func encodeDeepObject(prefix string, value any, values url.Values) {
134287
values.Set(prefix, fmt.Sprintf("%v", v))
135288
}
136289
}
290+
291+
// convertFormStringValue attempts to convert a string value to its appropriate type.
292+
// Handles: bool, int, float, or keeps as string.
293+
// Note: We're conservative with conversions to avoid misinterpreting strings like phone numbers.
294+
func convertFormStringValue(s string) any {
295+
// Try boolean
296+
if s == "true" {
297+
return true
298+
}
299+
if s == "false" {
300+
return false
301+
}
302+
303+
// Don't convert strings that start with + (likely phone numbers)
304+
if strings.HasPrefix(s, "+") {
305+
return s
306+
}
307+
308+
// Don't convert strings that contain spaces or parentheses (likely formatted values)
309+
if strings.ContainsAny(s, " ()") {
310+
return s
311+
}
312+
313+
// Try integer (only if it doesn't have leading zeros, which would indicate a string like "001")
314+
if len(s) > 0 && (s[0] != '0' || s == "0") {
315+
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
316+
return i
317+
}
318+
}
319+
320+
// Try float (only if it contains a decimal point)
321+
if strings.Contains(s, ".") {
322+
if f, err := strconv.ParseFloat(s, 64); err == nil {
323+
return f
324+
}
325+
}
326+
327+
// Keep as string
328+
return s
329+
}
330+
331+
// isFormNumeric checks if a string represents a number.
332+
func isFormNumeric(s string) bool {
333+
_, err := strconv.Atoi(s)
334+
return err == nil
335+
}
336+
337+
// mustFormAtoi converts a string to int (should only be called after isFormNumeric check).
338+
func mustFormAtoi(s string) int {
339+
i, _ := strconv.Atoi(s)
340+
return i
341+
}

0 commit comments

Comments
 (0)