From 55940cdfe709ff08dd702584f587ccc2f5855553 Mon Sep 17 00:00:00 2001 From: ipublic Date: Mon, 9 May 2022 18:11:40 -0400 Subject: [PATCH] Add Domain generators: contract, entity, install and operation Update documentation --- README.md | 161 +++++++++++------- .../domain/contract/contract_generator.rb | 89 ++++++++++ .../contract/templates/entity_contract.rb.tt | 13 ++ lib/generators/domain/entity/USAGE | 12 ++ .../domain/entity/entity_generator.rb | 92 ++++++++++ .../domain/entity/templates/entity.rb.tt | 14 ++ .../domain/generated_dry_attribute.rb | 75 ++++++++ .../domain/install/install_generator.rb | 39 +++++ lib/generators/domain/operation/USAGE | 9 + .../domain/operation/operation_generator.rb | 62 +++++++ .../operation/templates/operation.rb.tt | 13 ++ .../domain/templates/domain_contract.rb.tt | 14 ++ .../domain/templates/domain_types.rb.tt | 13 ++ lib/generators/domain/templates/types.rb.tt | 7 + lib/generators/rspec/contract/USAGE | 8 + .../rspec/contract/contract_generator.rb | 19 +++ .../contract/templates/contract_spec.rb.tt | 46 +++++ .../rspec/dry_type/dry_type_generator.rb | 22 +++ .../dry_type/templates/dry_types_spec.rb.tt | 7 + .../rspec/entity/entity_generator.rb | 21 +++ .../rspec/entity/templates/entity_spec.rb.tt | 30 ++++ lib/generators/rspec/operation/USAGE | 8 + .../rspec/operation/entity_generator.rb | 16 ++ .../rspec/operation/operation_generator.rb | 19 +++ .../operation/templates/operation_spec.rb.tt | 10 ++ 25 files changed, 757 insertions(+), 62 deletions(-) create mode 100644 lib/generators/domain/contract/contract_generator.rb create mode 100644 lib/generators/domain/contract/templates/entity_contract.rb.tt create mode 100644 lib/generators/domain/entity/USAGE create mode 100644 lib/generators/domain/entity/entity_generator.rb create mode 100644 lib/generators/domain/entity/templates/entity.rb.tt create mode 100644 lib/generators/domain/generated_dry_attribute.rb create mode 100644 lib/generators/domain/install/install_generator.rb create mode 100644 lib/generators/domain/operation/USAGE create mode 100644 lib/generators/domain/operation/operation_generator.rb create mode 100644 lib/generators/domain/operation/templates/operation.rb.tt create mode 100644 lib/generators/domain/templates/domain_contract.rb.tt create mode 100644 lib/generators/domain/templates/domain_types.rb.tt create mode 100644 lib/generators/domain/templates/types.rb.tt create mode 100644 lib/generators/rspec/contract/USAGE create mode 100644 lib/generators/rspec/contract/contract_generator.rb create mode 100644 lib/generators/rspec/contract/templates/contract_spec.rb.tt create mode 100644 lib/generators/rspec/dry_type/dry_type_generator.rb create mode 100644 lib/generators/rspec/dry_type/templates/dry_types_spec.rb.tt create mode 100644 lib/generators/rspec/entity/entity_generator.rb create mode 100644 lib/generators/rspec/entity/templates/entity_spec.rb.tt create mode 100644 lib/generators/rspec/operation/USAGE create mode 100644 lib/generators/rspec/operation/entity_generator.rb create mode 100644 lib/generators/rspec/operation/operation_generator.rb create mode 100644 lib/generators/rspec/operation/templates/operation_spec.rb.tt diff --git a/README.md b/README.md index 00102fad2..96002faf8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,8 @@ # AcaEntities -A portable domain model for Benefit Management solutions. +A portable Domain model for Benefit Management solutions. -IdeaCrew defines a Canonical Vocabulay (CV) for structuring and -exchanging information between services for its benefit management -technology solutions. AcaEntities defines those domain model entities, -validation contracts, transformations with other vocabularies, and -other supporting artifacts. +IdeaCrew defines a Canonical Vocabulay (CV) for structuring and exchanging information between services for its benefit management technology solutions. AcaEntities defines those domain model entities, validation contracts, transformations with other vocabularies, and other supporting artifacts. ## Installation @@ -18,107 +14,148 @@ gem 'aca_entities' And then execute: - $ bundle install +```sh +$ bundle install +Bundle complete! NN Gemfile dependencies, NN gems now installed. +``` Or install it yourself as: - $ gem install aca_entities +```sh +$ gem install aca_entities +``` ## Usage -AcaEntities is organized into a series of subject-area libraries that load only -the artifacts and dependencies associated with that subject. Each library includes -a ruby file for the namespace that requires all the depenencies for that library. +AcaEntities' canonical vocabularies help manage uniform, efficient data exchange across internal and external system services. Operations and helper features provide solutions for commonly-used data management functions. AcaEntties uses the [dry-rb](https://dry-rb.org/) gem library to support Domain-Driven Design and Clean Code tenets. -For example, {AcaEntities::Fdsh::Fdsh.rb} requires all dependencies to load the -FDSH namespace. +### Creating Domain Classes with `rails generate` -AcaEntities includes the following libraries: +Rails generators are command line tools that automate the process of creating or editing files with boilerplace code. AcaEntities provides Rails generators that support Domain-driven software development. -### {AcaEntities::Configuration::Encryption Encryption} +There are three primary `domain` generators: `domain:install, domain:entity` and `domain:operation`. These tools accelerate development by creating and configuring `Entity, Contract, Operation` and `Types` classes and associated Rspec files. They manage namespaces, dependencies and declarations consistent with IdeaCrew's Clean Code conventions. They also seed `yardoc` customization-ready content to easily produce development technical documentation. -Configuration setup to Encrypt and Decrypt sensitive information. +The `domain:install` generator configures a new Rails application (`domain_app` in this example) to use Domain components: + +```sh +$ rails generate domain:install + create app/domain/contracts/contract.rb + create app/domain/types.rb + create app/domain/domain_app/types.rb + invoke rspec + create spec/domain/domain_app/types_spec.rb +``` -Initializer file in other Applications(Rails App) +Use the `domain:entity` generator to create Domain Entity classes and associated attributes. This generator accepts arguments for an Entity name and an optional list of attributes and creates corresponding Entity and Contract files under the `app/domain` folder. Attribute arguments include a required attribute name value, with optional type and key parameters: +```sh +ATTRIBUTE_NAME[:type][:optional_key (default) | :required_key] ``` + +For example: + +```sh +$ rails g domain:entity organization id:integer:required_key description:string name:string submitted_at:date_time + create app/domain/organization.rb + generate domain:contract + rails generate domain:contract organization id:integer:required_key description:string name:string submitted_at:date_time + create app/domain/contracts/organization_contract.rb + invoke rspec + create spec/domain/contracts/organization_contract_spec.rb + invoke rspec + create spec/domain/organization_spec.rb +``` + +Use the `domain:operation` generator to create an Operation class under the `app/operations` folder. Operations are named in imperative form as they function as commands or actions. Since Operations often publish an Event, the generated file includes references to EventSource dependencies. For example: + +```sh +rails g domain:operation create_organization + create app/operations/create_organization.rb + invoke rspec + create spec/operations/create_organization_spec.rb +``` + +**Note:** executing `$ rails generate --help` on the command line will list all available generators. + +### Protecting Sensitive Data Using Symmetic Encryption + +AcaEntities includes encrypt/decrypt functions to encode PII and other senstive data content for transmission between services and for persisting to data stores. Applications and services can access this feature in two steps. + +First, add or extend an AcaEntities initialize file (`config/initializers/aca_entities.rb`) with secure key configuration + +```ruby +# config/initializers/aca_entities.rb + AcaEntities::Configuration::Encryption.configure do |config| - config.encrypted_key = ENV['SYMMETRIC_ENCRYPTION_ENCRYPTED_KEY'] - config.encrypted_iv = ENV['SYMMETRIC_ENCRYPTION_ENCRYPTED_IV'] - config.private_rsa_key = ENV['ENROLL_SYMMETRIC_ENCRYPTION_PRIVATE_KEY'] - config.app_env = Rails.env + config.encrypted_key = ENV['SYMMETRIC_ENCRYPTION_ENCRYPTED_KEY'] + config.encrypted_iv = ENV['SYMMETRIC_ENCRYPTION_ENCRYPTED_IV'] + config.private_rsa_key = ENV['ENROLL_SYMMETRIC_ENCRYPTION_PRIVATE_KEY'] + config.app_env = Rails.env end ``` -Operations to Encrypt and Decrypt +Second, call the appropriate Operation, passing data to be encrypted or decypted: + +```ruby +encrypted_value = AcaEntities::Operations::Encryption::Encrypt(sensitive_data) +decrypted_value = AcaEntities::Operations::Encryption::Decrypt(encrypted_value) +``` + +## Vocabularies -Encrypt - `AcaEntities::Operations::SymmetricEncryption::Encrypt` +The `lib/aca_entities/libraries/` folder defines a `core_libary` and other namespaced libraries designed to load required artifacts and dependencies rather than the entire library. For example, `lib/aca_entities/libraries/fdsh_library.rb` loads only the FDSH namespace and dependencies necessary to support communication with CMS services. -Decrypt - `AcaEntities::Operations::SymmetricEncryption::Decrypt` +### Account Transfer Protocol -### {AcaEntities::Atp ATP} +Vocabularies and transforms that enable account and eligibility information exchange between FFE Account Transfer Protocol (ATP) and IdeaCrew's CV. -Vocabularies and transforms that enable account and eligibility information exchange -between FFE Account Transfer Protocol (ATP) and IdeaCrew's CV. +### AsynApi -### {AcaEntities::AsynApi AsynApi} +Shared configuration files that enable publication/subscribe (pub/sub) service producers and consumers to exchange messages between mircoservices. Files are organized by microservice and communication protocol. -Interface definitions for service producers and consumers to exchange messages. -Typically used by the {https://github.com/ideacrew/aca_entities.git EventSource gem} -these files follow the {https://www.asyncapi.com/docs/specifications/v2.0.0 AsyncAPI 2.0} standard. +These yaml-formatted definitions use the [AsyncAPI 2.0](https://www.asyncapi.com/docs/specifications/v2.0.0) specification to describe API endpoints in a protocol-neutral form. Tools like IdeaCrew's [EventSource gem](https://github.com/ideacrew/aca_entities.git) use these definitions to instantiate connections and channels between described services. Other tools may use them to create self-documenting API endpoints. -### {AcaEntities::Crms CRMs} +### CRMs Integration with Customer Relationship Management (CRM) systems including SugarCRM. -### {AcaEntities::Fdsh FDSH} +### FDSH -Integration with CMS's Federal Data Sharing Hub (FDSH) that supports -eligibility determination and verification services necessary to operate a -State-based Exchange (SBE). +Integration with CMS's Federal Data Sharing Hub (FDSH) that supports eligibility determination and verification services necessary to operate a State-based Exchange (SBE). -### {AcaEntities::Google Google} +### Google -Integrations with Google Map services including interactive UI maps and -address geocoding services. +Integrations with Google Map services including interactive UI maps and address geocoding services. -### {AcaEntities::Ledger Ledger} +### Ledger Integrations with accounting services including Quickbooks Online. -### {AcaEntities::MagiMedicaid MagiMedicaid} +### MagiMedicaid -Integrations and code that enable Affordable Care Act (ACA) financial -assistance eligibility determinations, including MAGI Medicaid, Advance -Premium Tax Credit (APTC) and Cost Sharing Reductions (CSRs) +Integrations and code that enable Affordable Care Act (ACA) financial assistance eligibility determinations, including MAGI Medicaid, Advance Premium Tax Credit (APTC) and Cost Sharing Reductions (CSRs) -## Development +## Contributing -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. +AcaEnties vocabularies are based on [dry-rb](https://dry-rb.org/) libraries and support Domain-driven development. IdeaCrew conventions use Entity classes to define attributes and domain logic. Do not include data coercions or transformations in Entities. Rather, use Contracts and Operations to filter and shape data prior to instantiating Entity structs. -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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +Do include `yardoc` API documentation and Rspec specs for all Entities, Contracts, Operations and other content added to AcaEntities. -## Contributing +### Extending AcaEntities Domain Model -AcaEnties uses {https://dry-rb.org/ dry-rb} libriries to define domain entities: -{https://dry-rb.org/gems/dry-struct/1.0/ dry-struct} and validation contracts: -{https://dry-rb.org/gems/dry-validation/1.6/ dry-validation}. +Before adding new code to AcaEntties, review existing vocabularies to use or extend to support requirements. Start with the `lib/aca_entities/libraries/` folder. Next, search `lib/aca_entities` subfolders for suitable models. Note that some subfolders define vocabularies that aren't obvious from the folder name. -Entity definitions are flexible - nearly all define entities list each attribute as optional. Validation contracts, which can be applied based on specific scenarios, -are strict - attribute presence, coersion and type are specified at the contract -level only. +Projects that create new must use the `lib/aca_entities/libraries` and namespacing conventions to tie in the new vocaulary in a modular way. Projects that update existing vocabularies should include refactoring to do the same. -Include APi documentation and rspec specs for all entities, contracts, transforms -and other content added to AcaEntities. +### Issues -### Extending AcaEntities Domain Model +#### Known Issues -Before you add new code to AcaEntties, make sure existing domain models don't already -exist for your needs. Next, determine existing core model entities that you can reuse to build out the new namespace. +Some subject-specific vocabularies are unnecessarily loaded: 1) via `lib/aca_entities/libraries/core_library.rb`, or 2) via `lib/aca_entities.rb`. These should be refactored to use the `lib/aca_entities/libraries` convention to isolate the vocaulary and its dependencies to enable modular loading. -### Issues +#### Other Issues -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/aca_entities. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/aca_entities/blob/master/CODE_OF_CONDUCT.md). +Bug reports and pull requests are welcome on GitHub at [aca_entities](https://github.com/ideacrew/aca_entities). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ideacrew/aca_entities/blob/trunk/CODE_OF_CONDUCT.md). ## License @@ -126,4 +163,4 @@ The gem is available as open source under the terms of the [MIT License](https:/ ## Code of Conduct -Everyone interacting in the AcaEntities project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/aca_entities/blob/master/CODE_OF_CONDUCT.md). +Everyone interacting in the AcaEntities project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ideacrew/aca_entities/blob/master/CODE_OF_CONDUCT.md). diff --git a/lib/generators/domain/contract/contract_generator.rb b/lib/generators/domain/contract/contract_generator.rb new file mode 100644 index 000000000..075fe826b --- /dev/null +++ b/lib/generators/domain/contract/contract_generator.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative '../generated_dry_attribute' + +module Domain + # Generate a Domain Entity Contract file + class ContractGenerator < Rails::Generators::NamedBase # :nodoc: + source_root File.expand_path('templates', __dir__) + + ENTITY_CONTRACT_PATH = 'app/domain/contracts' + ENTITY_CONTRACT_TEMPLATE_FILENAME = 'entity_contract.rb' + DOC_PREFIX_TEXT = <<~ATTR.chomp.strip + # @!method call(opts) + # @param [Hash] opts the parameters to validate using this contract + ATTR + + desc 'Create Dry::Validations Contract file' + + argument :arguments, + type: :array, + default: [], + banner: + 'ATTRIBUTE_NAME[:type][:optional_key (default) | :require_key] ATTRIBUTE_NAME[:type][:optional_key (default) | :require_key]' + + check_class_collision suffix: 'Contract' + + def initialize(*args, &blk) + @local_args = args[0].dup + super(*args, &blk) + + @local_class_name = class_name.to_s.split('::').last + @doc_content = DOC_PREFIX_TEXT + @params_content = '' + @indentation = 2 + @class_indent = [class_path.size, 0].max + end + + # Convert arguments array into GeneratedDryAttribute objects + def parse_dry_arguments + attrs = @local_args.drop(1) + + (attrs || []).map do |attr| + parsed_attr = Generators::Domain::GeneratedDryAttribute.parse(attr) + @doc_content += doc_text(parsed_attr).chomp + @params_content += params_text(parsed_attr).chomp + end + end + + def create_entity_contract_file + template ENTITY_CONTRACT_TEMPLATE_FILENAME, contract_filename + end + + hook_for :test_framework, in: :rspec, as: :contract + + private + + def contract_filename + File.join(ENTITY_CONTRACT_PATH, class_path, "#{file_name}_contract.rb") + end + + def params_text(attr) + attr.key_required? ? required_attribute(attr) : optional_attribute(attr) + end + + def doc_text(attr) + attr.key_required? ? required_doc(attr) : optional_doc(attr) + end + + def required_attribute(attr) + data_type = attr.type + "\nrequired(:#{attr.name}).filled(:#{data_type})" + end + + def optional_attribute(attr) + data_type = attr.type.to_s + "\noptional(:#{attr.name}).maybe(:#{data_type})" + end + + def required_doc(attr) + data_type = attr.type.to_s.classify + "\n# @option opts [#{data_type}] :#{attr.name.to_sym} (required)" + end + + def optional_doc(attr) + data_type = attr.type.to_s.classify + "\n# @option opts [#{data_type}] :#{attr.name.to_sym} (optional)" + end + end +end diff --git a/lib/generators/domain/contract/templates/entity_contract.rb.tt b/lib/generators/domain/contract/templates/entity_contract.rb.tt new file mode 100644 index 000000000..83cb7c9ce --- /dev/null +++ b/lib/generators/domain/contract/templates/entity_contract.rb.tt @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +<% class_path.each_with_index do |path_fragment, index| -%> +<%= indent("module #{path_fragment.to_s.camelcase}", index * @indentation) %> +<% end -%> +<%= indent("# Schema, rules and macros for {#{class_name}} entity", @class_indent * @indentation) %> +<%= indent("class #{@local_class_name}Contract < Contracts::Contract", @class_indent * @indentation) %> +<%= indent(@doc_content, (@class_indent + 1) * (@indentation)) %> +<%= indent("params do", (@class_indent + 1) * (@indentation)) %> +<%= indent(@params_content.strip, (@class_indent + 2) * (@indentation)) %> +<% (0...(@class_indent + 2)).reverse_each do |index| -%> +<%= indent('end', index * @indentation) %> +<% end -%> diff --git a/lib/generators/domain/entity/USAGE b/lib/generators/domain/entity/USAGE new file mode 100644 index 000000000..2958134e3 --- /dev/null +++ b/lib/generators/domain/entity/USAGE @@ -0,0 +1,12 @@ +Usage: + bin/rails generate domain:entity NAME [attribute[:type][:key_requirement] attribute[:type][:key_requirement]] [options] + +Description: + Generate a Domain Entity file with optional attributes + +Example: + bin/rails generate domain:entity account title:type:require_key description:type:optional_key + + Creates an Account class and RSpec spec: + domain/account.rb + spec/domain/account_spec.rb diff --git a/lib/generators/domain/entity/entity_generator.rb b/lib/generators/domain/entity/entity_generator.rb new file mode 100644 index 000000000..300543790 --- /dev/null +++ b/lib/generators/domain/entity/entity_generator.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require_relative '../generated_dry_attribute' + +module Domain + # Generate a Domain Entity file + class EntityGenerator < Rails::Generators::NamedBase + source_root File.expand_path('templates', __dir__) + + ENTITY_PATH = 'app/domain' + ENTITY_TEMPLATE_FILENAME = 'entity.rb' + OPTIONAL_META = 'meta(ommitable: true)' + REQUIRED_META = 'meta(ommitable: false)' + + desc 'Generate a Domain Entity file with optional arguments' + + argument :arguments, + type: :array, + default: [], + banner: + 'ATTRIBUTE_NAME[:type][:optional_key (default) | :required_key] ATTRIBUTE_NAME[:type][:optional_key (default) | :required_key]' + + check_class_collision + + def initialize(*args) + @local_args = args[0] + super + @indentation = 2 + @class_indent = [class_path.size, 0].max + @local_class_name = class_name.to_s.split('::').last + end + + # Convert arguments array into GeneratedDryAttribute objects + def parse_dry_arguments + attrs = @local_args.drop(1) + self.arguments = (attrs || []).map { |attr| Generators::Domain::GeneratedDryAttribute.parse(attr) } + end + + def content + arguments.reduce('') do |block, attr| + param = attr.key_required? ? required_attribute(attr) : optional_attribute(attr) + block + param + end + end + + def create_entity_file + template ENTITY_TEMPLATE_FILENAME, entity_filename + end + + def create_contract_file + case self.behavior + when :invoke + generate 'domain:contract', @local_args + when :revoke + Rails::Generators.invoke 'domain:contract', @local_args, behavior: :revoke + end + end + + hook_for :test_framework, in: :rspec, as: :entity + + private + + def entity_filename + File.join(ENTITY_PATH, class_path, "#{file_name}.rb") + end + + def required_attribute(attr) + data_type = attr.type.to_s.camelcase + attr_name = attr.name.underscore + <<~ATTR.chomp + + # @!attribute [r] #{attr_name} + # ** REPLACE WITH DEFINITION FOR ATTRIBUTE: :#{attr_name} ** + # @return [#{data_type}] + attribute :#{attr_name}, ::Types::#{data_type}.#{REQUIRED_META} + + ATTR + end + + def optional_attribute(attr) + data_type = attr.type.to_s.camelcase + attr_name = attr.name.underscore + <<~ATTR.chomp + + # @!attribute [r] #{attr_name} + # ** REPLACE WITH DEFINITION FOR ATTRIBUTE: :#{attr_name} ** + # @return [#{data_type}] + attribute? :#{attr_name}, ::Types::#{data_type}.#{OPTIONAL_META}\n + ATTR + end + end +end diff --git a/lib/generators/domain/entity/templates/entity.rb.tt b/lib/generators/domain/entity/templates/entity.rb.tt new file mode 100644 index 000000000..8b85af938 --- /dev/null +++ b/lib/generators/domain/entity/templates/entity.rb.tt @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'dry-struct' +require 'dry-types' + +<% class_path.each_with_index do |path_fragment, index| -%> +<%= indent("module #{path_fragment.to_s.camelcase}", index * @indentation) %> +<% end -%> +<%= indent("# ** REPLACE WITH DEFINITION FOR ENTITY: #{@local_class_name} **", @class_indent * @indentation) %> +<%= indent("class #{@local_class_name} < Dry::Struct", @class_indent * @indentation) %> +<%= indent(content.strip, (@class_indent + 1) * @indentation) %> +<% (0...(@class_indent + 1)).reverse_each do |index| -%> +<%= indent('end', index * @indentation) %> +<% end -%> \ No newline at end of file diff --git a/lib/generators/domain/generated_dry_attribute.rb b/lib/generators/domain/generated_dry_attribute.rb new file mode 100644 index 000000000..8ec6f632b --- /dev/null +++ b/lib/generators/domain/generated_dry_attribute.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Generators + module Domain + # Parse arguments into attributes for insertion into Dry Gem classes + class GeneratedDryAttribute + attr_reader :name, :type, :key_option + + KEY_OPTIONS = %w[required_key optional_key].freeze + KEY_OPTION_DEFAULT = 'optional_key' + TYPE_DEFAULT = 'string' + DEFAULT_TYPES = %w[ + any + nil + symbol + class + true + false + bool + integer + float + decimal + string + date + date_time + time + array + hash + ].freeze + + class << self + def parse(attribute_definition) + name, type, key_option = attribute_definition.split(':') + + name = name.to_s if name + type = type.nil? ? TYPE_DEFAULT : type.to_s + key_option = key_option.nil? ? KEY_OPTION_DEFAULT : key_option.to_s + + # rubocop:disable Style/IfUnlessModifier + unless valid_type?(type) + raise ArgumentError, "Could not generate attribute '#{name}' with unknown type '#{type}'" + end + + unless valid_key_option?(key_option) + raise ArgumentError, "Could not generate attribute '#{name}' with unknown key option '#{key_option}'" + end + + # rubocop:enable Style/IfUnlessModifier + + new(name, type, key_option) + end + + def valid_key_option?(option) + return false if option.nil? + KEY_OPTIONS.include?(option.to_s) + end + + def valid_type?(type) + return false if type.nil? + DEFAULT_TYPES.include?(type.to_s) + end + end + + def initialize(name, type = nil, key_option = nil) + @name = name + @type = type || TYPE_DEFAULT + @key_option = key_option || KEY_OPTION_DEFAULT + end + + def key_required? + key_option != KEY_OPTION_DEFAULT + end + end + end +end diff --git a/lib/generators/domain/install/install_generator.rb b/lib/generators/domain/install/install_generator.rb new file mode 100644 index 000000000..65977bd2e --- /dev/null +++ b/lib/generators/domain/install/install_generator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Domain + # Generator that adds Domain Model to a Rails application + class InstallGenerator < Rails::Generators::Base + source_root File.expand_path('../../templates', __FILE__) + + def initialize(*args, &blk) + super(*args, &blk) + @app_name = app_name + end + + desc 'Create Domain folder, Contract subfolder, Application folder and Types files' + + def verify_required_gems + gem "event_source", git: "https://github.com/ideacrew/event_source.git", branch: 'trunk' + end + + def create_domain_contract_file + template 'domain_contract.rb', File.join('app/domain/contracts', 'contract.rb') + end + + def create_types_file + template 'types.rb', File.join("app/domain", 'types.rb') + end + + def create_domain_types_file + template 'domain_types.rb', File.join("app/domain/#{@app_name}", 'types.rb') + end + + hook_for :test_framework, in: :rspec, as: :dry_type + + private + + def app_name + Rails.application.class.name.chomp('::Application').underscore + end + end +end diff --git a/lib/generators/domain/operation/USAGE b/lib/generators/domain/operation/USAGE new file mode 100644 index 000000000..b7a10f658 --- /dev/null +++ b/lib/generators/domain/operation/USAGE @@ -0,0 +1,9 @@ +Description: + Create Domain Operation file + +Example: + bin/rails generate domain:operation accounts/update + + creates an Accounts::Update operation class and RSpec spec: + Operation: app/operations/accounts/update_operation.rb + Rspec: spec/operations/accounts/update_operation_spec.rb diff --git a/lib/generators/domain/operation/operation_generator.rb b/lib/generators/domain/operation/operation_generator.rb new file mode 100644 index 000000000..17b0032a1 --- /dev/null +++ b/lib/generators/domain/operation/operation_generator.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Domain # :nodoc: + class OperationGenerator < Rails::Generators::NamedBase # :nodoc: + source_root File.expand_path('templates', __dir__) + + desc 'Create Domain Operation file' + + check_class_collision + + def initialize(*args) + @local_args = args[0] + super + @indentation = 2 + @class_indent = [class_path.size, 0].max + @local_class_name = class_name.to_s.split('::').last + end + + def create_operation_file + template 'operation.rb', File.join('app/operations', class_path, "#{file_name}.rb") + end + + hook_for :test_framework, in: :rspec, as: :operation + + private + + def content + <<~RUBY.chomp + send(:include, Dry::Monads[:result, :do]) + send(:include, EventSource::Command) + + # @param [Hash] params the input parameters for {#{class_name}} operation + # @option params [String] :description (optional) + # @return [Dry::Monads::Result::Success] if operation was successful + # @return [Dry::Monads::Result::Failure] if operation errored + def call(params) + # values = yield validate(params) + # result = yield command(values) + # event = yield publish_event(result) + + # Success(event) + end + + private + + # Schema-validate the passed parameters. This step is typically done when + # parameters are first enter the Domain model + # def validate(params) + # end + + # Execute the command for this operation + # def command(values) + # end + + # Publish an event with the command output as the payload. + # The event name usually is the operation/command name in past tense form + # def publish_event(result) + # end + RUBY + end + end +end diff --git a/lib/generators/domain/operation/templates/operation.rb.tt b/lib/generators/domain/operation/templates/operation.rb.tt new file mode 100644 index 000000000..7ddfc39a3 --- /dev/null +++ b/lib/generators/domain/operation/templates/operation.rb.tt @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'dry/monads/do' + +<% class_path.each_with_index do |path_fragment, index| -%> +<%= indent("module #{path_fragment.to_s.camelcase}", index * @indentation) %> +<% end -%> +<%= indent("# ** REPLACE WITH DEFINITION FOR Operation: #{@local_class_name} **", @class_indent * @indentation) %> +<%= indent("class #{@local_class_name}", @class_indent * @indentation) %> +<%= indent(content.strip, (@class_indent + 1) * @indentation) %> +<% (0...(@class_indent + 1)).reverse_each do |index| -%> +<%= indent('end', index * @indentation) %> +<% end -%> \ No newline at end of file diff --git a/lib/generators/domain/templates/domain_contract.rb.tt b/lib/generators/domain/templates/domain_contract.rb.tt new file mode 100644 index 000000000..4878c5cb8 --- /dev/null +++ b/lib/generators/domain/templates/domain_contract.rb.tt @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Contracts + # Configuration values and shared rules and macros for validation contracts + class Contract < Dry::Validation::Contract + # config.messages.backend - the localization backend to use. Supported values are: :yaml and :i18n + # config.messages.backend = :i18n + # config.messages.default_locale - default I18n-compatible locale identifier + # config.messages.default_locale = :en + # config.messages.load_paths - an array of files paths that are used to load messages + # config.messages.top_namespace - the key in the locale files under which messages are defined, by default it's dry_validation + # config.messages.namespace - custom messages namespace for a contract class. Use this to differentiate common messages + end +end diff --git a/lib/generators/domain/templates/domain_types.rb.tt b/lib/generators/domain/templates/domain_types.rb.tt new file mode 100644 index 000000000..11a0c7256 --- /dev/null +++ b/lib/generators/domain/templates/domain_types.rb.tt @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'dry-types' + +module <%= @app_name.classify %> + # <%= @app_name.classify %> Custom Data Types + module Types + send(:include, Dry.Types) + send(:include, Dry::Logic) + + # Add custom types for this application here + end +end diff --git a/lib/generators/domain/templates/types.rb.tt b/lib/generators/domain/templates/types.rb.tt new file mode 100644 index 000000000..51e96551e --- /dev/null +++ b/lib/generators/domain/templates/types.rb.tt @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'dry-types' + +module Types + include Dry.Types() +end diff --git a/lib/generators/rspec/contract/USAGE b/lib/generators/rspec/contract/USAGE new file mode 100644 index 000000000..1e95b64ba --- /dev/null +++ b/lib/generators/rspec/contract/USAGE @@ -0,0 +1,8 @@ +Description: + Explain the generator + +Example: + bin/rails generate contract Thing + + This will create: + what/will/it/create diff --git a/lib/generators/rspec/contract/contract_generator.rb b/lib/generators/rspec/contract/contract_generator.rb new file mode 100644 index 000000000..914af7f8e --- /dev/null +++ b/lib/generators/rspec/contract/contract_generator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'generators/rspec' + +module Rspec + module Generators + # Generate a Domain Contract rspec file + class ContractGenerator < Rails::Generators::NamedBase + source_root File.expand_path('templates', __dir__) + + class_option :contract_specs, type: :boolean, default: true + + def create_contract_spec + return unless options[:contract_specs] + template 'contract_spec.rb', File.join('spec/domain/contracts', class_path, "#{file_name}_contract_spec.rb") + end + end + end +end diff --git a/lib/generators/rspec/contract/templates/contract_spec.rb.tt b/lib/generators/rspec/contract/templates/contract_spec.rb.tt new file mode 100644 index 000000000..ad77898fc --- /dev/null +++ b/lib/generators/rspec/contract/templates/contract_spec.rb.tt @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +<% module_namespacing do -%> +RSpec.describe <%= class_name %> do + subject { described_class.new } + + let(:required_params) { {} } + let(:optional_params) { {} } + let(:all_params) { required_params.merge(optional_params) } + + describe 'Calling the contract with no params' do + pending "add 'no params' scenario #{__FILE__}" + + # let(:error_message) { { required_params.keys.first ['is missing'] } } + # it 'should fail validation' do + # result = subject.call({}) + # expect(result.failure?).to be_truthy + # expect(result.errors.to_h).to include error_message + # end + end + + describe 'Calling contract with valid params' do + context 'Calling the contract with required params' do + pending "add 'required params' scenario #{__FILE__}" + + # it 'should pass validation' do + # result = subject.call(required_params) + # expect(result.success?).to be_truthy + # expect(result.to_h).to eq required_params + # end + end + + context 'Calling the contract with all params' do + pending "add 'all params' scenario #{__FILE__}" + + # it 'should pass validation' do + # result = subject.call(all_params) + # expect(result.success?).to be_truthy + # expect(result.to_h).to eq all_params + # end + end + end +end +<% end -%> diff --git a/lib/generators/rspec/dry_type/dry_type_generator.rb b/lib/generators/rspec/dry_type/dry_type_generator.rb new file mode 100644 index 000000000..b390c7b36 --- /dev/null +++ b/lib/generators/rspec/dry_type/dry_type_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'generators/rspec' + +module Rspec + module Generators + # Generate a Domain Types rspec file + class DryTypeGenerator < Rails::Generators::Base + source_root File.expand_path('templates', __dir__) + + desc 'Create Domain Types rspec file' + + class_option :install_specs, type: :boolean, default: true + + def create_dry_types_spec_file + return unless options[:install_specs] + @app_name = Rails.application.class.name.chomp('::Application').underscore + template 'dry_types_spec.rb', File.join("spec/domain/#{@app_name}", 'types_spec.rb') + end + end + end +end \ No newline at end of file diff --git a/lib/generators/rspec/dry_type/templates/dry_types_spec.rb.tt b/lib/generators/rspec/dry_type/templates/dry_types_spec.rb.tt new file mode 100644 index 000000000..b5e0fb160 --- /dev/null +++ b/lib/generators/rspec/dry_type/templates/dry_types_spec.rb.tt @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +<%= "RSpec.describe #{@app_name.classify}::Types do" %> + pending "add some scenarios (or delete) #{__FILE__}" +end diff --git a/lib/generators/rspec/entity/entity_generator.rb b/lib/generators/rspec/entity/entity_generator.rb new file mode 100644 index 000000000..968f888ed --- /dev/null +++ b/lib/generators/rspec/entity/entity_generator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'generators/rspec' + +module Rspec + module Generators + # Generate a Domain Entity rspec file + class EntityGenerator < Rails::Generators::NamedBase + source_root File.expand_path('templates', __dir__) + + desc 'Create Domain Entity rspec file' + + class_option :entity_specs, type: :boolean, default: true + + def create_entity_spec + return unless options[:entity_specs] + template 'entity_spec.rb', File.join('spec/domain', class_path, "#{file_name}_spec.rb") + end + end + end +end diff --git a/lib/generators/rspec/entity/templates/entity_spec.rb.tt b/lib/generators/rspec/entity/templates/entity_spec.rb.tt new file mode 100644 index 000000000..c1afa154f --- /dev/null +++ b/lib/generators/rspec/entity/templates/entity_spec.rb.tt @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +<% module_namespacing do -%> +RSpec.describe <%= class_name %> do + subject { described_class.new } + + let(:required_params) { {} } + let(:optional_params) { {} } + let(:all_params) { required_params.merge(optional_params) } + + describe 'With required arguments' do + pending "add 'required params' scenario #{__FILE__}" + + # it 'should initialize' do + # expect(subject.new(required_params)).to be_a described_class + # end + end + + describe 'With all arguments' do + pending "add 'all params' scenario #{__FILE__}" + + # it 'should initialize' do + # expect(subject.new(all_params)).to be_a described_class + # end + end + +end +<% end -%> diff --git a/lib/generators/rspec/operation/USAGE b/lib/generators/rspec/operation/USAGE new file mode 100644 index 000000000..6dd9c0b7c --- /dev/null +++ b/lib/generators/rspec/operation/USAGE @@ -0,0 +1,8 @@ +Description: + Explain the generator + +Example: + bin/rails generate operation Thing + + This will create: + what/will/it/create diff --git a/lib/generators/rspec/operation/entity_generator.rb b/lib/generators/rspec/operation/entity_generator.rb new file mode 100644 index 000000000..9412f5c6e --- /dev/null +++ b/lib/generators/rspec/operation/entity_generator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'generators/rspec' + +module Rspec + module Generators + # Generate a Domain Entity rspec file + class EntityGenerator < Base + desc 'Create Domain Entity rspec file' + + def create_entity_spec + template 'entity_spec.rb', File.join('spec/domain}', class_path, "#{file_name}_spec.rb") + end + end + end +end diff --git a/lib/generators/rspec/operation/operation_generator.rb b/lib/generators/rspec/operation/operation_generator.rb new file mode 100644 index 000000000..108f5c44c --- /dev/null +++ b/lib/generators/rspec/operation/operation_generator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Rspec + module Generators + # Generate a Domain Operation rspec file + class OperationGenerator < Rails::Generators::NamedBase + source_root File.expand_path('templates', __dir__) + + desc 'Create Domain Operation rspec file' + + class_option :operation_specs, type: :boolean, default: true + + def copy_operation_spec_file + return unless options[:operation_specs] + template 'operation_spec.rb', File.join('spec/operations', class_path, "#{file_name}_spec.rb") + end + end + end +end diff --git a/lib/generators/rspec/operation/templates/operation_spec.rb.tt b/lib/generators/rspec/operation/templates/operation_spec.rb.tt new file mode 100644 index 000000000..3746a33da --- /dev/null +++ b/lib/generators/rspec/operation/templates/operation_spec.rb.tt @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'rails_helper' + +<% module_namespacing do -%> +RSpec.describe <%= class_name %> do + # subject { described_class.new } + pending "add Operation scenarios here #{__FILE__}" +end +<% end -%>