carbonyte 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -3
  3. data/Rakefile +6 -0
  4. data/app/controllers/carbonyte/application_controller.rb +17 -0
  5. data/app/controllers/carbonyte/concerns/correlatable.rb +48 -0
  6. data/app/controllers/carbonyte/concerns/loggable.rb +16 -0
  7. data/app/controllers/carbonyte/concerns/policiable.rb +27 -0
  8. data/app/controllers/carbonyte/concerns/rescuable.rb +93 -47
  9. data/app/controllers/carbonyte/concerns/serializable.rb +48 -0
  10. data/app/interactors/carbonyte/application_interactor.rb +14 -0
  11. data/app/interactors/carbonyte/finders/application_finder.rb +4 -6
  12. data/app/policies/carbonyte/application_policy.rb +55 -0
  13. data/config/routes.rb +1 -0
  14. data/lib/carbonyte.rb +1 -0
  15. data/lib/carbonyte/engine.rb +14 -0
  16. data/lib/carbonyte/initializers.rb +9 -0
  17. data/lib/carbonyte/initializers/lograge.rb +74 -0
  18. data/lib/carbonyte/version.rb +1 -1
  19. data/spec/api/health_spec.rb +12 -0
  20. data/spec/controllers/concerns/rescuable_spec.rb +89 -0
  21. data/spec/controllers/concerns/serializable_spec.rb +69 -0
  22. data/spec/dummy/Rakefile +8 -0
  23. data/spec/dummy/app/controllers/application_controller.rb +7 -0
  24. data/spec/dummy/app/models/application_record.rb +5 -0
  25. data/spec/dummy/app/models/user.rb +5 -0
  26. data/spec/dummy/bin/rails +6 -0
  27. data/spec/dummy/bin/rake +6 -0
  28. data/spec/dummy/bin/setup +35 -0
  29. data/spec/dummy/config.ru +7 -0
  30. data/spec/dummy/config/application.rb +33 -0
  31. data/spec/dummy/config/boot.rb +7 -0
  32. data/spec/dummy/config/database.yml +25 -0
  33. data/spec/dummy/config/environment.rb +7 -0
  34. data/spec/dummy/config/environments/development.rb +64 -0
  35. data/spec/dummy/config/environments/production.rb +114 -0
  36. data/spec/dummy/config/environments/test.rb +51 -0
  37. data/spec/dummy/config/initializers/content_security_policy.rb +29 -0
  38. data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -0
  39. data/spec/dummy/config/initializers/inflections.rb +17 -0
  40. data/spec/dummy/config/initializers/wrap_parameters.rb +16 -0
  41. data/spec/dummy/config/locales/en.yml +33 -0
  42. data/spec/dummy/config/puma.rb +40 -0
  43. data/spec/dummy/config/routes.rb +5 -0
  44. data/spec/dummy/config/spring.rb +8 -0
  45. data/spec/dummy/config/storage.yml +34 -0
  46. data/spec/dummy/db/migrate/20201007132857_create_users.rb +11 -0
  47. data/spec/dummy/db/schema.rb +21 -0
  48. data/spec/interactors/application_interactor_spec.rb +23 -0
  49. data/spec/interactors/finders/application_finder_spec.rb +60 -0
  50. data/spec/policies/application_policy_spec.rb +20 -0
  51. data/spec/rails_helper.rb +65 -0
  52. data/spec/spec_helper.rb +101 -0
  53. data/spec/support/carbonyte/api_helpers.rb +13 -0
  54. data/spec/support/carbonyte/spec_helper.rb +7 -0
  55. metadata +81 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f88398d2111a4b6f2423dd798eb0d629fb7e9f78b54d49eea3b6fe28d60590ef
4
- data.tar.gz: ddf8b9fe408fa7f2fa8b85bdc7157db5bfd07aa17fb165d68c76c7e7148b6d6e
3
+ metadata.gz: e0182bc2a05957e2aa33ff9d29b1bef6e80e0bf46e043cab50ea83088a8b9979
4
+ data.tar.gz: 9975a93ce713a8a3dad2a0493905a75f257f9f2d36403ece1e145e0ed03896c3
5
5
  SHA512:
6
- metadata.gz: 7875ec1d6d77312e7d7a0ad4f9f2db33aae0db243ca0d4d9edcb55ec4d90816d94027821d6e7abeb9a06757064da42a84fccb9becb8097cbea19fe0f9047733c
7
- data.tar.gz: 5ff2e50321cb295d0726465488d2094152c30c63849c5af32bfb2a4b17dd70bb573a595d53d0d9a2306b42a54ff4c5ce8d371602728997515cc84df2e8ef55ab
6
+ metadata.gz: 4546a5fb7554b04c2cc1f38657d315c157611ee4cd16af526443c3d24d8216976392950016ce144ab7dc5eefb9949ea0070c4031c5dadea3a8dcd1fcfa10c61b
7
+ data.tar.gz: f9808c42d3dcea7d9d293ce3481dfd79b5215218587cedba4b62e114843cbc268bc793cc2b0a2f28e2d5a96e40e90ed2c8908601a277911c6e4d27cd433b08c6
data/README.md CHANGED
@@ -13,9 +13,6 @@ And then execute:
13
13
  $ bundle
14
14
  ```
15
15
 
16
- ## Usage
17
- TBD
18
-
19
16
  ## Contributing
20
17
  Before pushing:
21
18
  * Run `rubocop` to show code styles offences
data/Rakefile CHANGED
@@ -19,3 +19,9 @@ end
19
19
  load 'rails/tasks/statistics.rake'
20
20
 
21
21
  require 'bundler/gem_tasks'
22
+
23
+ require 'rspec/core/rake_task'
24
+
25
+ RSpec::Core::RakeTask.new(:spec)
26
+
27
+ task default: :spec
@@ -3,5 +3,22 @@
3
3
  module Carbonyte
4
4
  # Carbonyte base class for all controllers
5
5
  class ApplicationController < ActionController::API
6
+ include Concerns::Correlatable
7
+ include Concerns::Loggable
8
+ include Concerns::Rescuable
9
+ include Concerns::Policiable
10
+ include Concerns::Serializable
11
+
12
+ # GET /health
13
+ def health
14
+ payload = {
15
+ correlation_id: correlation_id,
16
+ db_status: ActiveRecord::Base.connected? ? 'OK' : 'Not Connected',
17
+ environment: Rails.env,
18
+ pid: ::Process.pid
19
+ }
20
+
21
+ render json: payload, status: :ok
22
+ end
6
23
  end
7
24
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'request_store'
4
+
5
+ module Carbonyte
6
+ # Module enclosing all concerns
7
+ module Concerns
8
+ # The Correlatable concern automatically manages the correlation ID
9
+ module Correlatable
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ before_action :find_or_create_correlation_id
14
+ after_action :send_correlation_id
15
+ end
16
+
17
+ # Retrieves the request correlation ID
18
+ def correlation_id
19
+ RequestStore.store[:correlation_id]
20
+ end
21
+
22
+ protected
23
+
24
+ # Finds the correlation ID in the request.
25
+ # Can be overridden to fit the application
26
+ def find_correlation_id
27
+ request.headers['X-Correlation-Id'] || request.headers['X-Request-Id']
28
+ end
29
+
30
+ # Generates a correlation ID (defaults to Rails auto-generated one).
31
+ # Can be overridden to fit the application
32
+ def create_correlation_id
33
+ request.request_id || SecureRandom.uuid
34
+ end
35
+
36
+ private
37
+
38
+ def find_or_create_correlation_id
39
+ correlation_id = find_correlation_id || create_correlation_id
40
+ RequestStore.store[:correlation_id] = correlation_id
41
+ end
42
+
43
+ def send_correlation_id
44
+ response.set_header('X-Correlation-Id', correlation_id)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carbonyte
4
+ module Concerns
5
+ # The Loggable concern integrates with lograge to log efficiently
6
+ module Loggable
7
+ extend ActiveSupport::Concern
8
+
9
+ # Used for logging IP.
10
+ # @see Carbonyte::Engine
11
+ def remote_ip
12
+ request.headers['HTTP_X_FORWARDED_FOR'] || request.headers['HTTP_X_REAL_IP'] || request.remote_ip
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carbonyte
4
+ module Concerns
5
+ # The Policiable concern integrates with Pundit to manage policies
6
+ module Policiable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Pundit
11
+ rescue_from Pundit::NotAuthorizedError, with: :policy_not_authorized
12
+ end
13
+
14
+ private
15
+
16
+ def policy_not_authorized(exc)
17
+ payload = {
18
+ code: exc.class.name,
19
+ source: { policy: exc.policy.class.name },
20
+ title: 'Not Authorized By Policy',
21
+ detail: exc.message
22
+ }
23
+ render json: serialized_errors(payload), status: :forbidden
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,61 +1,107 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carbonyte
4
- # The Rescuable concern rescues controllers from bubbling up the most common exceptions
5
- # and provides a uniformed response body in case of such errors.
6
- # @see https://jsonapi.org/format/#error-objects
7
- module Rescuable
8
- extend ActiveSupport::Concern
9
-
10
- included do
11
- rescue_from Pundit::NotAuthorizedError, with: :unauthorized
12
- rescue_from Interactor::Failure, with: :interactor_failure
13
- rescue_from ActiveRecord::RecordInvalid, with: :record_invalid
14
- end
4
+ module Concerns
5
+ # The Rescuable concern rescues controllers from bubbling up the most common exceptions
6
+ # and provides a uniformed response body in case of such errors.
7
+ # @see https://jsonapi.org/format/#error-objects
8
+ module Rescuable
9
+ extend ActiveSupport::Concern
15
10
 
16
- private
11
+ included do
12
+ # The first one MUST be StandardError as otherwise will catch all others
13
+ rescue_from StandardError, with: :internal_error
17
14
 
18
- def serialized_errors(payload)
19
- payload = [payload] unless payload.is_a?(Enumerable)
20
- payload.to_json
21
- end
15
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
16
+ rescue_from Interactor::Failure, with: :interactor_failure
17
+ rescue_from ActiveRecord::RecordInvalid,
18
+ ActiveRecord::RecordNotSaved,
19
+ with: :record_invalid
20
+ end
22
21
 
23
- def unauthorized(exc)
24
- payload = {
25
- code: exc.class.name,
26
- source: { policy: exc.policy.class.name },
27
- title: 'Not Authorized By Policy',
28
- detail: exc.message
29
- }
30
- render json: serialized_errors(payload), status: :unauthorized
31
- end
22
+ # Upon rescuing an exception, stores that exception in RequestStore for later logging
23
+ # @param exception [StandardError] the rescued exception
24
+ def rescue_with_handler(exception)
25
+ RequestStore.store[:rescued_exception] = exception
26
+ super
27
+ end
32
28
 
33
- def interactor_failure(exc)
34
- payload = {
35
- code: exc.class.name,
36
- source: { interactor: exc.context._called.last.class.name },
37
- title: 'Interactor Failure',
38
- detail: exc.context.error
39
- }
40
- render json: serialized_errors(payload), status: :unprocessable_entity
41
- end
29
+ # This is a special case
30
+ def route_not_found
31
+ payload = {
32
+ code: 'RoutingError',
33
+ source: request.path,
34
+ title: 'Route not found',
35
+ detail: "No route matches #{request.path}"
36
+ }
37
+ render json: serialized_errors(payload), status: :not_found
38
+ end
42
39
 
43
- def record_invalid(exc)
44
- payload = errors_for_record(exc.record, exc.class.name).flatten
45
- render json: serialized_errors(payload), status: :unprocessable_entity
46
- end
40
+ private
41
+
42
+ def serialized_errors(payload)
43
+ payload = [payload] unless payload.is_a?(Array)
44
+ { errors: payload }.to_json
45
+ end
47
46
 
48
- def errors_for_record(record, code)
49
- record.messages.map do |field, errors|
50
- errors.map do |error_message|
51
- {
52
- code: code,
53
- source: { pointer: "attributes/#{field}" },
54
- title: 'Invalid Field ',
55
- detail: error_message
56
- }
47
+ def unauthenticated(exc)
48
+ payload = {
49
+ code: exc.class.name,
50
+ title: 'User Not Authenticated',
51
+ detail: exc.message
52
+ }
53
+ render json: serialized_errors(payload), status: :unauthorized
54
+ end
55
+
56
+ def record_not_found(exc)
57
+ payload = {
58
+ code: exc.class.name,
59
+ source: {
60
+ model: exc.model,
61
+ id: exc.id
62
+ },
63
+ title: 'Resource Not Found',
64
+ detail: exc.message
65
+ }
66
+ render json: serialized_errors(payload), status: :not_found
67
+ end
68
+
69
+ def interactor_failure(exc)
70
+ payload = {
71
+ code: exc.class.name,
72
+ source: { interactor: exc.context.current_interactor.class.name },
73
+ title: 'Interactor Failure',
74
+ detail: exc.context.error
75
+ }
76
+ render json: serialized_errors(payload), status: :unprocessable_entity
77
+ end
78
+
79
+ def record_invalid(exc)
80
+ payload = errors_for_record(exc.record, exc.class.name).flatten
81
+ render json: serialized_errors(payload), status: :unprocessable_entity
82
+ end
83
+
84
+ def errors_for_record(record, code)
85
+ record.errors.messages.map do |field, errors|
86
+ errors.map do |error_message|
87
+ {
88
+ code: code,
89
+ source: { pointer: "attributes/#{field}" },
90
+ title: 'Invalid Field',
91
+ detail: error_message
92
+ }
93
+ end
57
94
  end
58
95
  end
96
+
97
+ def internal_error(exc)
98
+ payload = {
99
+ code: exc.class.name,
100
+ title: 'Internal Server Error',
101
+ detail: exc.message
102
+ }
103
+ render json: serialized_errors(payload), status: :internal_server_error
104
+ end
59
105
  end
60
106
  end
61
107
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carbonyte
4
+ module Concerns
5
+ # The Serializable concern helps with JSON:API serialization
6
+ module Serializable
7
+ extend ActiveSupport::Concern
8
+
9
+ # Helper method to render json responses
10
+ # @param object [Any] the object (or list) to be serialized
11
+ # @param serializer_class [JSONAPI::Serializer] serializer class to use. Inferred if not provided.
12
+ # @param status [Symbol, Number] the status code to return, defaults to 200 (OK)
13
+ # @example Serializes `user` using UserSerializer and returns status 200 (OK)
14
+ # user = User.find(123)
15
+ # serialize(user)
16
+ # @example Serializes `created_record` using the specified UserSerializer and returning status 201 (CREATED)
17
+ # serialize(created_record, serializer: UserSerializer, status: :created)
18
+ def serialize(object, serializer_class: nil, status: :ok)
19
+ serializer_class ||= serializer_for(object)
20
+ render json: serializer_class.new(object, serializer_options).serializable_hash.to_json, status: status
21
+ end
22
+
23
+ # Default options for serializers
24
+ def serializer_options
25
+ {
26
+ include: include_option,
27
+ params: {
28
+ current_user: current_user
29
+ }
30
+ }
31
+ end
32
+
33
+ # Default include options for serializers
34
+ def include_option
35
+ return [] unless params[:include]
36
+
37
+ params[:include].split(',').map(&:to_sym)
38
+ end
39
+
40
+ private
41
+
42
+ def serializer_for(object)
43
+ object = object.first if object.is_a?(Array)
44
+ "#{object.class.name}Serializer".constantize
45
+ end
46
+ end
47
+ end
48
+ end
@@ -4,5 +4,19 @@ module Carbonyte
4
4
  # Carbonyte base class for all interactors
5
5
  class ApplicationInteractor
6
6
  include Interactor
7
+
8
+ # Interactor hooks are not inherited by subclasses, so we need a hack.
9
+ # @see https://github.com/collectiveidea/interactor/issues/114
10
+ def self.inherited(klass)
11
+ klass.class_eval do
12
+ before do
13
+ context.current_interactor = self
14
+ end
15
+
16
+ after do
17
+ context.current_interactor = nil
18
+ end
19
+ end
20
+ end
7
21
  end
8
22
  end
@@ -25,8 +25,6 @@ module Carbonyte
25
25
  paginate
26
26
  end
27
27
 
28
- protected
29
-
30
28
  # Returns true if the provided relation can be included
31
29
  # @param _rel [Symbol] the relation to include
32
30
  def can_include?(_rel)
@@ -37,9 +35,9 @@ module Carbonyte
37
35
 
38
36
  def include_relations
39
37
  context.params[:include].split(',').each do |relation|
40
- next unless can_include?(relation)
38
+ next unless can_include?(relation.to_sym)
41
39
 
42
- context.scope = context.scope.includes(relation)
40
+ context.scope = context.scope.includes(relation.to_sym)
43
41
  end
44
42
  end
45
43
 
@@ -48,9 +46,9 @@ module Carbonyte
48
46
  end
49
47
 
50
48
  def sort_exp(prop)
51
- return prop unless prop.start_with?('-')
49
+ return prop.to_sym unless prop.start_with?('-')
52
50
 
53
- { prop[1..] => :desc }
51
+ { prop[1..].to_sym => :desc }
54
52
  end
55
53
 
56
54
  def sort
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carbonyte
4
+ # Carbonyte base class for all policies
5
+ class ApplicationPolicy
6
+ attr_reader :user, :record
7
+
8
+ # Initializes a new policy with the current user and the record
9
+ def initialize(user, record)
10
+ @user = user
11
+ @record = record
12
+ end
13
+
14
+ # Can the user get a list of records?
15
+ def index?
16
+ true
17
+ end
18
+
19
+ # Can the user get this specific record?
20
+ def show?
21
+ true
22
+ end
23
+
24
+ # Can the user create a new record?
25
+ def create?
26
+ true
27
+ end
28
+
29
+ # Can the user update this record?
30
+ def update?
31
+ true
32
+ end
33
+
34
+ # Can the user destroy this record?
35
+ def destroy?
36
+ true
37
+ end
38
+
39
+ # Carbonyte base class for all policy scopes
40
+ class Scope
41
+ attr_reader :user, :scope
42
+
43
+ # Initializes a new scope from the user and a base scope
44
+ def initialize(user, scope)
45
+ @user = user
46
+ @scope = scope
47
+ end
48
+
49
+ # Resolves the scope effectively triggering the query
50
+ def resolve
51
+ scope.all
52
+ end
53
+ end
54
+ end
55
+ end