rwc 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: ce1d3bb1432f4f7649493b3375e1b4e5e324c1cabb11ea5027993352d46d13a5
4
+ data.tar.gz: cabc69a6e3ad53158220ed03ddbc8e3e1547df18cb23a2f2bf5322351472d169
5
+ SHA512:
6
+ metadata.gz: 4deb613f2ccb4e672da2e6b92d8754f194d69712f990dba8cf7bc2f2b978116b28945a277f3fcb20b2dfdc6d52473d4c8112c265d4d0a83cfa9d44fadc811ac9
7
+ data.tar.gz: a735528f5941b9d0a473de7bfc6c42b29171ef822ec4bb7b4a96fb9366077591cf5a3db8e774c3c93d0371b393a793ed65f482f3dc228b1c2bb3469bcb4ce767
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-02-18
4
+
5
+ - Initial release
@@ -0,0 +1,84 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
9
+ ## Our Standards
10
+
11
+ Examples of behavior that contributes to a positive environment for our community include:
12
+
13
+ * Demonstrating empathy and kindness toward other people
14
+ * Being respectful of differing opinions, viewpoints, and experiences
15
+ * Giving and gracefully accepting constructive feedback
16
+ * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17
+ * Focusing on what is best not just for us as individuals, but for the overall community
18
+
19
+ Examples of unacceptable behavior include:
20
+
21
+ * The use of sexualized language or imagery, and sexual attention or
22
+ advances of any kind
23
+ * Trolling, insulting or derogatory comments, and personal or political attacks
24
+ * Public or private harassment
25
+ * Publishing others' private information, such as a physical or email
26
+ address, without their explicit permission
27
+ * Other conduct which could reasonably be considered inappropriate in a
28
+ professional setting
29
+
30
+ ## Enforcement Responsibilities
31
+
32
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33
+
34
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35
+
36
+ ## Scope
37
+
38
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39
+
40
+ ## Enforcement
41
+
42
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hasstrup.ezekiel@gmail.com. All complaints will be reviewed and investigated promptly and fairly.
43
+
44
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45
+
46
+ ## Enforcement Guidelines
47
+
48
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49
+
50
+ ### 1. Correction
51
+
52
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53
+
54
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55
+
56
+ ### 2. Warning
57
+
58
+ **Community Impact**: A violation through a single incident or series of actions.
59
+
60
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61
+
62
+ ### 3. Temporary Ban
63
+
64
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65
+
66
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67
+
68
+ ### 4. Permanent Ban
69
+
70
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
71
+
72
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
73
+
74
+ ## Attribution
75
+
76
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77
+ available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78
+
79
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80
+
81
+ [homepage]: https://www.contributor-covenant.org
82
+
83
+ For answers to common questions about this code of conduct, see the FAQ at
84
+ https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 TODO: Write your name
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,54 @@
1
+
2
+ # RwC (Rails with Context!)
3
+
4
+ This gem helps with generating services, inputs, decorators for Context-Based Rails Applications - A hexagonal way to build rails applications. Wrote about this [here](https://medium.com/@HasstrupEzekiel/context-based-programming-in-rails-0ce951a59c36).
5
+
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'rwc'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install rwc
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # generating services
27
+ rails generate rwc:service AnExampleService
28
+
29
+ # generating inputs
30
+ rails generate rwc:input AnExampleInput::WithNameSpace
31
+
32
+ # generating decorators
33
+ rails generate rwc:decorator AnExampleDecorator::WithDecorators
34
+ ```
35
+
36
+ ## Development
37
+
38
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
39
+
40
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
41
+
42
+ ## Contributing
43
+
44
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/hasstrup/rwc). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
45
+
46
+ ## License
47
+
48
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
49
+
50
+ ## Code of Conduct
51
+
52
+ Everyone interacting in the RwC project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
53
+
54
+
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Rwc
6
+ class DecoratorGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path("../templates", __dir__)
8
+
9
+ def create_decorator_file
10
+ template "decorator.rb.tt", "app/decorators/#{class_name.underscore}.rb"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwc
4
+ class InputGenerator < Rails::Generators::NamedBase
5
+ QUERY_TYPE = "query"
6
+ source_root File.expand_path("../templates", __dir__)
7
+
8
+ class_option :input_type, type: :string, default: "base",
9
+ desc: "One of query/create"
10
+
11
+ def create_input_file
12
+ source = query_input? ? "query_input.rb.tt" : "create_input.rb.tt"
13
+ template(source, "app/inputs/#{class_name.underscore}.rb")
14
+ end
15
+
16
+ private
17
+
18
+ def query_input?
19
+ class_name.downcase.include?(QUERY_TYPE) || options[:input_type] == QUERY_TYPE
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Rwc
6
+ class ServiceGenerator < ::Rails::Generators::NamedBase
7
+ source_root File.expand_path("../templates", __dir__)
8
+
9
+ def create_service_file
10
+ template "service.rb.tt", "app/services/#{class_name.underscore}.rb"
11
+ end
12
+ end
13
+ end
14
+
15
+ # end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < Rwc::Core::BaseInput
4
+ # define service body here
5
+ REQUIRED_KEYS = []
6
+ attributes()
7
+
8
+ def validate!
9
+ # define validation rules here
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < Rwc::Core::BaseDecorator
4
+ delegate_all
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < Rwc::Core::Queries::SimpleInput
4
+ # uncomment next line
5
+ # input_for #SampleClass
6
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < Rwc::Core::BaseService
4
+ performs_checks
5
+
6
+ def initialize(input:)
7
+ @input = input
8
+ end
9
+
10
+ def call
11
+ safely_execute do
12
+ # logic goes in here
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :input
19
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ # The ErrorHandling module provides mechanisms for managing errors
6
+ # within service classes. It includes methods for safely executing
7
+ # blocks of code and handling known error classes.
8
+ module Rwc
9
+ module Concerns
10
+ module ErrorHandling
11
+ class InvalidInputError < StandardError; end
12
+ class ServiceError < StandardError; end
13
+
14
+ module Core
15
+ # A list of error classes that can be rescued during safe execution.
16
+ ERROR_CLASSES = [
17
+ ActiveRecord::RecordNotFound,
18
+ ActiveRecord::RecordInvalid,
19
+ ActiveModel::UnknownAttributeError,
20
+ ActiveRecord::StatementInvalid,
21
+ InvalidInputError,
22
+ ServiceError
23
+ ].freeze
24
+
25
+ # Safely executes a block of code, running checks if needed.
26
+ #
27
+ # @yield [void] The block of code to execute.
28
+ # @return [Context] The context after execution.
29
+ def safely_execute(&block)
30
+ run_checks! if should_run_checks?
31
+ block.call
32
+ context
33
+ rescue *ERROR_CLASSES => e
34
+ fail!(error: e)
35
+ end
36
+
37
+ # Runs input validation checks. Must be implemented by the including class.
38
+ #
39
+ # @raise [NotImplementedError] When not implemented in a subclass.
40
+ def run_checks!
41
+ raise InvalidInputError, input.errors.flat_map(&:message) unless input.valid?
42
+ end
43
+ end
44
+
45
+ module Validatable
46
+ module ClassMethods
47
+ # Indicates that checks should be performed on input validation.
48
+ #
49
+ # @return [void]
50
+ def performs_checks
51
+ @should_run_checks = true
52
+ end
53
+
54
+ attr_reader :should_run_checks
55
+ end
56
+
57
+ # Checks if validation checks should be performed.
58
+ #
59
+ # @return [Boolean] True if checks should be performed; otherwise, false.
60
+ def should_run_checks?
61
+ self.class.should_run_checks
62
+ end
63
+
64
+ # Includes core error handling functionality and class methods.
65
+ #
66
+ # @param [Class] base The class to include the module in.
67
+ def self.included(base)
68
+ base.include(Core)
69
+ base.extend(ClassMethods)
70
+ end
71
+
72
+ # Raises a service error with the specified message.
73
+ #
74
+ # @param [String] message The error message to raise.
75
+ # @raise [BaseService::ServiceError] The raised error.
76
+ def raise_error!(message)
77
+ raise ServiceError, message
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwc
4
+ module Concerns
5
+ # Provides validation methods for input classes.
6
+ module Validation
7
+ # Checks if the input is valid by running validations.
8
+ #
9
+ # @return [Boolean] True if valid; otherwise, false.
10
+ def valid?
11
+ validate!
12
+ errors.empty?
13
+ end
14
+
15
+ # Validates the input. Must be implemented by including class.
16
+ #
17
+ # @raise [NotImplementedError] If not implemented.
18
+ def validate!
19
+ raise NotImplementedError
20
+ end
21
+
22
+ private
23
+
24
+ # Pipes an error into the errors array.
25
+ #
26
+ # @param [StandardError] error The error to be added.
27
+ def pipe_error(error)
28
+ @errors << error
29
+ end
30
+
31
+ # Validates the presence of a given key in the input.
32
+ #
33
+ # @param [Symbol] key The key to validate.
34
+ def validate_presence_of(key)
35
+ pipe_error(BaseInput::InputValidationError.new("#{key} is missing")) if input.send(key).blank?
36
+ end
37
+
38
+ # Creates an error with a specified message.
39
+ #
40
+ # @param [String] message The error message.
41
+ # @return [BaseInput::InputValidationError] The generated error.
42
+ def error(message)
43
+ BaseInput::InputValidationError.new(message)
44
+ end
45
+
46
+ # Validates polymorphic associations based on type and ID.
47
+ #
48
+ # @param [String] type The type of the association.
49
+ # @param [Integer] id The ID of the associated record.
50
+ # @param [Boolean] conditional Optional. If true, skips validation if id is blank.
51
+ def validate_polymorphic_association!(type, id, conditional: false)
52
+ within_error_context do
53
+ return if conditional && id.blank?
54
+
55
+ pipe_error(relation_not_found_error(type, id)) unless type.constantize.find_by(id:)
56
+ rescue NameError
57
+ pipe_error(invalid_type_error(type))
58
+ end
59
+ end
60
+
61
+ # Validates an association by checking its existence.
62
+ #
63
+ # @param [Integer] id The ID of the associated record.
64
+ # @param [Class] klass The class of the association.
65
+ # @param [Boolean] conditional Optional. If true, skips validation if id is blank.
66
+ def validate_association!(id, klass, conditional: false)
67
+ within_error_context do
68
+ return if conditional && id.blank?
69
+
70
+ pipe_error(relation_not_found_error(klass.to_s, id)) unless klass.exists?(id:)
71
+ end
72
+ end
73
+
74
+ # Validates the existence of a record associated with the input.
75
+ #
76
+ # @raise [BaseInput::InputValidationError] If validation fails.
77
+ def validate_record!
78
+ within_error_context do
79
+ validate_association!(id, self.class.target) if id
80
+ end
81
+ end
82
+
83
+ # Validates required keys for the input.
84
+ #
85
+ # @raise [BaseInput::InputValidationError] If any required keys are missing.
86
+ def validate_required_keys!
87
+ within_error_context do
88
+ self.class::REQUIRED_KEYS.each do |key|
89
+ validate_presence_of(key)
90
+ end
91
+ end
92
+ end
93
+
94
+ # Validates the ownership of a record by a user.
95
+ #
96
+ # @param [ActiveRecord::Base] record The record to check ownership against.
97
+ # @param [Integer] user_id The ID of the user to validate ownership.
98
+ def validate_ownership!(record, user_id)
99
+ within_error_context do
100
+ pipe_error(restricted_access_error) unless owner_for(record)&.id == user_id
101
+ end
102
+ end
103
+
104
+ # Executes a block if there are no errors.
105
+ #
106
+ # @yield The block to execute.
107
+ def within_error_context(&block)
108
+ block.call if errors.empty?
109
+ end
110
+
111
+ # Collates errors from specified keys into the errors array.
112
+ #
113
+ # @param [Array<Symbol>] keys The keys to collate errors for.
114
+ def collate_errors_for(*keys)
115
+ keys.each do |key|
116
+ within_error_context do
117
+ target = send(key)
118
+ next @errors += target.errors if target.respond_to?(:valid?) && !target.valid?
119
+
120
+ target.each do |node|
121
+ within_error_context do
122
+ @errors += node.errors unless node.valid?
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ # Generates an error for an invalid association type.
130
+ #
131
+ # @param [String] type The invalid type.
132
+ # @return [BaseInput::InputValidationError] The generated error.
133
+ def invalid_type_error(type)
134
+ BaseInput::InputValidationError.new("Invalid association type: #{type.capitalize}")
135
+ end
136
+
137
+ # Generates an error when a relation cannot be found.
138
+ #
139
+ # @param [String] type The type of the relation.
140
+ # @param [Integer] id The ID of the relation.
141
+ # @return [BaseInput::InputValidationError] The generated error.
142
+ def relation_not_found_error(type, id)
143
+ BaseInput::InputValidationError.new("Could not find any '#{type.capitalize}' with id: #{id}")
144
+ end
145
+
146
+ # Generates an error for restricted access.
147
+ #
148
+ # @return [BaseInput::InputValidationError] The generated error.
149
+ def restricted_access_error
150
+ BaseInput::InputValidationError.new('Unauthorized access')
151
+ end
152
+
153
+ # Generates an error for a missing field.
154
+ #
155
+ # @param [Symbol] field The missing field.
156
+ # @return [BaseInput::InputValidationError] The generated error.
157
+ def missing_field_error(field)
158
+ BaseInput::InputValidationError.new("#{field} is missing")
159
+ end
160
+ end
161
+ end
162
+ end
163
+
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module Rwc
6
+ module Core
7
+ class BaseDecorator
8
+ attr_accessor :context
9
+ attr_reader :object
10
+
11
+ def initialize(object, opts = {})
12
+ @object = object
13
+ @context = OpenStruct.new(**opts)
14
+ end
15
+
16
+ protected
17
+
18
+ attr_reader :extra
19
+
20
+ class << self
21
+ def decorate_collection(collection:, opts: {})
22
+ collection.map { |object| new(object, opts) }
23
+ end
24
+
25
+ def delegate_all
26
+ define_method(:method_missing) do |method_name, *args|
27
+ return object.send(method_name, *args) if object.respond_to?(method_name)
28
+
29
+ super(method_name, *args)
30
+ end
31
+ end
32
+
33
+ def include_in_json(*methods)
34
+ define_method(:as_json) do |*args|
35
+ included_hash = methods.index_with do |method|
36
+ send(method)
37
+ end
38
+ object.send(:as_json, *args).merge(included_hash)
39
+ end
40
+ end
41
+
42
+ def decorate(relation, context: {})
43
+ new(relation, context)
44
+ end
45
+
46
+ protected
47
+
48
+ def auto_delegate(*methods)
49
+ delegate(*methods, to: :object)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "concerns/validation"
4
+ require "ostruct"
5
+
6
+ module Rwc
7
+ module Core
8
+ # Represents a base input class for handling and validating input data.
9
+ class BaseInput
10
+ class InputValidationError < StandardError; end
11
+
12
+ include Concerns::Validation
13
+
14
+ class << self
15
+ attr_reader :fields, :target
16
+
17
+ # Defines the attributes that the input class can accept.
18
+ #
19
+ # @param [Symbol] args The attributes to be defined for the input.
20
+ def attributes(*args)
21
+ @fields = args
22
+ end
23
+
24
+ # Specifies the target class for polymorphic associations.
25
+ #
26
+ # @param [Class] target_klass The target class for validation.
27
+ def input_for(target_klass)
28
+ @target = target_klass
29
+ end
30
+ end
31
+
32
+ attr_reader :errors
33
+
34
+ # Initializes a new BaseInput instance.
35
+ #
36
+ # @param [Hash] kwargs The keyword arguments for the input.
37
+ # @option kwargs [Object] :context Additional context for the input validation.
38
+ def initialize(**kwargs)
39
+ @input = OpenStruct.new(**kwargs.except(:context))
40
+ @context = kwargs[:context]
41
+ @valid = false
42
+ @errors = []
43
+ end
44
+
45
+ # Returns human-readable error messages from the errors array.
46
+ #
47
+ # @return [String] The humanized error messages.
48
+ def humanized_error_messages
49
+ errors.map(&:message).join(", ")
50
+ end
51
+
52
+ # Delegates method calls to the input object.
53
+ #
54
+ # @param [Symbol] name The method name to be called.
55
+ # @param [Array] _args The arguments to be passed to the method.
56
+ # @return [Object] The result of the method call on the input object.
57
+ def method_missing(name, *_args)
58
+ input.send(name)
59
+ end
60
+
61
+ # Checks if the method is respondable based on defined attributes.
62
+ #
63
+ # @param [Symbol] name The method name.
64
+ # @param [Boolean] _include_private Whether to include private methods.
65
+ # @return [Boolean] True if the method is respondable; otherwise, false.
66
+ def respond_to_missing?(name, _include_private = false)
67
+ self.class.attributes.include?(name)
68
+ end
69
+
70
+ # Fetches a value from the input as a hash.
71
+ #
72
+ # @param [Array] args The keys to fetch values for.
73
+ # @return [Object] The fetched value.
74
+ def fetch(*args)
75
+ to_h.fetch(*args)
76
+ end
77
+
78
+ # Slices the input to return only specified keys.
79
+ #
80
+ # @param [Array] args The keys to slice from the input.
81
+ # @return [Hash] A hash containing only the specified keys.
82
+ def slice(*args)
83
+ to_h.slice(*args)
84
+ end
85
+
86
+ def to_h(*args)
87
+ input.to_h(*args)
88
+ end
89
+
90
+ private
91
+
92
+ attr_reader :input, :valid, :context
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,41 @@
1
+ require_relative "context"
2
+ require_relative "concerns/error_handling"
3
+
4
+ # BaseService serves as a foundational class for creating service objects
5
+ # in the application. It provides a standardized interface for service calls
6
+ # and integrates error handling mechanisms.
7
+ module Rwc
8
+ module Core
9
+ class BaseService
10
+ include Concerns::ErrorHandling::Validatable
11
+
12
+ # Calls the service with the provided keyword arguments.
13
+ #
14
+ # @param [Hash] kwargs The keyword arguments to be passed to the service.
15
+ # @return [Object] The result of the service call.
16
+ def self.call(**kwargs)
17
+ new(**kwargs).call
18
+ end
19
+
20
+ # Executes the service logic. Must be implemented in subclasses.
21
+ #
22
+ # @raise [NotImplementedError] When not implemented in a subclass.
23
+ def call
24
+ raise NotImplementedError
25
+ end
26
+
27
+ delegate :fail!, :succeed, to: :context
28
+
29
+ private
30
+
31
+ attr_reader :input
32
+
33
+ # Initializes the context for the service.
34
+ #
35
+ # @return [Context] The service context.
36
+ def context
37
+ @context ||= Context.new
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ # The ErrorHandling module provides mechanisms for managing errors
6
+ # within service classes. It includes methods for safely executing
7
+ # blocks of code and handling known error classes.
8
+ module Rwc
9
+ module Core
10
+ module Concerns
11
+ module ErrorHandling
12
+ class InvalidInputError < StandardError; end
13
+ class ServiceError < StandardError; end
14
+
15
+ module Core
16
+ # A list of error classes that can be rescued during safe execution.
17
+ ERROR_CLASSES = [
18
+ ActiveRecord::RecordNotFound,
19
+ ActiveRecord::RecordInvalid,
20
+ ActiveModel::UnknownAttributeError,
21
+ ActiveRecord::StatementInvalid,
22
+ InvalidInputError,
23
+ ServiceError
24
+ ].freeze
25
+
26
+ # Safely executes a block of code, running checks if needed.
27
+ #
28
+ # @yield [void] The block of code to execute.
29
+ # @return [Context] The context after execution.
30
+ def safely_execute(&block)
31
+ run_checks! if should_run_checks?
32
+ block.call
33
+ context
34
+ rescue *ERROR_CLASSES => e
35
+ fail!(error: e)
36
+ end
37
+
38
+ # Runs input validation checks. Must be implemented by the including class.
39
+ #
40
+ # @raise [NotImplementedError] When not implemented in a subclass.
41
+ def run_checks!
42
+ raise InvalidInputError, input.errors.flat_map(&:message) unless input.valid?
43
+ end
44
+ end
45
+
46
+ module Validatable
47
+ module ClassMethods
48
+ # Indicates that checks should be performed on input validation.
49
+ #
50
+ # @return [void]
51
+ def performs_checks
52
+ @should_run_checks = true
53
+ end
54
+
55
+ attr_reader :should_run_checks
56
+ end
57
+
58
+ # Checks if validation checks should be performed.
59
+ #
60
+ # @return [Boolean] True if checks should be performed; otherwise, false.
61
+ def should_run_checks?
62
+ self.class.should_run_checks
63
+ end
64
+
65
+ # Includes core error handling functionality and class methods.
66
+ #
67
+ # @param [Class] base The class to include the module in.
68
+ def self.included(base)
69
+ base.include(Core)
70
+ base.extend(ClassMethods)
71
+ end
72
+
73
+ # Raises a service error with the specified message.
74
+ #
75
+ # @param [String] message The error message to raise.
76
+ # @raise [BaseService::ServiceError] The raised error.
77
+ def raise_error!(message)
78
+ raise ServiceError, message
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwc
4
+ module Core
5
+ module Concerns
6
+ # Provides validation methods for input classes.
7
+ module Validation
8
+ # Checks if the input is valid by running validations.
9
+ #
10
+ # @return [Boolean] True if valid; otherwise, false.
11
+ def valid?
12
+ validate!
13
+ errors.empty?
14
+ end
15
+
16
+ # Validates the input. Must be implemented by including class.
17
+ #
18
+ # @raise [NotImplementedError] If not implemented.
19
+ def validate!
20
+ raise NotImplementedError
21
+ end
22
+
23
+ private
24
+
25
+ # Pipes an error into the errors array.
26
+ #
27
+ # @param [StandardError] error The error to be added.
28
+ def pipe_error(error)
29
+ @errors << error
30
+ end
31
+
32
+ # Validates the presence of a given key in the input.
33
+ #
34
+ # @param [Symbol] key The key to validate.
35
+ def validate_presence_of(key)
36
+ pipe_error(BaseInput::InputValidationError.new("#{key} is missing")) if input.send(key).blank?
37
+ end
38
+
39
+ # Creates an error with a specified message.
40
+ #
41
+ # @param [String] message The error message.
42
+ # @return [BaseInput::InputValidationError] The generated error.
43
+ def error(message)
44
+ BaseInput::InputValidationError.new(message)
45
+ end
46
+
47
+ # Validates polymorphic associations based on type and ID.
48
+ #
49
+ # @param [String] type The type of the association.
50
+ # @param [Integer] id The ID of the associated record.
51
+ # @param [Boolean] conditional Optional. If true, skips validation if id is blank.
52
+ def validate_polymorphic_association!(type, id, conditional: false)
53
+ within_error_context do
54
+ return if conditional && id.blank?
55
+
56
+ pipe_error(relation_not_found_error(type, id)) unless type.constantize.find_by(id: id)
57
+ rescue NameError
58
+ pipe_error(invalid_type_error(type))
59
+ end
60
+ end
61
+
62
+ # Validates an association by checking its existence.
63
+ #
64
+ # @param [Integer] id The ID of the associated record.
65
+ # @param [Class] klass The class of the association.
66
+ # @param [Boolean] conditional Optional. If true, skips validation if id is blank.
67
+ def validate_association!(id, klass, conditional: false)
68
+ within_error_context do
69
+ return if conditional && id.blank?
70
+
71
+ pipe_error(relation_not_found_error(klass.to_s, id)) unless klass.exists?(id: id)
72
+ end
73
+ end
74
+
75
+ # Validates the existence of a record associated with the input.
76
+ #
77
+ # @raise [BaseInput::InputValidationError] If validation fails.
78
+ def validate_record!
79
+ within_error_context do
80
+ validate_association!(id, self.class.target) if id
81
+ end
82
+ end
83
+
84
+ # Validates required keys for the input.
85
+ #
86
+ # @raise [BaseInput::InputValidationError] If any required keys are missing.
87
+ def validate_required_keys!
88
+ within_error_context do
89
+ self.class::REQUIRED_KEYS.each do |key|
90
+ validate_presence_of(key)
91
+ end
92
+ end
93
+ end
94
+
95
+ # Validates the ownership of a record by a user.
96
+ #
97
+ # @param [ActiveRecord::Base] record The record to check ownership against.
98
+ # @param [Integer] user_id The ID of the user to validate ownership.
99
+ def validate_ownership!(record, user_id)
100
+ within_error_context do
101
+ pipe_error(restricted_access_error) unless owner_for(record)&.id == user_id
102
+ end
103
+ end
104
+
105
+ # Executes a block if there are no errors.
106
+ #
107
+ # @yield The block to execute.
108
+ def within_error_context(&block)
109
+ block.call if errors.empty?
110
+ end
111
+
112
+ # Collates errors from specified keys into the errors array.
113
+ #
114
+ # @param [Array<Symbol>] keys The keys to collate errors for.
115
+ def collate_errors_for(*keys)
116
+ keys.each do |key|
117
+ within_error_context do
118
+ target = send(key)
119
+ next @errors += target.errors if target.respond_to?(:valid?) && !target.valid?
120
+
121
+ target.each do |node|
122
+ within_error_context do
123
+ @errors += node.errors unless node.valid?
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ # Generates an error for an invalid association type.
131
+ #
132
+ # @param [String] type The invalid type.
133
+ # @return [BaseInput::InputValidationError] The generated error.
134
+ def invalid_type_error(type)
135
+ BaseInput::InputValidationError.new("Invalid association type: #{type.capitalize}")
136
+ end
137
+
138
+ # Generates an error when a relation cannot be found.
139
+ #
140
+ # @param [String] type The type of the relation.
141
+ # @param [Integer] id The ID of the relation.
142
+ # @return [BaseInput::InputValidationError] The generated error.
143
+ def relation_not_found_error(type, id)
144
+ BaseInput::InputValidationError.new("Could not find any '#{type.capitalize}' with id: #{id}")
145
+ end
146
+
147
+ # Generates an error for restricted access.
148
+ #
149
+ # @return [BaseInput::InputValidationError] The generated error.
150
+ def restricted_access_error
151
+ BaseInput::InputValidationError.new("Unauthorized access")
152
+ end
153
+
154
+ # Generates an error for a missing field.
155
+ #
156
+ # @param [Symbol] field The missing field.
157
+ # @return [BaseInput::InputValidationError] The generated error.
158
+ def missing_field_error(field)
159
+ BaseInput::InputValidationError.new("#{field} is missing")
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwc
4
+ # The Context class is used to encapsulate the result of a service operation,
5
+ # holding information about success, errors, messages, and any relevant payload.
6
+ module Core
7
+ class Context
8
+ attr_reader :success, :errors, :messages, :payload
9
+
10
+ # Initializes a new Context instance.
11
+ #
12
+ # @return [void]
13
+ def initialize
14
+ @success = true
15
+ @errors = []
16
+ @messages = []
17
+ end
18
+
19
+ # Marks the context as failed, recording the provided error.
20
+ #
21
+ # @param [StandardError] error The error that caused the failure.
22
+ # @return [Context] The current context instance.
23
+ def fail!(error:)
24
+ @success = false
25
+ @errors = [error]
26
+
27
+ self
28
+ end
29
+
30
+ # Marks the context as successful, optionally providing a payload.
31
+ #
32
+ # @param [Object] payload The payload to be returned on success (default is nil).
33
+ # @return [Context] The current context instance.
34
+ def succeed(payload = nil)
35
+ clear_errors
36
+ @success = true
37
+ @payload = payload
38
+
39
+ self
40
+ end
41
+
42
+ # Sets the payload and clears any errors.
43
+ #
44
+ # @param [Object] payload The payload to set.
45
+ # @return [Context] The current context instance.
46
+ def payload!(payload:)
47
+ @errors = []
48
+ @payload = payload
49
+ end
50
+
51
+ # Checks if the operation was successful.
52
+ #
53
+ # @return [Boolean] True if the operation was successful; otherwise, false.
54
+ def success?
55
+ @success
56
+ end
57
+
58
+ # Returns a message string, combining messages or errors based on success state.
59
+ #
60
+ # @return [String] The combined success messages or error messages.
61
+ def message
62
+ success ? messages.join(", ") : errors.map(&:message).join(", ")
63
+ end
64
+
65
+ private
66
+
67
+ # Clears the recorded errors in the context.
68
+ #
69
+ # @return [void]
70
+ def clear_errors
71
+ @errors = []
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwc
4
+ module Queries
5
+ class SimpleInput
6
+ attr_reader :params
7
+
8
+ class << self
9
+ attr_reader :target_klass
10
+
11
+ def input_for(klass)
12
+ @target_klass = klass
13
+ end
14
+ end
15
+
16
+ def initialize(fields:, type: :group, **context)
17
+ @params = fields
18
+ @type = type
19
+ @_context = context || {}
20
+ end
21
+
22
+ def conditions
23
+ params.slice(*valid_fields)
24
+ end
25
+
26
+ def includes
27
+ params.dig(:includes)&.map(&:to_sym) & valid_includes
28
+ end
29
+
30
+ def context
31
+ @context ||= OpenStruct.new(**@_context)
32
+ end
33
+
34
+ def sanitize!
35
+ # for now just remove nil keys
36
+ params.compact!
37
+ self
38
+ end
39
+
40
+ def single?
41
+ type == :single
42
+ end
43
+
44
+ def __raw__
45
+ OpenStruct.new(**params)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :type
51
+
52
+ def valid_fields
53
+ self.class.target_klass.attribute_names.map(&:to_sym)
54
+ end
55
+
56
+ def valid_includes
57
+ self.class.target_klass.reflect_on_all_associations.map(&:name)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwc
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rwc.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rwc/version"
4
+ require_relative "rwc/core/base_decorator"
5
+ require_relative "rwc/core/base_input"
6
+ require_relative "rwc/core/base_service"
7
+ module Rwc
8
+ # Your code goes here...
9
+ end
data/rwc.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rwc/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rwc"
7
+ spec.version = Rwc::VERSION
8
+ spec.authors = ["Haldane Engineering"]
9
+ spec.email = ["hasstrup.ezekiel@gmail.com"]
10
+
11
+ spec.summary = "A gem for scaffolding Context-based rails application components."
12
+ spec.description = "https://medium.com/@HasstrupEzekiel/context-based-programming-in-rails-0ce951a59c36"
13
+ spec.homepage = "https://haldaneengineering.com"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.6.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
19
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # Uncomment to register a new dependency of your gem
34
+
35
+ spec.add_dependency "rails", ">= 6.0"
36
+ # For more information and examples about making a new gem, check out our
37
+ # guide at: https://bundler.io/guides/creating_gem.html
38
+ end
data/sig/rwc.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Rwc
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rwc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Haldane Engineering
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-08-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ description: https://medium.com/@HasstrupEzekiel/context-based-programming-in-rails-0ce951a59c36
28
+ email:
29
+ - hasstrup.ezekiel@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rspec"
35
+ - ".rubocop.yml"
36
+ - CHANGELOG.md
37
+ - CODE_OF_CONDUCT.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - lib/generators/rwc/decorator/decorator_generator.rb
42
+ - lib/generators/rwc/input/input_generator.rb
43
+ - lib/generators/rwc/service/service_generator.rb
44
+ - lib/generators/rwc/templates/create_input.rb.tt
45
+ - lib/generators/rwc/templates/decorator.rb.tt
46
+ - lib/generators/rwc/templates/query_input.rb.tt
47
+ - lib/generators/rwc/templates/service.rb.tt
48
+ - lib/rwc.rb
49
+ - lib/rwc/concerns/error_handling.rb
50
+ - lib/rwc/concerns/validation.rb
51
+ - lib/rwc/core/base_decorator.rb
52
+ - lib/rwc/core/base_input.rb
53
+ - lib/rwc/core/base_service.rb
54
+ - lib/rwc/core/concerns/error_handling.rb
55
+ - lib/rwc/core/concerns/validation.rb
56
+ - lib/rwc/core/context.rb
57
+ - lib/rwc/core/queries/simple_input.rb
58
+ - lib/rwc/version.rb
59
+ - rwc.gemspec
60
+ - sig/rwc.rbs
61
+ homepage: https://haldaneengineering.com
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://haldaneengineering.com
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 2.6.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.4.6
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: A gem for scaffolding Context-based rails application components.
85
+ test_files: []