philiprehberger-header_kit 0.1.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/lib/philiprehberger/header_kit/accept.rb +65 -0
- data/lib/philiprehberger/header_kit/cache_control.rb +59 -0
- data/lib/philiprehberger/header_kit/content_type.rb +53 -0
- data/lib/philiprehberger/header_kit/link.rb +93 -0
- data/lib/philiprehberger/header_kit/negotiation.rb +69 -0
- data/lib/philiprehberger/header_kit/version.rb +7 -0
- data/lib/philiprehberger/header_kit.rb +71 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '0483e3f7557d6bc4a5d455fa7fcfbee3808118f120f17427f73c1b763cdfa4dd'
|
|
4
|
+
data.tar.gz: f2834acace689c8b78da5e7af771348efa97918bf7eb71434e6317708ef9da62
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cbc23f4ecb68a3c80815a21f09de5156ccef17e9d9cc15a2d420409209f06119491d059ccfc9d3de26f268029cb78b910db12d88c56cf0918a14dea45d11f75e
|
|
7
|
+
data.tar.gz: 6c792d9fbff457ce2377ab95e641bce5626bb3a8ce5a01df1f0062cee51b9be05c0e9ecdc91e978938842213c5c76e9847cb521f1c91031d06f766b75be5df88
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Parse Accept headers with quality values and parameters
|
|
15
|
+
- Parse and build Cache-Control directives
|
|
16
|
+
- Parse Content-Type with charset and boundary
|
|
17
|
+
- Parse and build Link headers (RFC 8288)
|
|
18
|
+
- Content negotiation matching Accept against available types
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# philiprehberger-header_kit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/philiprehberger/rb-header-kit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/philiprehberger-header_kit)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/sponsors/philiprehberger)
|
|
7
|
+
|
|
8
|
+
HTTP header parsing, construction, and content negotiation
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Ruby >= 3.1
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add to your Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "philiprehberger-header_kit"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install directly:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem install philiprehberger-header_kit
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "philiprehberger/header_kit"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Parse Accept
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Philiprehberger::HeaderKit.parse_accept("text/html;q=0.9, application/json")
|
|
38
|
+
# => [{type: "application/json", quality: 1.0, params: {}},
|
|
39
|
+
# {type: "text/html", quality: 0.9, params: {}}]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Parse Cache-Control
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
Philiprehberger::HeaderKit.parse_cache_control("public, max-age=3600, must-revalidate")
|
|
46
|
+
# => {public: true, max_age: 3600, must_revalidate: true}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Build Cache-Control
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Philiprehberger::HeaderKit.build_cache_control(public: true, max_age: 3600)
|
|
53
|
+
# => "public, max-age=3600"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Parse Content-Type
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Philiprehberger::HeaderKit.parse_content_type("text/html; charset=utf-8")
|
|
60
|
+
# => {media_type: "text/html", charset: "utf-8", boundary: nil}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Parse Link
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Philiprehberger::HeaderKit.parse_link('<https://example.com/2>; rel="next"')
|
|
67
|
+
# => [{uri: "https://example.com/2", rel: "next", type: nil, title: nil}]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Build Link
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
Philiprehberger::HeaderKit.build_link([{uri: "https://example.com/2", rel: "next"}])
|
|
74
|
+
# => '<https://example.com/2>; rel="next"'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Content Negotiation
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Philiprehberger::HeaderKit.negotiate("text/html;q=0.9, application/json", ["text/html", "application/json"])
|
|
81
|
+
# => "application/json"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## API
|
|
85
|
+
|
|
86
|
+
| Method | Description |
|
|
87
|
+
|--------|-------------|
|
|
88
|
+
| `HeaderKit.parse_accept(header)` | Parse Accept header into sorted entries |
|
|
89
|
+
| `HeaderKit.parse_cache_control(header)` | Parse Cache-Control into directive hash |
|
|
90
|
+
| `HeaderKit.build_cache_control(directives)` | Build Cache-Control string from hash |
|
|
91
|
+
| `HeaderKit.parse_content_type(header)` | Parse Content-Type into components |
|
|
92
|
+
| `HeaderKit.parse_link(header)` | Parse Link header into entry array |
|
|
93
|
+
| `HeaderKit.build_link(links)` | Build Link header from array of hashes |
|
|
94
|
+
| `HeaderKit.negotiate(accept_header, available)` | Content negotiation, returns best match or nil |
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bundle install
|
|
100
|
+
bundle exec rspec # Run tests
|
|
101
|
+
bundle exec rubocop # Check code style
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HeaderKit
|
|
5
|
+
# Parses Accept headers into structured media type entries with quality values.
|
|
6
|
+
#
|
|
7
|
+
# Each entry contains the media type, a quality factor (0.0-1.0), and any
|
|
8
|
+
# additional parameters from the header.
|
|
9
|
+
module Accept
|
|
10
|
+
QUALITY_PATTERN = /\Aq\z/i
|
|
11
|
+
|
|
12
|
+
# Parse an Accept header string.
|
|
13
|
+
#
|
|
14
|
+
# @param header [String] the Accept header value
|
|
15
|
+
# @return [Array<Hash>] sorted by quality descending, each with :type, :quality, :params
|
|
16
|
+
def self.parse(header)
|
|
17
|
+
return [] if header.nil? || header.strip.empty?
|
|
18
|
+
|
|
19
|
+
entries = header.split(',').map { |entry| parse_entry(entry.strip) }
|
|
20
|
+
entries.compact.sort_by { |e| [-e[:quality], entries.index(e)] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parse a single media type entry from an Accept header.
|
|
24
|
+
#
|
|
25
|
+
# @param entry [String] a single media type with optional parameters
|
|
26
|
+
# @return [Hash, nil] parsed entry or nil if invalid
|
|
27
|
+
def self.parse_entry(entry)
|
|
28
|
+
return nil if entry.empty?
|
|
29
|
+
|
|
30
|
+
parts = entry.split(';').map(&:strip)
|
|
31
|
+
type = parts.shift
|
|
32
|
+
return nil if type.nil? || type.empty?
|
|
33
|
+
|
|
34
|
+
quality = 1.0
|
|
35
|
+
params = {}
|
|
36
|
+
|
|
37
|
+
parts.each do |param|
|
|
38
|
+
key, value = param.split('=', 2).map(&:strip)
|
|
39
|
+
next if key.nil? || key.empty?
|
|
40
|
+
|
|
41
|
+
if QUALITY_PATTERN.match?(key)
|
|
42
|
+
quality = parse_quality(value)
|
|
43
|
+
else
|
|
44
|
+
params[key] = value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{ type: type, quality: quality, params: params }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse a quality value, clamping to [0.0, 1.0].
|
|
52
|
+
#
|
|
53
|
+
# @param value [String, nil] the quality string
|
|
54
|
+
# @return [Float] parsed quality value
|
|
55
|
+
def self.parse_quality(value)
|
|
56
|
+
return 1.0 if value.nil? || value.empty?
|
|
57
|
+
|
|
58
|
+
q = value.to_f
|
|
59
|
+
q.clamp(0.0, 1.0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method :parse_entry, :parse_quality
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HeaderKit
|
|
5
|
+
# Parses and builds Cache-Control header values.
|
|
6
|
+
#
|
|
7
|
+
# Supports common directives: max-age, s-maxage, no-cache, no-store,
|
|
8
|
+
# public, private, must-revalidate, proxy-revalidate, no-transform, immutable.
|
|
9
|
+
module CacheControl
|
|
10
|
+
VALUE_DIRECTIVES = %w[max-age s-maxage max-stale min-fresh stale-while-revalidate stale-if-error].freeze
|
|
11
|
+
|
|
12
|
+
# Parse a Cache-Control header string into a directive hash.
|
|
13
|
+
#
|
|
14
|
+
# Boolean directives become `true`. Value directives are converted to integers.
|
|
15
|
+
# Keys are symbolized with hyphens replaced by underscores.
|
|
16
|
+
#
|
|
17
|
+
# @param header [String] the Cache-Control header value
|
|
18
|
+
# @return [Hash{Symbol => true, Integer, String}] parsed directives
|
|
19
|
+
def self.parse(header)
|
|
20
|
+
return {} if header.nil? || header.strip.empty?
|
|
21
|
+
|
|
22
|
+
directives = {}
|
|
23
|
+
|
|
24
|
+
header.split(',').each do |part|
|
|
25
|
+
part = part.strip
|
|
26
|
+
next if part.empty?
|
|
27
|
+
|
|
28
|
+
key, value = part.split('=', 2)
|
|
29
|
+
key = key.strip.downcase
|
|
30
|
+
sym = key.tr('-', '_').to_sym
|
|
31
|
+
|
|
32
|
+
if value
|
|
33
|
+
value = value.strip.delete('"')
|
|
34
|
+
directives[sym] = VALUE_DIRECTIVES.include?(key) ? value.to_i : value
|
|
35
|
+
else
|
|
36
|
+
directives[sym] = true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
directives
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build a Cache-Control header string from a directive hash.
|
|
44
|
+
#
|
|
45
|
+
# @param directives [Hash{Symbol => true, Integer, String}] directive hash
|
|
46
|
+
# @return [String] formatted Cache-Control header value
|
|
47
|
+
def self.build(directives)
|
|
48
|
+
return '' if directives.nil? || directives.empty?
|
|
49
|
+
|
|
50
|
+
parts = directives.map do |key, value|
|
|
51
|
+
directive = key.to_s.tr('_', '-')
|
|
52
|
+
value == true ? directive : "#{directive}=#{value}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
parts.join(', ')
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HeaderKit
|
|
5
|
+
# Parses Content-Type header values into media type, charset, and boundary components.
|
|
6
|
+
module ContentType
|
|
7
|
+
# Parse a Content-Type header string.
|
|
8
|
+
#
|
|
9
|
+
# @param header [String] the Content-Type header value
|
|
10
|
+
# @return [Hash] with :media_type, :charset, :boundary keys
|
|
11
|
+
def self.parse(header)
|
|
12
|
+
result = { media_type: nil, charset: nil, boundary: nil }
|
|
13
|
+
return result if header.nil? || header.strip.empty?
|
|
14
|
+
|
|
15
|
+
parts = header.split(';').map(&:strip)
|
|
16
|
+
result[:media_type] = parts.shift&.downcase
|
|
17
|
+
|
|
18
|
+
parts.each do |param|
|
|
19
|
+
key, value = param.split('=', 2).map(&:strip)
|
|
20
|
+
next if key.nil? || key.empty?
|
|
21
|
+
|
|
22
|
+
value = unquote(value) if value
|
|
23
|
+
|
|
24
|
+
case key.downcase
|
|
25
|
+
when 'charset'
|
|
26
|
+
result[:charset] = value
|
|
27
|
+
when 'boundary'
|
|
28
|
+
result[:boundary] = value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Remove surrounding double quotes from a value.
|
|
36
|
+
#
|
|
37
|
+
# @param value [String, nil] the value to unquote
|
|
38
|
+
# @return [String, nil] unquoted value
|
|
39
|
+
def self.unquote(value)
|
|
40
|
+
return value if value.nil?
|
|
41
|
+
|
|
42
|
+
value = value.strip
|
|
43
|
+
if value.start_with?('"') && value.end_with?('"')
|
|
44
|
+
value[1..-2]
|
|
45
|
+
else
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private_class_method :unquote
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HeaderKit
|
|
5
|
+
# Parses and builds RFC 8288 Link header values.
|
|
6
|
+
#
|
|
7
|
+
# Each link entry contains a URI and optional parameters such as rel, type, and title.
|
|
8
|
+
module Link
|
|
9
|
+
URI_PATTERN = /<([^>]*)>/
|
|
10
|
+
|
|
11
|
+
# Parse a Link header string into an array of link entries.
|
|
12
|
+
#
|
|
13
|
+
# @param header [String] the Link header value
|
|
14
|
+
# @return [Array<Hash>] each with :uri, :rel, :type, :title keys
|
|
15
|
+
def self.parse(header)
|
|
16
|
+
return [] if header.nil? || header.strip.empty?
|
|
17
|
+
|
|
18
|
+
header.split(/,(?=\s*<)/).map { |entry| parse_entry(entry.strip) }.compact
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Build a Link header string from an array of link hashes.
|
|
22
|
+
#
|
|
23
|
+
# @param links [Array<Hash>] each with :uri and optional :rel, :type, :title
|
|
24
|
+
# @return [String] formatted Link header value
|
|
25
|
+
def self.build(links)
|
|
26
|
+
return '' if links.nil? || links.empty?
|
|
27
|
+
|
|
28
|
+
parts = links.map do |link|
|
|
29
|
+
entry = "<#{link[:uri]}>"
|
|
30
|
+
entry += "; rel=\"#{link[:rel]}\"" if link[:rel]
|
|
31
|
+
entry += "; type=\"#{link[:type]}\"" if link[:type]
|
|
32
|
+
entry += "; title=\"#{link[:title]}\"" if link[:title]
|
|
33
|
+
entry
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
parts.join(', ')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse a single Link entry.
|
|
40
|
+
#
|
|
41
|
+
# @param entry [String] a single link entry
|
|
42
|
+
# @return [Hash, nil] parsed link or nil if invalid
|
|
43
|
+
def self.parse_entry(entry)
|
|
44
|
+
match = URI_PATTERN.match(entry)
|
|
45
|
+
return nil unless match
|
|
46
|
+
|
|
47
|
+
uri = match[1]
|
|
48
|
+
result = { uri: uri, rel: nil, type: nil, title: nil }
|
|
49
|
+
|
|
50
|
+
params_str = entry[match.end(0)..]
|
|
51
|
+
return result if params_str.nil? || params_str.strip.empty?
|
|
52
|
+
|
|
53
|
+
params_str.split(';').each do |param|
|
|
54
|
+
param = param.strip
|
|
55
|
+
next if param.empty?
|
|
56
|
+
|
|
57
|
+
key, value = param.split('=', 2).map(&:strip)
|
|
58
|
+
next if key.nil? || key.empty? || value.nil?
|
|
59
|
+
|
|
60
|
+
value = unquote(value)
|
|
61
|
+
|
|
62
|
+
case key.downcase
|
|
63
|
+
when 'rel'
|
|
64
|
+
result[:rel] = value
|
|
65
|
+
when 'type'
|
|
66
|
+
result[:type] = value
|
|
67
|
+
when 'title'
|
|
68
|
+
result[:title] = value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Remove surrounding double quotes from a value.
|
|
76
|
+
#
|
|
77
|
+
# @param value [String, nil] the value to unquote
|
|
78
|
+
# @return [String, nil] unquoted value
|
|
79
|
+
def self.unquote(value)
|
|
80
|
+
return value if value.nil?
|
|
81
|
+
|
|
82
|
+
value = value.strip
|
|
83
|
+
if value.start_with?('"') && value.end_with?('"')
|
|
84
|
+
value[1..-2]
|
|
85
|
+
else
|
|
86
|
+
value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private_class_method :parse_entry, :unquote
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HeaderKit
|
|
5
|
+
# Content negotiation logic for matching Accept headers against available media types.
|
|
6
|
+
module Negotiation
|
|
7
|
+
# Find the best matching media type from available types based on an Accept header.
|
|
8
|
+
#
|
|
9
|
+
# Matching rules (RFC 7231):
|
|
10
|
+
# 1. Exact type match
|
|
11
|
+
# 2. Subtype wildcard (e.g., text/* matches text/html)
|
|
12
|
+
# 3. Full wildcard (*/*) matches anything
|
|
13
|
+
#
|
|
14
|
+
# @param accept_header [String] the Accept header value
|
|
15
|
+
# @param available [Array<String>] list of available media types
|
|
16
|
+
# @return [String, nil] the best matching type, or nil if no match
|
|
17
|
+
def self.negotiate(accept_header, available)
|
|
18
|
+
return nil if available.nil? || available.empty?
|
|
19
|
+
return available.first if accept_header.nil? || accept_header.strip.empty?
|
|
20
|
+
|
|
21
|
+
accepted = Accept.parse(accept_header)
|
|
22
|
+
return nil if accepted.empty?
|
|
23
|
+
|
|
24
|
+
best_match = nil
|
|
25
|
+
best_quality = -1.0
|
|
26
|
+
best_specificity = -1
|
|
27
|
+
|
|
28
|
+
accepted.each do |entry|
|
|
29
|
+
available.each do |candidate|
|
|
30
|
+
specificity = match_specificity(entry[:type], candidate)
|
|
31
|
+
next unless specificity >= 0
|
|
32
|
+
next unless entry[:quality] > best_quality ||
|
|
33
|
+
(entry[:quality] == best_quality && specificity > best_specificity)
|
|
34
|
+
|
|
35
|
+
best_match = candidate
|
|
36
|
+
best_quality = entry[:quality]
|
|
37
|
+
best_specificity = specificity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
best_quality > 0.0 ? best_match : nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Calculate match specificity between an accept type pattern and a candidate.
|
|
45
|
+
#
|
|
46
|
+
# @param pattern [String] the accept type (may include wildcards)
|
|
47
|
+
# @param candidate [String] the available media type
|
|
48
|
+
# @return [Integer] specificity score (-1 = no match, 0 = */*, 1 = type/*, 2 = exact)
|
|
49
|
+
def self.match_specificity(pattern, candidate)
|
|
50
|
+
return 0 if pattern == '*/*'
|
|
51
|
+
|
|
52
|
+
pattern_type, pattern_sub = pattern.downcase.split('/', 2)
|
|
53
|
+
candidate_type, candidate_sub = candidate.downcase.split('/', 2)
|
|
54
|
+
|
|
55
|
+
return -1 unless pattern_type == candidate_type || pattern_type == '*'
|
|
56
|
+
|
|
57
|
+
if pattern_sub == '*'
|
|
58
|
+
1
|
|
59
|
+
elsif pattern_sub == candidate_sub
|
|
60
|
+
2
|
|
61
|
+
else
|
|
62
|
+
-1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :match_specificity
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'header_kit/version'
|
|
4
|
+
require_relative 'header_kit/accept'
|
|
5
|
+
require_relative 'header_kit/cache_control'
|
|
6
|
+
require_relative 'header_kit/content_type'
|
|
7
|
+
require_relative 'header_kit/link'
|
|
8
|
+
require_relative 'header_kit/negotiation'
|
|
9
|
+
|
|
10
|
+
module Philiprehberger
|
|
11
|
+
module HeaderKit
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Parse an Accept header into structured entries sorted by quality.
|
|
15
|
+
#
|
|
16
|
+
# @param header [String] the Accept header value
|
|
17
|
+
# @return [Array<Hash>] entries with :type, :quality, :params keys
|
|
18
|
+
def self.parse_accept(header)
|
|
19
|
+
Accept.parse(header)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Parse a Cache-Control header into a directive hash.
|
|
23
|
+
#
|
|
24
|
+
# @param header [String] the Cache-Control header value
|
|
25
|
+
# @return [Hash{Symbol => true, Integer, String}] parsed directives
|
|
26
|
+
def self.parse_cache_control(header)
|
|
27
|
+
CacheControl.parse(header)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build a Cache-Control header string from a directive hash.
|
|
31
|
+
#
|
|
32
|
+
# @param directives [Hash{Symbol => true, Integer, String}] directive hash
|
|
33
|
+
# @return [String] formatted Cache-Control header value
|
|
34
|
+
def self.build_cache_control(directives)
|
|
35
|
+
CacheControl.build(directives)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parse a Content-Type header into its components.
|
|
39
|
+
#
|
|
40
|
+
# @param header [String] the Content-Type header value
|
|
41
|
+
# @return [Hash] with :media_type, :charset, :boundary keys
|
|
42
|
+
def self.parse_content_type(header)
|
|
43
|
+
ContentType.parse(header)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Parse a Link header into an array of link entries.
|
|
47
|
+
#
|
|
48
|
+
# @param header [String] the Link header value
|
|
49
|
+
# @return [Array<Hash>] entries with :uri, :rel, :type, :title keys
|
|
50
|
+
def self.parse_link(header)
|
|
51
|
+
Link.parse(header)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Build a Link header string from an array of link hashes.
|
|
55
|
+
#
|
|
56
|
+
# @param links [Array<Hash>] each with :uri and optional :rel, :type, :title
|
|
57
|
+
# @return [String] formatted Link header value
|
|
58
|
+
def self.build_link(links)
|
|
59
|
+
Link.build(links)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Find the best matching media type via content negotiation.
|
|
63
|
+
#
|
|
64
|
+
# @param accept_header [String] the Accept header value
|
|
65
|
+
# @param available [Array<String>] list of available media types
|
|
66
|
+
# @return [String, nil] the best matching type, or nil if no match
|
|
67
|
+
def self.negotiate(accept_header, available)
|
|
68
|
+
Negotiation.negotiate(accept_header, available)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-header_kit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-27 00:00:00.000000000 Z
|
|
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.
|
|
15
|
+
email:
|
|
16
|
+
- me@philiprehberger.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/philiprehberger/header_kit.rb
|
|
25
|
+
- lib/philiprehberger/header_kit/accept.rb
|
|
26
|
+
- lib/philiprehberger/header_kit/cache_control.rb
|
|
27
|
+
- lib/philiprehberger/header_kit/content_type.rb
|
|
28
|
+
- lib/philiprehberger/header_kit/link.rb
|
|
29
|
+
- lib/philiprehberger/header_kit/negotiation.rb
|
|
30
|
+
- lib/philiprehberger/header_kit/version.rb
|
|
31
|
+
homepage: https://github.com/philiprehberger/rb-header-kit
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
homepage_uri: https://github.com/philiprehberger/rb-header-kit
|
|
36
|
+
source_code_uri: https://github.com/philiprehberger/rb-header-kit
|
|
37
|
+
changelog_uri: https://github.com/philiprehberger/rb-header-kit/blob/main/CHANGELOG.md
|
|
38
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-header-kit/issues
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
post_install_message:
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.1.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.5.22
|
|
56
|
+
signing_key:
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: HTTP header parsing, construction, and content negotiation
|
|
59
|
+
test_files: []
|