Skip to content

Commit 5c496bd

Browse files
committed
feat: Add FormUrlEncodedParser (parsing only, no coercion yet)
1 parent 22160f1 commit 5c496bd

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.spec.schema.Schema;
4+
import java.net.URLDecoder;
5+
import java.nio.charset.Charset;
6+
import java.nio.charset.IllegalCharsetNameException;
7+
import java.nio.charset.StandardCharsets;
8+
import java.nio.charset.UnsupportedCharsetException;
9+
import java.util.ArrayList;
10+
import java.util.LinkedHashMap;
11+
import java.util.List;
12+
import java.util.Map;
13+
14+
/** Parses an {@code application/x-www-form-urlencoded} request body. */
15+
public final class FormUrlEncodedParser {
16+
17+
/** Parses the body to a {@code Map<String, Object>} ({@code String} or {@code List<String>}). */
18+
public Map<String, Object> parse(byte[] body, String contentTypeHeader) {
19+
Charset charset = resolveCharset(contentTypeHeader);
20+
if (body.length == 0) {
21+
return new LinkedHashMap<>();
22+
}
23+
String text = new String(body, charset);
24+
Map<String, Object> out = new LinkedHashMap<>();
25+
for (String pair : text.split("&")) {
26+
if (pair.isEmpty()) {
27+
continue;
28+
}
29+
int eq = pair.indexOf('=');
30+
String rawKey = eq < 0 ? pair : pair.substring(0, eq);
31+
String rawValue = eq < 0 ? "" : pair.substring(eq + 1);
32+
String key = URLDecoder.decode(rawKey, charset);
33+
String value = URLDecoder.decode(rawValue, charset);
34+
addEntry(out, key, value);
35+
}
36+
return out;
37+
}
38+
39+
private static void addEntry(Map<String, Object> out, String key, String value) {
40+
Object existing = out.get(key);
41+
if (existing == null) {
42+
out.put(key, value);
43+
return;
44+
}
45+
if (existing instanceof List<?> list) {
46+
@SuppressWarnings("unchecked")
47+
List<String> typed = (List<String>) list;
48+
typed.add(value);
49+
return;
50+
}
51+
List<String> list = new ArrayList<>();
52+
list.add((String) existing);
53+
list.add(value);
54+
out.put(key, list);
55+
}
56+
57+
/**
58+
* Returns the parsed map after coercing field values against the given body schema. Coercion is
59+
* added in a subsequent task; for now this delegates to {@link #parse}.
60+
*/
61+
public Map<String, Object> parseAndCoerce(byte[] body, String contentTypeHeader, Schema schema) {
62+
return parse(body, contentTypeHeader);
63+
}
64+
65+
private static Charset resolveCharset(String header) {
66+
return ContentTypeHeader.parameter(header, "charset")
67+
.map(FormUrlEncodedParser::safeCharset)
68+
.orElse(StandardCharsets.UTF_8);
69+
}
70+
71+
private static Charset safeCharset(String name) {
72+
try {
73+
return Charset.forName(name);
74+
} catch (IllegalCharsetNameException | UnsupportedCharsetException _) {
75+
return StandardCharsets.UTF_8;
76+
}
77+
}
78+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.util.List;
7+
import java.util.Map;
8+
import org.junit.jupiter.api.Test;
9+
10+
class FormUrlEncodedParserTest {
11+
12+
private final FormUrlEncodedParser parser = new FormUrlEncodedParser();
13+
14+
@Test
15+
void emptyBodyReturnsEmptyMap() {
16+
assertThat(parser.parse(new byte[0], null)).isEmpty();
17+
}
18+
19+
@Test
20+
void singleField() {
21+
assertThat(parser.parse("a=1".getBytes(StandardCharsets.UTF_8), null))
22+
.containsExactly(Map.entry("a", "1"));
23+
}
24+
25+
@Test
26+
void multipleFields() {
27+
Map<String, Object> result = parser.parse("a=1&b=2".getBytes(StandardCharsets.UTF_8), null);
28+
assertThat(result).containsExactly(Map.entry("a", "1"), Map.entry("b", "2"));
29+
}
30+
31+
@Test
32+
void repeatedKeyBecomesList() {
33+
Map<String, Object> result = parser.parse("a=1&a=2".getBytes(StandardCharsets.UTF_8), null);
34+
assertThat(result).containsExactly(Map.entry("a", List.of("1", "2")));
35+
}
36+
37+
@Test
38+
void threeRepeatedValues() {
39+
Map<String, Object> result = parser.parse("x=1&x=2&x=3".getBytes(StandardCharsets.UTF_8), null);
40+
assertThat(result).containsExactly(Map.entry("x", List.of("1", "2", "3")));
41+
}
42+
43+
@Test
44+
void emptyValue() {
45+
assertThat(parser.parse("a=".getBytes(StandardCharsets.UTF_8), null))
46+
.containsExactly(Map.entry("a", ""));
47+
}
48+
49+
@Test
50+
void keyWithoutEquals() {
51+
assertThat(parser.parse("a".getBytes(StandardCharsets.UTF_8), null))
52+
.containsExactly(Map.entry("a", ""));
53+
}
54+
55+
@Test
56+
void percentDecodesKeyAndValue() {
57+
assertThat(parser.parse("a%20b=c%26d".getBytes(StandardCharsets.UTF_8), null))
58+
.containsExactly(Map.entry("a b", "c&d"));
59+
}
60+
61+
@Test
62+
void plusIsSpace() {
63+
assertThat(parser.parse("a=b+c".getBytes(StandardCharsets.UTF_8), null))
64+
.containsExactly(Map.entry("a", "b c"));
65+
}
66+
67+
@Test
68+
void charsetFromHeader() {
69+
byte[] iso = "x=räka".getBytes(StandardCharsets.ISO_8859_1);
70+
assertThat(parser.parse(iso, "application/x-www-form-urlencoded; charset=iso-8859-1"))
71+
.containsExactly(Map.entry("x", "räka"));
72+
}
73+
}

0 commit comments

Comments
 (0)