media_types 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +29 -0
- data/.travis.yml +20 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +42 -0
- data/README.md +150 -0
- data/Rakefile +10 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/media_types.rb +65 -0
- data/lib/media_types/base.rb +193 -0
- data/lib/media_types/base/collector.rb +44 -0
- data/lib/media_types/constructable_mime_type.rb +126 -0
- data/lib/media_types/minitest/assert_media_type_format.rb +20 -0
- data/lib/media_types/minitest/assert_media_types_registered.rb +143 -0
- data/lib/media_types/scheme.rb +238 -0
- data/lib/media_types/scheme/allow_nil.rb +18 -0
- data/lib/media_types/scheme/attribute.rb +34 -0
- data/lib/media_types/scheme/links.rb +32 -0
- data/lib/media_types/scheme/missing_validation.rb +24 -0
- data/lib/media_types/scheme/not_strict.rb +11 -0
- data/lib/media_types/version.rb +3 -0
- data/media_types.gemspec +32 -0
- metadata +151 -0
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'delegate'
|
4
|
+
|
5
|
+
module MediaTypes
|
6
|
+
class Base
|
7
|
+
class Collector < SimpleDelegator
|
8
|
+
|
9
|
+
def index(*args, **options)
|
10
|
+
view(INDEX_VIEW, *args, **options)
|
11
|
+
end
|
12
|
+
|
13
|
+
def create(*args, **options, &block)
|
14
|
+
view(CREATE_VIEW, *args, **options, &block)
|
15
|
+
end
|
16
|
+
|
17
|
+
def collection(*args, **options)
|
18
|
+
view(COLLECTION_VIEW, *args, **options)
|
19
|
+
end
|
20
|
+
|
21
|
+
def view(view, *args, **options)
|
22
|
+
register_type(*args, **options.merge(view: view))
|
23
|
+
end
|
24
|
+
|
25
|
+
def version(*args, **options, &block)
|
26
|
+
register_version(*args, **options, &block)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
# This is similar to having a decorator / interface only exposing certain methods. In this private section
|
32
|
+
# the +register_type+ and +register_version+ methods are made available
|
33
|
+
#
|
34
|
+
|
35
|
+
def register_type(*args, **options, &block)
|
36
|
+
__getobj__.instance_exec { register_type(*args, options, &block) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def register_version(*args, **options, &block)
|
40
|
+
__getobj__.instance_exec { register_version(*args, options, &block) }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'delegate'
|
4
|
+
require 'singleton'
|
5
|
+
|
6
|
+
module MediaTypes
|
7
|
+
class ConstructableMimeType < SimpleDelegator
|
8
|
+
|
9
|
+
def initialize(klazz, **opts)
|
10
|
+
super klazz
|
11
|
+
self.opts = opts
|
12
|
+
end
|
13
|
+
|
14
|
+
def version(version = NO_ARG)
|
15
|
+
return opts[:version] if version == NO_ARG
|
16
|
+
ConstructableMimeType.new(__getobj__, **with(version: version))
|
17
|
+
end
|
18
|
+
|
19
|
+
def view(view = NO_ARG)
|
20
|
+
return opts[:view] if view == NO_ARG
|
21
|
+
ConstructableMimeType.new(__getobj__, **with(view: view))
|
22
|
+
end
|
23
|
+
|
24
|
+
def suffix(suffix = NO_ARG)
|
25
|
+
return opts[:suffix] if suffix == NO_ARG
|
26
|
+
ConstructableMimeType.new(__getobj__, **with(suffix: suffix))
|
27
|
+
end
|
28
|
+
|
29
|
+
def collection
|
30
|
+
view(COLLECTION_VIEW)
|
31
|
+
end
|
32
|
+
|
33
|
+
def collection?
|
34
|
+
opts[:view] == COLLECTION_VIEW
|
35
|
+
end
|
36
|
+
|
37
|
+
def create
|
38
|
+
view(CREATE_VIEW)
|
39
|
+
end
|
40
|
+
|
41
|
+
def create?
|
42
|
+
opts[:view] == CREATE_VIEW
|
43
|
+
end
|
44
|
+
|
45
|
+
def index
|
46
|
+
view(INDEX_VIEW)
|
47
|
+
end
|
48
|
+
|
49
|
+
def index?
|
50
|
+
opts[:view] == INDEX_VIEW
|
51
|
+
end
|
52
|
+
|
53
|
+
def ===(other)
|
54
|
+
to_str.send(:===, other)
|
55
|
+
end
|
56
|
+
|
57
|
+
def ==(other)
|
58
|
+
to_str.send(:==, other)
|
59
|
+
end
|
60
|
+
|
61
|
+
def +(other)
|
62
|
+
to_str + other
|
63
|
+
end
|
64
|
+
|
65
|
+
def split(pattern = nil, *limit)
|
66
|
+
to_str.split(pattern, *limit)
|
67
|
+
end
|
68
|
+
|
69
|
+
def hash
|
70
|
+
to_str.hash
|
71
|
+
end
|
72
|
+
|
73
|
+
def to_str(qualifier = nil)
|
74
|
+
# TODO: remove warning by slicing out these arguments if they don't appear in the format
|
75
|
+
qualified(qualifier, @to_str ||= format(
|
76
|
+
opts.fetch(:format),
|
77
|
+
version: opts.fetch(:version),
|
78
|
+
suffix: opts.fetch(:suffix) { :json },
|
79
|
+
type: opts.fetch(:type),
|
80
|
+
view: format_view(opts[:view])
|
81
|
+
))
|
82
|
+
end
|
83
|
+
|
84
|
+
def valid?(output, **validation_opts)
|
85
|
+
__getobj__.valid?(
|
86
|
+
output,
|
87
|
+
version: opts[:version],
|
88
|
+
**validation_opts
|
89
|
+
)
|
90
|
+
end
|
91
|
+
|
92
|
+
def validate!(output, **validation_opts)
|
93
|
+
__getobj__.validate!(
|
94
|
+
output,
|
95
|
+
version: opts[:version],
|
96
|
+
**validation_opts
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
alias inspect to_str
|
101
|
+
alias to_s to_str
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
class NoArgumentGiven
|
106
|
+
include Singleton
|
107
|
+
end
|
108
|
+
|
109
|
+
NO_ARG = NoArgumentGiven.instance
|
110
|
+
|
111
|
+
attr_accessor :opts
|
112
|
+
|
113
|
+
def with(more_opts)
|
114
|
+
Hash(opts).merge(more_opts).dup
|
115
|
+
end
|
116
|
+
|
117
|
+
def qualified(qualifier, media_type)
|
118
|
+
return media_type unless qualifier
|
119
|
+
format('%<media_type>s; q=%<q>s', media_type: media_type, q: qualifier)
|
120
|
+
end
|
121
|
+
|
122
|
+
def format_view(view)
|
123
|
+
MediaTypes::Object.new(view).present? && ".#{view}" || ''
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Assertions
|
4
|
+
module MediaTypes
|
5
|
+
def assert_media_type_format(media_type, output, **opts)
|
6
|
+
if media_type.collection?
|
7
|
+
output[:_embedded].each do |embedded|
|
8
|
+
assert_media_type_format(media_type.view(nil), embedded, **opts)
|
9
|
+
end
|
10
|
+
return
|
11
|
+
end
|
12
|
+
|
13
|
+
if media_type.index?
|
14
|
+
return output[:_index] # TODO: sub_schema the "self" link
|
15
|
+
end
|
16
|
+
|
17
|
+
assert media_type.validate!(output, **opts)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'minitest/mock'
|
4
|
+
|
5
|
+
module MediaTypes
|
6
|
+
module Assertions
|
7
|
+
class << self
|
8
|
+
def block_exec_instance(instance, &block)
|
9
|
+
case block.arity
|
10
|
+
when 1, -1
|
11
|
+
instance.instance_exec(instance, &block)
|
12
|
+
else
|
13
|
+
instance.instance_exec(&block)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def assert_media_types_registered(media_type, &block)
|
19
|
+
collector = Collector.new(media_type)
|
20
|
+
Assertions.block_exec_instance(collector, &block)
|
21
|
+
assert_registered_types(media_type, collector.prepare_verify)
|
22
|
+
end
|
23
|
+
|
24
|
+
class Collector
|
25
|
+
def initialize(media_type)
|
26
|
+
self.media_type = media_type
|
27
|
+
self.registers = {}
|
28
|
+
end
|
29
|
+
|
30
|
+
def mime_type(mime_type, symbol:, synonyms: [])
|
31
|
+
registers[mime_type] = { symbol: symbol, synonyms: synonyms }
|
32
|
+
end
|
33
|
+
|
34
|
+
def formatted_mime_type(mime_type_format, &block)
|
35
|
+
collector = FormattedCollector.new(mime_type_format, {})
|
36
|
+
Assertions.block_exec_instance(collector, &block)
|
37
|
+
registers.merge!(collector.to_h)
|
38
|
+
end
|
39
|
+
|
40
|
+
def prepare_verify
|
41
|
+
expected_types_hash = registers.dup
|
42
|
+
expected_types_hash.each do |key, value|
|
43
|
+
expected_types_hash[key] = [value[:symbol], Array(value[:synonyms])]
|
44
|
+
end
|
45
|
+
|
46
|
+
expected_types_hash
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
attr_accessor :media_type, :registers
|
52
|
+
end
|
53
|
+
|
54
|
+
class FormattedCollector
|
55
|
+
def initialize(format, args = {})
|
56
|
+
self.mime_type_format = format
|
57
|
+
self.format_args = args
|
58
|
+
self.registers = {}
|
59
|
+
end
|
60
|
+
|
61
|
+
def version(version, **opts, &block)
|
62
|
+
new_format_args = format_args.merge(version: version)
|
63
|
+
register(new_format_args, **opts, &block)
|
64
|
+
end
|
65
|
+
|
66
|
+
def view(view, **opts, &block)
|
67
|
+
new_format_args = format_args.merge(view: view)
|
68
|
+
register(new_format_args, **opts, &block)
|
69
|
+
end
|
70
|
+
|
71
|
+
def create(**opts, &block)
|
72
|
+
view(Base::CREATE_VIEW, **opts, &block)
|
73
|
+
end
|
74
|
+
|
75
|
+
def collection(**opts, &block)
|
76
|
+
view(Base::COLLECTION_VIEW, **opts, &block)
|
77
|
+
end
|
78
|
+
|
79
|
+
def index(**opts, &block)
|
80
|
+
view(Base::INDEX_VIEW, **opts, &block)
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_h
|
84
|
+
Hash(registers)
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
attr_accessor :mime_type_format, :registers, :format_args
|
90
|
+
|
91
|
+
def register(new_format_args, symbol: nil, synonyms: [], &block)
|
92
|
+
if block_given?
|
93
|
+
collector = FormattedCollector.new(mime_type_format, new_format_args)
|
94
|
+
Assertions.block_exec_instance(collector, &block)
|
95
|
+
registers.merge!(collector.to_h)
|
96
|
+
else
|
97
|
+
formatted_mime_type_format = format(mime_type_format, **new_format_args)
|
98
|
+
registers[formatted_mime_type_format] = { symbol: symbol, synonyms: synonyms }
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
104
|
+
def assert_registered_types(media_type, expected_types_hash)
|
105
|
+
mock = Minitest::Mock.new
|
106
|
+
uncalled = expected_types_hash.dup
|
107
|
+
|
108
|
+
uncalled.length.times do
|
109
|
+
mock.expect(:call, nil) do |arguments|
|
110
|
+
type = arguments.fetch(:mime_type)
|
111
|
+
symbol = arguments.fetch(:symbol)
|
112
|
+
synonyms = arguments.fetch(:synonyms)
|
113
|
+
|
114
|
+
options = uncalled.delete(type)
|
115
|
+
options && options == [symbol, synonyms] || raise(
|
116
|
+
MockExpectationError,
|
117
|
+
format(
|
118
|
+
'Called with [type: %<type>s, symbol: %<symbol>s, synonyms: %<synonyms>s]' + "\n"\
|
119
|
+
'Resolved options: [%<resolved>s]' + "\n"\
|
120
|
+
'Uncalled options: [%<uncalled>s]',
|
121
|
+
type: type,
|
122
|
+
symbol: symbol,
|
123
|
+
synonyms: synonyms,
|
124
|
+
resolved: options,
|
125
|
+
uncalled: uncalled
|
126
|
+
)
|
127
|
+
)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
MediaTypes.stub(:register, mock) do
|
132
|
+
if block_given?
|
133
|
+
yield media_type
|
134
|
+
else
|
135
|
+
media_type.register.flatten
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
assert_mock mock
|
140
|
+
end
|
141
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,238 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'media_types/scheme/allow_nil'
|
4
|
+
require 'media_types/scheme/attribute'
|
5
|
+
require 'media_types/scheme/links'
|
6
|
+
require 'media_types/scheme/missing_validation'
|
7
|
+
require 'media_types/scheme/not_strict'
|
8
|
+
|
9
|
+
module MediaTypes
|
10
|
+
class ValidationError < ArgumentError
|
11
|
+
end
|
12
|
+
|
13
|
+
class ExhaustedOutputError < ValidationError
|
14
|
+
end
|
15
|
+
|
16
|
+
class StrictValidationError < ValidationError
|
17
|
+
end
|
18
|
+
|
19
|
+
class EmptyOutputError < ValidationError
|
20
|
+
end
|
21
|
+
|
22
|
+
##
|
23
|
+
# Media Type Schemes can validate content to a media type, by itself.
|
24
|
+
#
|
25
|
+
class Scheme
|
26
|
+
def initialize(allow_empty: false)
|
27
|
+
self.validations = {}
|
28
|
+
self.allow_empty = allow_empty
|
29
|
+
|
30
|
+
validations.default = MissingValidation.new
|
31
|
+
end
|
32
|
+
|
33
|
+
##
|
34
|
+
# Checks if the +output+ is valid
|
35
|
+
#
|
36
|
+
# @param [#each] output
|
37
|
+
# @param [Hash] opts
|
38
|
+
# @option exhaustive [Boolean] opts
|
39
|
+
# @option strict [Boolean] opts
|
40
|
+
#
|
41
|
+
# @return [Boolean] true if valid, false otherwise
|
42
|
+
#
|
43
|
+
def valid?(output, **opts)
|
44
|
+
validate(output, **opts)
|
45
|
+
rescue ExhaustedOutputError
|
46
|
+
!opts.fetch(:exhaustive) { true }
|
47
|
+
rescue ValidationError
|
48
|
+
false
|
49
|
+
end
|
50
|
+
|
51
|
+
class ValidationOptions
|
52
|
+
attr_accessor :exhaustive, :strict, :backtrace
|
53
|
+
|
54
|
+
def initialize(exhaustive: true, strict: true, backtrace: [])
|
55
|
+
self.exhaustive = exhaustive
|
56
|
+
self.strict = strict
|
57
|
+
self.backtrace = backtrace
|
58
|
+
end
|
59
|
+
|
60
|
+
def with_backtrace(backtrace)
|
61
|
+
ValidationOptions.new(exhaustive: exhaustive, strict: strict, backtrace: backtrace)
|
62
|
+
end
|
63
|
+
|
64
|
+
def trace(*traces)
|
65
|
+
with_backtrace(backtrace.dup.concat(traces))
|
66
|
+
end
|
67
|
+
|
68
|
+
def exhaustive!
|
69
|
+
ValidationOptions.new(exhaustive: true, strict: strict, backtrace: backtrace)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Validates the +output+ and raises on certain validation errors
|
75
|
+
#
|
76
|
+
# @param [#each] output output to validate
|
77
|
+
# @option opts [Boolean] exhaustive if true, the entire schema needs to be consumed
|
78
|
+
# @option opts [Boolean] strict if true, no extra keys may be present in +output+
|
79
|
+
# @option opts[Array<String>] backtrace the current backtrace for error messages
|
80
|
+
#
|
81
|
+
# @raise ExhaustedOutputError
|
82
|
+
# @raise StrictValidationError
|
83
|
+
# @raise EmptyOutputError
|
84
|
+
# @raise ValidationError
|
85
|
+
#
|
86
|
+
# @return [TrueClass]
|
87
|
+
#
|
88
|
+
def validate(output, options = nil, **opts)
|
89
|
+
options ||= ValidationOptions.new(**opts)
|
90
|
+
|
91
|
+
catch(:end) do
|
92
|
+
validate!(output, options, context: nil)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def validate!(output, call_options, **_opts)
|
97
|
+
empty_guard!(output, call_options)
|
98
|
+
|
99
|
+
exhaustive_guard!(validations.keys, call_options) do |mark|
|
100
|
+
all?(output, call_options) do |key, value, options:, context:|
|
101
|
+
mark.call(key)
|
102
|
+
|
103
|
+
validations[key].validate!(
|
104
|
+
value,
|
105
|
+
options.trace(key),
|
106
|
+
context: context
|
107
|
+
)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
##
|
113
|
+
# Adds an attribute to the schema
|
114
|
+
#
|
115
|
+
# @param key [Symbol] the attribute name
|
116
|
+
# @param type [Class, #===] The type of the value, can be anything that responds to #===
|
117
|
+
# @param opts [Hash] options
|
118
|
+
#
|
119
|
+
# @example Add an attribute named foo, expecting a string
|
120
|
+
#
|
121
|
+
# class MyMedia < Base
|
122
|
+
# current_schema do
|
123
|
+
# attribute :foo, String
|
124
|
+
# end
|
125
|
+
# end
|
126
|
+
#
|
127
|
+
def attribute(key, type = String, **opts, &block)
|
128
|
+
validations[key] = Attribute.new(type, **opts, &block)
|
129
|
+
end
|
130
|
+
|
131
|
+
##
|
132
|
+
# Allow for any key.
|
133
|
+
# The +block+ defines the Schema for each value.
|
134
|
+
#
|
135
|
+
# @param [Boolean] allow_empty if true, empty (no key/value present) is allowed
|
136
|
+
#
|
137
|
+
def any(allow_empty: false, &block)
|
138
|
+
scheme = Scheme.new(allow_empty: allow_empty)
|
139
|
+
scheme.instance_exec(&block)
|
140
|
+
|
141
|
+
validations.default = scheme
|
142
|
+
end
|
143
|
+
|
144
|
+
##
|
145
|
+
# Allow for extra keys in the schema/collection
|
146
|
+
# even when passing strict: true to #validate!
|
147
|
+
#
|
148
|
+
def not_strict
|
149
|
+
validations.default = NotStrict.new
|
150
|
+
end
|
151
|
+
|
152
|
+
##
|
153
|
+
# Expect a collection such as an array or hash.
|
154
|
+
# The +block+ defines the Schema for each item in that collection.
|
155
|
+
#
|
156
|
+
# @param [Symbol] key
|
157
|
+
# @param [Boolean] allow_empty, if true accepts 0 items in an array / hash
|
158
|
+
#
|
159
|
+
def collection(key, allow_empty: false, &block)
|
160
|
+
scheme = Scheme.new(allow_empty: allow_empty)
|
161
|
+
scheme.instance_exec(&block)
|
162
|
+
|
163
|
+
validations[key] = scheme
|
164
|
+
end
|
165
|
+
|
166
|
+
##
|
167
|
+
# Expect a link
|
168
|
+
#
|
169
|
+
def link(*args, **opts, &block)
|
170
|
+
validations.fetch(:_links) do
|
171
|
+
Links.new.tap do |links|
|
172
|
+
validations[:_links] = links
|
173
|
+
end
|
174
|
+
end.link(*args, **opts, &block)
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
attr_accessor :validations, :allow_empty
|
180
|
+
|
181
|
+
def empty_guard!(output, options)
|
182
|
+
return unless output.nil? || output.empty?
|
183
|
+
throw(:end, true) if allow_empty
|
184
|
+
raise_empty!(backtrace: options.backtrace)
|
185
|
+
end
|
186
|
+
|
187
|
+
class EnumerationContext
|
188
|
+
def initialize(validations:)
|
189
|
+
self.validations = validations
|
190
|
+
end
|
191
|
+
|
192
|
+
def enumerate(val)
|
193
|
+
self.key = val
|
194
|
+
self
|
195
|
+
end
|
196
|
+
|
197
|
+
attr_accessor :validations, :key
|
198
|
+
end
|
199
|
+
|
200
|
+
def all?(enumerable, options, &block)
|
201
|
+
context = EnumerationContext.new(validations: validations)
|
202
|
+
|
203
|
+
if enumerable.is_a?(Hash) || enumerable.respond_to?(:key)
|
204
|
+
return enumerable.all? do |key, value|
|
205
|
+
yield key, value, options: options, context: context.enumerate(key)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
enumerable.each_with_index.all? do |array_like_element, i|
|
210
|
+
all?(array_like_element, options.trace(i), &block)
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def raise_empty!(backtrace:)
|
215
|
+
raise EmptyOutputError, format('Expected output, got empty at %<backtrace>s', backtrace: backtrace.join('->'))
|
216
|
+
end
|
217
|
+
|
218
|
+
def raise_exhausted!(backtrace:, missing_keys:)
|
219
|
+
raise ExhaustedOutputError, format(
|
220
|
+
'Missing keys in output: %<missing_keys>s at [%<backtrace>s]',
|
221
|
+
missing_keys: missing_keys,
|
222
|
+
backtrace: backtrace.join('->')
|
223
|
+
)
|
224
|
+
end
|
225
|
+
|
226
|
+
def exhaustive_guard!(keys, options)
|
227
|
+
unless options.exhaustive
|
228
|
+
return yield(->(_) {})
|
229
|
+
end
|
230
|
+
|
231
|
+
exhaustive_keys = keys.dup
|
232
|
+
result = yield ->(key) { exhaustive_keys.delete(key) }
|
233
|
+
return result if exhaustive_keys.empty?
|
234
|
+
|
235
|
+
raise_exhausted!(missing_keys: exhaustive_keys, backtrace: options.backtrace)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|