protocol-media 0.0.1 → 0.2.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 +4 -4
- data/lib/protocol/media/map.rb +117 -0
- data/lib/protocol/media/range.rb +213 -0
- data/lib/protocol/media/set.rb +247 -0
- data/lib/protocol/media/type.rb +19 -107
- data/lib/protocol/media/version.rb +1 -1
- data/readme.md +9 -8
- data/releases.md +6 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41cc93afe36dc9af35b5ca77721b8859543b270627a3cb9f7f6cc2df2334d83e
|
|
4
|
+
data.tar.gz: 306bf200c34635b7eba643945121d6150054759ffa7ffa9ce5f08bf0668aa3b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ab4060ead68e7b62f6486e298155e23060019bf7eb4bc9d2524efcb5cfe65e0a018945d46305730359b0c7e6bca158b44632ce5b7ca06ffd6c3c0bc2b8d327c
|
|
7
|
+
data.tar.gz: 84ccb4d5c42a6431cdc7da7d5ba6d1c776581bb561f0bde140d002c7cc5549f13304023c5d55bf4385c3e1de5a17be86c9d0aefcd2f708969aef3476772faa39
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "type"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Media
|
|
10
|
+
# An immutable map from concrete media types to objects for range-based lookup.
|
|
11
|
+
class Map
|
|
12
|
+
# Incrementally constructs an immutable media map.
|
|
13
|
+
class Builder
|
|
14
|
+
# Initialize an empty media map builder.
|
|
15
|
+
def initialize
|
|
16
|
+
@index = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Associate a concrete media type with an object.
|
|
20
|
+
#
|
|
21
|
+
# @parameter media_type [String | Object] The concrete media type or compatible object.
|
|
22
|
+
# @parameter object [Object] The object associated with the media type.
|
|
23
|
+
def []=(media_type, object)
|
|
24
|
+
media_type = Type.for(media_type)
|
|
25
|
+
|
|
26
|
+
# Preserve the first registration as the default for wildcard queries:
|
|
27
|
+
@index["*/*"] ||= object
|
|
28
|
+
@index["#{media_type.type}/*"] ||= object
|
|
29
|
+
@index[media_type.name] = object
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Compile the current registrations into an immutable map.
|
|
33
|
+
# @returns [Map] The immutable media map.
|
|
34
|
+
def build
|
|
35
|
+
return Map.new(@index).freeze
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Construct an immutable map using a builder.
|
|
40
|
+
# @yields {|builder| ...} The mutable builder.
|
|
41
|
+
# @returns [Map] The immutable media map.
|
|
42
|
+
def self.build
|
|
43
|
+
builder = Builder.new
|
|
44
|
+
|
|
45
|
+
if block_given?
|
|
46
|
+
yield builder
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return builder.build
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Convert keyed entries into a media map.
|
|
53
|
+
#
|
|
54
|
+
# Existing maps are returned unchanged, including their frozen state.
|
|
55
|
+
#
|
|
56
|
+
# @parameter entries [Map | Enumerable] The existing map or keyed media range entries.
|
|
57
|
+
# @returns [Map] The existing map or a newly constructed immutable map.
|
|
58
|
+
def self.for(entries)
|
|
59
|
+
return entries if entries.instance_of?(self)
|
|
60
|
+
|
|
61
|
+
return build do |builder|
|
|
62
|
+
entries.each do |media_type, object|
|
|
63
|
+
builder[media_type] = object
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Initialize a new media map with a given index.
|
|
69
|
+
#
|
|
70
|
+
# The index is retained without copying or freezing it.
|
|
71
|
+
#
|
|
72
|
+
# @parameter index [Hash] The keyed media range entries.
|
|
73
|
+
def initialize(index)
|
|
74
|
+
@index = index
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Freeze the media map and its index.
|
|
78
|
+
# @returns [self]
|
|
79
|
+
def freeze
|
|
80
|
+
return self if frozen?
|
|
81
|
+
|
|
82
|
+
@index.freeze
|
|
83
|
+
|
|
84
|
+
return super
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Find the object matching a media type or range.
|
|
88
|
+
#
|
|
89
|
+
# @parameter media_range [String | Object] The media type or compatible range.
|
|
90
|
+
# @returns [Object | nil] The matching object, if one exists.
|
|
91
|
+
def [](media_range)
|
|
92
|
+
return lookup(Range.for(media_range))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Find the first object matching an ordered sequence of media ranges.
|
|
96
|
+
#
|
|
97
|
+
# @parameter media_ranges [Enumerable] The media types or ranges in preference order.
|
|
98
|
+
# @returns [Array(Object, Range | String) | nil] The matching object and original range, if one exists.
|
|
99
|
+
def for(media_ranges)
|
|
100
|
+
media_ranges.each do |media_range|
|
|
101
|
+
if object = lookup(Range.for(media_range))
|
|
102
|
+
return [object, media_range]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def lookup(media_range)
|
|
112
|
+
media_range = Range.build(media_range.type, media_range.subtype)
|
|
113
|
+
return @index[media_range.name]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
# Build a normalized media range.
|
|
20
|
+
#
|
|
21
|
+
# @parameter type [String] The top-level type.
|
|
22
|
+
# @parameter subtype [String] The subtype.
|
|
23
|
+
# @parameter parameters [Hash] The media range parameters.
|
|
24
|
+
# @returns [Range] The normalized media range.
|
|
25
|
+
def self.build(type, subtype = "*", parameters = {})
|
|
26
|
+
type = type.downcase
|
|
27
|
+
subtype = subtype.downcase
|
|
28
|
+
|
|
29
|
+
# A wildcard type requires a wildcard subtype:
|
|
30
|
+
if type == "*"
|
|
31
|
+
if subtype != "*"
|
|
32
|
+
raise ArgumentError, "Invalid wildcards in media range: #{type}/#{subtype}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
return new(type, subtype, parameters)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse strings into media ranges while preserving compatible objects.
|
|
40
|
+
#
|
|
41
|
+
# @parameter value [String | Object] A media range string or compatible object.
|
|
42
|
+
# @returns [Range | Object] The parsed range or original object.
|
|
43
|
+
def self.for(value)
|
|
44
|
+
if value.is_a?(String)
|
|
45
|
+
parse(value)
|
|
46
|
+
else
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse a media range.
|
|
52
|
+
#
|
|
53
|
+
# @parameter text [String] The media range, including any parameters.
|
|
54
|
+
# @parameter normalize_whitespace [Boolean] Whether to normalize whitespace in quoted parameter values.
|
|
55
|
+
# @returns [Range] The parsed media range.
|
|
56
|
+
def self.parse(text, normalize_whitespace = true)
|
|
57
|
+
scanner = StringScanner.new(text)
|
|
58
|
+
|
|
59
|
+
unless scanner.scan(MEDIA_TYPE)
|
|
60
|
+
raise ArgumentError, "Invalid media range: #{text.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
type = scanner[:type]
|
|
64
|
+
subtype = scanner[:subtype]
|
|
65
|
+
parameters = parse_parameters(scanner, normalize_whitespace)
|
|
66
|
+
|
|
67
|
+
unless scanner.eos?
|
|
68
|
+
raise ArgumentError, "Invalid media range: #{text.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return build(type, subtype, parameters)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse media type parameters from the scanner.
|
|
75
|
+
#
|
|
76
|
+
# @parameter scanner [StringScanner] The scanner positioned after the type and subtype.
|
|
77
|
+
# @parameter normalize_whitespace [Boolean] Whether to normalize whitespace in quoted values.
|
|
78
|
+
# @returns [Hash] The parsed parameters.
|
|
79
|
+
def self.parse_parameters(scanner, normalize_whitespace = true)
|
|
80
|
+
parameters = {}
|
|
81
|
+
|
|
82
|
+
while scanner.scan(PARAMETER)
|
|
83
|
+
key = scanner[:key]
|
|
84
|
+
|
|
85
|
+
if value = scanner[:value]
|
|
86
|
+
parameters[key] = value
|
|
87
|
+
elsif quoted_value = scanner[:quoted_value]
|
|
88
|
+
parameters[key] = unquote(quoted_value, normalize_whitespace)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
parameters
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @attribute [String] The top-level type, e.g. `text` or `*`.
|
|
96
|
+
attr_reader :type
|
|
97
|
+
|
|
98
|
+
# @attribute [String] The subtype, e.g. `plain` or `*`.
|
|
99
|
+
attr_reader :subtype
|
|
100
|
+
|
|
101
|
+
# @attribute [Hash] The media range parameters.
|
|
102
|
+
attr_reader :parameters
|
|
103
|
+
|
|
104
|
+
# @parameter type [String] The top-level type.
|
|
105
|
+
# @parameter subtype [String] The subtype.
|
|
106
|
+
# @parameter parameters [Hash] The media range parameters.
|
|
107
|
+
def initialize(type, subtype = "*", parameters = {})
|
|
108
|
+
@type = type
|
|
109
|
+
@subtype = subtype
|
|
110
|
+
@parameters = parameters
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The complete media range name.
|
|
114
|
+
#
|
|
115
|
+
# @returns [String]
|
|
116
|
+
def name
|
|
117
|
+
"#{@type}/#{@subtype}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
alias mime_type name
|
|
121
|
+
|
|
122
|
+
# Convert the media range and parameters to a string.
|
|
123
|
+
#
|
|
124
|
+
# @returns [String] The serialized media range.
|
|
125
|
+
def to_s
|
|
126
|
+
name + @parameters.collect do |key, value|
|
|
127
|
+
"; #{key}=#{quote(value.to_s)}"
|
|
128
|
+
end.join
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
alias to_str to_s
|
|
132
|
+
|
|
133
|
+
# Freeze the media range and its direct values.
|
|
134
|
+
# @returns [self]
|
|
135
|
+
def freeze
|
|
136
|
+
return self if frozen?
|
|
137
|
+
|
|
138
|
+
@type.freeze
|
|
139
|
+
@subtype.freeze
|
|
140
|
+
@parameters.freeze
|
|
141
|
+
|
|
142
|
+
return super
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Whether this range matches the given media type or range.
|
|
146
|
+
#
|
|
147
|
+
# @parameter media_range [Range] The media type or range to match.
|
|
148
|
+
# @returns [Boolean] Whether the type and subtype are compatible.
|
|
149
|
+
def match?(media_range)
|
|
150
|
+
# Both type components must be compatible:
|
|
151
|
+
unless component_match?(@type, media_range.type)
|
|
152
|
+
return false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Both subtype components must be compatible:
|
|
156
|
+
unless component_match?(@subtype, media_range.subtype)
|
|
157
|
+
return false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
return true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
alias === match?
|
|
164
|
+
|
|
165
|
+
# Compare this media range with another media range.
|
|
166
|
+
#
|
|
167
|
+
# @parameter other [Object] The object to compare.
|
|
168
|
+
# @returns [Boolean] Whether the values are equal.
|
|
169
|
+
def eql?(other)
|
|
170
|
+
other.instance_of?(self.class) && @type.eql?(other.type) && @subtype.eql?(other.subtype) && @parameters.eql?(other.parameters)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
alias == eql?
|
|
174
|
+
|
|
175
|
+
# Generate a hash key consistent with {#eql?}.
|
|
176
|
+
#
|
|
177
|
+
# @returns [Integer] The hash value.
|
|
178
|
+
def hash
|
|
179
|
+
[self.class, @type, @subtype, @parameters].hash
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def component_match?(component, other_component)
|
|
185
|
+
# A wildcard on either side matches any component:
|
|
186
|
+
if component == "*"
|
|
187
|
+
return true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if other_component == "*"
|
|
191
|
+
return true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
return component == other_component
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.unquote(value, normalize_whitespace)
|
|
198
|
+
value = value[1...-1]
|
|
199
|
+
value.gsub!(/\\(.)/, '\\1')
|
|
200
|
+
value.gsub!(/[\r\n]+\s+/, " ") if normalize_whitespace
|
|
201
|
+
value
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def quote(value)
|
|
205
|
+
if value.match?(/\A#{TOKEN}\z/)
|
|
206
|
+
value
|
|
207
|
+
else
|
|
208
|
+
"\"#{value.gsub(/["\\]/, "\\\\\\0")}\""
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
require "set"
|
|
9
|
+
|
|
10
|
+
module Protocol
|
|
11
|
+
module Media
|
|
12
|
+
# An immutable set of compatible media ranges.
|
|
13
|
+
class Set
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
# An immutable set which matches any media range.
|
|
17
|
+
class Any
|
|
18
|
+
include Enumerable
|
|
19
|
+
|
|
20
|
+
RANGE = Range.new("*", "*").freeze
|
|
21
|
+
private_constant :RANGE
|
|
22
|
+
|
|
23
|
+
# Whether the set contains a compatible media type or range.
|
|
24
|
+
# @parameter media_range [String | Object] The media type or range to validate.
|
|
25
|
+
# @returns [Boolean]
|
|
26
|
+
def include?(media_range)
|
|
27
|
+
media_range = Range.for(media_range)
|
|
28
|
+
|
|
29
|
+
Range.build(media_range.type, media_range.subtype)
|
|
30
|
+
|
|
31
|
+
return true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
alias === include?
|
|
35
|
+
alias match? include?
|
|
36
|
+
|
|
37
|
+
# Enumerate the universal media range.
|
|
38
|
+
# @yields {|media_range| ...} The universal media range.
|
|
39
|
+
# @returns [Enumerator | self]
|
|
40
|
+
def each
|
|
41
|
+
return to_enum unless block_given?
|
|
42
|
+
|
|
43
|
+
yield RANGE
|
|
44
|
+
|
|
45
|
+
return self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The number of membership ranges.
|
|
49
|
+
# @returns [Integer]
|
|
50
|
+
def size
|
|
51
|
+
return 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether there are no membership ranges.
|
|
55
|
+
# @returns [Boolean]
|
|
56
|
+
def empty?
|
|
57
|
+
return false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ANY = Any.new.freeze
|
|
62
|
+
|
|
63
|
+
private_constant :Any, :ANY
|
|
64
|
+
|
|
65
|
+
# Incrementally constructs an immutable media set.
|
|
66
|
+
class Builder
|
|
67
|
+
# Initialize an empty media set builder.
|
|
68
|
+
def initialize
|
|
69
|
+
@types = {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add a media range to the set under construction.
|
|
73
|
+
# @parameter media_range [String | Object] The media range or compatible object.
|
|
74
|
+
# @returns [self]
|
|
75
|
+
def add(media_range)
|
|
76
|
+
if @types.instance_of?(Any)
|
|
77
|
+
return self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
media_range = Range.for(media_range)
|
|
81
|
+
media_range = Range.build(media_range.type, media_range.subtype)
|
|
82
|
+
type = media_range.type.freeze
|
|
83
|
+
subtype = media_range.subtype.freeze
|
|
84
|
+
|
|
85
|
+
if type == "*"
|
|
86
|
+
@types = ANY
|
|
87
|
+
elsif subtype == "*"
|
|
88
|
+
@types[type] = ANY
|
|
89
|
+
elsif subtypes = @types[type]
|
|
90
|
+
unless subtypes.instance_of?(Any)
|
|
91
|
+
subtypes.add(subtype)
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
@types[type] = ::Set.new([subtype])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
alias << add
|
|
101
|
+
|
|
102
|
+
# Compile the current ranges into an immutable set.
|
|
103
|
+
# @returns [Set] The immutable media set.
|
|
104
|
+
def build
|
|
105
|
+
if @types.instance_of?(Any)
|
|
106
|
+
return @types
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
return Set.new(@types).freeze
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Construct an immutable set using a builder.
|
|
114
|
+
# @yields {|builder| ...} The mutable builder.
|
|
115
|
+
# @returns [Set] The immutable media set.
|
|
116
|
+
def self.build
|
|
117
|
+
builder = Builder.new
|
|
118
|
+
|
|
119
|
+
if block_given?
|
|
120
|
+
yield builder
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
return builder.build
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Convert a sequence of ranges into a media set.
|
|
127
|
+
#
|
|
128
|
+
# Existing sets are returned unchanged, including their frozen state.
|
|
129
|
+
#
|
|
130
|
+
# @parameter media_ranges [Set | Enumerable] The existing set or media ranges.
|
|
131
|
+
# @returns [Set] The existing set or a newly constructed immutable set.
|
|
132
|
+
def self.for(media_ranges)
|
|
133
|
+
# Preserve an existing media set:
|
|
134
|
+
if media_ranges.instance_of?(self)
|
|
135
|
+
return media_ranges
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Preserve the universal media set:
|
|
139
|
+
if media_ranges.instance_of?(Any)
|
|
140
|
+
return media_ranges
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return build do |builder|
|
|
144
|
+
media_ranges.each do |media_range|
|
|
145
|
+
builder << media_range
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Initialize a new media set with the given type index.
|
|
151
|
+
#
|
|
152
|
+
# The index is retained without copying or freezing it.
|
|
153
|
+
#
|
|
154
|
+
# @parameter types [Hash] The indexed media ranges.
|
|
155
|
+
def initialize(types)
|
|
156
|
+
@types = types
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Freeze the media set and its index.
|
|
160
|
+
# @returns [self]
|
|
161
|
+
def freeze
|
|
162
|
+
return self if frozen?
|
|
163
|
+
|
|
164
|
+
@types.each_value(&:freeze)
|
|
165
|
+
@types.freeze
|
|
166
|
+
|
|
167
|
+
return super
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether the set contains a compatible media type or range.
|
|
171
|
+
# @parameter media_range [String | Object] The media type or range to match.
|
|
172
|
+
# @returns [Boolean]
|
|
173
|
+
def include?(media_range)
|
|
174
|
+
media_range = normalize(media_range)
|
|
175
|
+
type = media_range.type
|
|
176
|
+
subtype = media_range.subtype
|
|
177
|
+
|
|
178
|
+
# A wildcard type matches any non-empty set:
|
|
179
|
+
if type == "*"
|
|
180
|
+
return !@types.empty?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
subtypes = @types[type]
|
|
184
|
+
|
|
185
|
+
# An unknown type cannot match:
|
|
186
|
+
unless subtypes
|
|
187
|
+
return false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A wildcard subtype matches any known type:
|
|
191
|
+
if subtype == "*"
|
|
192
|
+
return true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A type-wide wildcard matches every subtype:
|
|
196
|
+
if subtypes.instance_of?(Any)
|
|
197
|
+
return true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
return subtypes.include?(subtype)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
alias === include?
|
|
204
|
+
alias match? include?
|
|
205
|
+
|
|
206
|
+
# Enumerate the canonical media ranges which define membership.
|
|
207
|
+
# @yields {|media_range| ...} Each canonical media range.
|
|
208
|
+
# @returns [Enumerator | self]
|
|
209
|
+
def each
|
|
210
|
+
return to_enum unless block_given?
|
|
211
|
+
|
|
212
|
+
@types.each do |type, subtypes|
|
|
213
|
+
if subtypes.instance_of?(Any)
|
|
214
|
+
yield Range.new(type, "*")
|
|
215
|
+
else
|
|
216
|
+
subtypes.each do |subtype|
|
|
217
|
+
yield Range.new(type, subtype)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
return self
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The number of canonical membership ranges.
|
|
226
|
+
# @returns [Integer]
|
|
227
|
+
def size
|
|
228
|
+
return @types.sum do |_type, subtypes|
|
|
229
|
+
subtypes.size
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Whether the set contains no media ranges.
|
|
234
|
+
# @returns [Boolean]
|
|
235
|
+
def empty?
|
|
236
|
+
return @types.empty?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def normalize(media_range)
|
|
242
|
+
media_range = Range.for(media_range)
|
|
243
|
+
return Range.build(media_range.type, media_range.subtype)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
data/lib/protocol/media/type.rb
CHANGED
|
@@ -3,130 +3,42 @@
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
4
|
# Copyright, 2026, by Samuel Williams.
|
|
5
5
|
|
|
6
|
-
require_relative "
|
|
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
|
-
|
|
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.
|
|
11
|
+
class Type < Range
|
|
12
|
+
# Parse strings and normalize compatible media type objects.
|
|
20
13
|
#
|
|
21
|
-
# @parameter
|
|
22
|
-
# @
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
unless scanner.scan(MEDIA_TYPE)
|
|
28
|
-
raise ArgumentError, "Invalid media type: #{text.inspect}"
|
|
14
|
+
# @parameter value [String | Object] A media type string or compatible object.
|
|
15
|
+
# @returns [Type] The normalized concrete media type.
|
|
16
|
+
def self.for(value)
|
|
17
|
+
if value.is_a?(String)
|
|
18
|
+
return parse(value)
|
|
29
19
|
end
|
|
30
20
|
|
|
31
|
-
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)
|
|
21
|
+
return build(value.type, value.subtype, value.parameters)
|
|
40
22
|
end
|
|
41
23
|
|
|
42
|
-
#
|
|
24
|
+
# Build a normalized concrete media type.
|
|
43
25
|
#
|
|
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
|
|
71
|
-
|
|
72
26
|
# @parameter type [String] The top-level type.
|
|
73
27
|
# @parameter subtype [String] The subtype.
|
|
74
28
|
# @parameter parameters [Hash] The media type parameters.
|
|
75
|
-
|
|
76
|
-
|
|
29
|
+
# @returns [Type] The normalized concrete media type.
|
|
30
|
+
def self.build(type, subtype, parameters = {})
|
|
31
|
+
# Concrete media types cannot use a wildcard type:
|
|
32
|
+
if type == "*"
|
|
77
33
|
raise ArgumentError, "Media types cannot contain wildcards: #{type}/#{subtype}"
|
|
78
34
|
end
|
|
79
35
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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")}\""
|
|
36
|
+
# Concrete media types cannot use a wildcard subtype:
|
|
37
|
+
if subtype == "*"
|
|
38
|
+
raise ArgumentError, "Media types cannot contain wildcards: #{type}/#{subtype}"
|
|
129
39
|
end
|
|
40
|
+
|
|
41
|
+
return super
|
|
130
42
|
end
|
|
131
43
|
end
|
|
132
44
|
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
|
-
|
|
6
|
-
require "protocol/media/type"
|
|
5
|
+
## Usage
|
|
7
6
|
|
|
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
|
-
|
|
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.
|
data/releases.md
ADDED
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
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Williams
|
|
@@ -13,10 +13,14 @@ executables: []
|
|
|
13
13
|
extensions: []
|
|
14
14
|
extra_rdoc_files: []
|
|
15
15
|
files:
|
|
16
|
+
- lib/protocol/media/map.rb
|
|
17
|
+
- lib/protocol/media/range.rb
|
|
18
|
+
- lib/protocol/media/set.rb
|
|
16
19
|
- lib/protocol/media/type.rb
|
|
17
20
|
- lib/protocol/media/version.rb
|
|
18
21
|
- license.md
|
|
19
22
|
- readme.md
|
|
23
|
+
- releases.md
|
|
20
24
|
homepage: https://github.com/socketry/protocol-media
|
|
21
25
|
licenses:
|
|
22
26
|
- MIT
|