philiprehberger-header_kit 0.3.1 → 0.5.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: b397e499d587d20070ad785372aa81dc6574f583de2663cb105598ba229f3b52
4
- data.tar.gz: cf8de787ddaa0f5cf2c95efa89f17e8db64d0368262bdd691b29c029fc0587fe
3
+ metadata.gz: d06951b5686f64b328c573fc16904e84eddfc61424ab8491fc2da817c7241fca
4
+ data.tar.gz: 9bdc24a4fc3dabc8d34ccdc105564e5669e9024ada60869c09317fdc11f1c1d6
5
5
  SHA512:
6
- metadata.gz: 600588c8096d75b8b8559a2aa0d9477025a7e02706e3906f7c57297999f303083078f8dd7dfe75c70a56a2ddb6e424d88c69e94b11cfee8a9038eb1c1e7bc275
7
- data.tar.gz: 9984feba85757ee07365321359798fe91a87c0f44902c65c81f52bb1885683c0971ea78590eee5db427e952afe72bbc1dceee4eba39478ca90e24e04622bcb97
6
+ metadata.gz: 82e06b0e3e146393f17782091fd76c5813be7c46df09c6366d9e892d40a4d05e652655b0017c2cd8242a8812961b428f853cb771e74721e08c46b778911dd124
7
+ data.tar.gz: f9970a177aa61fa120ea97eceeeda50f19d9517c143b2d91c4bce924a26466c13de2ca88f2a5e00375cadebae0cf2f804436ace824ac0fa3a53d00dbf8f4efec
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-04-17
11
+
12
+ ### Added
13
+ - `HeaderKit.etag_match?(header_value, resource_etag)` for If-None-Match / If-Match evaluation with list, weak-prefix, and `*` wildcard support
14
+
15
+ ## [0.4.0] - 2026-04-04
16
+
17
+ ### Added
18
+
19
+ - `parse_retry_after` for parsing Retry-After headers (numeric seconds or HTTP date)
20
+
10
21
  ## [0.3.1] - 2026-03-31
11
22
 
12
23
  ### Changed
data/README.md CHANGED
@@ -181,6 +181,32 @@ Philiprehberger::HeaderKit.parse_via('1.1 vegur, 1.0 fred')
181
181
  # => [{protocol: "1.1", host: "vegur"}, {protocol: "1.0", host: "fred"}]
182
182
  ```
183
183
 
184
+ ### Conditional-request matching
185
+
186
+ ```ruby
187
+ Philiprehberger::HeaderKit.etag_match?('"abc"', 'abc')
188
+ # => true
189
+
190
+ Philiprehberger::HeaderKit.etag_match?('W/"abc", W/"xyz"', 'xyz')
191
+ # => true
192
+
193
+ Philiprehberger::HeaderKit.etag_match?('*', 'anything')
194
+ # => true
195
+
196
+ Philiprehberger::HeaderKit.etag_match?('"abc"', 'nope')
197
+ # => false
198
+ ```
199
+
200
+ ### Parse Retry-After
201
+
202
+ ```ruby
203
+ Philiprehberger::HeaderKit.parse_retry_after("120")
204
+ # => {seconds: 120}
205
+
206
+ Philiprehberger::HeaderKit.parse_retry_after("Fri, 04 Apr 2026 12:00:00 GMT")
207
+ # => {date: 2026-04-04 12:00:00 UTC}
208
+ ```
209
+
184
210
  ## API
185
211
 
186
212
  | Method | Description |
@@ -204,6 +230,8 @@ Philiprehberger::HeaderKit.parse_via('1.1 vegur, 1.0 fred')
204
230
  | `HeaderKit.security_headers(**options)` | Generate recommended security headers |
205
231
  | `HeaderKit.parse_forwarded(header)` | Parse RFC 7239 Forwarded header |
206
232
  | `HeaderKit.parse_via(header)` | Parse Via header into structured entries |
233
+ | `HeaderKit.parse_retry_after(header)` | Parse Retry-After header (seconds or HTTP date) |
234
+ | `HeaderKit.etag_match?(header_value, resource_etag)` | Check If-None-Match / If-Match against a resource ETag (list, `W/` weak prefix, `*` wildcard) |
207
235
 
208
236
  ## Development
209
237
 
@@ -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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Philiprehberger
6
+ module HeaderKit
7
+ module RetryAfter
8
+ module_function
9
+
10
+ def parse(header)
11
+ return nil if header.nil? || header.empty?
12
+
13
+ if header.match?(/\A\d+\z/)
14
+ { seconds: header.to_i }
15
+ else
16
+ { date: Time.httpdate(header) }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module HeaderKit
5
- VERSION = '0.3.1'
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end
@@ -9,11 +9,13 @@ 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'
15
16
  require_relative 'header_kit/security'
16
17
  require_relative 'header_kit/forwarded'
18
+ require_relative 'header_kit/retry_after'
17
19
 
18
20
  module Philiprehberger
19
21
  module HeaderKit
@@ -174,5 +176,29 @@ module Philiprehberger
174
176
  def self.parse_via(header)
175
177
  Forwarded.parse_via(header)
176
178
  end
179
+
180
+ # Check whether an If-None-Match / If-Match header matches a resource ETag.
181
+ #
182
+ # Accepts a single ETag, comma-separated list, weak-prefixed values
183
+ # (e.g. `W/"abc"`), or the `*` wildcard. Weak and strong ETags collapse
184
+ # to equality on the inner token.
185
+ #
186
+ # @param header_value [String, nil] the raw header value
187
+ # @param resource_etag [String, nil] the resource ETag (unquoted, no `W/` prefix)
188
+ # @return [Boolean] true if any value in the header matches the resource
189
+ def self.etag_match?(header_value, resource_etag)
190
+ Etag.match?(header_value, resource_etag)
191
+ end
192
+
193
+ # Parse a Retry-After header.
194
+ #
195
+ # Returns a hash with :seconds (Integer) for numeric values,
196
+ # or :date (Time) for HTTP date values. Returns nil for nil/empty input.
197
+ #
198
+ # @param header [String] the Retry-After header value
199
+ # @return [Hash, nil] hash with :seconds or :date key, or nil
200
+ def self.parse_retry_after(header)
201
+ RetryAfter.parse(header)
202
+ end
177
203
  end
178
204
  end
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.3.1
4
+ version: 0.5.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-31 00:00:00.000000000 Z
11
+ date: 2026-04-18 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,9 +32,11 @@ 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
39
+ - lib/philiprehberger/header_kit/retry_after.rb
38
40
  - lib/philiprehberger/header_kit/security.rb
39
41
  - lib/philiprehberger/header_kit/version.rb
40
42
  homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-header_kit