nucleus-core 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b2545a3c813f8b6c7fc829e527ed77dc5105b10857af9ea2621bcb8ac81264de
4
+ data.tar.gz: 2b1724c47837c91d2039625f3efdea8fbc550d3513f0a9a253b0cb5104dd7c8c
5
+ SHA512:
6
+ metadata.gz: 84e7a40ba56b63f86dedf43cf139386cb73e128e30986ba3fb169ac39e46dea24625658f466183302a5706edb9aca5b1414b8392663f753abf417807a2b32dae
7
+ data.tar.gz: 5f7237b583e69352ebd3a805cd3bd4bbbe73951a9e06b128f8bbac7950631a110fd1abcd246c284fc30c6a958574d6bb804fbf4077f31606fd0b0ecab4f63f01
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 dodgerogers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # NucleusCore
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/nucleus-core.svg)](https://rubygems.org/gems/nucleus-core)
4
+ [![Circle](https://circleci.com/gh/dodgerogers/nucleus-core/tree/main.svg?style=shield)](https://app.circleci.com/pipelines/github/dodgerogers/nucleus-core?branch=main)
5
+ [![Code Climate](https://codeclimate.com/github/dodgerogers/nucleus-core/badges/gpa.svg)](https://codeclimate.com/github/dodgerogers/nucleus-core)
6
+
7
+ NucleusCore Core is a framework to express and orchestrate business logic in a way that is agnostic to the framework.
8
+
9
+ ## This gem is still very much in development. A `nucleus-rails` gem will handle the adaptation of NucleusCore::View objects to the rails rendering methods.
10
+
11
+ Here are the classes NucleusCore exposes, they have preordained responsibilities, can be composed together, and tested simply in isolation from the framework.
12
+
13
+ - Policy (Authorization) - Can this user perform this process?
14
+ - Operation (Services) - Executes a single unit of business logic, or side effect (ScheduleAppointment, CancelOrder, UpdateAddress).
15
+ - Workflow (Service Orchestration) - Excecutes multiple units of work, and side effects (ApproveLoan, TakePayment, CancelFulfillments).
16
+ - View (Presentation) - A presentation object which can render to multiple formats.
17
+ - Repository (Data access) - Interacts with data sources to hide the implementation details to callers, and return Aggregates. Data sources could be an API, ActiveRecord, SQL, a local file, etc.
18
+ - Aggregate (Domain/business Object) - Maps data from the data source to an object the aplication defines, known as an anti corruption layer.
19
+
20
+ Below is an example using NucleusCore Core with Rails:
21
+
22
+ ```ruby
23
+ # controllers/payments_controller.rb
24
+ class PaymentsController < ApplicationController
25
+ def create
26
+ NucleusCore::Responder.handle_response do
27
+ policy.enforce!(:can_write?)
28
+
29
+ context, _process = HandleCheckoutWorkflow.call(invoice_params)
30
+
31
+ return context if !context.success?
32
+
33
+ return PaymentView.new(cart: context.cart, paid: context.paid)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def policy
40
+ Policy.new(current_user)
41
+ end
42
+
43
+ def invoice_params
44
+ params.slice(:cart_id)
45
+ end
46
+ end
47
+
48
+ # workflows/handle_checkout_workflow.rb
49
+ class HandleCheckoutWorkflow < NucleusCore::Workflow
50
+ def define
51
+ start_node(continue: :calculate_amount)
52
+ register_node(
53
+ state: :calculate_amount,
54
+ operation: FetchShoppingCart,
55
+ determine_signal: ->(context) { context.cart.total > 10 ? :discount : :pay },
56
+ signals: { discount: :apply_discount, pay: :take_payment }
57
+ )
58
+ register_node(
59
+ state: :apply_discount,
60
+ operation: ApplyDiscountToShoppingCart,
61
+ signals: { continue: :take_payment }
62
+ )
63
+ register_node(
64
+ state: :take_payment,
65
+ operation: ->(context) { context.paid = context.cart.paid },
66
+ determine_signal: ->(_) { :completed }
67
+ )
68
+ register_node(
69
+ state: :completed,
70
+ determine_signal: ->(_) { :wait }
71
+ )
72
+ end
73
+ end
74
+
75
+ # app/operations/fetch_shopping_cart.rb
76
+ class FetchShoppingCart < NucleusCore::Operation
77
+ def call
78
+ cart = ShoppingCartRepository.find(context.cart_id)
79
+
80
+ context.cart = cart
81
+ rescue NucleusCore::NotFound => e
82
+ context.fail!(e.message, exception: e)
83
+ end
84
+ end
85
+
86
+ # app/repositories/shopping_cart_repository.rb
87
+ class ShoppingCartRepository < NucleusCore::Repository
88
+ def self.find(cart_id)
89
+ cart = ShoppingCart.find(cart_id)
90
+
91
+ return ShoppingCart::Aggregate.new(cart)
92
+ rescue ActiveRecord::RecordNotFound => e
93
+ raise NucleusCore::NotFound, e.message
94
+ end
95
+
96
+ def self.discount(cart_id, percentage)
97
+ cart = find(cart_id, percentage=0.5)
98
+
99
+ cart.update!(total: cart.total * percentage, paid: true)
100
+
101
+ return ShoppingCart::Aggregate.new(cart)
102
+ rescue NucleusCore::NotFound => e
103
+ raise e
104
+ end
105
+ end
106
+
107
+ class ShoppingCart < ActiveRecord::Base
108
+ # ...
109
+ end
110
+
111
+ # app/aggregates/shopping_cart.rb
112
+ class ShoppingCart::Aggregate < NucleusCore::Aggregate
113
+ def initialize(cart)
114
+ super(id: cart.id, total: cart.total, paid: cart.paid, created_at: cart.created_at)
115
+ end
116
+ end
117
+
118
+ # app/operations/apply_discount_to_shopping_cart.rb
119
+ class ApplyDiscountToShoppingCart < NucleusCore::Operation
120
+ def call
121
+ cart = ShoppingCartRepository.discount(context.cart_id, 0.75)
122
+
123
+ context.cart
124
+ rescue NucleusCore::NotFound => e
125
+ context.fail!(e.message, exception: e)
126
+ end
127
+ end
128
+
129
+ # app/views/payments_view.rb
130
+ class NucleusCore::PaymentView < NucleusCore::View
131
+ def initialize(cart)
132
+ super(total: "$#{cart.total}", paid: cart.paid, created_at: cart.created_at)
133
+ end
134
+
135
+ def json_response
136
+ content = {
137
+ payment: {
138
+ price: price,
139
+ paid: paid,
140
+ created_at: created_at,
141
+ signature: SecureRandom.hex
142
+ }
143
+ }
144
+
145
+ NucleusCore::JsonResponse.new(content: content)
146
+ end
147
+
148
+ def pdf_response
149
+ pdf_string = generate_pdf_string(price, paid)
150
+
151
+ NucleusCore::PdfResponse.new(content: pdf_string)
152
+ end
153
+
154
+ private def generate_pdf_string(price, paid)
155
+ # pdf string genration...
156
+ end
157
+ end
158
+ ```
159
+
160
+ ---
161
+
162
+ - [Quick start](#quick-start)
163
+ - [Support](#support)
164
+ - [License](#license)
165
+ - [Code of conduct](#code-of-conduct)
166
+ - [Contribution guide](#contribution-guide)
167
+
168
+ ## Quick start
169
+
170
+ ```
171
+ $ gem install nucleus-core
172
+ ```
173
+
174
+ ```ruby
175
+ require "nucleus-core"
176
+ ```
177
+
178
+ ## Support
179
+
180
+ If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/dodgerogers/nucleus_core/issues/new) and I will do my best to provide a helpful answer. Happy hacking!
181
+
182
+ ## License
183
+
184
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
185
+
186
+ ## Code of conduct
187
+
188
+ Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
189
+
190
+ ## Contribution guide
191
+
192
+ Pull requests are welcome!
data/exe/nucleus ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "nucleus"
4
+ NucleusCore::CLI.new.call(ARGV)
@@ -0,0 +1 @@
1
+ class NucleusCore::Aggregate < NucleusCore::BasicObject; end
@@ -0,0 +1,24 @@
1
+ module NucleusCore
2
+ class BasicObject
3
+ def initialize(attrs={})
4
+ attrs.each_pair do |key, value|
5
+ define_singleton_method(key.to_s) do
6
+ instance_variable_get("@#{key}")
7
+ end
8
+
9
+ define_singleton_method("#{key}=") do |val|
10
+ instance_variable_set("@#{key}", val)
11
+ end
12
+
13
+ instance_variable_set("@#{key}", value)
14
+ end
15
+ end
16
+
17
+ def to_h
18
+ instance_variables
19
+ .reduce({}) do |acc, var|
20
+ acc.merge(var.to_s.delete("@") => instance_variable_get(var))
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ module NucleusCore
2
+ class CLI
3
+ def call(_argv)
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class NucleusCore::BaseException < StandardError; end
2
+ class NucleusCore::NotAuthorized < NucleusCore::BaseException; end
3
+ class NucleusCore::NotFound < NucleusCore::BaseException; end
4
+ class NucleusCore::Unprocessable < NucleusCore::BaseException; end
5
+ class NucleusCore::BadRequest < NucleusCore::BaseException; end
@@ -0,0 +1,11 @@
1
+ class Array
2
+ def self.wrap(object)
3
+ if object.nil?
4
+ []
5
+ elsif object.respond_to?(:to_ary)
6
+ object.to_ary || [object]
7
+ else
8
+ [object]
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,86 @@
1
+ # Rack::Utils patch for status code
2
+ module NucleusCore
3
+ module Rack
4
+ class Utils
5
+ HTTP_STATUS_CODES = {
6
+ 100 => "Continue",
7
+ 101 => "Switching Protocols",
8
+ 102 => "Processing",
9
+ 103 => "Early Hints",
10
+ 200 => "OK",
11
+ 201 => "Created",
12
+ 202 => "Accepted",
13
+ 203 => "Non-Authoritative Information",
14
+ 204 => "No Content",
15
+ 205 => "Reset Content",
16
+ 206 => "Partial Content",
17
+ 207 => "Multi-Status",
18
+ 208 => "Already Reported",
19
+ 226 => "IM Used",
20
+ 300 => "Multiple Choices",
21
+ 301 => "Moved Permanently",
22
+ 302 => "Found",
23
+ 303 => "See Other",
24
+ 304 => "Not Modified",
25
+ 305 => "Use Proxy",
26
+ 306 => "(Unused)",
27
+ 307 => "Temporary Redirect",
28
+ 308 => "Permanent Redirect",
29
+ 400 => "Bad Request",
30
+ 401 => "Unauthorized",
31
+ 402 => "Payment Required",
32
+ 403 => "Forbidden",
33
+ 404 => "Not Found",
34
+ 405 => "Method Not Allowed",
35
+ 406 => "Not Acceptable",
36
+ 407 => "Proxy Authentication Required",
37
+ 408 => "Request Timeout",
38
+ 409 => "Conflict",
39
+ 410 => "Gone",
40
+ 411 => "Length Required",
41
+ 412 => "Precondition Failed",
42
+ 413 => "Payload Too Large",
43
+ 414 => "URI Too Long",
44
+ 415 => "Unsupported Media Type",
45
+ 416 => "Range Not Satisfiable",
46
+ 417 => "Expectation Failed",
47
+ 421 => "Misdirected Request",
48
+ 422 => "Unprocessable Entity",
49
+ 423 => "Locked",
50
+ 424 => "Failed Dependency",
51
+ 425 => "Too Early",
52
+ 426 => "Upgrade Required",
53
+ 428 => "Precondition Required",
54
+ 429 => "Too Many Requests",
55
+ 431 => "Request Header Fields Too Large",
56
+ 451 => "Unavailable for Legal Reasons",
57
+ 500 => "Internal Server Error",
58
+ 501 => "Not Implemented",
59
+ 502 => "Bad Gateway",
60
+ 503 => "Service Unavailable",
61
+ 504 => "Gateway Timeout",
62
+ 505 => "HTTP Version Not Supported",
63
+ 506 => "Variant Also Negotiates",
64
+ 507 => "Insufficient Storage",
65
+ 508 => "Loop Detected",
66
+ 509 => "Bandwidth Limit Exceeded",
67
+ 510 => "Not Extended",
68
+ 511 => "Network Authentication Required"
69
+ }.freeze
70
+
71
+ SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map do |code, message|
72
+ [message.downcase.gsub(/\s|-|'/, "_").to_sym, code]
73
+ end.flatten]
74
+
75
+ def self.status_code(status)
76
+ if status.is_a?(Symbol)
77
+ return SYMBOL_TO_STATUS_CODE.fetch(status) do
78
+ raise ArgumentError, "Unrecognized status code #{status.inspect}"
79
+ end
80
+ end
81
+
82
+ status.to_i
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,68 @@
1
+ require "ostruct"
2
+
3
+ module NucleusCore
4
+ class Operation
5
+ class Context < OpenStruct
6
+ class Error < StandardError
7
+ attr_reader :exception
8
+
9
+ def initialize(message, opts={})
10
+ @exception = opts[:exception]
11
+
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ attr_reader :failure
17
+
18
+ def initialize(attrs={})
19
+ @failure = false
20
+
21
+ super(attrs)
22
+ end
23
+
24
+ def success?
25
+ !@failure
26
+ end
27
+
28
+ def fail!(message, attrs={})
29
+ @failure = true
30
+
31
+ self.message = message
32
+ self.exception = attrs.delete(:exception)
33
+
34
+ raise Context::Error, message
35
+ end
36
+ end
37
+
38
+ attr_reader :context
39
+
40
+ def initialize(args={})
41
+ @context = args.is_a?(Context) ? args : Context.new(args)
42
+ end
43
+
44
+ def self.call(args={})
45
+ operation = new(args)
46
+
47
+ operation.call
48
+
49
+ operation.context
50
+ rescue Context::Error
51
+ operation.context
52
+ end
53
+
54
+ def self.rollback(context)
55
+ operation = new(context)
56
+
57
+ operation.rollback
58
+
59
+ operation.context
60
+ end
61
+
62
+ def call
63
+ end
64
+
65
+ def rollback
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,23 @@
1
+ require "nucleus_core/exceptions"
2
+
3
+ module NucleusCore
4
+ class Policy
5
+ attr_reader :user, :record
6
+
7
+ def initialize(user, record=nil)
8
+ @user = user
9
+ @record = record
10
+ end
11
+
12
+ def enforce!(*policy_methods)
13
+ policy_methods.each do |policy_method_and_args|
14
+ next if send(*policy_method_and_args)
15
+
16
+ name = Array.wrap(policy_method_and_args).first
17
+ message = "You do not have access to: #{name}"
18
+
19
+ raise NucleusCore::NotAuthorized, message
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ module NucleusCore
2
+ class Repository
3
+ def self.execute
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,130 @@
1
+ require "set"
2
+
3
+ module NucleusCore
4
+ module Responder
5
+ def set_request_format(request=nil)
6
+ @request_format = request&.format&.to_sym || :json
7
+ end
8
+
9
+ def request_format
10
+ @request_format ||= set_request_format
11
+ end
12
+
13
+ # rubocop:disable Lint/RescueException:
14
+ def handle_response(&block)
15
+ entity = proc_to_lambda(&block)
16
+
17
+ render_entity(entity)
18
+ rescue Exception => e
19
+ handle_exception(e)
20
+ end
21
+ # rubocop:enable Lint/RescueException:
22
+
23
+ def handle_exception(exception)
24
+ logger(exception)
25
+
26
+ status = exception_to_status(exception)
27
+ attrs = { message: exception.message, status: status }
28
+ error = NucleusCore::ErrorView.new(attrs)
29
+
30
+ render_entity(error)
31
+ end
32
+
33
+ # Calling `return` in a block/proc returns from the outer calling scope as well.
34
+ # Lambdas do not have this limitation. So we convert the proc returned
35
+ # from a block method into a lambda to avoid 'return' exiting the method early.
36
+ # https://stackoverflow.com/questions/2946603/ruby-convert-proc-to-lambda
37
+ def proc_to_lambda(&block)
38
+ define_singleton_method(:_proc_to_lambda_, &block)
39
+
40
+ method(:_proc_to_lambda_).to_proc.call
41
+ end
42
+
43
+ def render_entity(entity)
44
+ return handle_context(entity) if entity.is_a?(NucleusCore::Operation::Context)
45
+ return render_response(entity) if subclass_of(entity, NucleusCore::ResponseAdapter)
46
+ return render_view(entity) if subclass_of(entity, NucleusCore::View)
47
+ end
48
+
49
+ def handle_context(context)
50
+ return render_nothing(context) if context.success?
51
+ return handle_exception(context.exception) if context.exception
52
+
53
+ message = context.message
54
+ attrs = { message: message, status: :unprocessable_entity }
55
+ error_view = NucleusCore::ErrorView.new(attrs)
56
+
57
+ render_view(error_view)
58
+ end
59
+
60
+ def render_view(view)
61
+ format_rendering = "#{request_format}_response".to_sym
62
+ renders_format = view.respond_to?(format_rendering)
63
+ format_response = view.send(format_rendering) if renders_format
64
+
65
+ raise NucleusCore::BadRequest, "#{request_format} is not supported" if format_response.nil?
66
+
67
+ render_response(format_response)
68
+ end
69
+
70
+ def render_response(entity)
71
+ render_headers(entity.headers)
72
+
73
+ render_method = {
74
+ NucleusCore::JsonResponse => :render_json,
75
+ NucleusCore::XmlResponse => :render_xml,
76
+ NucleusCore::PdfResponse => :render_pdf,
77
+ NucleusCore::CsvResponse => :render_csv,
78
+ NucleusCore::TextResponse => :render_text,
79
+ NucleusCore::NoResponse => :render_nothing
80
+ }.fetch(entity.class, nil)
81
+
82
+ response_adapter&.send(render_method, entity)
83
+ end
84
+
85
+ def render_headers(headers={})
86
+ (headers || {}).each do |k, v|
87
+ formatted_key = k.titleize.gsub(/\s *|_/, "-")
88
+
89
+ response_adapter&.set_header(formatted_key, v)
90
+ end
91
+ end
92
+
93
+ # rubocop:disable Lint/DuplicateBranch
94
+ def exception_to_status(exception)
95
+ config = exception_map
96
+
97
+ case exception
98
+ when NucleusCore::NotFound, *config.not_found
99
+ :not_found
100
+ when NucleusCore::BadRequest, *config.bad_request
101
+ :bad_request
102
+ when NucleusCore::NotAuthorized, *config.forbidden
103
+ :forbidden
104
+ when NucleusCore::Unprocessable, *config.unprocessable
105
+ :unprocessable_entity
106
+ when NucleusCore::BaseException, *config.server_error
107
+ :internal_server_error
108
+ else
109
+ :internal_server_error
110
+ end
111
+ end
112
+ # rubocop:enable Lint/DuplicateBranch
113
+
114
+ def subclass_of(entity, *classes)
115
+ Set[*entity.class.ancestors].intersect?(classes.to_set)
116
+ end
117
+
118
+ def logger(object, log_level=:info)
119
+ NucleusCore.configuration.logger&.send(log_level, object)
120
+ end
121
+
122
+ def exception_map
123
+ NucleusCore.configuration.exceptions_map
124
+ end
125
+
126
+ def response_adapter
127
+ NucleusCore.configuration.response_adapter
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,81 @@
1
+ class NucleusCore::ResponseAdapter < NucleusCore::BasicObject
2
+ def initialize(attrs={})
3
+ attributes = defaults
4
+ .merge(attrs)
5
+ .slice(*defaults.keys)
6
+ .tap do |hash|
7
+ hash[:status] = status_code(hash[:status])
8
+ end
9
+
10
+ super(attributes)
11
+ end
12
+
13
+ private
14
+
15
+ def defaults
16
+ { content: "", headers: {}, status: 200, location: nil }
17
+ end
18
+
19
+ def status_code(status=nil)
20
+ status = NucleusCore::Rack::Utils.status_code(status)
21
+ default_status = 200
22
+
23
+ status.zero? ? default_status : status
24
+ end
25
+ end
26
+
27
+ class NucleusCore::NoResponse < NucleusCore::ResponseAdapter
28
+ def initialize(attrs={})
29
+ attrs = attrs.merge(content: nil, type: "text/html; charset=utf-8")
30
+
31
+ super(attrs)
32
+ end
33
+ end
34
+
35
+ class NucleusCore::TextResponse < NucleusCore::ResponseAdapter
36
+ def initialize(attrs={})
37
+ attrs = attrs.merge(type: "application/text")
38
+
39
+ super(attrs)
40
+ end
41
+ end
42
+
43
+ class NucleusCore::JsonResponse < NucleusCore::ResponseAdapter
44
+ def initialize(attrs={})
45
+ attrs = attrs.merge(type: "application/json")
46
+
47
+ super(attrs)
48
+ end
49
+ end
50
+
51
+ class NucleusCore::XmlResponse < NucleusCore::ResponseAdapter
52
+ def initialize(attrs={})
53
+ attrs = attrs.merge(type: "application/xml")
54
+
55
+ super(attrs)
56
+ end
57
+ end
58
+
59
+ class NucleusCore::CsvResponse < NucleusCore::ResponseAdapter
60
+ def initialize(attrs={})
61
+ attrs = attrs.merge(
62
+ disposition: "attachment",
63
+ filename: attrs.fetch(:filename) { "response.csv" },
64
+ type: "text/csv; charset=UTF-8;"
65
+ )
66
+
67
+ super(attrs)
68
+ end
69
+ end
70
+
71
+ class NucleusCore::PdfResponse < NucleusCore::ResponseAdapter
72
+ def initialize(attrs={})
73
+ attrs = attrs.merge(
74
+ disposition: "inline",
75
+ filename: attrs.fetch(:filename) { "response.pdf" },
76
+ type: "application/pdf"
77
+ )
78
+
79
+ super(attrs)
80
+ end
81
+ end
@@ -0,0 +1,3 @@
1
+ module NucleusCore
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,17 @@
1
+ require "nucleus_core/response_adapter"
2
+
3
+ class NucleusCore::ErrorView < NucleusCore::View
4
+ def initialize(attrs={})
5
+ super(
6
+ {}.tap do |a|
7
+ a[:status] = attrs.fetch(:status, :unprocessable_entity)
8
+ a[:message] = attrs.fetch(:message, nil)
9
+ a[:errors] = attrs.fetch(:errors, [])
10
+ end
11
+ )
12
+ end
13
+
14
+ def json_response
15
+ NucleusCore::JsonResponse.new(content: to_h, status: status)
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ class NucleusCore::View < NucleusCore::BasicObject
2
+ def json_response
3
+ NucleusCore::JsonResponse.new(content: to_h, status: :ok)
4
+ end
5
+ end
@@ -0,0 +1,180 @@
1
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ClassLength, Metrics/AbcSize:
2
+ module NucleusCore
3
+ class Workflow
4
+ class Node
5
+ attr_reader :state, :operation, :rollback, :signals, :prepare_context, :determine_signal
6
+
7
+ def initialize(attrs={})
8
+ @state = attrs[:state]
9
+ @operation = attrs[:operation]
10
+ @rollback = attrs[:rollback]
11
+ @signals = attrs[:signals]
12
+ @prepare_context = attrs[:prepare_context]
13
+ @determine_signal = attrs[:determine_signal]
14
+ end
15
+ end
16
+
17
+ class Process
18
+ attr_accessor :state, :visited
19
+
20
+ def initialize(state)
21
+ @state = state
22
+ @visited = []
23
+ end
24
+
25
+ def visit(state)
26
+ @state = state
27
+ @visited.push(state)
28
+ end
29
+ end
30
+
31
+ # States
32
+ ###########################################################################
33
+ INITIAL_STATE = :initial
34
+
35
+ # Signals
36
+ ###########################################################################
37
+ CONTINUE = :continue
38
+ WAIT = :wait
39
+
40
+ # Node statuses
41
+ ###########################################################################
42
+ OK = :ok
43
+ FAILED = :failed
44
+
45
+ attr_accessor :process, :nodes, :context
46
+
47
+ def initialize(process: nil, context: {})
48
+ @nodes = {}
49
+ @process = process || Process.new(INITIAL_STATE)
50
+ @context = build_context(context)
51
+
52
+ init_nodes
53
+ end
54
+
55
+ def register_node(attrs={})
56
+ node = Node.new(attrs)
57
+
58
+ @nodes[node.state] = node
59
+ end
60
+
61
+ def start_node(signals={})
62
+ raise ArgumentError, "#{self.class}##{__method__}: missing signals" if signals.empty?
63
+
64
+ register_node(state: INITIAL_STATE, signals: signals)
65
+ end
66
+
67
+ def init_nodes
68
+ define
69
+ end
70
+
71
+ # Override this method to draw the workflow graph
72
+ def define
73
+ end
74
+
75
+ def self.call(signal: nil, process: nil, context: {})
76
+ workflow = new(process: process, context: context)
77
+
78
+ workflow.validate_nodes!
79
+ workflow.execute(signal)
80
+
81
+ [workflow.context, workflow.process]
82
+ end
83
+
84
+ def self.rollback(process:, context:)
85
+ workflow = new(process: process, context: context)
86
+ visited = workflow.process.visited.clone
87
+
88
+ visited.reverse_each do |state|
89
+ node = workflow.nodes[state]
90
+
91
+ next node.operation.rollback(context) if node.operation.is_a?(NucleusCore::Operation)
92
+ next node.rollback.call(context) if node.rollback.is_a?(Proc)
93
+ end
94
+ end
95
+
96
+ def validate_nodes!
97
+ start_nodes = nodes.values.count do |node|
98
+ node.state == INITIAL_STATE
99
+ end
100
+
101
+ raise ArgumentError, "#{self.class}: missing `:initial` start node" if start_nodes.zero?
102
+ raise ArgumentError, "#{self.class}: more than one start node detected" if start_nodes > 1
103
+ end
104
+
105
+ def execute(signal=nil)
106
+ signal ||= CONTINUE
107
+ current_state = process.state
108
+ next_signal = (fetch_node(current_state)&.signals || {})[signal]
109
+ current_node = fetch_node(next_signal)
110
+
111
+ context.fail!("invalid signal: #{signal}") if current_node.nil?
112
+
113
+ while next_signal
114
+ status, next_signal, @context = execute_node(current_node, context)
115
+
116
+ break if status == FAILED
117
+
118
+ process.visit(current_node.state)
119
+ current_node = fetch_node(next_signal)
120
+
121
+ break if next_signal == WAIT
122
+ end
123
+
124
+ context
125
+ rescue NucleusCore::Operation::Context::Error
126
+ context
127
+ rescue StandardError => e
128
+ fail_context(@context, e)
129
+ end
130
+
131
+ private
132
+
133
+ def build_context(context={})
134
+ return context if context.is_a?(NucleusCore::Operation::Context)
135
+
136
+ NucleusCore::Operation::Context.new(context)
137
+ end
138
+
139
+ def execute_node(node, context)
140
+ context = prepare_context(node, context)
141
+ operation = node.operation
142
+
143
+ operation&.call(context)
144
+
145
+ status = context.success? ? OK : FAILED
146
+ next_signal = determine_signal(node, context)
147
+
148
+ [status, next_signal, context]
149
+ end
150
+
151
+ def prepare_context(node, context)
152
+ return node.prepare_context.call(context) if node.prepare_context.is_a?(Proc)
153
+ return send(node.prepare_context, context) if node.prepare_context.is_a?(Symbol)
154
+
155
+ context
156
+ end
157
+
158
+ def determine_signal(node, context)
159
+ signal = CONTINUE
160
+ signal = node.determine_signal.call(context) if node.determine_signal.is_a?(Proc)
161
+ signal = send(node.determine_signal, context) if node.determine_signal.is_a?(Symbol)
162
+ node_signals = node.signals || {}
163
+
164
+ node_signals[signal]
165
+ end
166
+
167
+ def fetch_node(state)
168
+ nodes[state]
169
+ end
170
+
171
+ def fail_context(context, exception)
172
+ message = "Unhandled exception #{self.class}: #{exception.message}"
173
+
174
+ context.fail!(message, exception: exception)
175
+ rescue NucleusCore::Operation::Context::Error
176
+ context
177
+ end
178
+ end
179
+ end
180
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ClassLength, Metrics/AbcSize:
@@ -0,0 +1,94 @@
1
+ require "ostruct"
2
+ require "nucleus_core/exceptions"
3
+ require "json"
4
+ require "set"
5
+
6
+ Dir[File.join(__dir__, "nucleus_core", "extensions", "*.rb")].sort.each { |file| require file }
7
+
8
+ module NucleusCore
9
+ autoload :CLI, "nucleus_core/cli"
10
+ autoload :VERSION, "nucleus_core/version"
11
+ autoload :BasicObject, "nucleus_core/basic_object"
12
+ autoload :View, "nucleus_core/views/view"
13
+ autoload :ErrorView, "nucleus_core/views/error_view"
14
+ autoload :ResponseAdapter, "nucleus_core/response_adapter"
15
+ autoload :Aggregate, "nucleus_core/aggregate"
16
+ autoload :Policy, "nucleus_core/policy"
17
+ autoload :Operation, "nucleus_core/operation"
18
+ autoload :Workflow, "nucleus_core/workflow"
19
+ autoload :Responder, "nucleus_core/responder"
20
+
21
+ class Configuration
22
+ attr_reader :exceptions_map, :response_adapter
23
+ attr_accessor :logger
24
+
25
+ def initialize
26
+ @logger = nil
27
+ @response_adapter = nil
28
+ @exceptions_map = nil
29
+ end
30
+
31
+ def exceptions_map=(args={})
32
+ @exceptions_map = format_exceptions(args)
33
+ end
34
+
35
+ def response_adapter=(adapter)
36
+ @response_adapter = format_adapter(adapter)
37
+ end
38
+
39
+ private
40
+
41
+ def objectify(hash)
42
+ OpenStruct.new(hash)
43
+ end
44
+
45
+ ERROR_STATUSES = %i[not_found bad_request unauthorized unprocessable server_error].freeze
46
+
47
+ def format_exceptions(exceptions={})
48
+ exception_defaults = ERROR_STATUSES.reduce({}) { |acc, ex| acc.merge(ex => nil) }
49
+
50
+ objectify(
51
+ (exceptions || exception_defaults)
52
+ .slice(*exception_defaults.keys)
53
+ .reduce({}) do |acc, (key, value)|
54
+ acc.merge(key => Array.wrap(value))
55
+ end
56
+ )
57
+ end
58
+
59
+ def format_adapter(adapter={})
60
+ adapter.tap do |a|
61
+ verify_adapter!(a)
62
+ end
63
+ end
64
+
65
+ ADAPTER_METHODS = %i[
66
+ render_json render_xml render_text render_pdf render_csv render_nothing set_header
67
+ ].freeze
68
+
69
+ def verify_adapter!(adapter)
70
+ current_adapter_methods = Set[*(adapter.methods - Object.methods)]
71
+ required_adapter_methods = ADAPTER_METHODS.to_set
72
+
73
+ return if current_adapter_methods == required_adapter_methods
74
+
75
+ missing = current_adapter_methods.subtract(required_adapter_methods)
76
+
77
+ raise ArgumentError, "responder.adapter must implement: #{missing}"
78
+ end
79
+ end
80
+
81
+ class << self
82
+ def configuration
83
+ @configuration ||= Configuration.new
84
+ end
85
+
86
+ def reset
87
+ @configuration = Configuration.new
88
+ end
89
+
90
+ def configure
91
+ yield(configuration)
92
+ end
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,194 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nucleus-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - dodgerogers
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-02-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.11'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.11'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest-ci
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.4'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest-reporters
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 1.43.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 1.43.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-minitest
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 0.26.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 0.26.1
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-packaging
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - '='
102
+ - !ruby/object:Gem::Version
103
+ version: 0.5.2
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - '='
109
+ - !ruby/object:Gem::Version
110
+ version: 0.5.2
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-performance
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '='
116
+ - !ruby/object:Gem::Version
117
+ version: 1.15.2
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '='
123
+ - !ruby/object:Gem::Version
124
+ version: 1.15.2
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rake
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - '='
130
+ - !ruby/object:Gem::Version
131
+ version: 0.6.0
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - '='
137
+ - !ruby/object:Gem::Version
138
+ version: 0.6.0
139
+ description:
140
+ email:
141
+ - dodgerogers@hotmail.com
142
+ executables:
143
+ - nucleus
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - LICENSE.txt
148
+ - README.md
149
+ - exe/nucleus
150
+ - lib/nucleus_core.rb
151
+ - lib/nucleus_core/aggregate.rb
152
+ - lib/nucleus_core/basic_object.rb
153
+ - lib/nucleus_core/cli.rb
154
+ - lib/nucleus_core/exceptions.rb
155
+ - lib/nucleus_core/extensions/array.rb
156
+ - lib/nucleus_core/extensions/rack.rb
157
+ - lib/nucleus_core/operation.rb
158
+ - lib/nucleus_core/policy.rb
159
+ - lib/nucleus_core/repository.rb
160
+ - lib/nucleus_core/responder.rb
161
+ - lib/nucleus_core/response_adapter.rb
162
+ - lib/nucleus_core/version.rb
163
+ - lib/nucleus_core/views/error_view.rb
164
+ - lib/nucleus_core/views/view.rb
165
+ - lib/nucleus_core/workflow.rb
166
+ homepage: https://github.com/dodgerogers/nucleus-core
167
+ licenses:
168
+ - MIT
169
+ metadata:
170
+ bug_tracker_uri: https://github.com/dodgerogers/nucleus-core/issues
171
+ changelog_uri: https://github.com/dodgerogers/nucleus-core/releases
172
+ source_code_uri: https://github.com/dodgerogers/nucleus-core
173
+ homepage_uri: https://github.com/dodgerogers/nucleus-core
174
+ rubygems_mfa_required: 'true'
175
+ post_install_message:
176
+ rdoc_options: []
177
+ require_paths:
178
+ - lib
179
+ required_ruby_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: '2.6'
184
+ required_rubygems_version: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: '0'
189
+ requirements: []
190
+ rubygems_version: 3.4.5
191
+ signing_key:
192
+ specification_version: 4
193
+ summary: A Ruby business logic framework
194
+ test_files: []