philiprehberger-header_kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25bd279387146f50454c0585427061ebd9c1a35b336e85c670a13ad304580aa1
4
- data.tar.gz: 0c445388933a3a195d791ea09118f8728232d5c9fb87d15453b73d1c9283494e
3
+ metadata.gz: b1e00c5cf603e6f7db3b7ac63478c587d4d3f0dd54d40ee11cbcc730a9ea4bb9
4
+ data.tar.gz: c58a1210d23ae79c1ac86cd6340c7b6f30c6f6f575eb82b9c33caff1c5306eb3
5
5
  SHA512:
6
- metadata.gz: 45d125fac1583f2c654e759d8b17e178641132c667bb23902981eb533ddc6704f7028ac9571a1dea8e3c54249c0b92a650d2784c5cbb79e04e37a69f1ed974de
7
- data.tar.gz: 76bc7962e8f94207ce8ecd5356d758b9788d957fe70d761ac8aa5e7f24e0aab59bcd0ff65cb3631015addfbcca06d3393ae2ceb72b435b6fa711fcaee5423337
6
+ metadata.gz: dbc951e5c1cee317be92464e8c4639cbb65f963f87f61b2050b12ed1c75b2be29d7cd326514ce35472a36bcc53d61d8cc445581cc3e85ff24954345dd3100439
7
+ data.tar.gz: eda78dd329146c43b11f44594c9fdc9fa5acf31352a77af7cc1e610f4ff80a6d21f4feb52119efc36a51058ba40aad15b89f0635cff908e7c39098ac3c5dfe4a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-03-31
11
+
12
+ ### Added
13
+
14
+ - `parse_cors` and `build_cors` for CORS header handling
15
+ - `security_headers` for generating recommended security headers
16
+ - `parse_forwarded` for RFC 7239 Forwarded header parsing
17
+ - `parse_via` for Via header parsing
18
+
19
+ ## [0.2.0] - 2026-03-28
20
+
21
+ ### Added
22
+
23
+ - Parse Accept-Language headers with quality values
24
+ - Negotiate best language match from Accept-Language against available languages
25
+ - Parse Accept-Encoding headers with quality values
26
+ - Parse Authorization headers (Bearer, Basic, Digest schemes)
27
+ - Parse Cookie headers into name-value hashes
28
+ - Build Set-Cookie header strings with all standard attributes
29
+ - Build Accept header strings from structured type arrays
30
+
10
31
  ## [0.1.1] - 2026-03-26
11
32
 
12
33
  ### Changed
data/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-header-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-header-kit/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-header_kit.svg)](https://rubygems.org/gems/philiprehberger-header_kit)
5
+ [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-header-kit)](https://github.com/philiprehberger/rb-header-kit/releases)
6
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-header-kit)](https://github.com/philiprehberger/rb-header-kit/commits/main)
5
7
  [![License](https://img.shields.io/github/license/philiprehberger/rb-header-kit)](LICENSE)
8
+ [![Bug Reports](https://img.shields.io/github/issues/philiprehberger/rb-header-kit/bug)](https://github.com/philiprehberger/rb-header-kit/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
9
+ [![Feature Requests](https://img.shields.io/github/issues/philiprehberger/rb-header-kit/enhancement)](https://github.com/philiprehberger/rb-header-kit/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
6
10
  [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
7
11
 
8
12
  HTTP header parsing, construction, and content negotiation
@@ -39,6 +43,44 @@ Philiprehberger::HeaderKit.parse_accept("text/html;q=0.9, application/json")
39
43
  # {type: "text/html", quality: 0.9, params: {}}]
40
44
  ```
41
45
 
46
+ ### Build Accept
47
+
48
+ ```ruby
49
+ Philiprehberger::HeaderKit.build_accept([{type: "text/html"}, {type: "application/json", quality: 0.9}])
50
+ # => "text/html, application/json;q=0.9"
51
+ ```
52
+
53
+ ### Parse Accept-Language
54
+
55
+ ```ruby
56
+ Philiprehberger::HeaderKit.parse_accept_language("en-US,en;q=0.9,fr;q=0.8")
57
+ # => [{language: "en-US", quality: 1.0}, {language: "en", quality: 0.9}, {language: "fr", quality: 0.8}]
58
+ ```
59
+
60
+ ### Negotiate Language
61
+
62
+ ```ruby
63
+ Philiprehberger::HeaderKit.negotiate_language("en-US,fr;q=0.9", ["fr", "en"])
64
+ # => "en"
65
+ ```
66
+
67
+ ### Parse Accept-Encoding
68
+
69
+ ```ruby
70
+ Philiprehberger::HeaderKit.parse_accept_encoding("gzip, deflate;q=0.5, br;q=0.8")
71
+ # => [{encoding: "gzip", quality: 1.0}, {encoding: "br", quality: 0.8}, {encoding: "deflate", quality: 0.5}]
72
+ ```
73
+
74
+ ### Parse Authorization
75
+
76
+ ```ruby
77
+ Philiprehberger::HeaderKit.parse_authorization("Bearer eyJhbGciOiJIUzI1NiJ9")
78
+ # => {scheme: "Bearer", credentials: "eyJhbGciOiJIUzI1NiJ9"}
79
+
80
+ Philiprehberger::HeaderKit.parse_authorization('Digest username="admin", realm="example"')
81
+ # => {scheme: "Digest", params: {"username" => "admin", "realm" => "example"}}
82
+ ```
83
+
42
84
  ### Parse Cache-Control
43
85
 
44
86
  ```ruby
@@ -60,6 +102,20 @@ Philiprehberger::HeaderKit.parse_content_type("text/html; charset=utf-8")
60
102
  # => {media_type: "text/html", charset: "utf-8", boundary: nil}
61
103
  ```
62
104
 
105
+ ### Parse Cookie
106
+
107
+ ```ruby
108
+ Philiprehberger::HeaderKit.parse_cookie("session=abc123; user=john")
109
+ # => {"session" => "abc123", "user" => "john"}
110
+ ```
111
+
112
+ ### Build Set-Cookie
113
+
114
+ ```ruby
115
+ Philiprehberger::HeaderKit.build_set_cookie("session", "abc123", secure: true, httponly: true, path: "/")
116
+ # => "session=abc123; Path=/; Secure; HttpOnly"
117
+ ```
118
+
63
119
  ### Parse Link
64
120
 
65
121
  ```ruby
@@ -81,26 +137,94 @@ Philiprehberger::HeaderKit.negotiate("text/html;q=0.9, application/json", ["text
81
137
  # => "application/json"
82
138
  ```
83
139
 
140
+ ### Parse CORS
141
+
142
+ ```ruby
143
+ headers = {
144
+ 'Origin' => 'https://example.com',
145
+ 'Access-Control-Request-Method' => 'POST',
146
+ 'Access-Control-Request-Headers' => 'Content-Type, Authorization'
147
+ }
148
+ Philiprehberger::HeaderKit.parse_cors(headers)
149
+ # => {origin: "https://example.com", method: "POST", headers: ["Content-Type", "Authorization"]}
150
+ ```
151
+
152
+ ### Build CORS
153
+
154
+ ```ruby
155
+ Philiprehberger::HeaderKit.build_cors(
156
+ origin: "https://example.com",
157
+ methods: ["GET", "POST"],
158
+ headers: ["Content-Type"],
159
+ max_age: 3600,
160
+ credentials: true
161
+ )
162
+ # => {"Access-Control-Allow-Origin" => "https://example.com", ...}
163
+ ```
164
+
165
+ ### Security Headers
166
+
167
+ ```ruby
168
+ Philiprehberger::HeaderKit.security_headers
169
+ # => {"X-Content-Type-Options" => "nosniff", "X-Frame-Options" => "DENY", ...}
170
+
171
+ Philiprehberger::HeaderKit.security_headers(hsts: "max-age=31536000", csp: "default-src 'self'")
172
+ # => includes Strict-Transport-Security and Content-Security-Policy
173
+ ```
174
+
175
+ ### Parse Forwarded
176
+
177
+ ```ruby
178
+ Philiprehberger::HeaderKit.parse_forwarded('for=192.0.2.60;proto=http;by=203.0.113.43')
179
+ # => [{for: "192.0.2.60", proto: "http", by: "203.0.113.43"}]
180
+ ```
181
+
182
+ ### Parse Via
183
+
184
+ ```ruby
185
+ Philiprehberger::HeaderKit.parse_via('1.1 vegur, 1.0 fred')
186
+ # => [{protocol: "1.1", host: "vegur"}, {protocol: "1.0", host: "fred"}]
187
+ ```
188
+
84
189
  ## API
85
190
 
86
191
  | Method | Description |
87
192
  |--------|-------------|
88
193
  | `HeaderKit.parse_accept(header)` | Parse Accept header into sorted entries |
194
+ | `HeaderKit.build_accept(types)` | Build Accept header string from type array |
195
+ | `HeaderKit.parse_accept_language(header)` | Parse Accept-Language into sorted entries |
196
+ | `HeaderKit.negotiate_language(header, available)` | Language negotiation, returns best match or nil |
197
+ | `HeaderKit.parse_accept_encoding(header)` | Parse Accept-Encoding into sorted entries |
198
+ | `HeaderKit.parse_authorization(header)` | Parse Authorization header (Bearer, Basic, Digest) |
89
199
  | `HeaderKit.parse_cache_control(header)` | Parse Cache-Control into directive hash |
90
200
  | `HeaderKit.build_cache_control(directives)` | Build Cache-Control string from hash |
91
201
  | `HeaderKit.parse_content_type(header)` | Parse Content-Type into components |
202
+ | `HeaderKit.parse_cookie(header)` | Parse Cookie header into name-value hash |
203
+ | `HeaderKit.build_set_cookie(name, value, **opts)` | Build Set-Cookie header string |
92
204
  | `HeaderKit.parse_link(header)` | Parse Link header into entry array |
93
205
  | `HeaderKit.build_link(links)` | Build Link header from array of hashes |
94
206
  | `HeaderKit.negotiate(accept_header, available)` | Content negotiation, returns best match or nil |
207
+ | `HeaderKit.parse_cors(headers)` | Parse CORS-related request headers |
208
+ | `HeaderKit.build_cors(**options)` | Build CORS response headers |
209
+ | `HeaderKit.security_headers(**options)` | Generate recommended security headers |
210
+ | `HeaderKit.parse_forwarded(header)` | Parse RFC 7239 Forwarded header |
211
+ | `HeaderKit.parse_via(header)` | Parse Via header into structured entries |
95
212
 
96
213
  ## Development
97
214
 
98
215
  ```bash
99
216
  bundle install
100
- bundle exec rspec # Run tests
101
- bundle exec rubocop # Check code style
217
+ bundle exec rspec
218
+ bundle exec rubocop
102
219
  ```
103
220
 
221
+ ## Support
222
+
223
+ If you find this package useful, consider giving it a star on GitHub — it helps motivate continued maintenance and development.
224
+
225
+ [![LinkedIn](https://img.shields.io/badge/Philip%20Rehberger-LinkedIn-0A66C2?logo=linkedin)](https://www.linkedin.com/in/philiprehberger)
226
+ [![More packages](https://img.shields.io/badge/more-open%20source%20packages-blue)](https://philiprehberger.com/open-source-packages)
227
+
104
228
  ## License
105
229
 
106
230
  [MIT](LICENSE)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Builds Accept header strings from structured type entries.
6
+ module AcceptBuilder
7
+ # Build an Accept header string from an array of type hashes.
8
+ #
9
+ # @param types [Array<Hash>] each with :type and optional :quality
10
+ # @return [String] formatted Accept header value
11
+ def self.build(types)
12
+ return '' if types.nil? || types.empty?
13
+
14
+ parts = types.map do |entry|
15
+ type = entry[:type]
16
+ quality = entry[:quality]
17
+
18
+ if quality && quality < 1.0
19
+ "#{type};q=#{format_quality(quality)}"
20
+ else
21
+ type
22
+ end
23
+ end
24
+
25
+ parts.join(', ')
26
+ end
27
+
28
+ # Format a quality value, removing trailing zeros.
29
+ #
30
+ # @param quality [Float] the quality value
31
+ # @return [String] formatted quality string
32
+ def self.format_quality(quality)
33
+ formatted = format('%.3f', quality)
34
+ formatted.sub(/0+\z/, '').sub(/\.\z/, '.0')
35
+ end
36
+
37
+ private_class_method :format_quality
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Parses Accept-Encoding headers into structured encoding entries with quality values.
6
+ module AcceptEncoding
7
+ QUALITY_PATTERN = /\Aq\z/i
8
+
9
+ # Parse an Accept-Encoding header string.
10
+ #
11
+ # @param header [String] the Accept-Encoding header value
12
+ # @return [Array<Hash>] sorted by quality descending, each with :encoding, :quality
13
+ def self.parse(header)
14
+ return [] if header.nil? || header.strip.empty?
15
+
16
+ entries = header.split(',').map { |entry| parse_entry(entry.strip) }
17
+ entries.compact.sort_by { |e| [-e[:quality], entries.index(e)] }
18
+ end
19
+
20
+ # Parse a single encoding entry.
21
+ #
22
+ # @param entry [String] a single encoding with optional quality
23
+ # @return [Hash, nil] parsed entry or nil if invalid
24
+ def self.parse_entry(entry)
25
+ return nil if entry.empty?
26
+
27
+ parts = entry.split(';').map(&:strip)
28
+ encoding = parts.shift
29
+ return nil if encoding.nil? || encoding.empty?
30
+
31
+ quality = 1.0
32
+
33
+ parts.each do |param|
34
+ key, value = param.split('=', 2).map(&:strip)
35
+ next if key.nil? || key.empty?
36
+
37
+ quality = parse_quality(value) if QUALITY_PATTERN.match?(key)
38
+ end
39
+
40
+ { encoding: encoding.downcase, quality: quality }
41
+ end
42
+
43
+ # Parse a quality value, clamping to [0.0, 1.0].
44
+ #
45
+ # @param value [String, nil] the quality string
46
+ # @return [Float] parsed quality value
47
+ def self.parse_quality(value)
48
+ return 1.0 if value.nil? || value.empty?
49
+
50
+ value.to_f.clamp(0.0, 1.0)
51
+ end
52
+
53
+ private_class_method :parse_entry, :parse_quality
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Parses Accept-Language headers into structured language entries with quality values.
6
+ module AcceptLanguage
7
+ QUALITY_PATTERN = /\Aq\z/i
8
+
9
+ # Parse an Accept-Language header string.
10
+ #
11
+ # @param header [String] the Accept-Language header value
12
+ # @return [Array<Hash>] sorted by quality descending, each with :language, :quality
13
+ def self.parse(header)
14
+ return [] if header.nil? || header.strip.empty?
15
+
16
+ entries = header.split(',').map { |entry| parse_entry(entry.strip) }
17
+ entries.compact.sort_by { |e| [-e[:quality], entries.index(e)] }
18
+ end
19
+
20
+ # Negotiate the best language match from available languages.
21
+ #
22
+ # @param header [String] the Accept-Language header value
23
+ # @param available [Array<String>] list of available language tags
24
+ # @return [String, nil] the best matching language, or nil if no match
25
+ def self.negotiate(header, available)
26
+ return nil if available.nil? || available.empty?
27
+ return available.first if header.nil? || header.strip.empty?
28
+
29
+ parsed = parse(header)
30
+ return nil if parsed.empty?
31
+
32
+ parsed.each do |entry|
33
+ next if entry[:quality] <= 0.0
34
+
35
+ match = find_match(entry[:language], available)
36
+ return match if match
37
+ end
38
+
39
+ nil
40
+ end
41
+
42
+ # Parse a single language entry.
43
+ #
44
+ # @param entry [String] a single language tag with optional quality
45
+ # @return [Hash, nil] parsed entry or nil if invalid
46
+ def self.parse_entry(entry)
47
+ return nil if entry.empty?
48
+
49
+ parts = entry.split(';').map(&:strip)
50
+ language = parts.shift
51
+ return nil if language.nil? || language.empty?
52
+
53
+ quality = 1.0
54
+
55
+ parts.each do |param|
56
+ key, value = param.split('=', 2).map(&:strip)
57
+ next if key.nil? || key.empty?
58
+
59
+ quality = parse_quality(value) if QUALITY_PATTERN.match?(key)
60
+ end
61
+
62
+ { language: language, quality: quality }
63
+ end
64
+
65
+ # Parse a quality value, clamping to [0.0, 1.0].
66
+ #
67
+ # @param value [String, nil] the quality string
68
+ # @return [Float] parsed quality value
69
+ def self.parse_quality(value)
70
+ return 1.0 if value.nil? || value.empty?
71
+
72
+ value.to_f.clamp(0.0, 1.0)
73
+ end
74
+
75
+ # Find the best match for a language tag among available languages.
76
+ #
77
+ # @param tag [String] the requested language tag
78
+ # @param available [Array<String>] available language tags
79
+ # @return [String, nil] matching language or nil
80
+ def self.find_match(tag, available)
81
+ return available.first if tag == '*'
82
+
83
+ downcased = tag.downcase
84
+ exact = available.find { |a| a.downcase == downcased }
85
+ return exact if exact
86
+
87
+ prefix = available.find { |a| a.downcase.start_with?("#{downcased}-") }
88
+ return prefix if prefix
89
+
90
+ base = downcased.split('-').first
91
+ available.find { |a| a.downcase.split('-').first == base }
92
+ end
93
+
94
+ private_class_method :parse_entry, :parse_quality, :find_match
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Parses Authorization headers for Bearer, Basic, and Digest schemes.
6
+ module Authorization
7
+ DIGEST_PARAM_PATTERN = %r{(\w+)=(?:"([^"]*)"|([\w+/=]+))}
8
+
9
+ # Parse an Authorization header string.
10
+ #
11
+ # @param header [String] the Authorization header value
12
+ # @return [Hash] with :scheme and :credentials (Bearer/Basic) or :params (Digest)
13
+ def self.parse(header)
14
+ return { scheme: nil, credentials: nil } if header.nil? || header.strip.empty?
15
+
16
+ stripped = header.strip
17
+ space_index = stripped.index(' ')
18
+
19
+ unless space_index
20
+ return { scheme: stripped, credentials: nil }
21
+ end
22
+
23
+ scheme = stripped[0...space_index]
24
+ rest = stripped[(space_index + 1)..].strip
25
+
26
+ case scheme.downcase
27
+ when 'bearer', 'basic'
28
+ { scheme: scheme, credentials: rest }
29
+ when 'digest'
30
+ { scheme: scheme, params: parse_digest_params(rest) }
31
+ else
32
+ { scheme: scheme, credentials: rest }
33
+ end
34
+ end
35
+
36
+ # Parse Digest authorization parameters.
37
+ #
38
+ # @param params_str [String] the parameter string after "Digest "
39
+ # @return [Hash{String => String}] parsed key-value parameters
40
+ def self.parse_digest_params(params_str)
41
+ params = {}
42
+
43
+ params_str.scan(DIGEST_PARAM_PATTERN).each do |key, quoted_value, unquoted_value|
44
+ params[key] = quoted_value || unquoted_value
45
+ end
46
+
47
+ params
48
+ end
49
+
50
+ private_class_method :parse_digest_params
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Parses Cookie headers and builds Set-Cookie header strings.
6
+ module Cookie
7
+ # Parse a Cookie header string into a name-value hash.
8
+ #
9
+ # @param header [String] the Cookie header value
10
+ # @return [Hash{String => String}] cookie name-value pairs
11
+ def self.parse(header)
12
+ return {} if header.nil? || header.strip.empty?
13
+
14
+ cookies = {}
15
+
16
+ header.split(';').each do |pair|
17
+ pair = pair.strip
18
+ next if pair.empty?
19
+
20
+ name, value = pair.split('=', 2)
21
+ next if name.nil? || name.strip.empty?
22
+
23
+ cookies[name.strip] = (value || '').strip
24
+ end
25
+
26
+ cookies
27
+ end
28
+
29
+ # Build a Set-Cookie header string.
30
+ #
31
+ # @param name [String] cookie name
32
+ # @param value [String] cookie value
33
+ # @param expires [String, nil] Expires attribute value
34
+ # @param max_age [Integer, nil] Max-Age attribute value
35
+ # @param secure [Boolean] include Secure flag
36
+ # @param httponly [Boolean] include HttpOnly flag
37
+ # @param samesite [String, nil] SameSite attribute (Strict, Lax, None)
38
+ # @param path [String, nil] Path attribute
39
+ # @param domain [String, nil] Domain attribute
40
+ # @return [String] formatted Set-Cookie header value
41
+ def self.build_set_cookie(name, value, expires: nil, max_age: nil, secure: false, # rubocop:disable Metrics/ParameterLists
42
+ httponly: false, samesite: nil, path: nil, domain: nil)
43
+ parts = ["#{name}=#{value}"]
44
+ parts << "Expires=#{expires}" if expires
45
+ parts << "Max-Age=#{max_age}" if max_age
46
+ parts << "Domain=#{domain}" if domain
47
+ parts << "Path=#{path}" if path
48
+ parts << 'Secure' if secure
49
+ parts << 'HttpOnly' if httponly
50
+ parts << "SameSite=#{samesite}" if samesite
51
+
52
+ parts.join('; ')
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ module Cors
6
+ module_function
7
+
8
+ def parse(headers)
9
+ result = {}
10
+ result[:origin] = headers['Origin'] || headers['origin']
11
+ result[:method] = headers['Access-Control-Request-Method'] || headers['access-control-request-method']
12
+ request_headers = headers['Access-Control-Request-Headers'] || headers['access-control-request-headers']
13
+ result[:headers] = request_headers&.split(',')&.map(&:strip) || []
14
+ result
15
+ end
16
+
17
+ def build(origin:, methods: ['GET'], headers: [], max_age: nil, credentials: false, expose: [])
18
+ result = {}
19
+ result['Access-Control-Allow-Origin'] = origin
20
+ result['Access-Control-Allow-Methods'] = Array(methods).join(', ')
21
+ result['Access-Control-Allow-Headers'] = Array(headers).join(', ') unless headers.empty?
22
+ result['Access-Control-Max-Age'] = max_age.to_s if max_age
23
+ result['Access-Control-Allow-Credentials'] = 'true' if credentials
24
+ result['Access-Control-Expose-Headers'] = Array(expose).join(', ') unless expose.empty?
25
+ result
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ module Forwarded
6
+ module_function
7
+
8
+ def parse(header)
9
+ return [] if header.nil? || header.empty?
10
+
11
+ header.split(',').map do |part|
12
+ entry = {}
13
+ part.strip.split(';').each do |pair|
14
+ key, value = pair.strip.split('=', 2)
15
+ next unless key && value
16
+
17
+ entry[key.strip.downcase.to_sym] = unquote(value.strip)
18
+ end
19
+ entry
20
+ end
21
+ end
22
+
23
+ def parse_via(header)
24
+ return [] if header.nil? || header.empty?
25
+
26
+ header.split(',').map do |entry|
27
+ parts = entry.strip.split(/\s+/, 3)
28
+ result = {}
29
+ if parts.length >= 2
30
+ result[:protocol] = parts[0]
31
+ result[:host] = parts[1]
32
+ result[:comment] = parts[2] if parts[2]
33
+ else
34
+ result[:host] = parts[0]
35
+ end
36
+ result
37
+ end
38
+ end
39
+
40
+ def unquote(value)
41
+ value.start_with?('"') && value.end_with?('"') ? value[1..-2] : value
42
+ end
43
+
44
+ private_class_method :unquote
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ module Security
6
+ DEFAULTS = {
7
+ content_type_options: 'nosniff',
8
+ frame_options: 'DENY',
9
+ xss_protection: '0',
10
+ referrer_policy: 'strict-origin-when-cross-origin'
11
+ }.freeze
12
+
13
+ module_function
14
+
15
+ def headers(hsts: nil, csp: nil, frame: nil, content_type_options: nil, referrer_policy: nil)
16
+ result = {}
17
+ result['X-Content-Type-Options'] = content_type_options || DEFAULTS[:content_type_options]
18
+ result['X-Frame-Options'] = frame || DEFAULTS[:frame_options]
19
+ result['X-XSS-Protection'] = DEFAULTS[:xss_protection]
20
+ result['Referrer-Policy'] = referrer_policy || DEFAULTS[:referrer_policy]
21
+ result['Strict-Transport-Security'] = hsts if hsts
22
+ result['Content-Security-Policy'] = csp if csp
23
+ result
24
+ end
25
+ end
26
+ end
27
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module HeaderKit
5
- VERSION = '0.1.1'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -2,10 +2,18 @@
2
2
 
3
3
  require_relative 'header_kit/version'
4
4
  require_relative 'header_kit/accept'
5
+ require_relative 'header_kit/accept_builder'
6
+ require_relative 'header_kit/accept_encoding'
7
+ require_relative 'header_kit/accept_language'
8
+ require_relative 'header_kit/authorization'
5
9
  require_relative 'header_kit/cache_control'
6
10
  require_relative 'header_kit/content_type'
11
+ require_relative 'header_kit/cookie'
7
12
  require_relative 'header_kit/link'
8
13
  require_relative 'header_kit/negotiation'
14
+ require_relative 'header_kit/cors'
15
+ require_relative 'header_kit/security'
16
+ require_relative 'header_kit/forwarded'
9
17
 
10
18
  module Philiprehberger
11
19
  module HeaderKit
@@ -19,6 +27,47 @@ module Philiprehberger
19
27
  Accept.parse(header)
20
28
  end
21
29
 
30
+ # Build an Accept header string from an array of type hashes.
31
+ #
32
+ # @param types [Array<Hash>] each with :type and optional :quality
33
+ # @return [String] formatted Accept header value
34
+ def self.build_accept(types)
35
+ AcceptBuilder.build(types)
36
+ end
37
+
38
+ # Parse an Accept-Encoding header into structured entries sorted by quality.
39
+ #
40
+ # @param header [String] the Accept-Encoding header value
41
+ # @return [Array<Hash>] entries with :encoding, :quality keys
42
+ def self.parse_accept_encoding(header)
43
+ AcceptEncoding.parse(header)
44
+ end
45
+
46
+ # Parse an Accept-Language header into structured entries sorted by quality.
47
+ #
48
+ # @param header [String] the Accept-Language header value
49
+ # @return [Array<Hash>] entries with :language, :quality keys
50
+ def self.parse_accept_language(header)
51
+ AcceptLanguage.parse(header)
52
+ end
53
+
54
+ # Negotiate the best language from an Accept-Language header.
55
+ #
56
+ # @param header [String] the Accept-Language header value
57
+ # @param available [Array<String>] list of available language tags
58
+ # @return [String, nil] the best matching language, or nil if no match
59
+ def self.negotiate_language(header, available)
60
+ AcceptLanguage.negotiate(header, available)
61
+ end
62
+
63
+ # Parse an Authorization header into its components.
64
+ #
65
+ # @param header [String] the Authorization header value
66
+ # @return [Hash] with :scheme and :credentials or :params
67
+ def self.parse_authorization(header)
68
+ Authorization.parse(header)
69
+ end
70
+
22
71
  # Parse a Cache-Control header into a directive hash.
23
72
  #
24
73
  # @param header [String] the Cache-Control header value
@@ -43,6 +92,24 @@ module Philiprehberger
43
92
  ContentType.parse(header)
44
93
  end
45
94
 
95
+ # Parse a Cookie header into a name-value hash.
96
+ #
97
+ # @param header [String] the Cookie header value
98
+ # @return [Hash{String => String}] cookie name-value pairs
99
+ def self.parse_cookie(header)
100
+ Cookie.parse(header)
101
+ end
102
+
103
+ # Build a Set-Cookie header string.
104
+ #
105
+ # @param name [String] cookie name
106
+ # @param value [String] cookie value
107
+ # @param options [Hash] optional attributes (expires:, max_age:, secure:, httponly:, samesite:, path:, domain:)
108
+ # @return [String] formatted Set-Cookie header value
109
+ def self.build_set_cookie(name, value, **options)
110
+ Cookie.build_set_cookie(name, value, **options)
111
+ end
112
+
46
113
  # Parse a Link header into an array of link entries.
47
114
  #
48
115
  # @param header [String] the Link header value
@@ -67,5 +134,45 @@ module Philiprehberger
67
134
  def self.negotiate(accept_header, available)
68
135
  Negotiation.negotiate(accept_header, available)
69
136
  end
137
+
138
+ # Parse CORS-related headers from a request.
139
+ #
140
+ # @param headers [Hash] request headers hash
141
+ # @return [Hash] with :origin, :method, :headers keys
142
+ def self.parse_cors(headers)
143
+ Cors.parse(headers)
144
+ end
145
+
146
+ # Build CORS response headers.
147
+ #
148
+ # @param options [Hash] CORS options (origin:, methods:, headers:, max_age:, credentials:, expose:)
149
+ # @return [Hash{String => String}] response headers
150
+ def self.build_cors(**options)
151
+ Cors.build(**options)
152
+ end
153
+
154
+ # Generate recommended security response headers.
155
+ #
156
+ # @param options [Hash] overrides (hsts:, csp:, frame:, content_type_options:, referrer_policy:)
157
+ # @return [Hash{String => String}] security headers
158
+ def self.security_headers(**options)
159
+ Security.headers(**options)
160
+ end
161
+
162
+ # Parse an RFC 7239 Forwarded header.
163
+ #
164
+ # @param header [String] the Forwarded header value
165
+ # @return [Array<Hash>] parsed entries with symbol keys
166
+ def self.parse_forwarded(header)
167
+ Forwarded.parse(header)
168
+ end
169
+
170
+ # Parse a Via header into structured entries.
171
+ #
172
+ # @param header [String] the Via header value
173
+ # @return [Array<Hash>] entries with :protocol, :host, :comment keys
174
+ def self.parse_via(header)
175
+ Forwarded.parse_via(header)
176
+ end
70
177
  end
71
178
  end
metadata CHANGED
@@ -1,17 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-header_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-03-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Parse and build Accept, Cache-Control, Content-Type, and Link HTTP headers.
14
- Includes content negotiation for selecting the best response type.
13
+ description: Parse and build Accept, Accept-Language, Accept-Encoding, Authorization,
14
+ Cache-Control, Content-Type, Cookie, Link, CORS, Forwarded, and Via HTTP headers.
15
+ Includes content negotiation and security header generation.
15
16
  email:
16
17
  - me@philiprehberger.com
17
18
  executables: []
@@ -23,16 +24,24 @@ files:
23
24
  - README.md
24
25
  - lib/philiprehberger/header_kit.rb
25
26
  - lib/philiprehberger/header_kit/accept.rb
27
+ - lib/philiprehberger/header_kit/accept_builder.rb
28
+ - lib/philiprehberger/header_kit/accept_encoding.rb
29
+ - lib/philiprehberger/header_kit/accept_language.rb
30
+ - lib/philiprehberger/header_kit/authorization.rb
26
31
  - lib/philiprehberger/header_kit/cache_control.rb
27
32
  - lib/philiprehberger/header_kit/content_type.rb
33
+ - lib/philiprehberger/header_kit/cookie.rb
34
+ - lib/philiprehberger/header_kit/cors.rb
35
+ - lib/philiprehberger/header_kit/forwarded.rb
28
36
  - lib/philiprehberger/header_kit/link.rb
29
37
  - lib/philiprehberger/header_kit/negotiation.rb
38
+ - lib/philiprehberger/header_kit/security.rb
30
39
  - lib/philiprehberger/header_kit/version.rb
31
- homepage: https://github.com/philiprehberger/rb-header-kit
40
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-header_kit
32
41
  licenses:
33
42
  - MIT
34
43
  metadata:
35
- homepage_uri: https://github.com/philiprehberger/rb-header-kit
44
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-header_kit
36
45
  source_code_uri: https://github.com/philiprehberger/rb-header-kit
37
46
  changelog_uri: https://github.com/philiprehberger/rb-header-kit/blob/main/CHANGELOG.md
38
47
  bug_tracker_uri: https://github.com/philiprehberger/rb-header-kit/issues