media_types 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.
@@ -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