diff options
| author | Caleb Maclennan <caleb@alerque.com> | 2026-08-31 11:03:24 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-31 11:03:24 +0300 |
| commit | 5afe174eeffc7f3e3b9ef80b5871237620c68415 (patch) | |
| tree | 85c8bb14a445823dc7a683a53394901523f5f16b | |
| parent | b836466de5a78e9b4f29ca6c85a2bdecd7c69da9 (diff) | |
| parent | 3dd5fb0468977c6626fe4a0ea7b95222f14da09f (diff) | |
| download | luasocket-5afe174eeffc7f3e3b9ef80b5871237620c68415.tar.gz luasocket-5afe174eeffc7f3e3b9ef80b5871237620c68415.tar.bz2 luasocket-5afe174eeffc7f3e3b9ef80b5871237620c68415.zip | |
Merge pull request #470 from lunarmodules/header-canon
feat(headers): dynamically create cononicalized headers
| -rw-r--r-- | .github/workflows/build.yml | 1 | ||||
| -rw-r--r-- | docs/socket.html | 26 | ||||
| -rw-r--r-- | src/headers.lua | 148 | ||||
| -rw-r--r-- | test/headerstest.lua | 131 |
4 files changed, 209 insertions, 97 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e8a4117..22ce0c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml | |||
| @@ -48,6 +48,7 @@ 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 | lua test_tcp_receive_zero.lua | 53 | lua test_tcp_receive_zero.lua |
| 53 | lua test_udp_receive_zero.lua | 54 | lua test_udp_receive_zero.lua |
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> |
| 68 | You can obtain the <tt>headers</tt> namespace if case run-time | 68 | If a lowercase field name is not present as a key, |
| 69 | modifications are required by running: | 69 | <tt>socket.headers.canonic</tt> automatically derives a |
| 70 | capitalization by uppercasing the first letter of each | ||
| 71 | hyphen-separated word (e.g. <tt>x-request-id</tt> becomes | ||
| 72 | <tt>X-Request-Id</tt>), and caches the result for later lookups. | ||
| 73 | A small set of headers whose canonic capitalization is | ||
| 74 | irregular (such as <tt>ETag</tt>, <tt>Content-MD5</tt>, | ||
| 75 | <tt>MIME-Version</tt> and <tt>WWW-Authenticate</tt>) are kept as | ||
| 76 | explicit entries in the table and are not affected by this rule. | ||
| 77 | Because of this, <tt>pairs(socket.headers.canonic)</tt> only | ||
| 78 | shows the irregular entries plus any field name already looked | ||
| 79 | up or added — it is not an exhaustive list of every header | ||
| 80 | name the library knows how to capitalize. | ||
| 81 | </p> | ||
| 82 | |||
| 83 | <p> | ||
| 84 | You can register a custom canonic capitalization — for a | ||
| 85 | header the automatic rule would not reproduce, such as a custom | ||
| 86 | all-caps acronym — 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 |
| 74 | local headers = require("headers") | 91 | local headers = require("socket.headers") |
| 92 | |||
| 93 | -- register a custom capitalization | ||
| 94 | headers.setcanonic("X-Request-ID") | ||
| 75 | </pre> | 95 | </pre> |
| 76 | 96 | ||
| 77 | 97 | ||
diff --git a/src/headers.lua b/src/headers.lua index 1eb8223..004ef37 100644 --- a/src/headers.lua +++ b/src/headers.lua | |||
| @@ -7,98 +7,58 @@ local socket = require("socket") | |||
| 7 | socket.headers = {} | 7 | socket.headers = {} |
| 8 | local _M = socket.headers | 8 | local _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", | 12 | local 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", | 14 | end |
| 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 | ||
| 104 | return _M \ No newline at end of file | 16 | _M.canonic = {} |
| 17 | |||
| 18 | setmetatable(_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 | ||
| 38 | function _M.setcanonic(header) | ||
| 39 | if type(header) ~= "string" then | ||
| 40 | error("header must be a string", 2) | ||
| 41 | end | ||
| 42 | _M.canonic[header:lower()] = header | ||
| 43 | end | ||
| 44 | |||
| 45 | -- headers whose canonical capitalization titlecase() does not reproduce | ||
| 46 | -- (acronyms and other irregular capitalization). Anything not listed here | ||
| 47 | -- is generated and cached on first lookup by the __index above. | ||
| 48 | _M.setcanonic("Content-ID") | ||
| 49 | _M.setcanonic("Content-MD5") | ||
| 50 | _M.setcanonic("DSN-Gateway") | ||
| 51 | _M.setcanonic("ETag") | ||
| 52 | _M.setcanonic("Final-Log-ID") | ||
| 53 | _M.setcanonic("Message-ID") | ||
| 54 | _M.setcanonic("MIME-Version") | ||
| 55 | _M.setcanonic("Original-Envelope-ID") | ||
| 56 | _M.setcanonic("Received-From-MTA") | ||
| 57 | _M.setcanonic("Remote-MTA") | ||
| 58 | _M.setcanonic("Reporting-MTA") | ||
| 59 | _M.setcanonic("Resent-Message-ID") | ||
| 60 | _M.setcanonic("SMTP-Remote-Recipient") | ||
| 61 | _M.setcanonic("TE") | ||
| 62 | _M.setcanonic("WWW-Authenticate") | ||
| 63 | |||
| 64 | return _M | ||
diff --git a/test/headerstest.lua b/test/headerstest.lua new file mode 100644 index 0000000..cbf638f --- /dev/null +++ b/test/headerstest.lua | |||
| @@ -0,0 +1,131 @@ | |||
| 1 | local socket = require("socket") | ||
| 2 | local 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. | ||
| 7 | local 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 | |||
| 101 | local 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(1) | ||
| 107 | end | ||
| 108 | end | ||
| 109 | |||
| 110 | print("testing all known header fields resolve to their canonical form") | ||
| 111 | for lower, expected in pairs(canonic) do | ||
| 112 | check_canonic(lower, expected) | ||
| 113 | end | ||
| 114 | |||
| 115 | print("testing mixed/upper-case input normalizes correctly") | ||
| 116 | check_canonic("Content-Type", "Content-Type") | ||
| 117 | check_canonic("CONTENT-TYPE", "Content-Type") | ||
| 118 | check_canonic("ETAG", "ETag") | ||
| 119 | check_canonic("Www-Authenticate", "WWW-Authenticate") | ||
| 120 | |||
| 121 | print("testing novel unregistered headers are auto-titlecased") | ||
| 122 | check_canonic("x-request-id", "X-Request-Id") | ||
| 123 | check_canonic("x-custom-header-name", "X-Custom-Header-Name") | ||
| 124 | check_canonic("single", "Single") | ||
| 125 | |||
| 126 | print("testing setcanonic() registers a custom canonical capitalization") | ||
| 127 | headers.setcanonic("X-Request-ID") | ||
| 128 | check_canonic("x-request-id", "X-Request-ID") | ||
| 129 | check_canonic("X-REQUEST-ID", "X-Request-ID") | ||
| 130 | |||
| 131 | print("the library passed all tests") | ||
