carbonyte 0.1.0 → 0.2.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 (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