From a63fa963b7bd01f1b470bea7c5a2ed326ff760e6 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Sat, 29 Aug 2026 19:39:35 +0200 Subject: 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 --- .github/workflows/build.yml | 1 + docs/socket.html | 26 +++++++- src/headers.lua | 145 ++++++++++++++++---------------------------- test/headerstest.lua | 131 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 97 deletions(-) create mode 100644 test/headerstest.lua 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: lua ltn12test.lua lua mimetest.lua lua urltest.lua + lua headerstest.lua lua test_socket_error.lua 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.

-You can obtain the headers namespace if case run-time -modifications are required by running: +If a lowercase field name is not present as a key, +socket.headers.canonic automatically derives a +capitalization by uppercasing the first letter of each +hyphen-separated word (e.g. x-request-id becomes +X-Request-Id), and caches the result for later lookups. +A small set of headers whose canonic capitalization is +irregular (such as ETag, Content-MD5, +MIME-Version and WWW-Authenticate) are kept as +explicit entries in the table and are not affected by this rule. +Because of this, pairs(socket.headers.canonic) only +shows the irregular entries plus any field name already looked +up or added — it is not an exhaustive list of every header +name the library knows how to capitalize. +

+ +

+You can register a custom canonic capitalization — for a +header the automatic rule would not reproduce, such as a custom +all-caps acronym — with socket.headers.setcanonic:

 -- loads the headers module
-local headers = require("headers")
+local headers = require("socket.headers")
+
+-- register a custom capitalization
+headers.setcanonic("X-Request-ID")
 
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") socket.headers = {} local _M = socket.headers -_M.canonic = { - ["accept"] = "Accept", - ["accept-charset"] = "Accept-Charset", - ["accept-encoding"] = "Accept-Encoding", - ["accept-language"] = "Accept-Language", - ["accept-ranges"] = "Accept-Ranges", - ["action"] = "Action", - ["alternate-recipient"] = "Alternate-Recipient", - ["age"] = "Age", - ["allow"] = "Allow", - ["arrival-date"] = "Arrival-Date", - ["authorization"] = "Authorization", - ["bcc"] = "Bcc", - ["cache-control"] = "Cache-Control", - ["cc"] = "Cc", - ["comments"] = "Comments", - ["connection"] = "Connection", - ["content-description"] = "Content-Description", - ["content-disposition"] = "Content-Disposition", - ["content-encoding"] = "Content-Encoding", - ["content-id"] = "Content-ID", - ["content-language"] = "Content-Language", - ["content-length"] = "Content-Length", - ["content-location"] = "Content-Location", - ["content-md5"] = "Content-MD5", - ["content-range"] = "Content-Range", - ["content-transfer-encoding"] = "Content-Transfer-Encoding", - ["content-type"] = "Content-Type", - ["cookie"] = "Cookie", - ["date"] = "Date", - ["diagnostic-code"] = "Diagnostic-Code", - ["dsn-gateway"] = "DSN-Gateway", - ["etag"] = "ETag", - ["expect"] = "Expect", - ["expires"] = "Expires", - ["final-log-id"] = "Final-Log-ID", - ["final-recipient"] = "Final-Recipient", - ["from"] = "From", - ["host"] = "Host", - ["if-match"] = "If-Match", - ["if-modified-since"] = "If-Modified-Since", - ["if-none-match"] = "If-None-Match", - ["if-range"] = "If-Range", - ["if-unmodified-since"] = "If-Unmodified-Since", - ["in-reply-to"] = "In-Reply-To", - ["keywords"] = "Keywords", - ["last-attempt-date"] = "Last-Attempt-Date", - ["last-modified"] = "Last-Modified", - ["location"] = "Location", - ["max-forwards"] = "Max-Forwards", - ["message-id"] = "Message-ID", - ["mime-version"] = "MIME-Version", - ["original-envelope-id"] = "Original-Envelope-ID", - ["original-recipient"] = "Original-Recipient", - ["pragma"] = "Pragma", - ["proxy-authenticate"] = "Proxy-Authenticate", - ["proxy-authorization"] = "Proxy-Authorization", - ["range"] = "Range", - ["received"] = "Received", - ["received-from-mta"] = "Received-From-MTA", - ["references"] = "References", - ["referer"] = "Referer", - ["remote-mta"] = "Remote-MTA", - ["reply-to"] = "Reply-To", - ["reporting-mta"] = "Reporting-MTA", - ["resent-bcc"] = "Resent-Bcc", - ["resent-cc"] = "Resent-Cc", - ["resent-date"] = "Resent-Date", - ["resent-from"] = "Resent-From", - ["resent-message-id"] = "Resent-Message-ID", - ["resent-reply-to"] = "Resent-Reply-To", - ["resent-sender"] = "Resent-Sender", - ["resent-to"] = "Resent-To", - ["retry-after"] = "Retry-After", - ["return-path"] = "Return-Path", - ["sender"] = "Sender", - ["server"] = "Server", - ["smtp-remote-recipient"] = "SMTP-Remote-Recipient", - ["status"] = "Status", - ["subject"] = "Subject", - ["te"] = "TE", - ["to"] = "To", - ["trailer"] = "Trailer", - ["transfer-encoding"] = "Transfer-Encoding", - ["upgrade"] = "Upgrade", - ["user-agent"] = "User-Agent", - ["vary"] = "Vary", - ["via"] = "Via", - ["warning"] = "Warning", - ["will-retry-until"] = "Will-Retry-Until", - ["www-authenticate"] = "WWW-Authenticate", - ["x-mailer"] = "X-Mailer", -} +-- capitalizes the first letter of each hyphen-separated word, lowercases +-- the rest (e.g. "x-request-id" -> "X-Request-Id") +local function titlecase(header) + return (header:gsub("(%a)([%w]*)", function(a, b) return a:upper()..b:lower() end)) +end -return _M \ No newline at end of file +_M.canonic = {} + +setmetatable(_M.canonic, { + __index = function(t, key) + if type(key) ~= "string" then + return nil + end + + local lower = key:lower() + local v = rawget(t, lower) + if v then + return v + end + + v = titlecase(lower) + rawset(t, lower, v) + return v + end +}) + +-- adds a header with a given canonical capitalization, e.g. for headers +-- whose capitalization titlecase(header) would not reproduce correctly +function _M.setcanonic(header) + _M.canonic[header:lower()] = header +end + +-- headers whose canonical capitalization titlecase() does not reproduce +-- (acronyms and other irregular capitalization). Anything not listed here +-- is generated and cached on first lookup by the __index above. +_M.setcanonic("Content-ID") +_M.setcanonic("Content-MD5") +_M.setcanonic("DSN-Gateway") +_M.setcanonic("ETag") +_M.setcanonic("Final-Log-ID") +_M.setcanonic("Message-ID") +_M.setcanonic("MIME-Version") +_M.setcanonic("Original-Envelope-ID") +_M.setcanonic("Received-From-MTA") +_M.setcanonic("Remote-MTA") +_M.setcanonic("Reporting-MTA") +_M.setcanonic("Resent-Message-ID") +_M.setcanonic("SMTP-Remote-Recipient") +_M.setcanonic("TE") +_M.setcanonic("WWW-Authenticate") + +return _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 @@ +local socket = require("socket") +local headers = require("socket.headers") + +-- the original hardcoded table this file used to contain, kept here so the +-- refactor to dynamic generation + a small anomaly table can be checked for +-- regressions. +local canonic = { + ["accept"] = "Accept", + ["accept-charset"] = "Accept-Charset", + ["accept-encoding"] = "Accept-Encoding", + ["accept-language"] = "Accept-Language", + ["accept-ranges"] = "Accept-Ranges", + ["action"] = "Action", + ["alternate-recipient"] = "Alternate-Recipient", + ["age"] = "Age", + ["allow"] = "Allow", + ["arrival-date"] = "Arrival-Date", + ["authorization"] = "Authorization", + ["bcc"] = "Bcc", + ["cache-control"] = "Cache-Control", + ["cc"] = "Cc", + ["comments"] = "Comments", + ["connection"] = "Connection", + ["content-description"] = "Content-Description", + ["content-disposition"] = "Content-Disposition", + ["content-encoding"] = "Content-Encoding", + ["content-id"] = "Content-ID", + ["content-language"] = "Content-Language", + ["content-length"] = "Content-Length", + ["content-location"] = "Content-Location", + ["content-md5"] = "Content-MD5", + ["content-range"] = "Content-Range", + ["content-transfer-encoding"] = "Content-Transfer-Encoding", + ["content-type"] = "Content-Type", + ["cookie"] = "Cookie", + ["date"] = "Date", + ["diagnostic-code"] = "Diagnostic-Code", + ["dsn-gateway"] = "DSN-Gateway", + ["etag"] = "ETag", + ["expect"] = "Expect", + ["expires"] = "Expires", + ["final-log-id"] = "Final-Log-ID", + ["final-recipient"] = "Final-Recipient", + ["from"] = "From", + ["host"] = "Host", + ["if-match"] = "If-Match", + ["if-modified-since"] = "If-Modified-Since", + ["if-none-match"] = "If-None-Match", + ["if-range"] = "If-Range", + ["if-unmodified-since"] = "If-Unmodified-Since", + ["in-reply-to"] = "In-Reply-To", + ["keywords"] = "Keywords", + ["last-attempt-date"] = "Last-Attempt-Date", + ["last-modified"] = "Last-Modified", + ["location"] = "Location", + ["max-forwards"] = "Max-Forwards", + ["message-id"] = "Message-ID", + ["mime-version"] = "MIME-Version", + ["original-envelope-id"] = "Original-Envelope-ID", + ["original-recipient"] = "Original-Recipient", + ["pragma"] = "Pragma", + ["proxy-authenticate"] = "Proxy-Authenticate", + ["proxy-authorization"] = "Proxy-Authorization", + ["range"] = "Range", + ["received"] = "Received", + ["received-from-mta"] = "Received-From-MTA", + ["references"] = "References", + ["referer"] = "Referer", + ["remote-mta"] = "Remote-MTA", + ["reply-to"] = "Reply-To", + ["reporting-mta"] = "Reporting-MTA", + ["resent-bcc"] = "Resent-Bcc", + ["resent-cc"] = "Resent-Cc", + ["resent-date"] = "Resent-Date", + ["resent-from"] = "Resent-From", + ["resent-message-id"] = "Resent-Message-ID", + ["resent-reply-to"] = "Resent-Reply-To", + ["resent-sender"] = "Resent-Sender", + ["resent-to"] = "Resent-To", + ["retry-after"] = "Retry-After", + ["return-path"] = "Return-Path", + ["sender"] = "Sender", + ["server"] = "Server", + ["smtp-remote-recipient"] = "SMTP-Remote-Recipient", + ["status"] = "Status", + ["subject"] = "Subject", + ["te"] = "TE", + ["to"] = "To", + ["trailer"] = "Trailer", + ["transfer-encoding"] = "Transfer-Encoding", + ["upgrade"] = "Upgrade", + ["user-agent"] = "User-Agent", + ["vary"] = "Vary", + ["via"] = "Via", + ["warning"] = "Warning", + ["will-retry-until"] = "Will-Retry-Until", + ["www-authenticate"] = "WWW-Authenticate", + ["x-mailer"] = "X-Mailer", +} + +local check_canonic = function(lower, expected) + local got = headers.canonic[lower] + if got ~= expected then + print("canonic[" .. lower .. "] = " .. tostring(got) .. + ", expected " .. expected) + os.exit() + end +end + +print("testing all known header fields resolve to their canonical form") +for lower, expected in pairs(canonic) do + check_canonic(lower, expected) +end + +print("testing mixed/upper-case input normalizes correctly") +check_canonic("Content-Type", "Content-Type") +check_canonic("CONTENT-TYPE", "Content-Type") +check_canonic("ETAG", "ETag") +check_canonic("Www-Authenticate", "WWW-Authenticate") + +print("testing novel unregistered headers are auto-titlecased") +check_canonic("x-request-id", "X-Request-Id") +check_canonic("x-custom-header-name", "X-Custom-Header-Name") +check_canonic("single", "Single") + +print("testing setcanonic() registers a custom canonical capitalization") +headers.setcanonic("X-Request-ID") +check_canonic("x-request-id", "X-Request-ID") +check_canonic("X-REQUEST-ID", "X-Request-ID") + +print("the library passed all tests") -- cgit v1.2.3-55-g6feb