eipiai 0.6.0 → 0.7.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -4
  3. data/.rubocop.yml +3 -0
  4. data/.ruby-version +1 -0
  5. data/.wercker.yml +8 -1
  6. data/CHANGELOG.md +7 -0
  7. data/Gemfile +1 -0
  8. data/Rakefile +1 -0
  9. data/eipiai.gemspec +1 -0
  10. data/features/resources/api.feature +82 -0
  11. data/features/resources/collection.feature +60 -0
  12. data/features/resources/collection/get.feature +189 -0
  13. data/features/resources/collection/post.feature +230 -0
  14. data/features/resources/health.feature +27 -0
  15. data/features/resources/singular.feature +86 -0
  16. data/features/resources/singular/delete.feature +69 -0
  17. data/features/resources/singular/get.feature +146 -0
  18. data/features/resources/singular/post.feature +235 -0
  19. data/features/resources/singular/put.feature +164 -0
  20. data/features/step_definitions/steps.rb +10 -0
  21. data/features/step_definitions/webmachine_steps.rb +58 -0
  22. data/features/support/app.rb +14 -26
  23. data/features/support/env.rb +1 -0
  24. data/features/validation.feature +1 -0
  25. data/features/webmachine.feature +1 -56
  26. data/lib/eipiai.rb +1 -0
  27. data/lib/eipiai/configuration.rb +1 -0
  28. data/lib/eipiai/models.rb +1 -0
  29. data/lib/eipiai/models/collection.rb +1 -0
  30. data/lib/eipiai/models/representable.rb +6 -5
  31. data/lib/eipiai/roar.rb +1 -0
  32. data/lib/eipiai/roar/ext/hal.rb +3 -0
  33. data/lib/eipiai/roar/representers/api.rb +17 -6
  34. data/lib/eipiai/roar/representers/base.rb +1 -0
  35. data/lib/eipiai/validation.rb +1 -0
  36. data/lib/eipiai/validation/concerns/formatted_errors.rb +1 -0
  37. data/lib/eipiai/validation/validators/base.rb +2 -1
  38. data/lib/eipiai/validation/validators/sequel.rb +1 -0
  39. data/lib/eipiai/version.rb +2 -1
  40. data/lib/eipiai/webmachine.rb +2 -0
  41. data/lib/eipiai/webmachine/ext/decision.rb +1 -0
  42. data/lib/eipiai/webmachine/ext/request.rb +2 -1
  43. data/lib/eipiai/webmachine/resources/api.rb +7 -3
  44. data/lib/eipiai/webmachine/resources/base.rb +96 -19
  45. data/lib/eipiai/webmachine/resources/collection.rb +4 -18
  46. data/lib/eipiai/webmachine/resources/concerns/objectifiable.rb +1 -0
  47. data/lib/eipiai/webmachine/resources/concerns/representable.rb +69 -0
  48. data/lib/eipiai/webmachine/resources/health.rb +16 -10
  49. data/lib/eipiai/webmachine/resources/singular.rb +45 -10
  50. metadata +16 -4
  51. data/features/support/db.rb +0 -8
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'eipiai/roar/representers/base'
2
3
 
3
4
  module Eipiai
@@ -12,23 +13,33 @@ module Eipiai
12
13
 
13
14
  def self.populate_from_app(app)
14
15
  app.routes.each do |route|
15
- next unless route.resource.respond_to?(:link_identifier)
16
+ resource = route.resource.new(nil, nil)
17
+ next unless resource.respond_to?(:resource_relation) && resource.top_level_relation?
16
18
 
17
- templated = route.path_spec.any? { |r| r.is_a?(Symbol) }
18
- options = { rel: route.resource.link_identifier }
19
- options.merge!(templated: templated) if templated
19
+ options = {
20
+ rel: resource.resource_relation,
21
+ templated: templated?(route)
22
+ }
20
23
 
21
- add_route(route, options)
24
+ add_route(route, resource.query_keys, options.compact)
22
25
  end
23
26
  end
24
27
 
25
- def self.add_route(route, options)
28
+ def self.add_route(route, query_keys, options)
26
29
  link(options) do |context|
27
30
  request = context[:request]
28
31
  path = '/' + route.path_spec.map { |e| e.is_a?(Symbol) ? "{#{e}}" : e }.join('/')
32
+ path += "{?#{query_keys.join(',')}}" if query_keys.any?
29
33
 
30
34
  request.present? ? Addressable::URI.parse(request.uri).merge(path: path).to_s : path
31
35
  end
32
36
  end
37
+
38
+ def self.templated?(route)
39
+ query_keys = route.resource.new(nil, nil).query_keys
40
+ templated = query_keys.any? || route.path_spec.map(&:class).include?(Symbol)
41
+
42
+ templated || nil
43
+ end
33
44
  end
34
45
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'addressable/uri'
2
3
  require 'roar'
3
4
  require 'roar/decorator'
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'eipiai/validation/concerns/formatted_errors'
2
3
 
3
4
  require 'eipiai/validation/validators/base'
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # FormattedErrors
2
3
  #
3
4
  # copy/paste of the https://github.com/blendle/formatted_errors gem, until that
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Eipiai
2
3
  # Validator
3
4
  #
@@ -48,7 +49,7 @@ module Eipiai
48
49
  # @return [void]
49
50
  #
50
51
  def validate
51
- fail NotImplementedError, '#validate required'
52
+ raise NotImplementedError, '#validate required'
52
53
  end
53
54
 
54
55
  # errors
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'eipiai/validation/validators/base'
2
3
 
3
4
  module Eipiai
@@ -1,7 +1,8 @@
1
+ # frozen_string_literal: true
1
2
  # Eipiai
2
3
  #
3
4
  # The current version of the Eipiai library.
4
5
  #
5
6
  module Eipiai
6
- VERSION = '0.6.0'
7
+ VERSION = '0.7.0'
7
8
  end
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
1
2
  require 'webmachine'
2
3
 
3
4
  require 'eipiai/webmachine/ext/decision'
4
5
  require 'eipiai/webmachine/ext/request'
5
6
 
6
7
  require 'eipiai/webmachine/resources/concerns/objectifiable'
8
+ require 'eipiai/webmachine/resources/concerns/representable'
7
9
 
8
10
  require 'eipiai/webmachine/resources/api'
9
11
  require 'eipiai/webmachine/resources/base'
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'webmachine'
2
3
 
3
4
  module Webmachine
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'addressable/uri'
2
3
  require 'webmachine'
3
4
 
@@ -38,7 +39,7 @@ module Webmachine
38
39
  module URIReplacement
39
40
  # build_uri
40
41
  #
41
- # Given an uri object, and headers, calls `Webmachine::Request#build_uri`
42
+ # Given an uri object, and headers, calls `Webmachine::Request#build_uri`
42
43
  # and parses the result using Addressable (if available), or simply
43
44
  # returns the result as-is.
44
45
  #
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'eipiai/webmachine/resources/base'
2
3
 
3
4
  require 'active_support/core_ext/string/inflections'
@@ -15,13 +16,16 @@ module Eipiai
15
16
 
16
17
  # ApiResource
17
18
  #
18
- # The base resource which can be included in regular Webmachine::Resource
19
- # objects. It provides sensible defaults for a full-features REST API
20
- # endpoint.
19
+ # The API resource handles the /api endpoint, which is the main entrypoint for
20
+ # all HyperMedia based API requests.
21
21
  #
22
22
  class ApiResource < Webmachine::Resource
23
23
  include Resource
24
24
 
25
+ def top_level_relation?
26
+ true
27
+ end
28
+
25
29
  def allowed_methods
26
30
  %w(GET)
27
31
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'eipiai/webmachine/resources/singular'
2
3
  require 'eipiai/webmachine/resources/collection'
3
4
 
@@ -13,6 +14,7 @@ module Eipiai
13
14
  #
14
15
  module Resource
15
16
  include Objectifiable
17
+ include Representable
16
18
 
17
19
  # Includes the correct resource into the class, depending on its name.
18
20
  #
@@ -27,8 +29,57 @@ module Eipiai
27
29
  else
28
30
  base.send(:include, CollectionResource)
29
31
  end
32
+ end
33
+
34
+ # resource_relation
35
+ #
36
+ # The name to be used when linking to this resource as a relation.
37
+ #
38
+ # The string returned from this method will become the name of the link in
39
+ # the HAL+JSON representation of this resource.
40
+ #
41
+ # @example
42
+ # resource.resource_relation # => 'items'
43
+ #
44
+ # @return [String] resource relation name
45
+ #
46
+ def resource_relation
47
+ resource_name.demodulize.underscore
48
+ end
49
+
50
+ # top_level_relation?
51
+ #
52
+ # return `true` if the link relation to this resource should be added to the
53
+ # top-level API entrypoint.
54
+ #
55
+ # It is good practice to keep most link relations out of the top-level
56
+ # entrypoint, instead opting for nesting the resource within its parent
57
+ # resource links relations.
58
+ #
59
+ # @return [true, false]
60
+ #
61
+ def top_level_relation?
62
+ false
63
+ end
64
+
65
+ # query_keys
66
+ #
67
+ # Returns an array of optional query component keys this resource accepts.
68
+ # The keys are added to the link relation as templated variables.
69
+ #
70
+ # Defaults to an empty array, not exposing any optional query keys.
71
+ #
72
+ # @return [Array<String>] array of query keys
73
+ #
74
+ def query_keys
75
+ []
76
+ end
30
77
 
31
- base.extend(ClassMethods)
78
+ def service_available?
79
+ return true if Eipiai::HealthCheck.new.healthy?
80
+
81
+ response.headers['Retry-After'] = '60'
82
+ false
32
83
  end
33
84
 
34
85
  # malformed_request?
@@ -76,13 +127,17 @@ module Eipiai
76
127
  end
77
128
 
78
129
  def content_types_provided
79
- [['application/hal+json', :to_json]]
130
+ [['application/hal+json', :to_hal_json], ['application/json', :to_json]]
80
131
  end
81
132
 
82
133
  def content_types_accepted
83
134
  [['application/json', :from_json]]
84
135
  end
85
136
 
137
+ def post_is_create?
138
+ true
139
+ end
140
+
86
141
  # params
87
142
  #
88
143
  # Given a string in JSON format, returns the hash representation of that
@@ -107,12 +162,12 @@ module Eipiai
107
162
  {}
108
163
  end
109
164
 
110
- # to_hash
165
+ # to_h
111
166
  #
112
- # Given an object, calls `#to_hash` on that object,
167
+ # Given an object, calls `#to_h` on that object,
113
168
  #
114
- # If the object's `to_hash` implementation accepts any arguments, the
115
- # hash `{ request: request }` is sent as its first argument.
169
+ # If the object's `to_h` implementation accepts any arguments, the hash
170
+ # `{ request: request }` is sent as its first argument.
116
171
  #
117
172
  # In practice, this method is used without any parameters, causing the
118
173
  # method to call `represented`, which represents a Roar representer. This in
@@ -122,17 +177,23 @@ module Eipiai
122
177
  # @example
123
178
  # item = Item.new(uid: 'hello')
124
179
  # get('/item/hello')
125
- # resource.to_hash(item)['uid'] # => 'hello'
180
+ # resource.to_h(item)['uid'] # => 'hello'
126
181
  #
127
- # @param [Object] obj to call #to_hash on
182
+ # @param [Object] obj to call #to_h on
128
183
  # @return [Hash] hash representation of the object
129
184
  #
130
- def to_hash(obj = object)
131
- obj.method(:to_hash).arity.zero? ? obj.to_hash : obj.to_hash(request: request)
185
+ def to_h(obj = object)
186
+ obj.method(:to_h).arity.zero? ? obj.to_h : obj.to_h(request: request)
187
+ rescue
188
+ obj
132
189
  end
133
190
 
134
191
  def to_json
135
- to_hash.to_json
192
+ to_h.to_json
193
+ end
194
+
195
+ def to_hal_json
196
+ to_h.to_json
136
197
  end
137
198
 
138
199
  # new_object
@@ -154,6 +215,14 @@ module Eipiai
154
215
  @new_object ||= object_class.new.from_hash(params)
155
216
  end
156
217
 
218
+ def base_uri
219
+ request.base_uri
220
+ end
221
+
222
+ def create_uri
223
+ Addressable::URI.join(base_uri, create_path)
224
+ end
225
+
157
226
  private
158
227
 
159
228
  def json_error_body(errors)
@@ -162,14 +231,22 @@ module Eipiai
162
231
  true
163
232
  end
164
233
 
165
- # ClassMethods
166
- #
167
- # These methods will be defined on the class in which this module is
168
- # included.
169
- #
170
- module ClassMethods
171
- def link_identifier
172
- name.chomp('Resource').demodulize.underscore.to_sym
234
+ def content_type_handler
235
+ content_type = request.headers['accept']
236
+ media_type = Webmachine::MediaType.parse(content_type)
237
+
238
+ content_types_provided.find { |ct, _| media_type.type_matches?(ct) }.last
239
+ rescue ArgumentError
240
+ nil
241
+ end
242
+
243
+ def handle_post_or_put_with_optional_content(created: true)
244
+ if content_type_handler
245
+ response.body = send(content_type_handler)
246
+ created ? 201 : 200
247
+ else
248
+ response.headers['Location'] ||= create_uri.to_s
249
+ created ? 201 : 204
173
250
  end
174
251
  end
175
252
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'eipiai/webmachine/resources/base'
2
3
 
3
4
  module Eipiai
@@ -15,33 +16,18 @@ module Eipiai
15
16
  %w(GET POST)
16
17
  end
17
18
 
18
- def post_is_create?
19
- true
20
- end
21
-
22
19
  def create_path
23
20
  new_object.path
24
21
  end
25
22
 
26
23
  def from_json
27
- new_object.save && handle_post_response
24
+ new_object.save
25
+ handle_post_or_put_with_optional_content
28
26
  end
29
27
 
30
28
  private
31
29
 
32
- def content_type_handler
33
- content_type = response.headers[Webmachine::CONTENT_TYPE]
34
- media_type = Webmachine::MediaType.parse(content_type)
35
-
36
- content_types_provided.find { |ct, _| media_type.type_matches?(ct) }.last
37
- end
38
-
39
- def handle_post_response
40
- response.body = send(content_type_handler)
41
- true
42
- end
43
-
44
- def to_hash
30
+ def to_h
45
31
  super(request.get? ? object : new_object)
46
32
  end
47
33
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Eipiai
2
3
  module Resource
3
4
  # Objectifiable
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ module Eipiai
3
+ module Resource
4
+ # Representable
5
+ #
6
+ # When included in a resource, provides basic methods to get to the
7
+ # "representer" that can be used to represent the object that the resource
8
+ # provides access to.
9
+ #
10
+ module Representable
11
+ private
12
+
13
+ # resource_name
14
+ #
15
+ # Take the resource class name, and strip `Resource` part from the name.
16
+ #
17
+ # @example
18
+ # resource.send :resource_name # => 'Eipiai::TestApp::Items'
19
+ #
20
+ # @return [String] resource name
21
+ #
22
+ def resource_name
23
+ self.class.name.chomp('Resource')
24
+ end
25
+
26
+ # singular_representer_class
27
+ #
28
+ # Constant, representing the representer belonging to the defined
29
+ # `object`.
30
+ #
31
+ # If the resource is called `ItemResource`, the representer will be
32
+ # `ItemRepresenter`.
33
+ #
34
+ # Returns `nil` if constant does not exist.
35
+ #
36
+ # @example
37
+ # resource.send :singular_representer_class # => ItemRepresenter
38
+ #
39
+ # @return [Class, nil] representer class, or nil if not found
40
+ #
41
+ def singular_representer_class
42
+ "#{resource_name.singularize}Representer".constantize
43
+ rescue NameError
44
+ nil
45
+ end
46
+
47
+ # collection_representer_class
48
+ #
49
+ # Constant, representing the representer belonging to the defined
50
+ # collection of `object`s.
51
+ #
52
+ # If the resource is called `ItemResource`, the representer will be
53
+ # `ItemsRepresenter`.
54
+ #
55
+ # Returns `nil` if constant does not exist.
56
+ #
57
+ # @example
58
+ # resource.send :collection_representer_class # => ItemsRepresenter
59
+ #
60
+ # @return [Class, nil] representer class, or nil if not found
61
+ #
62
+ def collection_representer_class
63
+ "#{resource_name.pluralize}Representer".constantize
64
+ rescue NameError
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end