philiprehberger-header_kit 0.5.0 → 0.7.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: d06951b5686f64b328c573fc16904e84eddfc61424ab8491fc2da817c7241fca
4
- data.tar.gz: 9bdc24a4fc3dabc8d34ccdc105564e5669e9024ada60869c09317fdc11f1c1d6
3
+ metadata.gz: 8a240cb6bc501c7737d126492c7ef3d97eeb12dfde706a9223eb4e4021f0ded1
4
+ data.tar.gz: 1eda330a4c801573f6a96fa909562859e8636d5f015905a97cc298afad22cc8b
5
5
  SHA512:
6
- metadata.gz: 82e06b0e3e146393f17782091fd76c5813be7c46df09c6366d9e892d40a4d05e652655b0017c2cd8242a8812961b428f853cb771e74721e08c46b778911dd124
7
- data.tar.gz: f9970a177aa61fa120ea97eceeeda50f19d9517c143b2d91c4bce924a26466c13de2ca88f2a5e00375cadebae0cf2f804436ace824ac0fa3a53d00dbf8f4efec
6
+ metadata.gz: aa7df933af28801ee3819f973a69b8a4bc95fc6bb1129bc15821ca9454df21836e57874ecab24a7ff44eabd4618d3439dfb5e1fd9c8a83b1a459af80c0bfd0c8
7
+ data.tar.gz: 1583acd2a93ff399eaa4a559e86551b43788363305eb9f43066fe97eef353d012b575034416c2b09d5e950943e20d307d033c7595d9e7601868e43ff3c31ceb5
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.7.0] - 2026-05-08
11
+
12
+ ### Added
13
+ - `HeaderKit.parse_range(header)` — parses an RFC 7233 Range header into `{ unit:, ranges: [{ first:, last: }, ...] }`. Supports byte ranges, suffix ranges (`-N`), and open-ended ranges (`N-`). Returns `nil` for invalid input.
14
+ - `HeaderKit.build_range(unit, ranges)` — builds a Range header value from an array of `{ first:, last: }` hashes, `[first, last]` arrays, or Ruby `Range` objects (inclusive/exclusive both supported)
15
+
16
+ ## [0.6.0] - 2026-04-23
17
+
18
+ ### Added
19
+ - `HeaderKit.build_accept_encoding(encodings)` for constructing Accept-Encoding headers from an array of `{encoding:, quality:}` hashes, mirroring the existing `build_accept` builder
20
+
10
21
  ## [0.5.0] - 2026-04-17
11
22
 
12
23
  ### 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
@@ -207,6 +218,23 @@ Philiprehberger::HeaderKit.parse_retry_after("Fri, 04 Apr 2026 12:00:00 GMT")
207
218
  # => {date: 2026-04-04 12:00:00 UTC}
208
219
  ```
209
220
 
221
+ ### Parse and Build Range
222
+
223
+ ```ruby
224
+ Philiprehberger::HeaderKit.parse_range("bytes=0-499")
225
+ # => { unit: "bytes", ranges: [{ first: 0, last: 499 }] }
226
+
227
+ Philiprehberger::HeaderKit.parse_range("bytes=0-99, 200-, -50")
228
+ # => { unit: "bytes", ranges: [
229
+ # { first: 0, last: 99 },
230
+ # { first: 200, last: nil },
231
+ # { first: nil, last: 50 }
232
+ # ] }
233
+
234
+ Philiprehberger::HeaderKit.build_range("bytes", [(0..99), { first: 200, last: nil }])
235
+ # => "bytes=0-99, 200-"
236
+ ```
237
+
210
238
  ## API
211
239
 
212
240
  | Method | Description |
@@ -216,6 +244,7 @@ Philiprehberger::HeaderKit.parse_retry_after("Fri, 04 Apr 2026 12:00:00 GMT")
216
244
  | `HeaderKit.parse_accept_language(header)` | Parse Accept-Language into sorted entries |
217
245
  | `HeaderKit.negotiate_language(header, available)` | Language negotiation, returns best match or nil |
218
246
  | `HeaderKit.parse_accept_encoding(header)` | Parse Accept-Encoding into sorted entries |
247
+ | `HeaderKit.build_accept_encoding(encodings)` | Build Accept-Encoding header string from encoding array |
219
248
  | `HeaderKit.parse_authorization(header)` | Parse Authorization header (Bearer, Basic, Digest) |
220
249
  | `HeaderKit.parse_cache_control(header)` | Parse Cache-Control into directive hash |
221
250
  | `HeaderKit.build_cache_control(directives)` | Build Cache-Control string from hash |
@@ -231,6 +260,8 @@ Philiprehberger::HeaderKit.parse_retry_after("Fri, 04 Apr 2026 12:00:00 GMT")
231
260
  | `HeaderKit.parse_forwarded(header)` | Parse RFC 7239 Forwarded header |
232
261
  | `HeaderKit.parse_via(header)` | Parse Via header into structured entries |
233
262
  | `HeaderKit.parse_retry_after(header)` | Parse Retry-After header (seconds or HTTP date) |
263
+ | `HeaderKit.parse_range(header)` | Parse Range header into `{ unit:, ranges: }` (returns nil for invalid input) |
264
+ | `HeaderKit.build_range(unit, ranges)` | Build a Range header from hashes, arrays, or Ruby Ranges |
234
265
  | `HeaderKit.etag_match?(header_value, resource_etag)` | Check If-None-Match / If-Match against a resource ETag (list, `W/` weak prefix, `*` wildcard) |
235
266
 
236
267
  ## Development
@@ -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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ # Parses and builds the HTTP Range request header (RFC 7233 §3.1).
6
+ #
7
+ # Format: `<unit>=<start>-<end>[, <start>-<end>]*`
8
+ # Supports byte ranges, suffix ranges (`-N`), and open-ended ranges (`N-`).
9
+ module Range
10
+ module_function
11
+
12
+ # Parse a Range header value.
13
+ #
14
+ # @param header [String, nil] the raw header value
15
+ # @return [Hash{Symbol => Object}, nil] hash with :unit (String) and :ranges
16
+ # (Array of Hash with :first/:last keys, where either may be nil for
17
+ # open-ended), or nil for nil/blank/invalid input
18
+ # @example
19
+ # parse('bytes=0-499') # => { unit: 'bytes', ranges: [{ first: 0, last: 499 }] }
20
+ # parse('bytes=500-') # => { unit: 'bytes', ranges: [{ first: 500, last: nil }] }
21
+ # parse('bytes=-500') # => { unit: 'bytes', ranges: [{ first: nil, last: 500 }] }
22
+ # parse('bytes=0-99, 200-') # => { unit: 'bytes', ranges: [{ first: 0, last: 99 }, { first: 200, last: nil }] }
23
+ def parse(header)
24
+ return nil if header.nil?
25
+
26
+ header = header.to_s.strip
27
+ return nil if header.empty?
28
+
29
+ unit, _, spec = header.partition('=')
30
+ unit = unit.strip
31
+ spec = spec.strip
32
+ return nil if unit.empty? || spec.empty?
33
+
34
+ ranges = spec.split(',').map { |part| parse_range(part.strip) }
35
+ return nil if ranges.any?(&:nil?)
36
+
37
+ { unit: unit, ranges: ranges }
38
+ end
39
+
40
+ # Build a Range header value.
41
+ #
42
+ # @param unit [String] the range unit (e.g. 'bytes')
43
+ # @param ranges [Array<Hash, Array, Range>] each as `{ first:, last: }`,
44
+ # a `[first, last]` array, or a Ruby `Range` (inclusive)
45
+ # @return [String] formatted Range header
46
+ # @example
47
+ # build('bytes', [{ first: 0, last: 499 }]) # => "bytes=0-499"
48
+ # build('bytes', [[0, 499], [500, nil]]) # => "bytes=0-499, 500-"
49
+ # build('bytes', [(0..99), { first: nil, last: 50 }]) # => "bytes=0-99, -50"
50
+ def build(unit, ranges)
51
+ list = ranges.is_a?(Array) ? ranges : [ranges]
52
+ formatted = list.map { |r| format_range(r) }
53
+ "#{unit}=#{formatted.join(', ')}"
54
+ end
55
+
56
+ def parse_range(raw)
57
+ return nil unless raw.match?(/\A-?\d*-\d*\z/)
58
+ return nil if raw == '-'
59
+
60
+ first_str, _, last_str = raw.partition('-')
61
+ first = first_str.empty? ? nil : Integer(first_str, 10)
62
+ last = last_str.empty? ? nil : Integer(last_str, 10)
63
+ return nil if first.nil? && last.nil?
64
+ return nil if first && last && first > last
65
+
66
+ { first: first, last: last }
67
+ rescue ArgumentError
68
+ nil
69
+ end
70
+ private_class_method :parse_range
71
+
72
+ def format_range(spec)
73
+ first, last =
74
+ case spec
75
+ when ::Range then [spec.first, spec.exclude_end? ? spec.last - 1 : spec.last]
76
+ when Array then [spec[0], spec[1]]
77
+ when Hash then [spec[:first] || spec['first'], spec[:last] || spec['last']]
78
+ else raise ArgumentError, "Unsupported range entry: #{spec.inspect}"
79
+ end
80
+
81
+ "#{first}-#{last}"
82
+ end
83
+ private_class_method :format_range
84
+ end
85
+ end
86
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module HeaderKit
5
- VERSION = '0.5.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
@@ -15,6 +15,7 @@ require_relative 'header_kit/negotiation'
15
15
  require_relative 'header_kit/cors'
16
16
  require_relative 'header_kit/security'
17
17
  require_relative 'header_kit/forwarded'
18
+ require_relative 'header_kit/range'
18
19
  require_relative 'header_kit/retry_after'
19
20
 
20
21
  module Philiprehberger
@@ -45,6 +46,14 @@ module Philiprehberger
45
46
  AcceptEncoding.parse(header)
46
47
  end
47
48
 
49
+ # Build an Accept-Encoding header string from an array of encoding hashes.
50
+ #
51
+ # @param encodings [Array<Hash>] each with :encoding and optional :quality
52
+ # @return [String] formatted Accept-Encoding header value
53
+ def self.build_accept_encoding(encodings)
54
+ AcceptEncoding.build(encodings)
55
+ end
56
+
48
57
  # Parse an Accept-Language header into structured entries sorted by quality.
49
58
  #
50
59
  # @param header [String] the Accept-Language header value
@@ -190,6 +199,23 @@ module Philiprehberger
190
199
  Etag.match?(header_value, resource_etag)
191
200
  end
192
201
 
202
+ # Parse a Range request header (RFC 7233 §3.1).
203
+ #
204
+ # @param header [String] the Range header value
205
+ # @return [Hash{Symbol => Object}, nil] hash with :unit and :ranges, or nil for invalid input
206
+ def self.parse_range(header)
207
+ Range.parse(header)
208
+ end
209
+
210
+ # Build a Range request header value.
211
+ #
212
+ # @param unit [String] the range unit (e.g. 'bytes')
213
+ # @param ranges [Array<Hash, Array, ::Range>] each as `{ first:, last: }`, `[first, last]`, or `::Range`
214
+ # @return [String] formatted Range header
215
+ def self.build_range(unit, ranges)
216
+ Range.build(unit, ranges)
217
+ end
218
+
193
219
  # Parse a Retry-After header.
194
220
  #
195
221
  # 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.5.0
4
+ version: 0.7.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-18 00:00:00.000000000 Z
11
+ date: 2026-05-08 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.
@@ -36,6 +36,7 @@ files:
36
36
  - lib/philiprehberger/header_kit/forwarded.rb
37
37
  - lib/philiprehberger/header_kit/link.rb
38
38
  - lib/philiprehberger/header_kit/negotiation.rb
39
+ - lib/philiprehberger/header_kit/range.rb
39
40
  - lib/philiprehberger/header_kit/retry_after.rb
40
41
  - lib/philiprehberger/header_kit/security.rb
41
42
  - lib/philiprehberger/header_kit/version.rb