eipiai 0.6.0 → 0.7.0

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