Skip to content

Commit 90426fc

Browse files
authored
Merge pull request #34 from Frixuu/headers
BREAKING: Overhaul HTTP headers (make case-insensitive, allow for multiples)
2 parents 2745cc9 + 2529799 commit 90426fc

14 files changed

Lines changed: 754 additions & 53 deletions

File tree

tests/Test.hx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Test {
1616
TestCookie.main();
1717
TestCredentialsProvider.main();
1818
TestEndpointExample.main();
19+
TestHeaders.main();
1920
TestJwks.main();
2021
TestJwt.main();
2122
TestOAuth2.main();

tests/TestCookie.hx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import haxe.Http;
22
import weblink.Cookie;
33
import weblink.Weblink;
4+
import weblink.http.HeaderMap;
45

56
class TestCookie {
67
public static function main() {
@@ -21,7 +22,7 @@ class TestCookie {
2122
var http = new Http("http://localhost:2000");
2223
http.onStatus = function(status) {
2324
if (status == 200) {
24-
var headers = http.responseHeaders;
25+
final headers = HeaderMap.copyFrom(http.responseHeaders);
2526
if (headers.get("Set-Cookie") != "foo=bar") {
2627
throw 'Set-Cookie not foo=bar. got ${headers.get("Set-Cookie")}';
2728
}

tests/TestHeaders.hx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import weblink.http.HeaderMap;
2+
import weblink.http.HeaderName;
3+
import weblink.http.HeaderValue;
4+
5+
class TestHeaders {
6+
public static function main() {
7+
trace("Starting Headers Test");
8+
9+
switch (HeaderName.tryNormalizeString("Foo")) {
10+
case Valid(_):
11+
case _:
12+
throw "Foo should be a valid header name";
13+
}
14+
15+
switch (HeaderName.tryNormalizeString("hello world")) {
16+
case ForbiddenChar(_):
17+
case _:
18+
throw "'hello world' should not be a valid header name";
19+
}
20+
21+
switch (HeaderName.tryNormalizeString("Øßą")) {
22+
case NotAscii(_):
23+
case _:
24+
throw "'Øßą' should not be a valid header name";
25+
}
26+
27+
switch (HeaderValue.validateString("hello world", true)) {
28+
case Valid(_):
29+
case _:
30+
throw "'hello world' should be a valid header value";
31+
}
32+
33+
switch (HeaderValue.validateString("Øß", false)) {
34+
case Valid(_):
35+
case _:
36+
throw "'Øß' should be a valid header value in loose mode";
37+
}
38+
39+
switch (HeaderValue.validateString("Øß", true)) {
40+
case NotAscii(_):
41+
case _:
42+
throw "'Øß' should be an invalid header value in strict mode";
43+
}
44+
45+
final map = new HeaderMap();
46+
if (Lambda.count(map) != 0)
47+
throw "fresh map should be empty";
48+
49+
map.add("foo", "bar");
50+
if (Lambda.count(map) != 1)
51+
throw "map should have one entry";
52+
if (map.get("foo") != "bar")
53+
throw "map should have foo=bar";
54+
if (map.get("FOO") != "bar")
55+
throw "map treats keys case-sensitively";
56+
57+
map.add("Foo", "baz");
58+
if (Lambda.count(map) != 1)
59+
throw "map should have one entry";
60+
if (map.getAll("fOo").length != 2)
61+
throw "map should have two values for foo";
62+
63+
map.set("foo", "qux");
64+
if (map.getAll("fOo").length != 1)
65+
throw "the result of map#set should be a single value";
66+
67+
map.set("stupid", "example");
68+
if (Lambda.count(map) != 2)
69+
throw "map should have two entries";
70+
71+
map.clear();
72+
if (Lambda.count(map) != 0)
73+
throw "cleared map should be empty";
74+
75+
trace("done");
76+
}
77+
}

weblink/Compression.hx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import haxe.zip.Compress;
55

66
class Compression {
77
public static function deflateCompressionMiddleware(request:Request, response:Response):Void {
8-
if (response.headers == null)
9-
response.headers = new List<Header>();
10-
response.headers.add({key: 'Content-Encoding', value: 'deflate'});
8+
response.headers.add(ContentEncoding, "deflate");
119
response.write = function(bytes:Bytes):Bytes {
1210
return Compress.run(bytes, 9);
1311
}

weblink/Header.hx

Lines changed: 0 additions & 3 deletions
This file was deleted.

weblink/Request.hx

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
package weblink;
22

3-
import haxe.ds.StringMap;
43
import haxe.http.HttpMethod;
54
import haxe.io.Bytes;
65
import weblink._internal.Server;
6+
import weblink.http.HeaderMap;
7+
import weblink.http.HeaderName;
8+
import weblink.http.HeaderValue;
9+
10+
using StringTools;
711

812
class Request {
913
public var cookies:List<Cookie>;
1014
public var path:String;
1115
public var basePath:String;
16+
1217
/** Contains values for parameters declared in the route matched, if there are any. **/
1318
public var routeParams:Map<String, String>;
19+
1420
public var ip:String;
1521
public var baseUrl:String;
16-
public var headers:StringMap<String>;
22+
public final headers:HeaderMap;
1723
public var text:String;
1824
public var method:HttpMethod;
1925
public var data:Bytes;
@@ -22,15 +28,13 @@ class Request {
2228

2329
var chunkSize:Null<Int>;
2430

25-
public var encoding:Array<String> = [];
31+
public final encoding:Array<String>;
2632

2733
var pos:Int;
2834

2935
private function new(lines:Array<String>) {
30-
headers = new StringMap<String>();
3136
data = null;
32-
// for (line in lines)
33-
// Sys.println(line);
37+
3438
if (lines.length == 0)
3539
return;
3640
var index = 0;
@@ -42,39 +46,70 @@ class Request {
4246
if (index2 == -1)
4347
index2 = index3;
4448
if (index2 != -1) {
45-
basePath = path.substr(0,index2);
46-
}else{
49+
basePath = path.substr(0, index2);
50+
} else {
4751
basePath = path;
4852
}
49-
// trace(basePath);
50-
// trace(path);
51-
//trace(first.substring(0, index - 1).toUpperCase());
53+
5254
method = first.substring(0, index - 1).toUpperCase();
55+
56+
final headers = this.headers = new HeaderMap();
5357
for (i in 0...lines.length - 1) {
5458
if (lines[i] == "") {
5559
lines = lines.slice(i + 1);
5660
break;
5761
}
5862
index = lines[i].indexOf(":");
59-
headers.set(lines[i].substring(0, index), lines[i].substring(index + 2));
63+
64+
final left = lines[i].substring(0, index);
65+
switch (HeaderName.tryNormalizeString(left)) {
66+
case Valid(name):
67+
final right = lines[i].substring(index + 2).trim();
68+
switch (HeaderValue.validateString(right, false)) {
69+
case Valid(value):
70+
if (name.allowsRawCommaSeparatedValues()) {
71+
for (subvalue in value.split(",").map(v -> v.trim())) {
72+
headers.add(name, cast subvalue);
73+
}
74+
} else if (name.allowsRawSemicolonSeparatedValues()) {
75+
for (subvalue in value.split(";").map(v -> v.trim())) {
76+
headers.add(name, cast subvalue);
77+
}
78+
} else if (name.doesNotAllowRepeats() && headers.exists(name)) {
79+
// Idea: respond with 400 Bad Request
80+
headers.set(name, value);
81+
} else {
82+
headers.add(name, value);
83+
}
84+
case _:
85+
// Silently ignore that the header value is invalid
86+
// Idea: respond with 400 Bad Request immediately
87+
}
88+
case _:
89+
// Silently ignore that the header name is invalid
90+
// Idea: respond with 400 Bad Request immediately
91+
}
6092
}
61-
baseUrl = headers.get("Host");
6293

63-
if (headers.exists("Cookie")) {
64-
cookies = new List<Cookie>();
65-
var string = headers.get("Cookie");
94+
this.baseUrl = headers.get(Host);
6695

67-
for (sub in string.split(";")) {
68-
string = StringTools.trim(sub);
69-
// Split into the component Keyvalue pair for the cookie.
70-
var keyVal = string.split("=");
71-
cookies.add(new Cookie(keyVal[0], keyVal[1]));
96+
this.cookies = new List<Cookie>();
97+
final cookieValues = headers.getAll(Cookie);
98+
if (cookieValues != null) {
99+
for (value in cookieValues) {
100+
final parts = value.split("=");
101+
this.cookies.add(new Cookie(parts[0], parts[1]));
72102
}
73103
}
74104

75-
if (headers.exists("Transfer-Encoding")) {
76-
encoding = headers.get("Transfer-Encoding").split(",");
105+
this.encoding = [];
106+
final encodingValues = headers.getAll(TransferEncoding);
107+
if (encodingValues != null) {
108+
for (value in encodingValues) {
109+
this.encoding.push(value);
110+
}
77111
}
112+
78113
if (method == Post || method == Put) {
79114
chunked = false;
80115
if (encoding.indexOf("chunked") > -1) {

weblink/Response.hx

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import weblink.Cookie;
88
import weblink._internal.HttpStatusMessage;
99
import weblink._internal.Server;
1010
import weblink._internal.Socket;
11+
import weblink.http.HeaderMap;
1112

1213
private typedef Write = (bytes:Bytes) -> Bytes;
1314

1415
class Response {
1516
public var status:HttpStatus;
16-
public var contentType:String;
17-
public var headers:Null<List<Header>>;
17+
public var contentType(get, set):String;
18+
public final headers:HeaderMap;
1819
public var cookies:List<Cookie> = new List<Cookie>();
1920
public var write:Null<Write>;
2021

@@ -25,10 +26,20 @@ class Response {
2526
private function new(socket:Socket, server:Server) {
2627
this.socket = socket;
2728
this.server = server;
29+
this.headers = new HeaderMap();
2830
contentType = "text/html";
2931
status = OK;
3032
}
3133

34+
public inline function set_contentType(value:String):String {
35+
this.headers.set(ContentType, value);
36+
return value;
37+
}
38+
39+
public inline function get_contentType():String {
40+
return this.headers.get(ContentType);
41+
}
42+
3243
public function sendBytes(bytes:Bytes) {
3344
final socket = this.socket;
3445
if (socket == null) {
@@ -41,7 +52,7 @@ class Response {
4152
}
4253

4354
try {
44-
socket.writeString(sendHeaders(bytes.length).toString());
55+
socket.writeString(collectHeaders(bytes.length).toString());
4556
socket.writeBytes(bytes);
4657
} catch (_:Eof) {
4758
// The connection has already been closed, silently ignore
@@ -52,7 +63,6 @@ class Response {
5263

5364
public inline function redirect(path:String) {
5465
status = MovedPermanently;
55-
headers = new List<Header>();
5666
var string = initLine();
5767
string += 'Location: $path\r\n\r\n';
5868
socket.writeString(string);
@@ -78,22 +88,47 @@ class Response {
7888
return 'HTTP/1.1 $status ${HttpStatusMessage.fromCode(status)}\r\n';
7989
}
8090

81-
public inline function sendHeaders(length:Int):StringBuf {
91+
public inline function collectHeaders(length:Int):StringBuf {
8292
var string = new StringBuf();
83-
string.add(initLine()
84-
+ // 'Acess-Control-Allow-Origin: *\r\n' +
85-
'Connection: ${close ? "close" : "keep-alive"}\r\n'
86-
+ 'Content-type: $contentType\r\n'
87-
+ 'Content-length: $length\r\n');
93+
string.add(this.initLine());
94+
95+
this.headers.add(Connection, close ? (cast "close") : (cast "keep-alive"));
96+
this.headers.set(ContentLength, Std.string(length));
97+
8898
for (cookie in cookies) {
89-
string.add("Set-Cookie: " + cookie.resolveToResponseString() + "\r\n");
99+
this.headers.add(SetCookie, cookie.resolveToResponseString());
90100
}
91-
if (headers != null) {
92-
for (header in headers) {
93-
string.add(header.key + ": " + header.value + "\r\n");
101+
102+
for (headerName => values in this.headers) {
103+
if (values.length <= 0) // Sanity check
104+
continue;
105+
106+
if (headerName == SetCookie) {
107+
for (headerValue in values) {
108+
string.add(headerName);
109+
string.add(": ");
110+
string.add(headerValue);
111+
string.add("\r\n");
112+
}
113+
} else {
114+
string.add(headerName);
115+
string.add(": ");
116+
if (values.length == 1) {
117+
string.add(values[0]);
118+
} else if (headerName.allowsRawCommaSeparatedValues()) {
119+
string.add(values.join(", "));
120+
} else if (headerName.allowsRawSemicolonSeparatedValues()) {
121+
string.add(values.join("; "));
122+
} else if (headerName.doesNotAllowRepeats()) {
123+
throw 'unique header "$headerName" has ${values.length} assigned values';
124+
} else {
125+
string.add(Lambda.map(values, v -> '"$v"').join(", "));
126+
}
127+
string.add("\r\n");
94128
}
95-
headers = null;
96129
}
130+
131+
this.headers.clear();
97132
string.add("\r\n");
98133
return string;
99134
}

weblink/Weblink.hx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,8 @@ class Weblink {
126126
request.path = request.basePath.substr(1);
127127
var ext = request.path.extension();
128128
var mime = weblink._internal.Mime.types.get(ext);
129-
response.headers = new List<Header>();
130129
if (_cors.length > 0)
131-
response.headers.add({key: "Access-Control-Allow-Origin", value: _cors});
130+
response.headers.add(AccessControlAllowOrigin, _cors);
132131
response.contentType = mime == null ? "text/plain" : mime;
133132
var path = Path.join([_dir, request.basePath.substr(_path.length)]).normalize();
134133
if (path == "")

0 commit comments

Comments
 (0)