protocol-media 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2b4e5ba22b3b99614f42ca660be8745ea4d8b16a83dc8b97728461a5b4601d6
4
- data.tar.gz: d9eea7f70012140ce42dee2501b48281b17476a5207e017ecd3a44891fac74fa
3
+ metadata.gz: fcbab91700c5745af1309a24fa474c1c6b05a491bf995c657eb3dbcc739d1b8e
4
+ data.tar.gz: bf8a3fd2b20cdf4f54695333c33effdb509cf458cd1386d277294541bb1df2a7
5
5
  SHA512:
6
- metadata.gz: bb8adf6b0a4aa0df12f48cac05123671dc00bbdb53ee46825cd8e26676020fb4685302c99e4c1967e68ca6b2ab36d706b7f0b884a3e48c6d80d124ed48e43810
7
- data.tar.gz: '0961e8287d2d249e37ffdf576fe17d27da6727070cacaa4694b8c84b6c0ef3f8b98a4f88b8cfca5f93758b8058b1522cc4cc728653d085985d5355fad9106df1'
6
+ metadata.gz: 92c4bac474b6aa04042b6152449c11dcab50d82dd1a0ca9c1dcb4d34878b7362c533b62bdc680ec9b33697748e8934c8598224102dc6b7a4989a55d76aef87d1
7
+ data.tar.gz: ce4ff11c22723981cdd2d09ae0c4cecdce7144c807bc73bf6cf0f72cf870d5cd3b7aa1fdbe5f6944e46f152c7d06a4d11f4bfd164e715edbdbec53dbc87fe2b8
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "range"
7
+
8
+ module Protocol
9
+ module Media
10
+ # Maps media types and ranges to objects using type/subtype compatibility.
11
+ class Map
12
+ # Initialize an empty media map.
13
+ def initialize
14
+ @entries = {}
15
+ end
16
+
17
+ # Associate a media type or range with an object.
18
+ #
19
+ # @parameter range [String | Object] The media type or compatible range.
20
+ # @parameter object [Object] The object associated with the range.
21
+ def []=(range, object)
22
+ range = Range.for(range)
23
+
24
+ @entries[name(range)] = [range, object]
25
+ end
26
+
27
+ # Find the object matching a media type or range.
28
+ #
29
+ # Exact type/subtype registrations take priority, followed by the first compatible registration.
30
+ #
31
+ # @parameter range [String | Object] The media type or compatible range.
32
+ # @returns [Object | nil] The matching object, if one exists.
33
+ def [](range)
34
+ if entry = lookup(Range.for(range))
35
+ entry.last
36
+ end
37
+ end
38
+
39
+ # Find the first object matching an ordered sequence of media ranges.
40
+ #
41
+ # @parameter ranges [Enumerable] The media types or ranges in preference order.
42
+ # @returns [Array(Object, Range | String) | nil] The matching object and original range, if one exists.
43
+ def for(ranges)
44
+ ranges.each do |range|
45
+ if entry = lookup(Range.for(range))
46
+ return [entry.last, range]
47
+ end
48
+ end
49
+
50
+ return nil
51
+ end
52
+
53
+ # Freeze the map and its internal entries.
54
+ #
55
+ # @returns [self] The frozen map.
56
+ def freeze
57
+ unless frozen?
58
+ @entries.each_value(&:freeze)
59
+ @entries.freeze
60
+ end
61
+
62
+ super
63
+ end
64
+
65
+ private
66
+
67
+ def name(range)
68
+ "#{range.type.downcase}/#{range.subtype.downcase}"
69
+ end
70
+
71
+ def lookup(range)
72
+ range_name = name(range)
73
+ return @entries[range_name] if @entries.key?(range_name)
74
+
75
+ @entries.each_value do |entry|
76
+ return entry if match?(range, entry.first)
77
+ end
78
+
79
+ return nil
80
+ end
81
+
82
+ def match?(left, right)
83
+ match_component?(left.type, right.type) && match_component?(left.subtype, right.subtype)
84
+ end
85
+
86
+ def match_component?(left, right)
87
+ left == "*" || right == "*" || left.casecmp?(right)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "version"
7
+
8
+ require "strscan"
9
+
10
+ module Protocol
11
+ module Media
12
+ # A media range and its parameters.
13
+ class Range
14
+ TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i
15
+ QUOTED_STRING = /"(?:.(?!(?<!\\)"))*.?"/
16
+ MEDIA_TYPE = /(?<type>#{TOKEN})\/(?<subtype>#{TOKEN})/
17
+ PARAMETER = /\s*;\s*(?<key>#{TOKEN})=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
18
+
19
+ # Parse strings into media ranges while preserving compatible objects.
20
+ #
21
+ # @parameter value [String | Object] A media range string or compatible object.
22
+ # @returns [Range | Object] The parsed range or original object.
23
+ def self.for(value)
24
+ if value.is_a?(String)
25
+ parse(value)
26
+ else
27
+ value
28
+ end
29
+ end
30
+
31
+ # Parse a media range.
32
+ #
33
+ # @parameter text [String] The media range, including any parameters.
34
+ # @parameter normalize_whitespace [Boolean] Whether to normalize whitespace in quoted parameter values.
35
+ # @returns [Range] The parsed media range.
36
+ def self.parse(text, normalize_whitespace = true)
37
+ scanner = StringScanner.new(text)
38
+
39
+ unless scanner.scan(MEDIA_TYPE)
40
+ raise ArgumentError, "Invalid media range: #{text.inspect}"
41
+ end
42
+
43
+ type = scanner[:type]
44
+ subtype = scanner[:subtype]
45
+ parameters = parse_parameters(scanner, normalize_whitespace)
46
+
47
+ unless scanner.eos?
48
+ raise ArgumentError, "Invalid media range: #{text.inspect}"
49
+ end
50
+
51
+ new(type, subtype, parameters)
52
+ end
53
+
54
+ # Parse media type parameters from the scanner.
55
+ #
56
+ # @parameter scanner [StringScanner] The scanner positioned after the type and subtype.
57
+ # @parameter normalize_whitespace [Boolean] Whether to normalize whitespace in quoted values.
58
+ # @returns [Hash] The parsed parameters.
59
+ def self.parse_parameters(scanner, normalize_whitespace = true)
60
+ parameters = {}
61
+
62
+ while scanner.scan(PARAMETER)
63
+ key = scanner[:key]
64
+
65
+ if value = scanner[:value]
66
+ parameters[key] = value
67
+ elsif quoted_value = scanner[:quoted_value]
68
+ parameters[key] = unquote(quoted_value, normalize_whitespace)
69
+ end
70
+ end
71
+
72
+ parameters
73
+ end
74
+
75
+ # @attribute [String] The top-level type, e.g. `text` or `*`.
76
+ attr_reader :type
77
+
78
+ # @attribute [String] The subtype, e.g. `plain` or `*`.
79
+ attr_reader :subtype
80
+
81
+ # @attribute [Hash] The media range parameters.
82
+ attr_reader :parameters
83
+
84
+ # @parameter type [String] The top-level type.
85
+ # @parameter subtype [String] The subtype.
86
+ # @parameter parameters [Hash] The media range parameters.
87
+ def initialize(type, subtype = "*", parameters = {})
88
+ unless valid_wildcard?(type, subtype)
89
+ raise ArgumentError, "Invalid wildcards in media range: #{type}/#{subtype}"
90
+ end
91
+
92
+ @type = type.downcase
93
+ @subtype = subtype.downcase
94
+ @parameters = parameters
95
+ end
96
+
97
+ # The complete media range name.
98
+ #
99
+ # @returns [String]
100
+ def name
101
+ "#{@type}/#{@subtype}"
102
+ end
103
+
104
+ alias mime_type name
105
+
106
+ # Convert the media range and parameters to a string.
107
+ #
108
+ # @returns [String] The serialized media range.
109
+ def to_s
110
+ name + @parameters.collect do |key, value|
111
+ "; #{key}=#{quote(value.to_s)}"
112
+ end.join
113
+ end
114
+
115
+ alias to_str to_s
116
+
117
+ # Whether this range matches the given media type or range.
118
+ #
119
+ # @parameter other [Range] The media type or range to match.
120
+ # @returns [Boolean] Whether the type and subtype are compatible.
121
+ def match?(other)
122
+ (@type == "*" || other.type == "*" || @type == other.type) &&
123
+ (@subtype == "*" || other.subtype == "*" || @subtype == other.subtype)
124
+ end
125
+
126
+ alias === match?
127
+
128
+ # Compare this media range with another media range.
129
+ #
130
+ # @parameter other [Object] The object to compare.
131
+ # @returns [Boolean] Whether the values are equal.
132
+ def ==(other)
133
+ other.instance_of?(self.class) && @type == other.type && @subtype == other.subtype && @parameters == other.parameters
134
+ end
135
+
136
+ alias eql? ==
137
+
138
+ # Generate a hash key consistent with {#eql?}.
139
+ #
140
+ # @returns [Integer] The hash value.
141
+ def hash
142
+ [self.class, @type, @subtype, @parameters].hash
143
+ end
144
+
145
+ private
146
+
147
+ def valid_wildcard?(type, subtype)
148
+ return subtype == "*" if type == "*"
149
+ return false if type.include?("*")
150
+
151
+ subtype == "*" || !subtype.include?("*")
152
+ end
153
+
154
+ def self.unquote(value, normalize_whitespace)
155
+ value = value[1...-1]
156
+ value.gsub!(/\\(.)/, '\\1')
157
+ value.gsub!(/[\r\n]+\s+/, " ") if normalize_whitespace
158
+ value
159
+ end
160
+
161
+ def quote(value)
162
+ if value.match?(/\A#{TOKEN}\z/)
163
+ value
164
+ else
165
+ "\"#{value.gsub(/["\\]/, "\\\\\\0")}\""
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -3,71 +3,12 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require_relative "version"
7
-
8
- require "strscan"
6
+ require_relative "range"
9
7
 
10
8
  module Protocol
11
9
  module Media
12
10
  # A concrete media type and its parameters.
13
- class Type
14
- TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i
15
- QUOTED_STRING = /"(?:.(?!(?<!\\)"))*.?"/
16
- MEDIA_TYPE = /(?<type>#{TOKEN})\/(?<subtype>#{TOKEN})/
17
- PARAMETER = /\s*;\s*(?<key>#{TOKEN})=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
18
-
19
- # Parse a concrete media type.
20
- #
21
- # @parameter text [String] The media type, including any parameters.
22
- # @parameter normalize_whitespace [Boolean] Whether to normalize whitespace in quoted parameter values.
23
- # @returns [Type] The parsed media type.
24
- def self.parse(text, normalize_whitespace = true)
25
- scanner = StringScanner.new(text)
26
-
27
- unless scanner.scan(MEDIA_TYPE)
28
- raise ArgumentError, "Invalid media type: #{text.inspect}"
29
- end
30
-
31
- type = scanner[:type]
32
- subtype = scanner[:subtype]
33
- parameters = parse_parameters(scanner, normalize_whitespace)
34
-
35
- unless scanner.eos?
36
- raise ArgumentError, "Invalid media type: #{text.inspect}"
37
- end
38
-
39
- new(type, subtype, parameters)
40
- end
41
-
42
- # Parse media type parameters from the scanner.
43
- #
44
- # @parameter scanner [StringScanner] The scanner positioned after the type and subtype.
45
- # @parameter normalize_whitespace [Boolean] Whether to normalize whitespace in quoted values.
46
- # @returns [Hash] The parsed parameters.
47
- def self.parse_parameters(scanner, normalize_whitespace = true)
48
- parameters = {}
49
-
50
- while scanner.scan(PARAMETER)
51
- key = scanner[:key]
52
-
53
- if value = scanner[:value]
54
- parameters[key] = value
55
- elsif quoted_value = scanner[:quoted_value]
56
- parameters[key] = unquote(quoted_value, normalize_whitespace)
57
- end
58
- end
59
-
60
- parameters
61
- end
62
-
63
- # @attribute [String] The top-level type, e.g. `text`.
64
- attr_reader :type
65
-
66
- # @attribute [String] The subtype, e.g. `plain`.
67
- attr_reader :subtype
68
-
69
- # @attribute [Hash] The media type parameters.
70
- attr_reader :parameters
11
+ class Type < Range
71
12
 
72
13
  # @parameter type [String] The top-level type.
73
14
  # @parameter subtype [String] The subtype.
@@ -77,56 +18,7 @@ module Protocol
77
18
  raise ArgumentError, "Media types cannot contain wildcards: #{type}/#{subtype}"
78
19
  end
79
20
 
80
- @type = type.downcase
81
- @subtype = subtype.downcase
82
- @parameters = parameters
83
- end
84
-
85
- # The complete media type name.
86
- #
87
- # @returns [String]
88
- def name
89
- "#{@type}/#{@subtype}"
90
- end
91
-
92
- alias mime_type name
93
-
94
- # Convert the media type and parameters to a string.
95
- def to_s
96
- name + @parameters.collect do |key, value|
97
- "; #{key}=#{quote(value.to_s)}"
98
- end.join
99
- end
100
-
101
- alias to_str to_s
102
-
103
- # Compare this media type with another media type.
104
- def ==(other)
105
- other.is_a?(Type) && @type == other.type && @subtype == other.subtype && @parameters == other.parameters
106
- end
107
-
108
- alias eql? ==
109
-
110
- # Generate a hash key consistent with {#eql?}.
111
- def hash
112
- [@type, @subtype, @parameters].hash
113
- end
114
-
115
- private
116
-
117
- def self.unquote(value, normalize_whitespace)
118
- value = value[1...-1]
119
- value.gsub!(/\\(.)/, '\\1')
120
- value.gsub!(/[\r\n]+\s+/, " ") if normalize_whitespace
121
- value
122
- end
123
-
124
- def quote(value)
125
- if value.match?(/\A#{TOKEN}\z/)
126
- value
127
- else
128
- "\"#{value.gsub(/["\\]/, "\\\\\\0")}\""
129
- end
21
+ super
130
22
  end
131
23
  end
132
24
  end
@@ -7,6 +7,6 @@
7
7
  module Protocol
8
8
  # Models media types used by internet protocols and data formats.
9
9
  module Media
10
- VERSION = "0.0.1"
10
+ VERSION = "0.1.0"
11
11
  end
12
12
  end
data/readme.md CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  Provides a small representation of media types which can be shared by protocol implementations and registry backends.
4
4
 
5
- ``` ruby
6
- require "protocol/media/type"
5
+ ## Usage
7
6
 
8
- type = Protocol::Media::Type.parse("text/plain; charset=utf-8")
9
- type.type # => "text"
10
- type.subtype # => "plain"
11
- type.parameters # => {"charset" => "utf-8"}
12
- ```
7
+ Please see the [project documentation](https://socketry.github.io/protocol-media/) for more details.
13
8
 
14
- Registry data and indexed lookup are provided separately by `protocol-media-data`.
9
+ - [Getting Started](https://socketry.github.io/protocol-media/guides/getting-started/index) - This guide explains how to represent, compare, and map media types with `protocol-media`.
10
+
11
+ - [Content Negotiation](https://socketry.github.io/protocol-media/guides/content-negotiation/index) - This guide explains how to combine `protocol-media` with an HTTP parser to select an application representation.
12
+
13
+ ## See Also
14
+
15
+ - [Protocol::Media::Registry](https://github.com/socketry/protocol-media-registry) provides registry data and indexed lookup by media type or file extension.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-media
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -13,6 +13,8 @@ executables: []
13
13
  extensions: []
14
14
  extra_rdoc_files: []
15
15
  files:
16
+ - lib/protocol/media/map.rb
17
+ - lib/protocol/media/range.rb
16
18
  - lib/protocol/media/type.rb
17
19
  - lib/protocol/media/version.rb
18
20
  - license.md