media_types 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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