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 +4 -4
- data/lib/protocol/media/map.rb +91 -0
- data/lib/protocol/media/range.rb +170 -0
- data/lib/protocol/media/type.rb +3 -111
- data/lib/protocol/media/version.rb +1 -1
- data/readme.md +9 -8
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcbab91700c5745af1309a24fa474c1c6b05a491bf995c657eb3dbcc739d1b8e
|
|
4
|
+
data.tar.gz: bf8a3fd2b20cdf4f54695333c33effdb509cf458cd1386d277294541bb1df2a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/protocol/media/type.rb
CHANGED
|
@@ -3,71 +3,12 @@
|
|
|
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
|
-
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
|
-
|
|
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
|
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.
|
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.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
|