philiprehberger-header_kit 0.4.0 → 0.6.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: 7515c78405c89a2690d8a4fc48b802bdf9c5b79dd9deae14fe07de8a8673d2df
4
- data.tar.gz: ca05991394f985b4b5b0148a6db2c826bc9101eb04787b6f6adf9c4d2839ab1b
3
+ metadata.gz: 255a0f1f4321e63ae82d6acaf028cddb9a041d4f1594b16dcbb8f2a51f54a286
4
+ data.tar.gz: 1a0ea312d1937e9e17c7cabe31f7822345d0de02f4627d09c597c5a71a5b7715
5
5
  SHA512:
6
- metadata.gz: 02ce3113575c5c98db4e4b010138861a983488db0e888b09644dfe6162b48af2864190b815f82d33d1e0b6cbf5855eed176b1ec6bd30fdd73966f7be59b538b1
7
- data.tar.gz: 387dd665c493e2d308e3cb76581733691d231d2160e3c6ad6cee724a818470f3fb31e089dd607206680738508b204f5630c0c48fae4f1c05ed89ed97c2bd4057
6
+ metadata.gz: ee48374d55f01e11ae5b499c31556037801082a6631ed60306d544109897715bf5510eff7403675e2e94795257903efe289469bd790181cc2c32fef70e65a975
7
+ data.tar.gz: 40cf00b3c4d3d9f88d50ab21f08fc2dfb2d75b9ee13e879f985d59b28a14e81c600376d13d8f13f9bafb4edd3ff9350d6c4f6d5766abf20e9dd94bd2fd9e706c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-04-23
11
+
12
+ ### Added
13
+ - `HeaderKit.build_accept_encoding(encodings)` for constructing Accept-Encoding headers from an array of `{encoding:, quality:}` hashes, mirroring the existing `build_accept` builder
14
+
15
+ ## [0.5.0] - 2026-04-17
16
+
17
+ ### Added
18
+ - `HeaderKit.etag_match?(header_value, resource_etag)` for If-None-Match / If-Match evaluation with list, weak-prefix, and `*` wildcard support
19
+
10
20
  ## [0.4.0] - 2026-04-04
11
21
 
12
22
  ### Added
data/README.md CHANGED
@@ -66,6 +66,17 @@ Philiprehberger::HeaderKit.parse_accept_encoding("gzip, deflate;q=0.5, br;q=0.8"
66
66
  # => [{encoding: "gzip", quality: 1.0}, {encoding: "br", quality: 0.8}, {encoding: "deflate", quality: 0.5}]
67
67
  ```
68
68
 
69
+ ### Build Accept-Encoding
70
+
71
+ ```ruby
72
+ Philiprehberger::HeaderKit.build_accept_encoding([
73
+ {encoding: "gzip"},
74
+ {encoding: "br", quality: 0.9},
75
+ {encoding: "deflate", quality: 0.5}
76
+ ])
77
+ # => "gzip, br;q=0.9, deflate;q=0.5"
78
+ ```
79
+
69
80
  ### Parse Authorization
70
81
 
71
82
  ```ruby
@@ -181,6 +192,22 @@ Philiprehberger::HeaderKit.parse_via('1.1 vegur, 1.0 fred')
181
192
  # => [{protocol: "1.1", host: "vegur"}, {protocol: "1.0", host: "fred"}]
182
193
  ```
183
194
 
195
+ ### Conditional-request matching
196
+
197
+ ```ruby
198
+ Philiprehberger::HeaderKit.etag_match?('"abc"', 'abc')
199
+ # => true
200
+
201
+ Philiprehberger::HeaderKit.etag_match?('W/"abc", W/"xyz"', 'xyz')
202
+ # => true
203
+
204
+ Philiprehberger::HeaderKit.etag_match?('*', 'anything')
205
+ # => true
206
+
207
+ Philiprehberger::HeaderKit.etag_match?('"abc"', 'nope')
208
+ # => false
209
+ ```
210
+
184
211
  ### Parse Retry-After
185
212
 
186
213
  ```ruby
@@ -200,6 +227,7 @@ Philiprehberger::HeaderKit.parse_retry_after("Fri, 04 Apr 2026 12:00:00 GMT")
200
227
  | `HeaderKit.parse_accept_language(header)` | Parse Accept-Language into sorted entries |
201
228
  | `HeaderKit.negotiate_language(header, available)` | Language negotiation, returns best match or nil |
202
229
  | `HeaderKit.parse_accept_encoding(header)` | Parse Accept-Encoding into sorted entries |
230
+ | `HeaderKit.build_accept_encoding(encodings)` | Build Accept-Encoding header string from encoding array |
203
231
  | `HeaderKit.parse_authorization(header)` | Parse Authorization header (Bearer, Basic, Digest) |
204
232
  | `HeaderKit.parse_cache_control(header)` | Parse Cache-Control into directive hash |
205
233
  | `HeaderKit.build_cache_control(directives)` | Build Cache-Control string from hash |
@@ -215,6 +243,7 @@ Philiprehberger::HeaderKit.parse_retry_after("Fri, 04 Apr 2026 12:00:00 GMT")
215
243
  | `HeaderKit.parse_forwarded(header)` | Parse RFC 7239 Forwarded header |
216
244
  | `HeaderKit.parse_via(header)` | Parse Via header into structured entries |
217
245
  | `HeaderKit.parse_retry_after(header)` | Parse Retry-After header (seconds or HTTP date) |
246
+ | `HeaderKit.etag_match?(header_value, resource_etag)` | Check If-None-Match / If-Match against a resource ETag (list, `W/` weak prefix, `*` wildcard) |
218
247
 
219
248
  ## Development
220
249
 
@@ -50,7 +50,37 @@ module Philiprehberger
50
50
  value.to_f.clamp(0.0, 1.0)
51
51
  end
52
52
 
53
- private_class_method :parse_entry, :parse_quality
53
+ # Build an Accept-Encoding header string from an array of encoding hashes.
54
+ #
55
+ # @param encodings [Array<Hash>] each with :encoding and optional :quality
56
+ # @return [String] formatted Accept-Encoding header value
57
+ def self.build(encodings)
58
+ return '' if encodings.nil? || encodings.empty?
59
+
60
+ parts = encodings.map do |entry|
61
+ encoding = entry[:encoding]
62
+ quality = entry[:quality]
63
+
64
+ if quality && quality < 1.0
65
+ "#{encoding};q=#{format_quality(quality)}"
66
+ else
67
+ encoding
68
+ end
69
+ end
70
+
71
+ parts.join(', ')
72
+ end
73
+
74
+ # Format a quality value, removing trailing zeros.
75
+ #
76
+ # @param quality [Float] the quality value
77
+ # @return [String] formatted quality string
78
+ def self.format_quality(quality)
79
+ formatted = format('%.3f', quality)
80
+ formatted.sub(/0+\z/, '').sub(/\.\z/, '.0')
81
+ end
82
+
83
+ private_class_method :parse_entry, :parse_quality, :format_quality
54
84
  end
55
85
  end
56
86
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Evaluates If-None-Match / If-Match style header values against a
6
+ # resource ETag.
7
+ #
8
+ # Accepts single ETags, comma-separated lists, weak-prefixed values
9
+ # (e.g. `W/"abc"`), and the `*` wildcard. Strong and weak semantics
10
+ # collapse to equality on the inner token — callers that need strict
11
+ # strong/weak differentiation should implement that separately.
12
+ module Etag
13
+ module_function
14
+
15
+ # Check whether a header value matches a resource ETag.
16
+ #
17
+ # @param header_value [String, nil] the raw If-None-Match / If-Match header
18
+ # @param resource_etag [String, nil] the resource ETag (unquoted, no `W/` prefix)
19
+ # @return [Boolean] true if any value in the header matches the resource
20
+ def match?(header_value, resource_etag)
21
+ return false if header_value.nil?
22
+
23
+ stripped = header_value.strip
24
+ return false if stripped.empty?
25
+
26
+ tokens = split_values(stripped)
27
+ return false if tokens.empty?
28
+
29
+ tokens.any? { |token| token_matches?(token, resource_etag) }
30
+ end
31
+
32
+ # Split a header value into individual ETag tokens.
33
+ #
34
+ # Quoted sections (including escaped characters) are preserved so commas
35
+ # inside a quoted ETag do not split the value.
36
+ #
37
+ # @param value [String] the header value to split
38
+ # @return [Array<String>] trimmed, non-empty tokens
39
+ def split_values(value)
40
+ tokens = []
41
+ buffer = +''
42
+ in_quotes = false
43
+ escaped = false
44
+
45
+ value.each_char do |char|
46
+ if escaped
47
+ buffer << char
48
+ escaped = false
49
+ elsif in_quotes && char == '\\'
50
+ buffer << char
51
+ escaped = true
52
+ elsif char == '"'
53
+ buffer << char
54
+ in_quotes = !in_quotes
55
+ elsif char == ',' && !in_quotes
56
+ tokens << buffer.strip unless buffer.strip.empty?
57
+ buffer = +''
58
+ else
59
+ buffer << char
60
+ end
61
+ end
62
+
63
+ tokens << buffer.strip unless buffer.strip.empty?
64
+ tokens
65
+ end
66
+
67
+ # Determine whether a single token matches the resource ETag.
68
+ #
69
+ # @param token [String] a single header token
70
+ # @param resource_etag [String, nil] the resource ETag
71
+ # @return [Boolean] true if the token matches
72
+ def token_matches?(token, resource_etag)
73
+ return !resource_etag.nil? if token == '*'
74
+ return false if resource_etag.nil?
75
+
76
+ unwrap(token) == resource_etag
77
+ end
78
+
79
+ # Strip weak prefix and surrounding quotes from an ETag token.
80
+ #
81
+ # Backslash-escaped characters inside the quoted value are unescaped.
82
+ #
83
+ # @param token [String] a single ETag token
84
+ # @return [String] the inner ETag value
85
+ def unwrap(token)
86
+ value = token.sub(%r{\AW/}i, '')
87
+
88
+ return value unless value.start_with?('"') && value.end_with?('"') && value.length >= 2
89
+
90
+ inner = value[1..-2]
91
+ inner.gsub(/\\(.)/, '\1')
92
+ end
93
+ end
94
+ end
95
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module HeaderKit
5
- VERSION = '0.4.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ require_relative 'header_kit/authorization'
9
9
  require_relative 'header_kit/cache_control'
10
10
  require_relative 'header_kit/content_type'
11
11
  require_relative 'header_kit/cookie'
12
+ require_relative 'header_kit/etag'
12
13
  require_relative 'header_kit/link'
13
14
  require_relative 'header_kit/negotiation'
14
15
  require_relative 'header_kit/cors'
@@ -44,6 +45,14 @@ module Philiprehberger
44
45
  AcceptEncoding.parse(header)
45
46
  end
46
47
 
48
+ # Build an Accept-Encoding header string from an array of encoding hashes.
49
+ #
50
+ # @param encodings [Array<Hash>] each with :encoding and optional :quality
51
+ # @return [String] formatted Accept-Encoding header value
52
+ def self.build_accept_encoding(encodings)
53
+ AcceptEncoding.build(encodings)
54
+ end
55
+
47
56
  # Parse an Accept-Language header into structured entries sorted by quality.
48
57
  #
49
58
  # @param header [String] the Accept-Language header value
@@ -176,6 +185,19 @@ module Philiprehberger
176
185
  Forwarded.parse_via(header)
177
186
  end
178
187
 
188
+ # Check whether an If-None-Match / If-Match header matches a resource ETag.
189
+ #
190
+ # Accepts a single ETag, comma-separated list, weak-prefixed values
191
+ # (e.g. `W/"abc"`), or the `*` wildcard. Weak and strong ETags collapse
192
+ # to equality on the inner token.
193
+ #
194
+ # @param header_value [String, nil] the raw header value
195
+ # @param resource_etag [String, nil] the resource ETag (unquoted, no `W/` prefix)
196
+ # @return [Boolean] true if any value in the header matches the resource
197
+ def self.etag_match?(header_value, resource_etag)
198
+ Etag.match?(header_value, resource_etag)
199
+ end
200
+
179
201
  # Parse a Retry-After header.
180
202
  #
181
203
  # Returns a hash with :seconds (Integer) for numeric values,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-header_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.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-04-05 00:00:00.000000000 Z
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Parse and build Accept, Accept-Language, Accept-Encoding, Authorization,
14
14
  Cache-Control, Content-Type, Cookie, Link, CORS, Forwarded, and Via HTTP headers.
@@ -32,6 +32,7 @@ files:
32
32
  - lib/philiprehberger/header_kit/content_type.rb
33
33
  - lib/philiprehberger/header_kit/cookie.rb
34
34
  - lib/philiprehberger/header_kit/cors.rb
35
+ - lib/philiprehberger/header_kit/etag.rb
35
36
  - lib/philiprehberger/header_kit/forwarded.rb
36
37
  - lib/philiprehberger/header_kit/link.rb
37
38
  - lib/philiprehberger/header_kit/negotiation.rb