aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThijs Schreijer <thijs@thijsschreijer.nl>2026-08-29 19:39:35 +0200
committerThijs Schreijer <thijs@thijsschreijer.nl>2026-08-29 19:43:26 +0200
commita63fa963b7bd01f1b470bea7c5a2ed326ff760e6 (patch)
tree5f421854e7f3de2096bdc979e99f022f3918e649
parent84c2bb905bda5f1fc58195171c75723b5fec6e87 (diff)
downloadluasocket-a63fa963b7bd01f1b470bea7c5a2ed326ff760e6.tar.gz
luasocket-a63fa963b7bd01f1b470bea7c5a2ed326ff760e6.tar.bz2
luasocket-a63fa963b7bd01f1b470bea7c5a2ed326ff760e6.zip
feat(headers): dynamically create cononicalized headers
Instead of a static list dynamically add entries in proper casing. Also allowing to add non-standard ones through `setcanonic`. closes #442 fixes #440
-rw-r--r--.github/workflows/build.yml1
-rw-r--r--docs/socket.html26
-rw-r--r--src/headers.lua145
-rw-r--r--test/headerstest.lua131
4 files changed, 206 insertions, 97 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 53d6ebc..b8043c5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -48,5 +48,6 @@ jobs:
48 lua ltn12test.lua 48 lua ltn12test.lua
49 lua mimetest.lua 49 lua mimetest.lua
50 lua urltest.lua 50 lua urltest.lua
51 lua headerstest.lua
51 lua test_socket_error.lua 52 lua test_socket_error.lua
52 kill %1 53 kill %1
diff --git a/docs/socket.html b/docs/socket.html
index 983f1c9..570f6d5 100644
--- a/docs/socket.html
+++ b/docs/socket.html
@@ -65,13 +65,33 @@ whenever the field name is sent out.
65</p> 65</p>
66 66
67<p> 67<p>
68You can obtain the <tt>headers</tt> namespace if case run-time 68If a lowercase field name is not present as a key,
69modifications are required by running: 69<tt>socket.headers.canonic</tt> automatically derives a
70capitalization by uppercasing the first letter of each
71hyphen-separated word (e.g. <tt>x-request-id</tt> becomes
72<tt>X-Request-Id</tt>), and caches the result for later lookups.
73A small set of headers whose canonic capitalization is
74irregular (such as <tt>ETag</tt>, <tt>Content-MD5</tt>,
75<tt>MIME-Version</tt> and <tt>WWW-Authenticate</tt>) are kept as
76explicit entries in the table and are not affected by this rule.
77Because of this, <tt>pairs(socket.headers.canonic)</tt> only
78shows the irregular entries plus any field name already looked
79up or added &mdash; it is not an exhaustive list of every header
80name the library knows how to capitalize.
81</p>
82
83<p>
84You can register a custom canonic capitalization &mdash; for a
85header the automatic rule would not reproduce, such as a custom
86all-caps acronym &mdash; with <tt>socket.headers.setcanonic</tt>:
70</p> 87</p>
71 88
72<pre class="example"> 89<pre class="example">
73-- loads the headers module 90-- loads the headers module
74local headers = require("headers") 91local headers = require("socket.headers")
92
93-- register a custom capitalization
94headers.setcanonic("X-Request-ID")
75</pre> 95</pre>
76 96
77 97
diff --git a/src/headers.lua b/src/headers.lua
index 1eb8223..f8b8ef8 100644
--- a/src/headers.lua
+++ b/src/headers.lua
@@ -7,98 +7,55 @@ local socket = require("socket")
7socket.headers = {} 7socket.headers = {}
8local _M = socket.headers 8local _M = socket.headers
9 9
10_M.canonic = { 10-- capitalizes the first letter of each hyphen-separated word, lowercases
11 ["accept"] = "Accept", 11-- the rest (e.g. "x-request-id" -> "X-Request-Id")
12 ["accept-charset"] = "Accept-Charset", 12local function titlecase(header)
13 ["accept-encoding"] = "Accept-Encoding", 13 return (header:gsub("(%a)([%w]*)", function(a, b) return a:upper()..b:lower() end))
14 ["accept-language"] = "Accept-Language", 14end
15 ["accept-ranges"] = "Accept-Ranges",
16 ["action"] = "Action",
17 ["alternate-recipient"] = "Alternate-Recipient",
18 ["age"] = "Age",
19 ["allow"] = "Allow",
20 ["arrival-date"] = "Arrival-Date",
21 ["authorization"] = "Authorization",
22 ["bcc"] = "Bcc",
23 ["cache-control"] = "Cache-Control",
24 ["cc"] = "Cc",
25 ["comments"] = "Comments",
26 ["connection"] = "Connection",
27 ["content-description"] = "Content-Description",
28 ["content-disposition"] = "Content-Disposition",
29 ["content-encoding"] = "Content-Encoding",
30 ["content-id"] = "Content-ID",
31 ["content-language"] = "Content-Language",
32 ["content-length"] = "Content-Length",
33 ["content-location"] = "Content-Location",
34 ["content-md5"] = "Content-MD5",
35 ["content-range"] = "Content-Range",
36 ["content-transfer-encoding"] = "Content-Transfer-Encoding",
37 ["content-type"] = "Content-Type",
38 ["cookie"] = "Cookie",
39 ["date"] = "Date",
40 ["diagnostic-code"] = "Diagnostic-Code",
41 ["dsn-gateway"] = "DSN-Gateway",
42 ["etag"] = "ETag",
43 ["expect"] = "Expect",
44 ["expires"] = "Expires",
45 ["final-log-id"] = "Final-Log-ID",
46 ["final-recipient"] = "Final-Recipient",
47 ["from"] = "From",
48 ["host"] = "Host",
49 ["if-match"] = "If-Match",
50 ["if-modified-since"] = "If-Modified-Since",
51 ["if-none-match"] = "If-None-Match",
52 ["if-range"] = "If-Range",
53 ["if-unmodified-since"] = "If-Unmodified-Since",
54 ["in-reply-to"] = "In-Reply-To",
55 ["keywords"] = "Keywords",
56 ["last-attempt-date"] = "Last-Attempt-Date",
57 ["last-modified"] = "Last-Modified",
58 ["location"] = "Location",
59 ["max-forwards"] = "Max-Forwards",
60 ["message-id"] = "Message-ID",
61 ["mime-version"] = "MIME-Version",
62 ["original-envelope-id"] = "Original-Envelope-ID",
63 ["original-recipient"] = "Original-Recipient",
64 ["pragma"] = "Pragma",
65 ["proxy-authenticate"] = "Proxy-Authenticate",
66 ["proxy-authorization"] = "Proxy-Authorization",
67 ["range"] = "Range",
68 ["received"] = "Received",
69 ["received-from-mta"] = "Received-From-MTA",
70 ["references"] = "References",
71 ["referer"] = "Referer",
72 ["remote-mta"] = "Remote-MTA",
73 ["reply-to"] = "Reply-To",
74 ["reporting-mta"] = "Reporting-MTA",
75 ["resent-bcc"] = "Resent-Bcc",
76 ["resent-cc"] = "Resent-Cc",
77 ["resent-date"] = "Resent-Date",
78 ["resent-from"] = "Resent-From",
79 ["resent-message-id"] = "Resent-Message-ID",
80 ["resent-reply-to"] = "Resent-Reply-To",
81 ["resent-sender"] = "Resent-Sender",
82 ["resent-to"] = "Resent-To",
83 ["retry-after"] = "Retry-After",
84 ["return-path"] = "Return-Path",
85 ["sender"] = "Sender",
86 ["server"] = "Server",
87 ["smtp-remote-recipient"] = "SMTP-Remote-Recipient",
88 ["status"] = "Status",
89 ["subject"] = "Subject",
90 ["te"] = "TE",
91 ["to"] = "To",
92 ["trailer"] = "Trailer",
93 ["transfer-encoding"] = "Transfer-Encoding",
94 ["upgrade"] = "Upgrade",
95 ["user-agent"] = "User-Agent",
96 ["vary"] = "Vary",
97 ["via"] = "Via",
98 ["warning"] = "Warning",
99 ["will-retry-until"] = "Will-Retry-Until",
100 ["www-authenticate"] = "WWW-Authenticate",
101 ["x-mailer"] = "X-Mailer",
102}
103 15
104return _M \ No newline at end of file 16_M.canonic = {}
17
18setmetatable(_M.canonic, {
19 __index = function(t, key)
20 if type(key) ~= "string" then
21 return nil
22 end
23
24 local lower = key:lower()
25 local v = rawget(t, lower)
26 if v then
27 return v
28 end
29
30 v = titlecase(lower)
31 rawset(t, lower, v)
32 return v
33 end
34})
35
36-- adds a header with a given canonical capitalization, e.g. for headers
37-- whose capitalization titlecase(header) would not reproduce correctly
38function _M.setcanonic(header)
39 _M.canonic[header:lower()] = header
40end
41
42-- headers whose canonical capitalization titlecase() does not reproduce
43-- (acronyms and other irregular capitalization). Anything not listed here
44-- is generated and cached on first lookup by the __index above.
45_M.setcanonic("Content-ID")
46_M.setcanonic("Content-MD5")
47_M.setcanonic("DSN-Gateway")
48_M.setcanonic("ETag")
49_M.setcanonic("Final-Log-ID")
50_M.setcanonic("Message-ID")
51_M.setcanonic("MIME-Version")
52_M.setcanonic("Original-Envelope-ID")
53_M.setcanonic("Received-From-MTA")
54_M.setcanonic("Remote-MTA")
55_M.setcanonic("Reporting-MTA")
56_M.setcanonic("Resent-Message-ID")
57_M.setcanonic("SMTP-Remote-Recipient")
58_M.setcanonic("TE")
59_M.setcanonic("WWW-Authenticate")
60
61return _M
diff --git a/test/headerstest.lua b/test/headerstest.lua
new file mode 100644
index 0000000..1387fc7
--- /dev/null
+++ b/test/headerstest.lua
@@ -0,0 +1,131 @@
1local socket = require("socket")
2local headers = require("socket.headers")
3
4-- the original hardcoded table this file used to contain, kept here so the
5-- refactor to dynamic generation + a small anomaly table can be checked for
6-- regressions.
7local canonic = {
8 ["accept"] = "Accept",
9 ["accept-charset"] = "Accept-Charset",
10 ["accept-encoding"] = "Accept-Encoding",
11 ["accept-language"] = "Accept-Language",
12 ["accept-ranges"] = "Accept-Ranges",
13 ["action"] = "Action",
14 ["alternate-recipient"] = "Alternate-Recipient",
15 ["age"] = "Age",
16 ["allow"] = "Allow",
17 ["arrival-date"] = "Arrival-Date",
18 ["authorization"] = "Authorization",
19 ["bcc"] = "Bcc",
20 ["cache-control"] = "Cache-Control",
21 ["cc"] = "Cc",
22 ["comments"] = "Comments",
23 ["connection"] = "Connection",
24 ["content-description"] = "Content-Description",
25 ["content-disposition"] = "Content-Disposition",
26 ["content-encoding"] = "Content-Encoding",
27 ["content-id"] = "Content-ID",
28 ["content-language"] = "Content-Language",
29 ["content-length"] = "Content-Length",
30 ["content-location"] = "Content-Location",
31 ["content-md5"] = "Content-MD5",
32 ["content-range"] = "Content-Range",
33 ["content-transfer-encoding"] = "Content-Transfer-Encoding",
34 ["content-type"] = "Content-Type",
35 ["cookie"] = "Cookie",
36 ["date"] = "Date",
37 ["diagnostic-code"] = "Diagnostic-Code",
38 ["dsn-gateway"] = "DSN-Gateway",
39 ["etag"] = "ETag",
40 ["expect"] = "Expect",
41 ["expires"] = "Expires",
42 ["final-log-id"] = "Final-Log-ID",
43 ["final-recipient"] = "Final-Recipient",
44 ["from"] = "From",
45 ["host"] = "Host",
46 ["if-match"] = "If-Match",
47 ["if-modified-since"] = "If-Modified-Since",
48 ["if-none-match"] = "If-None-Match",
49 ["if-range"] = "If-Range",
50 ["if-unmodified-since"] = "If-Unmodified-Since",
51 ["in-reply-to"] = "In-Reply-To",
52 ["keywords"] = "Keywords",
53 ["last-attempt-date"] = "Last-Attempt-Date",
54 ["last-modified"] = "Last-Modified",
55 ["location"] = "Location",
56 ["max-forwards"] = "Max-Forwards",
57 ["message-id"] = "Message-ID",
58 ["mime-version"] = "MIME-Version",
59 ["original-envelope-id"] = "Original-Envelope-ID",
60 ["original-recipient"] = "Original-Recipient",
61 ["pragma"] = "Pragma",
62 ["proxy-authenticate"] = "Proxy-Authenticate",
63 ["proxy-authorization"] = "Proxy-Authorization",
64 ["range"] = "Range",
65 ["received"] = "Received",
66 ["received-from-mta"] = "Received-From-MTA",
67 ["references"] = "References",
68 ["referer"] = "Referer",
69 ["remote-mta"] = "Remote-MTA",
70 ["reply-to"] = "Reply-To",
71 ["reporting-mta"] = "Reporting-MTA",
72 ["resent-bcc"] = "Resent-Bcc",
73 ["resent-cc"] = "Resent-Cc",
74 ["resent-date"] = "Resent-Date",
75 ["resent-from"] = "Resent-From",
76 ["resent-message-id"] = "Resent-Message-ID",
77 ["resent-reply-to"] = "Resent-Reply-To",
78 ["resent-sender"] = "Resent-Sender",
79 ["resent-to"] = "Resent-To",
80 ["retry-after"] = "Retry-After",
81 ["return-path"] = "Return-Path",
82 ["sender"] = "Sender",
83 ["server"] = "Server",
84 ["smtp-remote-recipient"] = "SMTP-Remote-Recipient",
85 ["status"] = "Status",
86 ["subject"] = "Subject",
87 ["te"] = "TE",
88 ["to"] = "To",
89 ["trailer"] = "Trailer",
90 ["transfer-encoding"] = "Transfer-Encoding",
91 ["upgrade"] = "Upgrade",
92 ["user-agent"] = "User-Agent",
93 ["vary"] = "Vary",
94 ["via"] = "Via",
95 ["warning"] = "Warning",
96 ["will-retry-until"] = "Will-Retry-Until",
97 ["www-authenticate"] = "WWW-Authenticate",
98 ["x-mailer"] = "X-Mailer",
99}
100
101local check_canonic = function(lower, expected)
102 local got = headers.canonic[lower]
103 if got ~= expected then
104 print("canonic[" .. lower .. "] = " .. tostring(got) ..
105 ", expected " .. expected)
106 os.exit()
107 end
108end
109
110print("testing all known header fields resolve to their canonical form")
111for lower, expected in pairs(canonic) do
112 check_canonic(lower, expected)
113end
114
115print("testing mixed/upper-case input normalizes correctly")
116check_canonic("Content-Type", "Content-Type")
117check_canonic("CONTENT-TYPE", "Content-Type")
118check_canonic("ETAG", "ETag")
119check_canonic("Www-Authenticate", "WWW-Authenticate")
120
121print("testing novel unregistered headers are auto-titlecased")
122check_canonic("x-request-id", "X-Request-Id")
123check_canonic("x-custom-header-name", "X-Custom-Header-Name")
124check_canonic("single", "Single")
125
126print("testing setcanonic() registers a custom canonical capitalization")
127headers.setcanonic("X-Request-ID")
128check_canonic("x-request-id", "X-Request-ID")
129check_canonic("X-REQUEST-ID", "X-Request-ID")
130
131print("the library passed all tests")