nucleus-core 0.1.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.
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: []