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 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
+ [![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
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-header_kit.svg)](https://rubygems.org/gems/philiprehberger-header_kit)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-header-kit)](LICENSE)
6
+ [![Sponsor](https://img.shields.io/badge/sponsor-philiprehberger-pink?logo=githubsponsors)](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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module HeaderKit
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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: []