├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .rubocop.yml ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── strict.rb └── strict │ ├── accessor │ ├── attributes.rb │ └── module.rb │ ├── assignment_error.rb │ ├── attribute.rb │ ├── attributes │ ├── class.rb │ ├── coercer.rb │ ├── configuration.rb │ ├── dsl.rb │ └── instance.rb │ ├── coercers │ ├── array.rb │ └── hash.rb │ ├── configuration.rb │ ├── dsl │ ├── coercible.rb │ └── validatable.rb │ ├── error.rb │ ├── implementation_does_not_conform_error.rb │ ├── initialization_error.rb │ ├── interface.rb │ ├── interfaces │ ├── coercer.rb │ └── instance.rb │ ├── method.rb │ ├── method_call_error.rb │ ├── method_definition_error.rb │ ├── method_return_error.rb │ ├── methods │ ├── configuration.rb │ ├── dsl.rb │ ├── module.rb │ └── verifiable_method.rb │ ├── object.rb │ ├── parameter.rb │ ├── reader │ ├── attributes.rb │ └── module.rb │ ├── return.rb │ ├── validators │ ├── all_of.rb │ ├── any_of.rb │ ├── anything.rb │ ├── array_of.rb │ ├── boolean.rb │ ├── hash_of.rb │ └── range_of.rb │ ├── value.rb │ └── version.rb ├── sig └── strict.rbs ├── strict.gemspec └── test ├── strict ├── accessor │ └── attributes_test.rb ├── assignment_error_test.rb ├── attribute_test.rb ├── attributes │ ├── configuration_test.rb │ ├── configured_test.rb │ └── dsl_test.rb ├── coercers │ ├── array_test.rb │ └── hash_test.rb ├── configuration_test.rb ├── implementation_does_not_conform_error_test.rb ├── initialization_error_test.rb ├── interface_test.rb ├── method_call_error_test.rb ├── method_definition_error_test.rb ├── method_return_error_test.rb ├── method_test.rb ├── methods │ └── dsl_test.rb ├── object_test.rb ├── parameter_test.rb ├── reader │ └── attributes_test.rb ├── return_test.rb ├── validators │ ├── all_of_test.rb │ ├── any_of_test.rb │ ├── anything_test.rb │ ├── array_of_test.rb │ ├── boolean_test.rb │ ├── hash_of_test.rb │ └── range_of_test.rb └── value_test.rb ├── strict_test.rb └── test_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - "3.0.0" 18 | - "3.1.2" 19 | - "3.2.2" 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run the default task 29 | run: bundle exec rake 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/ruby,macos,windows,linux 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=ruby,macos,windows,linux 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### Ruby ### 53 | *.gem 54 | *.rbc 55 | /.config 56 | /coverage/ 57 | /InstalledFiles 58 | /pkg/ 59 | /spec/reports/ 60 | /spec/examples.txt 61 | /test/tmp/ 62 | /test/version_tmp/ 63 | /tmp/ 64 | 65 | # Used by dotenv library to load environment variables. 66 | # .env 67 | 68 | # Ignore Byebug command history file. 69 | .byebug_history 70 | 71 | ## Specific to RubyMotion: 72 | .dat* 73 | .repl_history 74 | build/ 75 | *.bridgesupport 76 | build-iPhoneOS/ 77 | build-iPhoneSimulator/ 78 | 79 | ## Specific to RubyMotion (use of CocoaPods): 80 | # 81 | # We recommend against adding the Pods directory to your .gitignore. However 82 | # you should judge for yourself, the pros and cons are mentioned at: 83 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 84 | # vendor/Pods/ 85 | 86 | ## Documentation cache and generated files: 87 | /.yardoc/ 88 | /_yardoc/ 89 | /doc/ 90 | /rdoc/ 91 | 92 | ## Environment normalization: 93 | /.bundle/ 94 | /vendor/bundle 95 | /lib/bundler/man/ 96 | 97 | # for a library or gem, you might want to ignore these files since the code is 98 | # intended to run in multiple environments; otherwise, check them in: 99 | # Gemfile.lock 100 | # .ruby-version 101 | # .ruby-gemset 102 | 103 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 104 | .rvmrc 105 | 106 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 107 | # .rubocop-https?--* 108 | 109 | ### Windows ### 110 | # Windows thumbnail cache files 111 | Thumbs.db 112 | Thumbs.db:encryptable 113 | ehthumbs.db 114 | ehthumbs_vista.db 115 | 116 | # Dump file 117 | *.stackdump 118 | 119 | # Folder config file 120 | [Dd]esktop.ini 121 | 122 | # Recycle Bin used on file shares 123 | $RECYCLE.BIN/ 124 | 125 | # Windows Installer files 126 | *.cab 127 | *.msi 128 | *.msix 129 | *.msm 130 | *.msp 131 | 132 | # Windows shortcuts 133 | *.lnk 134 | 135 | # End of https://www.toptal.com/developers/gitignore/api/ruby,macos,windows,linux 136 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-minitest 3 | - rubocop-rake 4 | 5 | AllCops: 6 | NewCops: enable 7 | TargetRubyVersion: 3.0.0 8 | 9 | Style/CaseEquality: 10 | Enabled: false 11 | 12 | Style/Documentation: 13 | Enabled: false 14 | 15 | Style/StringLiterals: 16 | EnforcedStyle: double_quotes 17 | 18 | Metrics/BlockLength: 19 | Exclude: 20 | - "test/**/*" 21 | - "*.gemspec" 22 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.0.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.0.0] - 2022-10-01 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 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 me@kkt.dev. 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. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in strict.gemspec 6 | gemspec 7 | 8 | group :development do 9 | gem "debug", ">= 1.0.0" 10 | gem "gem-release", "~> 2.2" 11 | gem "minitest", "~> 5.0" 12 | gem "minitest-spec-context", "~> 0.0.4" 13 | gem "rake", "~> 13.0" 14 | gem "rubocop", "~> 1.21" 15 | gem "rubocop-minitest", "~> 0.22" 16 | gem "rubocop-rake", "~> 0.6" 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | strict (1.5.0) 5 | zeitwerk (~> 2.6) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.2) 11 | debug (1.9.1) 12 | irb (~> 1.10) 13 | reline (>= 0.3.8) 14 | gem-release (2.2.2) 15 | io-console (0.7.2) 16 | irb (1.11.1) 17 | rdoc 18 | reline (>= 0.4.2) 19 | json (2.7.1) 20 | language_server-protocol (3.17.0.3) 21 | minitest (5.21.2) 22 | minitest-spec-context (0.0.5) 23 | parallel (1.24.0) 24 | parser (3.3.0.5) 25 | ast (~> 2.4.1) 26 | racc 27 | psych (5.1.2) 28 | stringio 29 | racc (1.7.3) 30 | rainbow (3.1.1) 31 | rake (13.1.0) 32 | rdoc (6.6.2) 33 | psych (>= 4.0.0) 34 | regexp_parser (2.9.0) 35 | reline (0.4.2) 36 | io-console (~> 0.5) 37 | rexml (3.2.6) 38 | rubocop (1.60.2) 39 | json (~> 2.3) 40 | language_server-protocol (>= 3.17.0) 41 | parallel (~> 1.10) 42 | parser (>= 3.3.0.2) 43 | rainbow (>= 2.2.2, < 4.0) 44 | regexp_parser (>= 1.8, < 3.0) 45 | rexml (>= 3.2.5, < 4.0) 46 | rubocop-ast (>= 1.30.0, < 2.0) 47 | ruby-progressbar (~> 1.7) 48 | unicode-display_width (>= 2.4.0, < 3.0) 49 | rubocop-ast (1.30.0) 50 | parser (>= 3.2.1.0) 51 | rubocop-minitest (0.34.5) 52 | rubocop (>= 1.39, < 2.0) 53 | rubocop-ast (>= 1.30.0, < 2.0) 54 | rubocop-rake (0.6.0) 55 | rubocop (~> 1.0) 56 | ruby-progressbar (1.13.0) 57 | stringio (3.1.0) 58 | unicode-display_width (2.5.0) 59 | zeitwerk (2.6.12) 60 | 61 | PLATFORMS 62 | arm64-darwin-21 63 | x86_64-linux 64 | 65 | DEPENDENCIES 66 | debug (>= 1.0.0) 67 | gem-release (~> 2.2) 68 | minitest (~> 5.0) 69 | minitest-spec-context (~> 0.0.4) 70 | rake (~> 13.0) 71 | rubocop (~> 1.21) 72 | rubocop-minitest (~> 0.22) 73 | rubocop-rake (~> 0.6) 74 | strict! 75 | 76 | BUNDLED WITH 77 | 2.3.23 78 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Kyle Thompson 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Strict 2 | 3 | Strict provides a means to strictly validate instantiation of values, instantiation and attribute assignment of objects, and method calls at runtime. 4 | 5 | ## Installation 6 | 7 | Install the gem and add to the application's Gemfile by executing: 8 | 9 | ```sh 10 | $ bundle add strict 11 | ``` 12 | 13 | If bundler is not being used to manage dependencies, install the gem by executing: 14 | 15 | ```sh 16 | $ gem install strict 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### `Strict::Value` 22 | 23 | ```rb 24 | class Money 25 | include Strict::Value 26 | 27 | attributes do 28 | amount_in_cents Integer 29 | currency AnyOf("USD", "CAD"), default: "USD" 30 | end 31 | end 32 | 33 | Money.new(amount_in_cents: 100_00) 34 | # => # 35 | 36 | Money.new(amount_in_cents: 100_00, currency: "CAD") 37 | # => # 38 | 39 | Money.new(amount_in_cents: 100.00) 40 | # => Strict::InitializationError 41 | 42 | Money.new(amount_in_cents: 100_00).with(amount_in_cents: 200_00) 43 | # => # 44 | 45 | Money.new(amount_in_cents: 100_00).amount_in_cents = 50_00 46 | # => NoMethodError 47 | 48 | Money.new(amount_in_cents: 100_00) == Money.new(amount_in_cents: 100_00) 49 | # => true 50 | ``` 51 | 52 | ### `Strict::Object` 53 | 54 | ```rb 55 | class Stateful 56 | include Strict::Object 57 | 58 | attributes do 59 | some_state String 60 | dependency Anything(), default: nil 61 | end 62 | end 63 | 64 | Stateful.new(some_state: "123") 65 | # => # 66 | 67 | Stateful.new(some_state: "123").with(some_state: "456") 68 | # => NoMethodError 69 | 70 | Stateful.new(some_state: "123").some_state = "456" 71 | # => "456" 72 | # => # 73 | 74 | Stateful.new(some_state: "123").some_state = 456 75 | # => Strict::AssignmentError 76 | 77 | Stateful.new(some_state: "123") == Stateful.new(some_state: "123") 78 | # => false 79 | ``` 80 | 81 | ### `Strict::Method` 82 | 83 | ```rb 84 | class UpdateEmail 85 | extend Strict::Method 86 | 87 | sig do 88 | user_id String, coerce: ->(value) { value.to_s } 89 | email String 90 | returns AnyOf(true, nil) 91 | end 92 | def call(user_id:, email:) 93 | # contrived logic 94 | user_id == email 95 | end 96 | end 97 | 98 | UpdateEmail.new.call(user_id: 123, email: "123") 99 | # => true 100 | 101 | UpdateEmail.new.call(user_id: "123", email: "123") 102 | # => true 103 | 104 | UpdateEmail.new.call(user_id: "123", email: 123) 105 | # => Strict::MethodCallError 106 | 107 | UpdateEmail.new.call(user_id: "123", email: "456") 108 | # => Strict::MethodReturnError 109 | ``` 110 | 111 | ### `Strict::Interface` 112 | 113 | ```rb 114 | class Storage 115 | extend Strict::Interface 116 | 117 | expose(:write) do 118 | key String 119 | contents String 120 | returns Boolean() 121 | end 122 | 123 | expose(:read) do 124 | key String 125 | returns AnyOf(String, nil) 126 | end 127 | end 128 | 129 | module Storages 130 | class Memory 131 | def initialize 132 | @storage = {} 133 | end 134 | 135 | def write(key:, contents:) 136 | storage[key] = contents 137 | true 138 | end 139 | 140 | def read(key:) 141 | storage[key] 142 | end 143 | 144 | private 145 | 146 | attr_reader :storage 147 | end 148 | end 149 | 150 | storage = Storage.new(Storages::Memory.new) 151 | # => #> 152 | 153 | storage.write(key: "some/path/to/file.rb", contents: "Hello") 154 | # => true 155 | 156 | storage.write(key: "some/path/to/file.rb", contents: {}) 157 | # => Strict::MethodCallError 158 | 159 | storage.read(key: "some/path/to/file.rb") 160 | # => "Hello" 161 | 162 | storage.read(key: "some/path/to/other.rb") 163 | # => nil 164 | 165 | module Storages 166 | class Wat 167 | def write(key:) 168 | end 169 | end 170 | end 171 | 172 | storage = Storage.new(Storages::Wat.new) 173 | # => Strict::ImplementationDoesNotConformError 174 | ``` 175 | 176 | ### Configuration 177 | 178 | Strict exposes some configuration options which can be configured globally via `Strict.configure { ... }` or overridden 179 | within a block via `Strict.with_overrides(...) { ... }`. 180 | 181 | #### Example 182 | 183 | ```ruby 184 | # Globally 185 | 186 | Strict.configure do |c| 187 | c.sample_rate = 0.75 # run validation ~75% of the time 188 | end 189 | 190 | Strict.configure do |c| 191 | c.sample_rate = 0 # disable validation (Strict becomes Lenient) 192 | end 193 | 194 | Strict.configure do |c| 195 | c.sample_rate = 1 # always run validation 196 | end 197 | 198 | # Locally within the block (only applies to the current thread) 199 | 200 | Strict.with_overrides(sample_rate: 0) do 201 | # Use Strict as you normally would 202 | 203 | Strict.with_overrides(sample_rate: 0.5) do 204 | # Overrides can be nested 205 | end 206 | end 207 | ``` 208 | 209 | #### `Strict.configuration.random` 210 | 211 | The instance of a `Random::Formatter` that Strict uses in tandom with the `sample_rate` to determine when validation 212 | should be checked. 213 | 214 | **Default**: `Random.new` 215 | 216 | #### `Strict.configuration.sample_rate` 217 | 218 | The rate of samples Strict will consider when validating attributes, parameters, and return values. A rate of 0.25 will 219 | validate roughly 25% of the time, a rate of 0 will disable validation entirely, and a rate of 1 will always 220 | run validations. The `sample_rate` is used in tandem with `random` to determine whether validation should be run. 221 | 222 | **Default**: 1 223 | 224 | ## Development 225 | 226 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 227 | 228 | 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). 229 | 230 | ## Contributing 231 | 232 | Bug reports and pull requests are welcome on GitHub at https://github.com/kylekthompson/strict. 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/kylekthompson/strict/blob/main/CODE_OF_CONDUCT.md). 233 | 234 | ## License 235 | 236 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 237 | 238 | ## Code of Conduct 239 | 240 | Everyone interacting in the Strict project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/kylekthompson/strict/blob/main/CODE_OF_CONDUCT.md). 241 | 242 | ## Credit 243 | 244 | I can't thank [Tom Dalling](https://github.com/tomdalling) enough for his excellent [ValueSemantics](https://github.com/tomdalling/value_semantics) gem. Strict is heavily inspired and influenced by Tom's work and has some borrowed concepts and code. 245 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | require "rubocop/rake_task" 13 | 14 | RuboCop::RakeTask.new 15 | 16 | task default: %i[test rubocop] 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "strict" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/strict.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "zeitwerk" 4 | loader = Zeitwerk::Loader.for_gem 5 | loader.setup 6 | 7 | module Strict 8 | ISSUE_TRACKER = "https://github.com/kylekthompson/strict/issues" 9 | 10 | class << self 11 | def configuration 12 | thread_configuration || global_configuration 13 | end 14 | 15 | def configure 16 | raise Strict::Error, "cannot reconfigure overridden configuration" if overridden? 17 | 18 | yield(configuration) 19 | end 20 | 21 | def with_overrides(**overrides) 22 | original_thread_configuration = thread_configuration 23 | 24 | begin 25 | self.thread_configuration = Strict::Configuration.new(**configuration.to_h.merge(overrides)) 26 | yield 27 | ensure 28 | self.thread_configuration = original_thread_configuration 29 | end 30 | end 31 | 32 | private 33 | 34 | def overridden? 35 | !!thread_configuration 36 | end 37 | 38 | def thread_configuration 39 | Thread.current[:configuration] 40 | end 41 | 42 | def thread_configuration=(configuration) 43 | Thread.current[:configuration] = configuration 44 | end 45 | 46 | def global_configuration 47 | @global_configuration ||= Strict::Configuration.new 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/strict/accessor/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Accessor 5 | module Attributes 6 | def attributes(&block) 7 | block ||= -> {} 8 | configuration = Strict::Attributes::Dsl.run(&block) 9 | include Module.new(configuration) 10 | include Strict::Attributes::Instance 11 | extend Strict::Attributes::Class 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/strict/accessor/module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Accessor 5 | class Module < ::Module 6 | attr_reader :configuration 7 | 8 | # rubocop:disable Metrics/MethodLength 9 | def initialize(configuration) 10 | super() 11 | 12 | @configuration = configuration 13 | const_set(Strict::Attributes::Class::CONSTANT, configuration) 14 | configuration.attributes.each do |attribute| 15 | module_eval(<<~RUBY, __FILE__, __LINE__ + 1) 16 | def #{attribute.name} # def name 17 | #{attribute.instance_variable} # @instance_variable 18 | end # end 19 | RUBY 20 | 21 | module_eval(<<~RUBY, __FILE__, __LINE__ + 1) 22 | def #{attribute.name}=(value) # def name=(value) 23 | attribute = self.class.strict_attributes.named!(:#{attribute.name}) # attribute = self.class.strict_attributes.named!(:name) 24 | value = attribute.coerce(value, for_class: self.class) # value = attribute.coerce(value, for_class: self.class) 25 | if attribute.valid?(value) # if attribute.valid?(value) 26 | #{attribute.instance_variable} = value # @instance_variable = value 27 | else # else 28 | raise Strict::AssignmentError.new( # raise Strict::AssignmentError.new( 29 | assignable_class: self.class, # assignable_class: self.class, 30 | invalid_attribute: attribute, # invalid_attribute: attribute, 31 | value: value # value: value 32 | ) # ) 33 | end # end 34 | end # end 35 | RUBY 36 | end 37 | end 38 | # rubocop:enable Metrics/MethodLength 39 | 40 | def inspect 41 | "#<#{self.class} (#{configuration.attributes.map(&:name).join(', ')})>" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/strict/assignment_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class AssignmentError < Error 5 | attr_reader :invalid_attribute, :value 6 | 7 | def initialize(assignable_class:, invalid_attribute:, value:) 8 | super(message_from(assignable_class: assignable_class, invalid_attribute: invalid_attribute, value: value)) 9 | 10 | @invalid_attribute = invalid_attribute 11 | @value = value 12 | end 13 | 14 | private 15 | 16 | def message_from(assignable_class:, invalid_attribute:, value:) 17 | details = invalid_attribute_message_from(invalid_attribute, value) 18 | "Assignment to #{invalid_attribute.name} of #{assignable_class} failed because:\n#{details}" 19 | end 20 | 21 | def invalid_attribute_message_from(invalid_attribute, value) 22 | " - got #{value.inspect}, expected #{invalid_attribute.validator.inspect}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/strict/attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class Attribute 5 | NOT_PROVIDED = ::Object.new.freeze 6 | 7 | class << self 8 | def make(name, validator = Validators::Anything.instance, coerce: false, **defaults) 9 | unless valid_defaults?(**defaults) 10 | raise ArgumentError, "Only one of 'default', 'default_value', or 'default_generator' can be provided" 11 | end 12 | 13 | new( 14 | name: name.to_sym, 15 | validator: validator, 16 | default_generator: make_default_generator(**defaults), 17 | coercer: coerce 18 | ) 19 | end 20 | 21 | private 22 | 23 | def valid_defaults?(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED) 24 | defaults_provided = [default, default_value, default_generator].count do |default_option| 25 | !default_option.equal?(NOT_PROVIDED) 26 | end 27 | 28 | defaults_provided <= 1 29 | end 30 | 31 | def make_default_generator(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED) 32 | if !default.equal?(NOT_PROVIDED) 33 | default.respond_to?(:call) ? default : -> { default } 34 | elsif !default_value.equal?(NOT_PROVIDED) 35 | -> { default_value } 36 | elsif !default_generator.equal?(NOT_PROVIDED) 37 | default_generator 38 | else 39 | NOT_PROVIDED 40 | end 41 | end 42 | end 43 | 44 | attr_reader :name, :validator, :default_generator, :coercer, :instance_variable 45 | 46 | def initialize(name:, validator:, default_generator:, coercer:) 47 | @name = name.to_sym 48 | @validator = validator 49 | @default_generator = default_generator 50 | @coercer = coercer 51 | @optional = !default_generator.equal?(NOT_PROVIDED) 52 | @instance_variable = "@#{name.to_s.chomp('!').chomp('?')}" 53 | end 54 | 55 | def optional? 56 | @optional 57 | end 58 | 59 | def valid?(value) 60 | return true unless Strict.configuration.validate? 61 | 62 | validator === value 63 | end 64 | 65 | def coerce(value, for_class:) 66 | return value unless coercer 67 | 68 | case coercer 69 | when Symbol 70 | for_class.public_send(coercer, value) 71 | when true 72 | for_class.public_send("coerce_#{name}", value) 73 | else 74 | coercer.call(value) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/strict/attributes/class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Attributes 5 | module Class 6 | CONSTANT = :STRICT_INTERNAL_ATTRIBUTES_CONFIGURATION__ 7 | 8 | def strict_attributes 9 | self::STRICT_INTERNAL_ATTRIBUTES_CONFIGURATION__ 10 | end 11 | 12 | def coercer 13 | Coercer.new(self) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/strict/attributes/coercer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Attributes 5 | class Coercer 6 | attr_reader :attributes_class 7 | 8 | def initialize(attributes_class) 9 | @attributes_class = attributes_class 10 | end 11 | 12 | def call(value) 13 | return value if value.nil? || !value.respond_to?(:to_h) 14 | 15 | coerce(value.to_h) 16 | end 17 | 18 | private 19 | 20 | NOT_PROVIDED = ::Object.new.freeze 21 | 22 | def coerce(hash) 23 | attributes_class.new( 24 | **attributes_class.strict_attributes.each_with_object({}) do |attribute, attributes| 25 | value = hash.fetch(attribute.name) { hash.fetch(attribute.name.to_s, NOT_PROVIDED) } 26 | attributes[attribute.name] = value unless value.equal?(NOT_PROVIDED) 27 | end 28 | ) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/strict/attributes/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Strict 6 | module Attributes 7 | class Configuration 8 | include Enumerable 9 | extend Forwardable 10 | 11 | class UnknownAttributeError < Error 12 | attr_reader :attribute_name 13 | 14 | def initialize(attribute_name:) 15 | super(message_from(attribute_name: attribute_name)) 16 | 17 | @attribute_name = attribute_name 18 | end 19 | 20 | private 21 | 22 | def message_from(attribute_name:) 23 | "Strict tried to find an attribute named #{attribute_name} but was unable. " \ 24 | "It's likely this in an internal bug, feel free to open an issue at #{Strict::ISSUE_TRACKER} for help." 25 | end 26 | end 27 | 28 | def_delegator :attributes, :each 29 | 30 | attr_reader :attributes 31 | 32 | def initialize(attributes:) 33 | @attributes = attributes 34 | @attributes_index = attributes.to_h { |a| [a.name, a] } 35 | end 36 | 37 | def named!(name) 38 | attributes_index.fetch(name) { raise UnknownAttributeError.new(attribute_name: name) } 39 | end 40 | 41 | private 42 | 43 | attr_reader :attributes_index 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/strict/attributes/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Attributes 5 | class Dsl < BasicObject 6 | class << self 7 | def run(&block) 8 | dsl = new 9 | dsl.instance_eval(&block) 10 | ::Strict::Attributes::Configuration.new(attributes: dsl.__strict_dsl_internal_attributes.values) 11 | end 12 | end 13 | 14 | include ::Strict::Dsl::Coercible 15 | include ::Strict::Dsl::Validatable 16 | 17 | attr_reader :__strict_dsl_internal_attributes 18 | 19 | def initialize 20 | @__strict_dsl_internal_attributes = {} 21 | end 22 | 23 | def strict_attribute(*args, **kwargs) 24 | attribute = ::Strict::Attribute.make(*args, **kwargs) 25 | __strict_dsl_internal_attributes[attribute.name] = attribute 26 | nil 27 | end 28 | 29 | def method_missing(name, *args, **kwargs) 30 | if respond_to_missing?(name) 31 | strict_attribute(name, *args, **kwargs) 32 | else 33 | super 34 | end 35 | end 36 | 37 | def respond_to_missing?(method_name, _include_private = nil) 38 | first_letter = method_name.to_s.each_char.first 39 | first_letter.eql?(first_letter.downcase) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/strict/attributes/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Attributes 5 | module Instance 6 | # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 7 | def initialize(**attributes) 8 | remaining_attributes = Set.new(attributes.keys) 9 | invalid_attributes = nil 10 | missing_attributes = nil 11 | 12 | self.class.strict_attributes.each do |attribute| 13 | if remaining_attributes.delete?(attribute.name) 14 | value = attributes.fetch(attribute.name) 15 | elsif attribute.optional? 16 | value = attribute.default_generator.call 17 | else 18 | missing_attributes ||= [] 19 | missing_attributes << attribute.name 20 | next 21 | end 22 | 23 | value = attribute.coerce(value, for_class: self.class) 24 | if attribute.valid?(value) 25 | instance_variable_set(attribute.instance_variable, value) 26 | else 27 | invalid_attributes ||= {} 28 | invalid_attributes[attribute] = value 29 | end 30 | end 31 | 32 | return if remaining_attributes.none? && invalid_attributes.nil? && missing_attributes.nil? 33 | 34 | raise InitializationError.new( 35 | initializable_class: self.class, 36 | remaining_attributes: remaining_attributes, 37 | invalid_attributes: invalid_attributes, 38 | missing_attributes: missing_attributes 39 | ) 40 | end 41 | # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 42 | 43 | def to_h 44 | self.class.strict_attributes.to_h do |attribute| 45 | [attribute.name, public_send(attribute.name)] 46 | end 47 | end 48 | 49 | def inspect 50 | if self.class.strict_attributes.any? 51 | "#<#{self.class} #{to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>" 52 | else 53 | "#<#{self.class}>" 54 | end 55 | end 56 | 57 | def pretty_print(pp) 58 | pp.object_group(self) do 59 | to_h.each do |key, value| 60 | pp.breakable 61 | pp.text("#{key}=") 62 | pp.pp(value) 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/strict/coercers/array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Coercers 5 | class Array 6 | attr_reader :element_coercer 7 | 8 | def initialize(element_coercer = nil) 9 | @element_coercer = element_coercer 10 | end 11 | 12 | def call(value) 13 | return value if value.nil? || !value.respond_to?(:to_a) 14 | 15 | array = value.to_a 16 | return array unless element_coercer 17 | 18 | array.map { |element| element_coercer.call(element) } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/strict/coercers/hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Coercers 5 | class Hash 6 | attr_reader :key_coercer, :value_coercer 7 | 8 | def initialize(key_coercer = nil, value_coercer = nil) 9 | @key_coercer = key_coercer 10 | @value_coercer = value_coercer 11 | end 12 | 13 | def call(value) 14 | return value if value.nil? || !value.respond_to?(:to_h) 15 | 16 | if key_coercer && value_coercer 17 | coerce_keys_and_values(value.to_h) 18 | elsif key_coercer 19 | coerce_keys(value.to_h) 20 | elsif value_coercer 21 | coerce_values(value.to_h) 22 | else 23 | value.to_h 24 | end 25 | end 26 | 27 | private 28 | 29 | def coerce_keys_and_values(hash) = hash.to_h { |key, value| [key_coercer.call(key), value_coercer.call(value)] } 30 | def coerce_keys(hash) = hash.transform_keys { |key| key_coercer.call(key) } 31 | def coerce_values(hash) = hash.transform_values { |value| value_coercer.call(value) } 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/strict/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class Configuration 5 | attr_reader :random, :sample_rate 6 | 7 | def initialize(random: nil, sample_rate: nil) 8 | self.random = random || Random.new 9 | self.sample_rate = sample_rate || 1 10 | end 11 | 12 | def random=(random) 13 | case random 14 | when Random::Formatter 15 | @random = random 16 | else 17 | raise Strict::Error, "Expected a Random::Formatter, got: #{random.inspect}." 18 | end 19 | end 20 | 21 | def sample_rate=(rate) 22 | case rate 23 | when 0..1 24 | @sample_rate = rate 25 | else 26 | raise Strict::Error, "Expected a sample rate between 0 and 1 (inclusive), got: #{rate.inspect}. " \ 27 | "A rate of 0 will disable strict validation. " \ 28 | "A rate of 1 will validate 100% of the time. " \ 29 | "A rate of 0.25 will validate roughly 25% of the time." 30 | end 31 | end 32 | 33 | def validate? 34 | sample_rate >= 1 || (sample_rate > 0 && random.rand < sample_rate) # rubocop:disable Style/NumericPredicate 35 | end 36 | 37 | def to_h 38 | { 39 | random: random, 40 | sample_rate: sample_rate 41 | } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/strict/dsl/coercible.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Dsl 5 | module Coercible 6 | # rubocop:disable Naming/MethodName 7 | 8 | def ToArray(with: nil) = ::Strict::Coercers::Array.new(with) 9 | def ToHash(with_keys: nil, with_values: nil) = ::Strict::Coercers::Hash.new(with_keys, with_values) 10 | 11 | # rubocop:enable Naming/MethodName 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/strict/dsl/validatable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Dsl 5 | module Validatable 6 | # rubocop:disable Naming/MethodName 7 | 8 | def AllOf(*subvalidators) = ::Strict::Validators::AllOf.new(*subvalidators) 9 | def AnyOf(*subvalidators) = ::Strict::Validators::AnyOf.new(*subvalidators) 10 | def Anything = ::Strict::Validators::Anything.instance 11 | def ArrayOf(element_validator) = ::Strict::Validators::ArrayOf.new(element_validator) 12 | def Boolean = ::Strict::Validators::Boolean.instance 13 | 14 | def HashOf(key_validator_to_value_validator) 15 | if key_validator_to_value_validator.size != 1 16 | raise ArgumentError, "HashOf's usage is: HashOf(KeyValidator => ValueValidator)" 17 | end 18 | 19 | key_validator, value_validator = key_validator_to_value_validator.first 20 | ::Strict::Validators::HashOf.new(key_validator, value_validator) 21 | end 22 | 23 | def RangeOf(element_validator) = ::Strict::Validators::RangeOf.new(element_validator) 24 | 25 | # rubocop:enable Naming/MethodName 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/strict/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | Error = Class.new(StandardError) 5 | end 6 | -------------------------------------------------------------------------------- /lib/strict/implementation_does_not_conform_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class ImplementationDoesNotConformError < Error 5 | attr_reader :interface, :receiver, :missing_methods, :invalid_method_definitions 6 | 7 | def initialize(interface:, receiver:, missing_methods:, invalid_method_definitions:) # rubocop:disable Metrics/MethodLength 8 | super( 9 | message_from( 10 | interface: interface, 11 | receiver: receiver, 12 | missing_methods: missing_methods, 13 | invalid_method_definitions: invalid_method_definitions 14 | ) 15 | ) 16 | 17 | @interface = interface 18 | @receiver = receiver 19 | @missing_methods = missing_methods 20 | @invalid_method_definitions = invalid_method_definitions 21 | end 22 | 23 | private 24 | 25 | def message_from(interface:, receiver:, missing_methods:, invalid_method_definitions:) 26 | details = [ 27 | missing_methods_message_from(missing_methods), 28 | invalid_method_definitions_message_from(invalid_method_definitions) 29 | ].compact.join("\n") 30 | 31 | case receiver 32 | when ::Class, ::Module 33 | "#{receiver}'s implementation of #{interface} does not conform:\n#{details}" 34 | else 35 | "#{receiver.class}'s implementation of #{interface} does not conform:\n#{details}" 36 | end 37 | end 38 | 39 | def missing_methods_message_from(missing_methods) 40 | return nil unless missing_methods 41 | 42 | details = missing_methods.map do |method_name| 43 | " - #{method_name}" 44 | end.join("\n") 45 | 46 | " Some methods exposed in the interface were not defined in the implementation:\n#{details}" 47 | end 48 | 49 | def invalid_method_definitions_message_from(invalid_method_definitions) 50 | return nil if invalid_method_definitions.empty? 51 | 52 | methods_details = invalid_method_definitions.map do |name, invalid_method_definition| 53 | method_details = [ 54 | missing_parameters_message_from(invalid_method_definition.fetch(:missing_parameters)), 55 | additional_parameters_message_from(invalid_method_definition.fetch(:additional_parameters)), 56 | non_keyword_parameters_message_from(invalid_method_definition.fetch(:non_keyword_parameters)) 57 | ].compact.join("\n") 58 | 59 | " #{name}:\n#{method_details}" 60 | end.join("\n") 61 | 62 | " Some methods defined in the implementation did not conform to their interface:\n#{methods_details}" 63 | end 64 | 65 | def missing_parameters_message_from(missing_parameters) 66 | return nil unless missing_parameters.any? 67 | 68 | details = missing_parameters.map do |parameter_name| 69 | " - #{parameter_name}" 70 | end.join("\n") 71 | 72 | " Some parameters were expected, but were not in the parameter list:\n#{details}" 73 | end 74 | 75 | def additional_parameters_message_from(additional_parameters) 76 | return nil unless additional_parameters.any? 77 | 78 | details = additional_parameters.map do |parameter_name| 79 | " - #{parameter_name}" 80 | end.join("\n") 81 | 82 | " Some parameters were not expected, but were in the parameter list:\n#{details}" 83 | end 84 | 85 | def non_keyword_parameters_message_from(non_keyword_parameters) 86 | return nil unless non_keyword_parameters.any? 87 | 88 | details = non_keyword_parameters.map do |parameter_name| 89 | " - #{parameter_name}" 90 | end.join("\n") 91 | 92 | " Some parameters were not keywords, but only keywords are supported:\n#{details}" 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/strict/initialization_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class InitializationError < Error 5 | attr_reader :remaining_attributes, :invalid_attributes, :missing_attributes 6 | 7 | def initialize(initializable_class:, remaining_attributes:, invalid_attributes:, missing_attributes:) # rubocop:disable Metrics/MethodLength 8 | super( 9 | message_from( 10 | initializable_class: initializable_class, 11 | remaining_attributes: remaining_attributes, 12 | invalid_attributes: invalid_attributes, 13 | missing_attributes: missing_attributes 14 | ) 15 | ) 16 | 17 | @remaining_attributes = remaining_attributes 18 | @invalid_attributes = invalid_attributes 19 | @missing_attributes = missing_attributes 20 | end 21 | 22 | private 23 | 24 | def message_from(initializable_class:, remaining_attributes:, invalid_attributes:, missing_attributes:) 25 | details = [ 26 | invalid_attributes_message_from(invalid_attributes), 27 | missing_attributes_message_from(missing_attributes), 28 | remaining_attributes_message_from(remaining_attributes) 29 | ].compact.join("\n") 30 | 31 | "Initialization of #{initializable_class} failed because:\n#{details}" 32 | end 33 | 34 | def invalid_attributes_message_from(invalid_attributes) 35 | return nil unless invalid_attributes 36 | 37 | details = invalid_attributes.map do |attribute, value| 38 | " - #{attribute.name}: got #{value.inspect}, expected #{attribute.validator.inspect}" 39 | end.join("\n") 40 | 41 | " Some attributes were invalid:\n#{details}" 42 | end 43 | 44 | def missing_attributes_message_from(missing_attributes) 45 | return nil unless missing_attributes 46 | 47 | details = missing_attributes.map do |attribute_name| 48 | " - #{attribute_name}" 49 | end.join("\n") 50 | 51 | " Some attributes were missing:\n#{details}" 52 | end 53 | 54 | def remaining_attributes_message_from(remaining_attributes) 55 | return nil if remaining_attributes.none? 56 | 57 | details = remaining_attributes.map do |attribute_name| 58 | " - #{attribute_name}" 59 | end.join("\n") 60 | 61 | " Some attributes were provided, but not defined:\n#{details}" 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/strict/interface.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Interface 5 | def self.extended(mod) 6 | mod.extend(Strict::Method) 7 | mod.include(Interfaces::Instance) 8 | end 9 | 10 | def coercer 11 | Interfaces::Coercer.new(self) 12 | end 13 | 14 | # rubocop:disable Metrics/MethodLength 15 | def expose(method_name, &block) 16 | sig = sig(&block) 17 | parameter_list = [ 18 | *sig.parameters.map { |parameter| "#{parameter.name}:" }, 19 | "&block" 20 | ].join(", ") 21 | argument_list = [ 22 | *sig.parameters.map { |parameter| "#{parameter.name}: #{parameter.name}" }, 23 | "&block" 24 | ].join(", ") 25 | 26 | module_eval(<<~RUBY, __FILE__, __LINE__ + 1) 27 | def #{method_name}(#{parameter_list}) # def method_name(one:, two:, three:, &block) 28 | implementation.#{method_name}(#{argument_list}) # implementation.method_name(one: one, two: two, three: three, &block) 29 | end # end 30 | RUBY 31 | end 32 | # rubocop:enable Metrics/MethodLength 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/strict/interfaces/coercer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Interfaces 5 | class Coercer 6 | attr_reader :interface_class 7 | 8 | def initialize(interface_class) 9 | @interface_class = interface_class 10 | end 11 | 12 | def call(value) 13 | return value if value.nil? || value.instance_of?(interface_class) 14 | 15 | interface_class.new(value) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/strict/interfaces/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Interfaces 5 | module Instance 6 | attr_reader :implementation 7 | 8 | # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 9 | def initialize(implementation) 10 | missing_methods = nil 11 | invalid_method_definitions = Hash.new do |h, k| 12 | h[k] = { additional_parameters: [], missing_parameters: [], non_keyword_parameters: [] } 13 | end 14 | 15 | self.class.strict_instance_methods.each do |method_name, strict_method| # rubocop:disable Metrics/BlockLength 16 | unless implementation.respond_to?(method_name) 17 | missing_methods ||= [] 18 | missing_methods << method_name 19 | next 20 | end 21 | 22 | expected_parameters = Set.new(strict_method.parameters.map(&:name)) 23 | defined_parameters = Set.new 24 | has_keyword_splat = false 25 | 26 | implementation.method(method_name).parameters.each do |kind, parameter_name| 27 | case kind 28 | when :block, :rest 29 | next 30 | when :keyrest 31 | has_keyword_splat = true 32 | next 33 | end 34 | 35 | if expected_parameters.include?(parameter_name) 36 | defined_parameters.add(parameter_name) 37 | invalid_method_definitions[method_name][:non_keyword_parameters] << parameter_name if kind != :keyreq 38 | else 39 | invalid_method_definitions[method_name][:additional_parameters] << parameter_name 40 | end 41 | end 42 | 43 | next if has_keyword_splat 44 | 45 | missing_parameters = expected_parameters - defined_parameters 46 | invalid_method_definitions[method_name][:missing_parameters] = missing_parameters if missing_parameters.any? 47 | end 48 | 49 | if missing_methods || !invalid_method_definitions.empty? 50 | raise Strict::ImplementationDoesNotConformError.new( 51 | interface: self.class, 52 | receiver: implementation, 53 | missing_methods: missing_methods, 54 | invalid_method_definitions: invalid_method_definitions 55 | ) 56 | end 57 | 58 | @implementation = implementation 59 | end 60 | # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/strict/method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Method 5 | def self.extended(mod) 6 | return if mod.singleton_class? 7 | 8 | mod.singleton_class.extend(self) 9 | end 10 | 11 | def sig(&block) 12 | instance = singleton_class? ? self : singleton_class 13 | instance.instance_variable_set(:@__strict_method_internal_last_sig_configuration, Methods::Dsl.run(&block)) 14 | end 15 | 16 | def strict_class_methods 17 | instance = singleton_class? ? self : singleton_class 18 | if instance.instance_variable_defined?(:@__strict_method_internal_class_methods) 19 | instance.instance_variable_get(:@__strict_method_internal_class_methods) 20 | else 21 | instance.instance_variable_set(:@__strict_method_internal_class_methods, {}) 22 | end 23 | end 24 | 25 | def strict_instance_methods 26 | instance = singleton_class? ? self : singleton_class 27 | if instance.instance_variable_defined?(:@__strict_method_internal_instance_methods) 28 | instance.instance_variable_get(:@__strict_method_internal_instance_methods) 29 | else 30 | instance.instance_variable_set(:@__strict_method_internal_instance_methods, {}) 31 | end 32 | end 33 | 34 | # rubocop:disable Metrics/MethodLength 35 | def singleton_method_added(method_name) 36 | super 37 | 38 | sig = singleton_class.instance_variable_get(:@__strict_method_internal_last_sig_configuration) 39 | singleton_class.instance_variable_set(:@__strict_method_internal_last_sig_configuration, nil) 40 | return unless sig 41 | 42 | verifiable_method = Methods::VerifiableMethod.new( 43 | method: singleton_class.instance_method(method_name), 44 | parameters: sig.parameters, 45 | returns: sig.returns, 46 | instance: false 47 | ) 48 | verifiable_method.verify_definition! 49 | strict_class_methods[method_name] = verifiable_method 50 | singleton_class.prepend(Methods::Module.new(verifiable_method)) 51 | end 52 | 53 | def method_added(method_name) 54 | super 55 | 56 | sig = singleton_class.instance_variable_get(:@__strict_method_internal_last_sig_configuration) 57 | singleton_class.instance_variable_set(:@__strict_method_internal_last_sig_configuration, nil) 58 | return unless sig 59 | 60 | verifiable_method = Methods::VerifiableMethod.new( 61 | method: instance_method(method_name), 62 | parameters: sig.parameters, 63 | returns: sig.returns, 64 | instance: true 65 | ) 66 | verifiable_method.verify_definition! 67 | strict_instance_methods[method_name] = verifiable_method 68 | prepend(Methods::Module.new(verifiable_method)) 69 | end 70 | # rubocop:enable Metrics/MethodLength 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/strict/method_call_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class MethodCallError < Error 5 | attr_reader :verifiable_method, :remaining_args, :remaining_kwargs, :invalid_parameters, :missing_parameters 6 | 7 | def initialize(verifiable_method:, remaining_args:, remaining_kwargs:, invalid_parameters:, missing_parameters:) # rubocop:disable Metrics/MethodLength 8 | super( 9 | message_from( 10 | verifiable_method: verifiable_method, 11 | remaining_args: remaining_args, 12 | remaining_kwargs: remaining_kwargs, 13 | invalid_parameters: invalid_parameters, 14 | missing_parameters: missing_parameters 15 | ) 16 | ) 17 | 18 | @verifiable_method = verifiable_method 19 | @remaining_args = remaining_args 20 | @remaining_kwargs = remaining_kwargs 21 | @invalid_parameters = invalid_parameters 22 | @missing_parameters = missing_parameters 23 | end 24 | 25 | private 26 | 27 | def message_from(verifiable_method:, remaining_args:, remaining_kwargs:, invalid_parameters:, missing_parameters:) 28 | details = [ 29 | invalid_parameters_message_from(invalid_parameters), 30 | missing_parameters_message_from(missing_parameters), 31 | remaining_args_message_from(remaining_args), 32 | remaining_kwargs_message_from(remaining_kwargs) 33 | ].compact.join("\n") 34 | 35 | "Calling #{verifiable_method} failed because:\n#{details}" 36 | end 37 | 38 | def invalid_parameters_message_from(invalid_parameters) 39 | return nil unless invalid_parameters 40 | 41 | details = invalid_parameters.map do |parameter, value| 42 | " - #{parameter.name}: got #{value.inspect}, expected #{parameter.validator.inspect}" 43 | end.join("\n") 44 | 45 | " Some arguments were invalid:\n#{details}" 46 | end 47 | 48 | def missing_parameters_message_from(missing_parameters) 49 | return nil unless missing_parameters 50 | 51 | details = missing_parameters.map do |parameter_name| 52 | " - #{parameter_name}" 53 | end.join("\n") 54 | 55 | " Some arguments were missing:\n#{details}" 56 | end 57 | 58 | def remaining_args_message_from(remaining_args) 59 | return nil if remaining_args.none? 60 | 61 | details = remaining_args.map do |arg| 62 | " - #{arg.inspect}" 63 | end.join("\n") 64 | 65 | " Additional positional arguments were provided, but not defined:\n#{details}" 66 | end 67 | 68 | def remaining_kwargs_message_from(remaining_kwargs) 69 | return nil if remaining_kwargs.none? 70 | 71 | details = remaining_kwargs.map do |key, value| 72 | " - #{key}: #{value.inspect}" 73 | end.join("\n") 74 | 75 | " Additional keyword arguments were provided, but not defined:\n#{details}" 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/strict/method_definition_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class MethodDefinitionError < Error 5 | attr_reader :verifiable_method, :missing_parameters, :additional_parameters 6 | 7 | def initialize(verifiable_method:, missing_parameters:, additional_parameters:) 8 | super( 9 | message_from( 10 | verifiable_method: verifiable_method, 11 | missing_parameters: missing_parameters, 12 | additional_parameters: additional_parameters 13 | ) 14 | ) 15 | 16 | @verifiable_method = verifiable_method 17 | @missing_parameters = missing_parameters 18 | @additional_parameters = additional_parameters 19 | end 20 | 21 | private 22 | 23 | def message_from(verifiable_method:, missing_parameters:, additional_parameters:) 24 | details = [ 25 | missing_parameters_message_from(missing_parameters), 26 | additional_parameters_message_from(additional_parameters) 27 | ].compact.join("\n") 28 | 29 | "Defining #{verifiable_method} failed because:\n#{details}" 30 | end 31 | 32 | def missing_parameters_message_from(missing_parameters) 33 | return nil unless missing_parameters.any? 34 | 35 | details = missing_parameters.map do |parameter_name| 36 | " - #{parameter_name}" 37 | end.join("\n") 38 | 39 | " Some parameters were in the sig, but were not in the parameter list:\n#{details}" 40 | end 41 | 42 | def additional_parameters_message_from(additional_parameters) 43 | return nil unless additional_parameters.any? 44 | 45 | details = additional_parameters.map do |parameter_name| 46 | " - #{parameter_name}" 47 | end.join("\n") 48 | 49 | " Some parameters were not in the sig, but were in the parameter list:\n#{details}" 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/strict/method_return_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class MethodReturnError < Error 5 | attr_reader :verifiable_method, :value 6 | 7 | def initialize(verifiable_method:, value:) 8 | super(message_from(verifiable_method: verifiable_method, value: value)) 9 | 10 | @verifiable_method = verifiable_method 11 | @value = value 12 | end 13 | 14 | private 15 | 16 | def message_from(verifiable_method:, value:) 17 | details = invalid_returns_message_from(verifiable_method, value) 18 | "#{verifiable_method}'s return value was invalid because:\n#{details}" 19 | end 20 | 21 | def invalid_returns_message_from(verifiable_method, value) 22 | " - got #{value.inspect}, expected #{verifiable_method.returns.validator.inspect}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/strict/methods/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Methods 5 | class Configuration 6 | attr_reader :parameters, :returns 7 | 8 | def initialize(parameters:, returns:) 9 | @parameters = parameters 10 | @returns = returns 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/strict/methods/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Methods 5 | class Dsl < BasicObject 6 | class << self 7 | def run(&block) 8 | dsl = new 9 | dsl.instance_eval(&block) 10 | ::Strict::Methods::Configuration.new( 11 | parameters: dsl.__strict_dsl_internal_parameters.values, 12 | returns: dsl.__strict_dsl_internal_returns 13 | ) 14 | end 15 | end 16 | 17 | include ::Strict::Dsl::Coercible 18 | include ::Strict::Dsl::Validatable 19 | 20 | attr_reader :__strict_dsl_internal_parameters, :__strict_dsl_internal_returns 21 | 22 | def initialize 23 | @__strict_dsl_internal_parameters = {} 24 | @__strict_dsl_internal_returns = ::Strict::Return.make 25 | end 26 | 27 | def returns(*args, **kwargs) 28 | self.__strict_dsl_internal_returns = ::Strict::Return.make(*args, **kwargs) 29 | nil 30 | end 31 | 32 | def strict_parameter(*args, **kwargs) 33 | parameter = ::Strict::Parameter.make(*args, **kwargs) 34 | __strict_dsl_internal_parameters[parameter.name] = parameter 35 | nil 36 | end 37 | 38 | def method_missing(name, *args, **kwargs) 39 | if respond_to_missing?(name) 40 | strict_parameter(name, *args, **kwargs) 41 | else 42 | super 43 | end 44 | end 45 | 46 | def respond_to_missing?(method_name, _include_private = nil) 47 | first_letter = method_name.to_s.each_char.first 48 | first_letter.eql?(first_letter.downcase) 49 | end 50 | 51 | private 52 | 53 | attr_writer :__strict_dsl_internal_returns 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/strict/methods/module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Methods 5 | class Module < ::Module 6 | attr_reader :verifiable_method 7 | 8 | def initialize(verifiable_method) 9 | super() 10 | 11 | @verifiable_method = verifiable_method 12 | define_method verifiable_method.name do |*args, **kwargs, &block| 13 | args, kwargs = verifiable_method.verify_parameters!(*args, **kwargs) 14 | 15 | super(*args, **kwargs, &block).tap do |value| 16 | verifiable_method.verify_returns!(value) 17 | end 18 | end 19 | end 20 | 21 | def inspect 22 | "#<#{self.class} (#{verifiable_method.name})>" 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/strict/methods/verifiable_method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Strict 6 | module Methods 7 | class VerifiableMethod # rubocop:disable Metrics/ClassLength 8 | extend Forwardable 9 | 10 | class UnknownParameterError < Error 11 | attr_reader :parameter_name 12 | 13 | def initialize(parameter_name:) 14 | super(message_from(parameter_name: parameter_name)) 15 | 16 | @parameter_name = parameter_name 17 | end 18 | 19 | private 20 | 21 | def message_from(parameter_name:) 22 | "Strict tried to find a parameter named #{parameter_name} but was unable. " \ 23 | "It's likely this in an internal bug, feel free to open an issue at #{Strict::ISSUE_TRACKER} for help." 24 | end 25 | end 26 | 27 | def_delegator :method, :name 28 | 29 | attr_reader :parameters, :returns 30 | 31 | def initialize(method:, parameters:, returns:, instance:) 32 | @method = method 33 | @parameters = parameters 34 | @parameters_index = parameters.to_h { |p| [p.name, p] } 35 | @returns = returns 36 | @instance = instance 37 | end 38 | 39 | def to_s 40 | "#{method.owner}#{separator}#{name}" 41 | end 42 | 43 | def verify_definition! 44 | expected_parameters = Set.new(parameters.map(&:name)) 45 | defined_parameters = Set.new(method.parameters.filter_map { |kind, name| name unless kind == :block }) 46 | return if expected_parameters == defined_parameters 47 | 48 | missing_parameters = expected_parameters - defined_parameters 49 | additional_parameters = defined_parameters - expected_parameters 50 | raise Strict::MethodDefinitionError.new( 51 | verifiable_method: self, 52 | missing_parameters: missing_parameters, 53 | additional_parameters: additional_parameters 54 | ) 55 | end 56 | 57 | # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength 58 | # TODO(kkt): clean this up- it's late, though, and the tests are passing 59 | def verify_parameters!(*args, **kwargs) 60 | invalid_parameters = nil 61 | missing_parameters = nil 62 | 63 | positional_arguments = [] 64 | keyword_arguments = {} 65 | 66 | # TODO(kkt): doesn't handle oddly sorted optional positional parameters like def foo(opt = nil, req) 67 | method.parameters.each do |kind, name| 68 | case kind 69 | when POSITIONAL 70 | parameter_kind = :positional 71 | value = args.any? ? args.shift : NOT_PROVIDED 72 | when REST 73 | parameter_kind = :rest 74 | value = [*args] 75 | args.clear 76 | when KEYWORD 77 | parameter_kind = :keyword 78 | value = kwargs.key?(name) ? kwargs.delete(name) : NOT_PROVIDED 79 | when KEYREST 80 | parameter_kind = :keyrest 81 | value = { **kwargs } 82 | kwargs.clear 83 | end 84 | next unless parameter_kind 85 | 86 | parameter = parameter_named!(name) 87 | if value.equal?(NOT_PROVIDED) && parameter.optional? 88 | value = parameter.default_generator.call 89 | elsif value.equal?(NOT_PROVIDED) 90 | missing_parameters ||= [] 91 | missing_parameters << parameter.name 92 | next 93 | end 94 | 95 | value = parameter.coerce(value) 96 | if parameter.valid?(value) 97 | case parameter_kind 98 | when :positional 99 | positional_arguments << value 100 | when :rest 101 | positional_arguments.concat(value) 102 | when :keyword 103 | keyword_arguments[name] = value 104 | when :keyrest 105 | keyword_arguments.merge!(value) 106 | end 107 | else 108 | invalid_parameters ||= {} 109 | invalid_parameters[parameter] = value 110 | end 111 | end 112 | 113 | if args.empty? && kwargs.empty? && invalid_parameters.nil? && missing_parameters.nil? 114 | [positional_arguments, keyword_arguments] 115 | else 116 | raise Strict::MethodCallError.new( 117 | verifiable_method: self, 118 | remaining_args: args, 119 | remaining_kwargs: kwargs, 120 | invalid_parameters: invalid_parameters, 121 | missing_parameters: missing_parameters 122 | ) 123 | end 124 | end 125 | # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength 126 | 127 | def verify_returns!(value) 128 | value = returns.coerce(value) 129 | return if returns.valid?(value) 130 | 131 | raise Strict::MethodReturnError.new(verifiable_method: self, value: value) 132 | end 133 | 134 | private 135 | 136 | POSITIONAL = Set.new(%i[req opt]) 137 | private_constant :POSITIONAL 138 | REST = :rest 139 | private_constant :REST 140 | KEYWORD = Set.new(%i[keyreq key]) 141 | private_constant :KEYWORD 142 | KEYREST = :keyrest 143 | private_constant :KEYREST 144 | NOT_PROVIDED = ::Object.new.freeze 145 | private_constant :NOT_PROVIDED 146 | 147 | attr_reader :method, :parameters_index 148 | 149 | def instance? 150 | @instance 151 | end 152 | 153 | def separator 154 | instance? ? "#" : "." 155 | end 156 | 157 | def parameter_named!(name) 158 | parameters_index.fetch(name) { raise UnknownParameterError.new(parameter_name: name) } 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/strict/object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Object 5 | def self.included(mod) 6 | mod.extend(Accessor::Attributes) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/strict/parameter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class Parameter 5 | NOT_PROVIDED = ::Object.new.freeze 6 | 7 | class << self 8 | def make(name, validator = Validators::Anything.instance, coerce: false, **defaults) 9 | unless valid_defaults?(**defaults) 10 | raise ArgumentError, "Only one of 'default', 'default_value', or 'default_generator' can be provided" 11 | end 12 | 13 | new( 14 | name: name.to_sym, 15 | validator: validator, 16 | default_generator: make_default_generator(**defaults), 17 | coercer: coerce 18 | ) 19 | end 20 | 21 | private 22 | 23 | def valid_defaults?(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED) 24 | defaults_provided = [default, default_value, default_generator].count do |default_option| 25 | !default_option.equal?(NOT_PROVIDED) 26 | end 27 | 28 | defaults_provided <= 1 29 | end 30 | 31 | def make_default_generator(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED) 32 | if !default.equal?(NOT_PROVIDED) 33 | default.respond_to?(:call) ? default : -> { default } 34 | elsif !default_value.equal?(NOT_PROVIDED) 35 | -> { default_value } 36 | elsif !default_generator.equal?(NOT_PROVIDED) 37 | default_generator 38 | else 39 | NOT_PROVIDED 40 | end 41 | end 42 | end 43 | 44 | attr_reader :name, :validator, :default_generator, :coercer 45 | 46 | def initialize(name:, validator:, default_generator:, coercer:) 47 | @name = name.to_sym 48 | @validator = validator 49 | @default_generator = default_generator 50 | @coercer = coercer 51 | @optional = !default_generator.equal?(NOT_PROVIDED) 52 | end 53 | 54 | def optional? 55 | @optional 56 | end 57 | 58 | def valid?(value) 59 | return true unless Strict.configuration.validate? 60 | 61 | validator === value 62 | end 63 | 64 | def coerce(value) 65 | return value unless coercer 66 | 67 | coercer.call(value) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/strict/reader/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Reader 5 | module Attributes 6 | def attributes(&block) 7 | block ||= -> {} 8 | configuration = Strict::Attributes::Dsl.run(&block) 9 | include Module.new(configuration) 10 | include Strict::Attributes::Instance 11 | extend Strict::Attributes::Class 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/strict/reader/module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Reader 5 | class Module < ::Module 6 | attr_reader :configuration 7 | 8 | def initialize(configuration) 9 | super() 10 | 11 | @configuration = configuration 12 | const_set(Strict::Attributes::Class::CONSTANT, configuration) 13 | configuration.attributes.each do |attribute| 14 | module_eval( 15 | "def #{attribute.name} = #{attribute.instance_variable}", # def name = @instance_variable 16 | __FILE__, 17 | __LINE__ - 2 18 | ) 19 | end 20 | end 21 | 22 | def inspect 23 | "#<#{self.class} (#{configuration.attributes.map(&:name).join(', ')})>" 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/strict/return.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | class Return 5 | class << self 6 | def make(validator = Validators::Anything.instance, coerce: false) 7 | new(validator: validator, coercer: coerce) 8 | end 9 | end 10 | 11 | attr_reader :validator, :coercer 12 | 13 | def initialize(validator:, coercer:) 14 | @validator = validator 15 | @coercer = coercer 16 | end 17 | 18 | def valid?(value) 19 | return true unless Strict.configuration.validate? 20 | 21 | validator === value 22 | end 23 | 24 | def coerce(value) 25 | return value unless coercer 26 | 27 | coercer.call(value) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/strict/validators/all_of.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Validators 5 | class AllOf 6 | attr_reader :subvalidators 7 | 8 | def initialize(*subvalidators) 9 | @subvalidators = subvalidators 10 | end 11 | 12 | def ===(value) 13 | subvalidators.all? do |subvalidator| 14 | subvalidator === value 15 | end 16 | end 17 | 18 | def inspect 19 | "AllOf(#{subvalidators.map(&:inspect).join(', ')})" 20 | end 21 | alias to_s inspect 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/strict/validators/any_of.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Validators 5 | class AnyOf 6 | attr_reader :subvalidators 7 | 8 | def initialize(*subvalidators) 9 | @subvalidators = subvalidators 10 | end 11 | 12 | def ===(value) 13 | subvalidators.any? do |subvalidator| 14 | subvalidator === value 15 | end 16 | end 17 | 18 | def inspect 19 | "AnyOf(#{subvalidators.map(&:inspect).join(', ')})" 20 | end 21 | alias to_s inspect 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/strict/validators/anything.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "singleton" 4 | 5 | module Strict 6 | module Validators 7 | class Anything 8 | include Singleton 9 | 10 | def ===(_value) 11 | true 12 | end 13 | 14 | def inspect 15 | "Anything()" 16 | end 17 | alias to_s inspect 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/strict/validators/array_of.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Validators 5 | class ArrayOf 6 | attr_reader :element_validator 7 | 8 | def initialize(element_validator) 9 | @element_validator = element_validator 10 | end 11 | 12 | def ===(value) 13 | Array === value && value.all? do |v| 14 | element_validator === v 15 | end 16 | end 17 | 18 | def inspect 19 | "ArrayOf(#{element_validator.inspect})" 20 | end 21 | alias to_s inspect 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/strict/validators/boolean.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "singleton" 4 | 5 | module Strict 6 | module Validators 7 | class Boolean 8 | include Singleton 9 | 10 | def ===(value) 11 | value.equal?(true) || value.equal?(false) 12 | end 13 | 14 | def inspect 15 | "Boolean()" 16 | end 17 | alias to_s inspect 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/strict/validators/hash_of.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Validators 5 | class HashOf 6 | attr_reader :key_validator, :value_validator 7 | 8 | def initialize(key_validator, value_validator) 9 | @key_validator = key_validator 10 | @value_validator = value_validator 11 | end 12 | 13 | def ===(value) 14 | Hash === value && value.all? do |k, v| 15 | key_validator === k && value_validator === v 16 | end 17 | end 18 | 19 | def inspect 20 | "HashOf(#{key_validator.inspect} => #{value_validator.inspect})" 21 | end 22 | alias to_s inspect 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/strict/validators/range_of.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Validators 5 | class RangeOf 6 | attr_reader :element_validator 7 | 8 | def initialize(element_validator) 9 | @element_validator = element_validator 10 | end 11 | 12 | def ===(value) 13 | Range === value && [value.begin, value.end].compact.all? do |v| 14 | element_validator === v 15 | end 16 | end 17 | 18 | def inspect 19 | "RangeOf(#{element_validator.inspect})" 20 | end 21 | alias to_s inspect 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/strict/value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | module Value 5 | def self.included(mod) 6 | mod.extend(Reader::Attributes) 7 | end 8 | 9 | def with(**attributes) 10 | self.class.new(**to_h.merge(attributes)) 11 | end 12 | 13 | def eql?(other) 14 | self.class.equal?(other.class) && to_h.eql?(other.to_h) 15 | end 16 | alias == eql? 17 | 18 | def hash 19 | [self.class, to_h].hash 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/strict/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strict 4 | VERSION = "1.5.0" 5 | end 6 | -------------------------------------------------------------------------------- /sig/strict.rbs: -------------------------------------------------------------------------------- 1 | module Strict 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /strict.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/strict/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "strict" 7 | spec.version = Strict::VERSION 8 | spec.authors = ["Kyle Thompson"] 9 | spec.email = ["me@kkt.dev"] 10 | 11 | spec.summary = "Strictly define a contract for your objects and methods" 12 | spec.homepage = "https://github.com/kylekthompson/strict" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 3.0.0" 15 | 16 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 17 | 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | spec.metadata["source_code_uri"] = spec.homepage 20 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 21 | spec.metadata["rubygems_mfa_required"] = "true" 22 | 23 | spec.files = Dir.chdir(__dir__) do 24 | `git ls-files -z`.split("\x0").reject do |f| 25 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 26 | end 27 | end 28 | spec.bindir = "exe" 29 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 30 | spec.require_paths = ["lib"] 31 | 32 | spec.add_dependency "zeitwerk", "~> 2.6" 33 | end 34 | -------------------------------------------------------------------------------- /test/strict/accessor/attributes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class AccessorClass 6 | extend Strict::Accessor::Attributes 7 | 8 | attributes do 9 | foo Integer 10 | bar String, coerce: :some_coercer 11 | baz String, default: "some string" 12 | end 13 | 14 | def self.some_coercer(value) 15 | value.to_s 16 | end 17 | end 18 | 19 | describe Strict::Accessor::Attributes do 20 | it "exposes the configuration on the class" do 21 | assert_instance_of Strict::Attributes::Configuration, AccessorClass.strict_attributes 22 | assert_equal %i[foo bar baz], AccessorClass.strict_attributes.map(&:name) 23 | end 24 | 25 | it "exposes writer methods" do 26 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 27 | instance.foo = 2 28 | 29 | assert_equal 2, instance.foo 30 | end 31 | 32 | it "exposes reader methods" do 33 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 34 | 35 | assert_equal 1, instance.foo 36 | end 37 | 38 | it "does not allow invalid arguments at initialization" do 39 | error = assert_raises(Strict::InitializationError) do 40 | AccessorClass.new(foo: "1", bar: "2", baz: "3") 41 | end 42 | 43 | assert_match(/foo/, error.message) 44 | end 45 | 46 | it "coerces arguments that can be coerced at initialization" do 47 | instance = AccessorClass.new(foo: 1, bar: 2, baz: "3") 48 | 49 | assert_equal "2", instance.bar 50 | end 51 | 52 | it "does not require optional attributes at initialization" do 53 | instance = AccessorClass.new(foo: 1, bar: "2") 54 | 55 | assert_equal "some string", instance.baz 56 | end 57 | 58 | it "requires mandatory attributes at initialization" do 59 | error = assert_raises(Strict::InitializationError) do 60 | AccessorClass.new(foo: 1, baz: "3") 61 | end 62 | 63 | assert_match(/bar/, error.message) 64 | end 65 | 66 | it "does not allow additional attributes at initialization" do 67 | error = assert_raises(Strict::InitializationError) do 68 | AccessorClass.new(foo: 1, bar: "2", baz: "3", bat: "uh oh") 69 | end 70 | 71 | assert_match(/bat/, error.message) 72 | end 73 | 74 | it "aggregates errors at initialization" do 75 | error = assert_raises(Strict::InitializationError) do 76 | AccessorClass.new(foo: "1", baz: "3", bat: "uh oh") 77 | end 78 | 79 | assert_match(/foo/, error.message) 80 | assert_match(/bar/, error.message) 81 | assert_match(/bat/, error.message) 82 | end 83 | 84 | it "does not allow invalid arguments at assignment" do 85 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 86 | 87 | error = assert_raises(Strict::AssignmentError) do 88 | instance.foo = "1" 89 | end 90 | 91 | assert_match(/foo/, error.message) 92 | end 93 | 94 | it "coerces arguments that can be coerced at assignment" do 95 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 96 | instance.bar = 3 97 | 98 | assert_equal "3", instance.bar 99 | end 100 | 101 | it "turns into a hash of attributes" do 102 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 103 | 104 | assert_equal({ foo: 1, bar: "2", baz: "3" }, instance.to_h) 105 | end 106 | 107 | it "can be inspected" do 108 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 109 | 110 | assert_equal "#", instance.inspect 111 | end 112 | 113 | it "can be pretty printed" do 114 | instance = AccessorClass.new(foo: 1, bar: "2", baz: "3") 115 | output = StringIO.new 116 | PP.pp(instance, output, 5) 117 | 118 | assert_equal <<~OUTPUT, output.string 119 | # 123 | OUTPUT 124 | end 125 | 126 | it "exposes a coercer" do 127 | instance = AccessorClass.coercer.call(foo: 1, bar: "2", baz: "3") 128 | 129 | assert_instance_of AccessorClass, instance 130 | assert_equal 1, instance.foo 131 | assert_equal "2", instance.bar 132 | assert_equal "3", instance.baz 133 | 134 | instance = AccessorClass.coercer.call("1") 135 | 136 | assert_equal "1", instance 137 | 138 | assert_raises(Strict::InitializationError) do 139 | AccessorClass.coercer.call({}) 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/strict/assignment_error_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::AssignmentError do 6 | describe ".new" do 7 | before do 8 | @assignable_class = Class.new 9 | end 10 | 11 | it "builds a message with the invalid attribute" do 12 | error = Strict::AssignmentError.new( 13 | assignable_class: @assignable_class, 14 | invalid_attribute: Strict::Attribute.make(:attr_one, Strict::Validators::AnyOf.new(1, "2", nil)), 15 | value: 2 16 | ) 17 | 18 | expected_message = <<~MESSAGE.chomp 19 | Assignment to attr_one of #{@assignable_class} failed because: 20 | - got 2, expected AnyOf(1, "2", nil) 21 | MESSAGE 22 | 23 | assert_equal expected_message, error.message 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/strict/attribute_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Attribute do 6 | describe ".make" do 7 | it "has defaults when only given a name" do 8 | attribute = Strict::Attribute.make("attr_name") 9 | 10 | assert_equal :attr_name, attribute.name 11 | assert_equal Strict::Validators::Anything.instance, attribute.validator 12 | assert_equal Strict::Attribute::NOT_PROVIDED, attribute.default_generator 13 | refute attribute.coercer 14 | assert_equal "@attr_name", attribute.instance_variable 15 | refute_predicate attribute, :optional? 16 | end 17 | 18 | it "accepts a combination of all arguments" do 19 | attribute = Strict::Attribute.make(:attr_name, Strict::Validators::Boolean.instance, coerce: true, default: 1) 20 | 21 | assert_equal :attr_name, attribute.name 22 | assert_equal Strict::Validators::Boolean.instance, attribute.validator 23 | refute_equal Strict::Attribute::NOT_PROVIDED, attribute.default_generator 24 | assert_equal 1, attribute.default_generator.call 25 | assert attribute.coercer 26 | assert_equal "@attr_name", attribute.instance_variable 27 | assert_predicate attribute, :optional? 28 | end 29 | 30 | it "accepts a validator" do 31 | attribute = Strict::Attribute.make(:attr_name, Strict::Validators::Boolean.instance) 32 | 33 | assert_equal Strict::Validators::Boolean.instance, attribute.validator 34 | end 35 | 36 | it "accepts a coerce value" do 37 | attribute = Strict::Attribute.make(:attr_name, coerce: true) 38 | 39 | assert attribute.coercer 40 | end 41 | 42 | it "accepts a value for 'default'" do 43 | attribute = Strict::Attribute.make(:attr_name, default: 1) 44 | 45 | refute_equal Strict::Attribute::NOT_PROVIDED, attribute.default_generator 46 | assert_equal 1, attribute.default_generator.call 47 | assert_predicate attribute, :optional? 48 | end 49 | 50 | it "accepts a callable for 'default'" do 51 | attribute = Strict::Attribute.make(:attr_name, default: -> { 1 }) 52 | 53 | refute_equal Strict::Attribute::NOT_PROVIDED, attribute.default_generator 54 | assert_equal 1, attribute.default_generator.call 55 | assert_predicate attribute, :optional? 56 | end 57 | 58 | it "accepts a value for 'default_value'" do 59 | attribute = Strict::Attribute.make(:attr_name, default_value: -> { 1 }) 60 | 61 | refute_equal Strict::Attribute::NOT_PROVIDED, attribute.default_generator 62 | assert_equal 1, attribute.default_generator.call.call 63 | assert_predicate attribute, :optional? 64 | end 65 | 66 | it "accepts a callable for 'default_generator'" do 67 | attribute = Strict::Attribute.make(:attr_name, default_generator: -> { 1 }) 68 | 69 | refute_equal Strict::Attribute::NOT_PROVIDED, attribute.default_generator 70 | assert_equal 1, attribute.default_generator.call 71 | assert_predicate attribute, :optional? 72 | end 73 | 74 | it "does not accept multiple defaults" do 75 | assert_raises(ArgumentError) do 76 | Strict::Attribute.make(:attr_name, default: 1, default_value: 1) 77 | end 78 | end 79 | end 80 | 81 | describe "#valid?" do 82 | it "uses the validator to check if the value is valid" do 83 | attribute = Strict::Attribute.make(:attr_name, Strict::Validators::Boolean.instance) 84 | 85 | assert attribute.valid?(true) 86 | assert attribute.valid?(false) 87 | refute attribute.valid?(nil) 88 | refute attribute.valid?(1) 89 | 90 | attribute = Strict::Attribute.make( 91 | :attr_name, 92 | Strict::Validators::AnyOf.new(Strict::Validators::Boolean.instance, nil) 93 | ) 94 | 95 | assert attribute.valid?(true) 96 | assert attribute.valid?(false) 97 | assert attribute.valid?(nil) 98 | refute attribute.valid?(1) 99 | end 100 | 101 | it "does not call the validator if sampling indicates not to" do 102 | validator = Class.new do 103 | attr_accessor :called 104 | 105 | def initialize 106 | @called = false 107 | end 108 | 109 | def ===(value) 110 | self.called = true 111 | Strict::Validators::Boolean.instance === value 112 | end 113 | end.new 114 | attribute = Strict::Attribute.make(:attr_name, validator) 115 | 116 | refute validator.called 117 | Strict.with_overrides(sample_rate: 0) do 118 | assert attribute.valid?(true) 119 | refute validator.called 120 | assert attribute.valid?(false) 121 | refute validator.called 122 | assert attribute.valid?(nil) 123 | refute validator.called 124 | assert attribute.valid?(1) 125 | refute validator.called 126 | end 127 | 128 | Strict.with_overrides(sample_rate: 1) do 129 | refute attribute.valid?(nil) 130 | assert validator.called 131 | end 132 | end 133 | end 134 | 135 | describe "#coerce" do 136 | it "returns the value if coercion is not enabled" do 137 | attribute = Strict::Attribute.make(:attr_name, coerce: false) 138 | 139 | assert_equal "value", attribute.coerce("value", for_class: nil) 140 | end 141 | 142 | it "calls #coerce_attr_name if coercion is enabled" do 143 | attribute = Strict::Attribute.make(:attr_name, coerce: true) 144 | 145 | assert_equal "coerced value", attribute.coerce( 146 | "value", 147 | for_class: Module.new { def self.coerce_attr_name(value) = "coerced #{value}" } 148 | ) 149 | end 150 | 151 | it "calls the method name if a coercion method is passed" do 152 | attribute = Strict::Attribute.make(:attr_name, coerce: :some_method) 153 | 154 | assert_equal "coerced value", attribute.coerce( 155 | "value", 156 | for_class: Module.new { def self.some_method(value) = "coerced #{value}" } 157 | ) 158 | end 159 | 160 | it "calls the callable if one is passed" do 161 | attribute = Strict::Attribute.make(:attr_name, coerce: ->(value) { "coerced #{value}" }) 162 | 163 | assert_equal "coerced value", attribute.coerce("value", for_class: nil) 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/strict/attributes/configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Attributes::Configuration do 6 | it "enumerates" do 7 | configuration = Strict::Attributes::Configuration.new( 8 | attributes: [Strict::Attribute.make(:foo), Strict::Attribute.make(:bar)] 9 | ) 10 | 11 | assert_equal 2, configuration.count 12 | assert_equal %i[foo bar], configuration.map(&:name) 13 | end 14 | 15 | describe "#named!" do 16 | it "returns the attribute of the provided name" do 17 | configuration = Strict::Attributes::Configuration.new( 18 | attributes: [Strict::Attribute.make(:foo), Strict::Attribute.make(:bar)] 19 | ) 20 | 21 | attribute = configuration.named!(:bar) 22 | 23 | assert_equal :bar, attribute.name 24 | end 25 | 26 | it "raises on unknown attributes" do 27 | configuration = Strict::Attributes::Configuration.new( 28 | attributes: [Strict::Attribute.make(:foo), Strict::Attribute.make(:bar)] 29 | ) 30 | 31 | assert_raises(Strict::Attributes::Configuration::UnknownAttributeError) do 32 | configuration.named!(:unknown) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/strict/attributes/configured_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Attributes::Class do 6 | it "aligns the constant with the lookup method" do 7 | mod = Module.new do 8 | const_set(Strict::Attributes::Class::CONSTANT, "config value") 9 | 10 | extend Strict::Attributes::Class 11 | end 12 | 13 | assert_equal "config value", mod.strict_attributes 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/strict/attributes/dsl_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Attributes::Dsl do 6 | describe ".run" do 7 | it "creates a configuration with valid identifiers and arguments" do 8 | configuration = Strict::Attributes::Dsl.run do 9 | no_arguments 10 | _underscore_identifier 11 | question? 12 | unsafe! 13 | default_with_value default: 1 14 | default_with_callable default: -> { 1 } 15 | default_value default_value: 1 16 | default_generator default_generator: -> { 1 } 17 | coerce coerce: true 18 | coerce_method coerce: :some_method 19 | coerce_array coerce: ToArray() 20 | coerce_array_with coerce: ToArray(with: ->(element) { element.to_s }) 21 | coerce_hash coerce: ToHash() 22 | coerce_hash_with coerce: ToHash( 23 | with_keys: ->(element) { element.to_s }, 24 | with_values: ->(element) { element.to_s } 25 | ) 26 | all_of AllOf(Enumerable, Comparable) 27 | any_of AnyOf(Integer, String, nil) 28 | anything Anything() 29 | array_of ArrayOf(Anything()) 30 | boolean Boolean() 31 | hash_of HashOf(Integer => String) 32 | range_of RangeOf(Numeric) 33 | end 34 | 35 | assert_equal Strict::Validators::Anything.instance, configuration.named!(:no_arguments).validator 36 | refute_nil configuration.named!(:_underscore_identifier) 37 | refute_nil configuration.named!(:question?) 38 | refute_nil configuration.named!(:unsafe!) 39 | assert_equal 1, configuration.named!(:default_with_value).default_generator.call 40 | assert_equal 1, configuration.named!(:default_with_callable).default_generator.call 41 | assert_equal 1, configuration.named!(:default_value).default_generator.call 42 | assert_equal 1, configuration.named!(:default_generator).default_generator.call 43 | assert_equal "coerced value", configuration.named!(:coerce).coerce( 44 | "value", 45 | for_class: Module.new { def self.coerce_coerce(value) = "coerced #{value}" } 46 | ) 47 | assert_equal "coerced value", configuration.named!(:coerce_method).coerce( 48 | "value", 49 | for_class: Module.new { def self.some_method(value) = "coerced #{value}" } 50 | ) 51 | assert_equal [[:one, 1]], configuration.named!(:coerce_array).coerce({ one: 1 }, for_class: nil) 52 | assert_equal %w[1 2], configuration.named!(:coerce_array_with).coerce([1, 2], for_class: nil) 53 | assert_equal({ one: 1 }, configuration.named!(:coerce_hash).coerce([[:one, 1]], for_class: nil)) 54 | assert_equal({ "one" => "1" }, configuration.named!(:coerce_hash_with).coerce([[:one, 1]], for_class: nil)) 55 | assert_instance_of Strict::Validators::AllOf, configuration.named!(:all_of).validator 56 | assert_instance_of Strict::Validators::AnyOf, configuration.named!(:any_of).validator 57 | assert_instance_of Strict::Validators::Anything, configuration.named!(:anything).validator 58 | assert_instance_of Strict::Validators::ArrayOf, configuration.named!(:array_of).validator 59 | assert_instance_of Strict::Validators::Boolean, configuration.named!(:boolean).validator 60 | assert_instance_of Strict::Validators::HashOf, configuration.named!(:hash_of).validator 61 | assert_instance_of Strict::Validators::RangeOf, configuration.named!(:range_of).validator 62 | end 63 | 64 | it "allows overwriting attributes" do 65 | configuration = Strict::Attributes::Dsl.run do 66 | foo String 67 | foo Integer 68 | end 69 | 70 | assert_equal %i[foo], configuration.map(&:name) 71 | assert_equal [Integer], configuration.map(&:validator) 72 | end 73 | 74 | it "allows manually creating attributes" do 75 | configuration = Strict::Attributes::Dsl.run do 76 | strict_attribute :if 77 | end 78 | 79 | assert_equal [:if], configuration.map(&:name) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/strict/coercers/array_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Coercers::Array do 6 | describe "#call" do 7 | it "returns nil when passed nil" do 8 | assert_nil Strict::Coercers::Array.new(->(val) { val + 1 }).call(nil) 9 | end 10 | 11 | it "returns the value when passed something that doesn't turn into an array" do 12 | assert_equal "1", Strict::Coercers::Array.new(->(val) { val + 1 }).call("1") 13 | end 14 | 15 | it "returns the array with the element coercer applied given an array" do 16 | assert_equal [2, 3, 4], Strict::Coercers::Array.new(->(val) { val + 1 }).call([1, 2, 3]) 17 | end 18 | 19 | it "returns the array itself with no element coercer" do 20 | assert_equal [[:one, 1], [:two, 2]], Strict::Coercers::Array.new(nil).call({ one: 1, two: 2 }) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/strict/coercers/hash_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Coercers::Hash do 6 | describe "#call" do 7 | it "returns nil when passed nil" do 8 | assert_nil Strict::Coercers::Hash.new(->(val) { val.to_s }, ->(val) { val + 1 }).call(nil) 9 | end 10 | 11 | it "returns the value when passed something that doesn't turn into a hash" do 12 | assert_equal "1", Strict::Coercers::Hash.new(->(val) { val.to_s }, ->(val) { val + 1 }).call("1") 13 | end 14 | 15 | it "returns the hash with the key and value coercers applied given a hash" do 16 | assert_equal( 17 | { "one" => 2, "two" => 3 }, 18 | Strict::Coercers::Hash.new(->(val) { val.to_s }, ->(val) { val + 1 }).call({ one: 1, two: 2 }) 19 | ) 20 | end 21 | 22 | it "returns the hash with just a key coercer applied given a hash" do 23 | assert_equal( 24 | { "one" => 1, "two" => 2 }, 25 | Strict::Coercers::Hash.new(->(val) { val.to_s }, nil).call({ one: 1, two: 2 }) 26 | ) 27 | end 28 | 29 | it "returns the hash with just a value coercer applied given a hash" do 30 | assert_equal( 31 | { one: 2, two: 3 }, 32 | Strict::Coercers::Hash.new(nil, ->(val) { val + 1 }).call({ one: 1, two: 2 }) 33 | ) 34 | end 35 | 36 | it "returns the hash itself with no coercers" do 37 | assert_equal( 38 | { one: 1, two: 2 }, 39 | Strict::Coercers::Hash.new(nil, nil).call([[:one, 1], [:two, 2]]) 40 | ) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/strict/configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "securerandom" 5 | 6 | describe Strict::Configuration do 7 | it "defaults to a sample_rate of 1" do 8 | configuration = Strict::Configuration.new 9 | 10 | assert_equal 1, configuration.sample_rate 11 | end 12 | 13 | it "defaults to a new instance of Random" do 14 | configuration_one = Strict::Configuration.new 15 | configuration_two = Strict::Configuration.new 16 | 17 | assert_instance_of Random, configuration_one.random 18 | assert_instance_of Random, configuration_two.random 19 | refute_equal configuration_two.random, configuration_one.random 20 | end 21 | 22 | describe "#sample_rate=" do 23 | it "ensures the rate is between 0 and 1" do 24 | configuration = Strict::Configuration.new 25 | 26 | configuration.sample_rate = 0 27 | 28 | assert_equal 0, configuration.sample_rate 29 | 30 | configuration.sample_rate = 1 31 | 32 | assert_equal 1, configuration.sample_rate 33 | 34 | configuration.sample_rate = 0.5 35 | 36 | assert_in_delta 0.5, configuration.sample_rate 37 | 38 | assert_raises Strict::Error do 39 | configuration.sample_rate = 1.1 40 | end 41 | 42 | assert_raises Strict::Error do 43 | configuration.sample_rate = -0.1 44 | end 45 | end 46 | end 47 | 48 | describe "#random=" do 49 | it "ensures it is a random formatter" do 50 | configuration = Strict::Configuration.new 51 | 52 | configuration.random = SecureRandom 53 | 54 | assert_equal SecureRandom, configuration.random 55 | 56 | random = Random.new(1) 57 | configuration.random = random 58 | 59 | assert_equal random, configuration.random 60 | 61 | assert_raises Strict::Error do 62 | configuration.random = 0 63 | end 64 | end 65 | end 66 | 67 | describe "#validate?" do 68 | it "is false when the sample rate is 0" do 69 | configuration = Strict::Configuration.new 70 | 71 | configuration.sample_rate = 0 72 | 73 | refute_predicate configuration, :validate? 74 | end 75 | 76 | it "is true when the sample rate is 1" do 77 | configuration = Strict::Configuration.new 78 | 79 | configuration.sample_rate = 1 80 | 81 | assert_predicate configuration, :validate? 82 | end 83 | 84 | it "is true roughly (sample_rate * 100)% of the time" do 85 | configuration = Strict::Configuration.new 86 | configuration.sample_rate = 0.25 87 | 88 | results = Hash.new { |h, k| h[k] = 0 } 89 | 10_000.times do 90 | results[configuration.validate?] += 1 91 | end 92 | 93 | assert_in_delta 2500, results.fetch(true), 300 94 | end 95 | end 96 | 97 | describe "#to_h" do 98 | it "returns the attributes the comprise the configuration" do 99 | configuration = Strict::Configuration.new 100 | 101 | assert_equal( 102 | { 103 | random: configuration.random, 104 | sample_rate: configuration.sample_rate 105 | }, 106 | configuration.to_h 107 | ) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/strict/implementation_does_not_conform_error_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module ImplementationDoesNotConformErrorTest 6 | # rubocop:disable Lint/EmptyClass 7 | class Interface 8 | end 9 | 10 | class Implementation 11 | end 12 | # rubocop:enable Lint/EmptyClass 13 | end 14 | 15 | describe Strict::ImplementationDoesNotConformError do 16 | describe ".new" do 17 | it "builds a message with only missing methods" do 18 | error = Strict::ImplementationDoesNotConformError.new( 19 | interface: ImplementationDoesNotConformErrorTest::Interface, 20 | receiver: ImplementationDoesNotConformErrorTest::Implementation, 21 | missing_methods: %i[first_method second_method], 22 | invalid_method_definitions: {} 23 | ) 24 | 25 | expected_message = <<~MESSAGE.chomp 26 | ImplementationDoesNotConformErrorTest::Implementation's implementation of ImplementationDoesNotConformErrorTest::Interface does not conform: 27 | Some methods exposed in the interface were not defined in the implementation: 28 | - first_method 29 | - second_method 30 | MESSAGE 31 | 32 | assert_equal expected_message, error.message 33 | end 34 | 35 | it "builds a message with one invalid method definition" do 36 | error = Strict::ImplementationDoesNotConformError.new( 37 | interface: ImplementationDoesNotConformErrorTest::Interface, 38 | receiver: ImplementationDoesNotConformErrorTest::Implementation, 39 | missing_methods: nil, 40 | invalid_method_definitions: { 41 | first_method: { missing_parameters: %i[foo bar], additional_parameters: [], non_keyword_parameters: [] } 42 | } 43 | ) 44 | 45 | expected_message = <<~MESSAGE.chomp 46 | ImplementationDoesNotConformErrorTest::Implementation's implementation of ImplementationDoesNotConformErrorTest::Interface does not conform: 47 | Some methods defined in the implementation did not conform to their interface: 48 | first_method: 49 | Some parameters were expected, but were not in the parameter list: 50 | - foo 51 | - bar 52 | MESSAGE 53 | 54 | assert_equal expected_message, error.message 55 | end 56 | 57 | it "builds a message with multiple invalid method definitions" do 58 | error = Strict::ImplementationDoesNotConformError.new( 59 | interface: ImplementationDoesNotConformErrorTest::Interface, 60 | receiver: ImplementationDoesNotConformErrorTest::Implementation, 61 | missing_methods: nil, 62 | invalid_method_definitions: { 63 | first_method: { missing_parameters: %i[foo bar], additional_parameters: [], non_keyword_parameters: [] }, 64 | second_method: { missing_parameters: %i[fizz buzz], additional_parameters: [], non_keyword_parameters: [] } 65 | } 66 | ) 67 | 68 | expected_message = <<~MESSAGE.chomp 69 | ImplementationDoesNotConformErrorTest::Implementation's implementation of ImplementationDoesNotConformErrorTest::Interface does not conform: 70 | Some methods defined in the implementation did not conform to their interface: 71 | first_method: 72 | Some parameters were expected, but were not in the parameter list: 73 | - foo 74 | - bar 75 | second_method: 76 | Some parameters were expected, but were not in the parameter list: 77 | - fizz 78 | - buzz 79 | MESSAGE 80 | 81 | assert_equal expected_message, error.message 82 | end 83 | 84 | it "builds a message with missing, additional, and non-keyword parameters" do 85 | error = Strict::ImplementationDoesNotConformError.new( 86 | interface: ImplementationDoesNotConformErrorTest::Interface, 87 | receiver: ImplementationDoesNotConformErrorTest::Implementation, 88 | missing_methods: nil, 89 | invalid_method_definitions: { 90 | first_method: { 91 | missing_parameters: %i[foo bar], 92 | additional_parameters: %i[fizz buzz], 93 | non_keyword_parameters: %i[bar bat] 94 | } 95 | } 96 | ) 97 | 98 | expected_message = <<~MESSAGE.chomp 99 | ImplementationDoesNotConformErrorTest::Implementation's implementation of ImplementationDoesNotConformErrorTest::Interface does not conform: 100 | Some methods defined in the implementation did not conform to their interface: 101 | first_method: 102 | Some parameters were expected, but were not in the parameter list: 103 | - foo 104 | - bar 105 | Some parameters were not expected, but were in the parameter list: 106 | - fizz 107 | - buzz 108 | Some parameters were not keywords, but only keywords are supported: 109 | - bar 110 | - bat 111 | MESSAGE 112 | 113 | assert_equal expected_message, error.message 114 | end 115 | 116 | it "builds a message with missing methods and missing, additional, and non-keyword parameters for many methods" do 117 | error = Strict::ImplementationDoesNotConformError.new( 118 | interface: ImplementationDoesNotConformErrorTest::Interface, 119 | receiver: ImplementationDoesNotConformErrorTest::Implementation, 120 | missing_methods: %i[first_method second_method], 121 | invalid_method_definitions: { 122 | third_method: { 123 | missing_parameters: %i[a b], 124 | additional_parameters: %i[c d], 125 | non_keyword_parameters: %i[e f] 126 | }, 127 | fourth_method: { 128 | missing_parameters: %i[g h], 129 | additional_parameters: %i[i j], 130 | non_keyword_parameters: %i[k l] 131 | } 132 | } 133 | ) 134 | 135 | expected_message = <<~MESSAGE.chomp 136 | ImplementationDoesNotConformErrorTest::Implementation's implementation of ImplementationDoesNotConformErrorTest::Interface does not conform: 137 | Some methods exposed in the interface were not defined in the implementation: 138 | - first_method 139 | - second_method 140 | Some methods defined in the implementation did not conform to their interface: 141 | third_method: 142 | Some parameters were expected, but were not in the parameter list: 143 | - a 144 | - b 145 | Some parameters were not expected, but were in the parameter list: 146 | - c 147 | - d 148 | Some parameters were not keywords, but only keywords are supported: 149 | - e 150 | - f 151 | fourth_method: 152 | Some parameters were expected, but were not in the parameter list: 153 | - g 154 | - h 155 | Some parameters were not expected, but were in the parameter list: 156 | - i 157 | - j 158 | Some parameters were not keywords, but only keywords are supported: 159 | - k 160 | - l 161 | MESSAGE 162 | 163 | assert_equal expected_message, error.message 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/strict/initialization_error_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::InitializationError do 6 | describe ".new" do 7 | before do 8 | @initializable_class = Class.new 9 | end 10 | 11 | it "builds a message with only invalid attributes" do 12 | error = Strict::InitializationError.new( 13 | initializable_class: @initializable_class, 14 | remaining_attributes: [], 15 | invalid_attributes: { 16 | Strict::Attribute.make(:attr_one, Strict::Validators::AnyOf.new(1, "2", nil)) => 2, 17 | Strict::Attribute.make(:attr_two, nil) => 2 18 | }, 19 | missing_attributes: nil 20 | ) 21 | 22 | expected_message = <<~MESSAGE.chomp 23 | Initialization of #{@initializable_class} failed because: 24 | Some attributes were invalid: 25 | - attr_one: got 2, expected AnyOf(1, "2", nil) 26 | - attr_two: got 2, expected nil 27 | MESSAGE 28 | 29 | assert_equal expected_message, error.message 30 | end 31 | 32 | it "builds a message with only missing attributes" do 33 | error = Strict::InitializationError.new( 34 | initializable_class: @initializable_class, 35 | remaining_attributes: [], 36 | invalid_attributes: nil, 37 | missing_attributes: %i[attr_three attr_four] 38 | ) 39 | 40 | expected_message = <<~MESSAGE.chomp 41 | Initialization of #{@initializable_class} failed because: 42 | Some attributes were missing: 43 | - attr_three 44 | - attr_four 45 | MESSAGE 46 | 47 | assert_equal expected_message, error.message 48 | end 49 | 50 | it "builds a message with only remaining attributes" do 51 | error = Strict::InitializationError.new( 52 | initializable_class: @initializable_class, 53 | remaining_attributes: %i[attr_five attr_six], 54 | invalid_attributes: nil, 55 | missing_attributes: nil 56 | ) 57 | 58 | expected_message = <<~MESSAGE.chomp 59 | Initialization of #{@initializable_class} failed because: 60 | Some attributes were provided, but not defined: 61 | - attr_five 62 | - attr_six 63 | MESSAGE 64 | 65 | assert_equal expected_message, error.message 66 | end 67 | 68 | it "builds a message with all kinds of attributes" do 69 | error = Strict::InitializationError.new( 70 | initializable_class: @initializable_class, 71 | remaining_attributes: %i[attr_five attr_six], 72 | invalid_attributes: { 73 | Strict::Attribute.make(:attr_one, Strict::Validators::AnyOf.new(1, "2", nil)) => 2, 74 | Strict::Attribute.make(:attr_two, nil) => 2 75 | }, 76 | missing_attributes: %i[attr_three attr_four] 77 | ) 78 | 79 | expected_message = <<~MESSAGE.chomp 80 | Initialization of #{@initializable_class} failed because: 81 | Some attributes were invalid: 82 | - attr_one: got 2, expected AnyOf(1, "2", nil) 83 | - attr_two: got 2, expected nil 84 | Some attributes were missing: 85 | - attr_three 86 | - attr_four 87 | Some attributes were provided, but not defined: 88 | - attr_five 89 | - attr_six 90 | MESSAGE 91 | 92 | assert_equal expected_message, error.message 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/strict/interface_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module InterfaceTest 6 | class Interface 7 | extend Strict::Interface 8 | 9 | expose(:first_method) do 10 | foo Integer 11 | bar String 12 | returns String 13 | end 14 | 15 | expose(:second_method) do 16 | baz String 17 | bat Integer 18 | returns Integer 19 | end 20 | 21 | expose(:third_method) do 22 | fizz Integer 23 | buzz String 24 | returns String 25 | end 26 | 27 | expose(:no_params) do 28 | returns String 29 | end 30 | end 31 | 32 | # rubocop:disable Lint/UnusedMethodArgument 33 | class BadImplementation 34 | def first_method(foo, bar:, extra:, &block) 35 | "first_method" 36 | end 37 | 38 | def second_method(baz:) 39 | 2 40 | end 41 | end 42 | 43 | class GoodImplementation 44 | def first_method(foo:, bar:, &block) 45 | "1" 46 | end 47 | 48 | def second_method(baz:, bat:) 49 | 2 50 | end 51 | 52 | def third_method(fizz:, buzz:) 53 | "3" 54 | end 55 | 56 | def no_params 57 | "4" 58 | end 59 | end 60 | # rubocop:enable Lint/UnusedMethodArgument 61 | end 62 | 63 | describe Strict::Interface do 64 | before do 65 | @interface = Class.new do 66 | extend Strict::Interface 67 | 68 | expose(:call) do 69 | one String 70 | two String 71 | returns String 72 | end 73 | end 74 | end 75 | 76 | describe ".new" do 77 | it "raises when given a bad implementation" do 78 | error = assert_raises(Strict::ImplementationDoesNotConformError) do 79 | InterfaceTest::Interface.new(InterfaceTest::BadImplementation.new) 80 | end 81 | 82 | assert_match(/first_method/, error.message) 83 | assert_match(/second_method/, error.message) 84 | assert_match(/third_method/, error.message) 85 | assert_match(/no_params/, error.message) 86 | end 87 | 88 | it "does not raise when given a good implementation" do 89 | interface = InterfaceTest::Interface.new(InterfaceTest::GoodImplementation.new) 90 | 91 | assert_equal "1", interface.first_method(foo: 1, bar: "2") 92 | assert_equal 2, interface.second_method(baz: "1", bat: 2) 93 | assert_equal "3", interface.third_method(fizz: 1, buzz: "2") 94 | assert_equal "4", interface.no_params 95 | end 96 | 97 | it "raises when missing a parameter" do 98 | assert_raises(Strict::ImplementationDoesNotConformError) do 99 | @interface.new( 100 | Class.new do 101 | def call(one:); end 102 | end.new 103 | ) 104 | end 105 | end 106 | 107 | it "raises when given an extra parameter" do 108 | assert_raises(Strict::ImplementationDoesNotConformError) do 109 | @interface.new( 110 | Class.new do 111 | def call(one:, two:, three:); end 112 | end.new 113 | ) 114 | end 115 | end 116 | 117 | it "raises when given a non-keyword parameter" do 118 | assert_raises(Strict::ImplementationDoesNotConformError) do 119 | @interface.new( 120 | Class.new do 121 | def call(one, two:); end 122 | end.new 123 | ) 124 | end 125 | end 126 | 127 | it "raises when missing a method" do 128 | assert_raises(Strict::ImplementationDoesNotConformError) do 129 | @interface.new( 130 | Class.new.new 131 | ) 132 | end 133 | end 134 | 135 | it "does not raise when other methods are defined" do 136 | @interface.new( 137 | Class.new do 138 | def call(one:, two:); end 139 | def other(one:, two:); end 140 | end.new 141 | ) 142 | end 143 | 144 | it "does not raise when keyword args are entirely splatted" do 145 | @interface.new( 146 | Class.new do 147 | def call(**kwargs); end 148 | end.new 149 | ) 150 | end 151 | 152 | it "does not raise when keyword args are partially splatted" do 153 | @interface.new( 154 | Class.new do 155 | def call(one:, **kwargs); end 156 | end.new 157 | ) 158 | end 159 | 160 | it "does not raise when non-keyword args are entirely splatted" do 161 | @interface.new( 162 | Class.new do 163 | def call(*args, one:, two:); end 164 | end.new 165 | ) 166 | end 167 | 168 | it "raises when non-keyword args are partially splatted" do 169 | assert_raises(Strict::ImplementationDoesNotConformError) do 170 | @interface.new( 171 | Class.new do 172 | def call(foo, *args, one:, two:); end 173 | end.new 174 | ) 175 | end 176 | end 177 | 178 | it "does not raise when non-keyword and keyword args are entirely splatted" do 179 | @interface.new( 180 | Class.new do 181 | def call(*args, **kwargs); end 182 | end.new 183 | ) 184 | end 185 | end 186 | 187 | describe ".coercer" do 188 | it "returns nil when coercing nil" do 189 | assert_nil InterfaceTest::Interface.coercer.call(nil) 190 | end 191 | 192 | it "returns the interface when passed an instance of the interface" do 193 | interface = InterfaceTest::Interface.new(InterfaceTest::GoodImplementation.new) 194 | 195 | assert_equal interface, InterfaceTest::Interface.coercer.call(interface) 196 | end 197 | 198 | it "attempts to instantiate the interface otherwise" do 199 | interface = InterfaceTest::Interface.coercer.call(InterfaceTest::GoodImplementation.new) 200 | 201 | assert_instance_of InterfaceTest::Interface, interface 202 | assert_instance_of InterfaceTest::GoodImplementation, interface.implementation 203 | assert_equal "1", interface.first_method(foo: 1, bar: "2") 204 | 205 | assert_raises(Strict::ImplementationDoesNotConformError) do 206 | InterfaceTest::Interface.coercer.call(InterfaceTest::BadImplementation.new) 207 | end 208 | end 209 | end 210 | 211 | describe "exposed methods" do 212 | it "behaves like a Strict::Method" do 213 | interface = InterfaceTest::Interface.new(InterfaceTest::GoodImplementation.new) 214 | 215 | assert_raises(Strict::MethodCallError) do 216 | interface.first_method(foo: "1", bar: "2") 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /test/strict/method_call_error_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::MethodCallError do 6 | describe ".new" do 7 | before do 8 | @verifiable_method = Strict::Methods::VerifiableMethod.new( 9 | method: Strict::Methods::VerifiableMethod.instance_method(:instance?), 10 | parameters: [], 11 | returns: [], 12 | instance: true 13 | ) 14 | end 15 | 16 | it "builds a message with only remaining args" do 17 | error = Strict::MethodCallError.new( 18 | verifiable_method: @verifiable_method, 19 | remaining_args: [1, "2"], 20 | remaining_kwargs: {}, 21 | invalid_parameters: nil, 22 | missing_parameters: nil 23 | ) 24 | 25 | expected_message = <<~MESSAGE.chomp 26 | Calling Strict::Methods::VerifiableMethod#instance? failed because: 27 | Additional positional arguments were provided, but not defined: 28 | - 1 29 | - "2" 30 | MESSAGE 31 | 32 | assert_equal expected_message, error.message 33 | end 34 | 35 | it "builds a message with only remaining kwargs" do 36 | error = Strict::MethodCallError.new( 37 | verifiable_method: @verifiable_method, 38 | remaining_args: [], 39 | remaining_kwargs: { one: 1, two: "2" }, 40 | invalid_parameters: nil, 41 | missing_parameters: nil 42 | ) 43 | 44 | expected_message = <<~MESSAGE.chomp 45 | Calling Strict::Methods::VerifiableMethod#instance? failed because: 46 | Additional keyword arguments were provided, but not defined: 47 | - one: 1 48 | - two: "2" 49 | MESSAGE 50 | 51 | assert_equal expected_message, error.message 52 | end 53 | 54 | it "builds a message with only invalid parameters" do 55 | error = Strict::MethodCallError.new( 56 | verifiable_method: @verifiable_method, 57 | remaining_args: [], 58 | remaining_kwargs: {}, 59 | invalid_parameters: { 60 | Strict::Parameter.make(:param_one, Strict::Validators::AnyOf.new(1, "2", nil)) => 2, 61 | Strict::Parameter.make(:param_two, nil) => 2 62 | }, 63 | missing_parameters: nil 64 | ) 65 | 66 | expected_message = <<~MESSAGE.chomp 67 | Calling Strict::Methods::VerifiableMethod#instance? failed because: 68 | Some arguments were invalid: 69 | - param_one: got 2, expected AnyOf(1, "2", nil) 70 | - param_two: got 2, expected nil 71 | MESSAGE 72 | 73 | assert_equal expected_message, error.message 74 | end 75 | 76 | it "builds a message with only missing parameters" do 77 | error = Strict::MethodCallError.new( 78 | verifiable_method: @verifiable_method, 79 | remaining_args: [], 80 | remaining_kwargs: {}, 81 | invalid_parameters: nil, 82 | missing_parameters: %i[param_one param_two] 83 | ) 84 | 85 | expected_message = <<~MESSAGE.chomp 86 | Calling Strict::Methods::VerifiableMethod#instance? failed because: 87 | Some arguments were missing: 88 | - param_one 89 | - param_two 90 | MESSAGE 91 | 92 | assert_equal expected_message, error.message 93 | end 94 | 95 | it "builds a message with all kinds of problems" do 96 | error = Strict::MethodCallError.new( 97 | verifiable_method: @verifiable_method, 98 | remaining_args: [1, "2"], 99 | remaining_kwargs: { three: 3, four: "4" }, 100 | invalid_parameters: { 101 | Strict::Parameter.make(:five, Strict::Validators::AnyOf.new(1, "2", nil)) => 2, 102 | Strict::Parameter.make(:six, nil) => 2 103 | }, 104 | missing_parameters: %i[seven eight] 105 | ) 106 | 107 | expected_message = <<~MESSAGE.chomp 108 | Calling Strict::Methods::VerifiableMethod#instance? failed because: 109 | Some arguments were invalid: 110 | - five: got 2, expected AnyOf(1, "2", nil) 111 | - six: got 2, expected nil 112 | Some arguments were missing: 113 | - seven 114 | - eight 115 | Additional positional arguments were provided, but not defined: 116 | - 1 117 | - "2" 118 | Additional keyword arguments were provided, but not defined: 119 | - three: 3 120 | - four: "4" 121 | MESSAGE 122 | 123 | assert_equal expected_message, error.message 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/strict/method_definition_error_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::MethodDefinitionError do 6 | describe ".new" do 7 | before do 8 | @verifiable_method = Strict::Methods::VerifiableMethod.new( 9 | method: Strict::Methods::VerifiableMethod.instance_method(:instance?), 10 | parameters: [], 11 | returns: [], 12 | instance: true 13 | ) 14 | end 15 | 16 | it "builds a message with only missing parameters" do 17 | error = Strict::MethodDefinitionError.new( 18 | verifiable_method: @verifiable_method, 19 | missing_parameters: %i[one two], 20 | additional_parameters: [] 21 | ) 22 | 23 | expected_message = <<~MESSAGE.chomp 24 | Defining Strict::Methods::VerifiableMethod#instance? failed because: 25 | Some parameters were in the sig, but were not in the parameter list: 26 | - one 27 | - two 28 | MESSAGE 29 | 30 | assert_equal expected_message, error.message 31 | end 32 | 33 | it "builds a message with only additional parameters" do 34 | error = Strict::MethodDefinitionError.new( 35 | verifiable_method: @verifiable_method, 36 | missing_parameters: [], 37 | additional_parameters: %i[one two] 38 | ) 39 | 40 | expected_message = <<~MESSAGE.chomp 41 | Defining Strict::Methods::VerifiableMethod#instance? failed because: 42 | Some parameters were not in the sig, but were in the parameter list: 43 | - one 44 | - two 45 | MESSAGE 46 | 47 | assert_equal expected_message, error.message 48 | end 49 | 50 | it "builds a message with all kinds of problems" do 51 | error = Strict::MethodDefinitionError.new( 52 | verifiable_method: @verifiable_method, 53 | missing_parameters: %i[one two], 54 | additional_parameters: %i[three four] 55 | ) 56 | 57 | expected_message = <<~MESSAGE.chomp 58 | Defining Strict::Methods::VerifiableMethod#instance? failed because: 59 | Some parameters were in the sig, but were not in the parameter list: 60 | - one 61 | - two 62 | Some parameters were not in the sig, but were in the parameter list: 63 | - three 64 | - four 65 | MESSAGE 66 | 67 | assert_equal expected_message, error.message 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/strict/method_return_error_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::MethodReturnError do 6 | describe ".new" do 7 | before do 8 | @verifiable_method = Strict::Methods::VerifiableMethod.new( 9 | method: Strict::Methods::VerifiableMethod.instance_method(:instance?), 10 | parameters: [], 11 | returns: Strict::Return.make(Strict::Validators::AnyOf.new(1, "2", nil)), 12 | instance: true 13 | ) 14 | end 15 | 16 | it "builds a message with an invalid value" do 17 | error = Strict::MethodReturnError.new(verifiable_method: @verifiable_method, value: 2) 18 | 19 | expected_message = <<~MESSAGE.chomp 20 | Strict::Methods::VerifiableMethod#instance?'s return value was invalid because: 21 | - got 2, expected AnyOf(1, "2", nil) 22 | MESSAGE 23 | 24 | assert_equal expected_message, error.message 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/strict/method_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Method do 6 | it "supports a mix of positional and keyword parameters" do 7 | instance = Class.new do 8 | extend Strict::Method 9 | 10 | sig do 11 | one Integer 12 | two Float 13 | end 14 | def call(one, two:); end 15 | end.new 16 | 17 | assert_nil instance.call(1, two: 2.2) 18 | end 19 | 20 | it "supports rest parameters" do 21 | instance = Class.new do 22 | extend Strict::Method 23 | 24 | sig do 25 | one Integer 26 | rest Array 27 | two Float 28 | keyrest Hash 29 | returns Array 30 | end 31 | def call(one, *rest, two:, **keyrest) 32 | [one, rest, two, keyrest] 33 | end 34 | end.new 35 | 36 | assert_equal [1, [2, 3], 2.2, { other: 1 }], instance.call(1, 2, 3, two: 2.2, other: 1) 37 | end 38 | 39 | it "does not validate blocks, but passes them through" do 40 | instance = Class.new do 41 | extend Strict::Method 42 | 43 | sig do 44 | one Integer 45 | two Integer 46 | returns Integer 47 | end 48 | def call(one, two:, &block) 49 | one + two + block.call 50 | end 51 | end.new 52 | 53 | assert_equal 6, instance.call(1, two: 2) { 3 } 54 | end 55 | 56 | it "coerces arguments" do 57 | instance = Class.new do 58 | extend Strict::Method 59 | 60 | sig do 61 | one Integer, coerce: ->(value) { value.to_i } 62 | returns Integer 63 | end 64 | def call(one) 65 | one 66 | end 67 | end.new 68 | 69 | assert_equal 1, instance.call("1") 70 | end 71 | 72 | it "does not require optional parameters" do 73 | instance = Class.new do 74 | extend Strict::Method 75 | 76 | sig do 77 | one Integer, default: 1 78 | returns Integer 79 | end 80 | def call(one) 81 | one 82 | end 83 | end.new 84 | 85 | assert_equal 1, instance.call 86 | end 87 | 88 | it "ignores the defaults on the method itself when the sig has one" do 89 | instance = Class.new do 90 | extend Strict::Method 91 | 92 | sig do 93 | one Integer, default: 1 94 | returns Integer 95 | end 96 | def call(one: 2) 97 | one 98 | end 99 | end.new 100 | 101 | assert_equal 1, instance.call 102 | end 103 | 104 | it "invalidates postitional parameters" do 105 | instance = Class.new do 106 | extend Strict::Method 107 | 108 | sig do 109 | one Integer 110 | two Float 111 | three String 112 | end 113 | def call(one, two, three); end 114 | end.new 115 | 116 | assert_nil instance.call(1, 2.2, "3") 117 | 118 | error = assert_raises(Strict::MethodCallError) do 119 | instance.call(1, 2.2, 3) 120 | end 121 | 122 | assert_match(/three/, error.message) 123 | 124 | error = assert_raises(Strict::MethodCallError) do 125 | instance.call(1, 2.2) 126 | end 127 | 128 | assert_match(/three/, error.message) 129 | 130 | error = assert_raises(Strict::MethodCallError) do 131 | instance.call(1, 2.2, "3", 4) 132 | end 133 | 134 | assert_match(/4/, error.message) 135 | end 136 | 137 | it "invalidates keyword parameters" do 138 | instance = Class.new do 139 | extend Strict::Method 140 | 141 | sig do 142 | one Integer 143 | two Float 144 | three String 145 | end 146 | def call(one:, two:, three:); end 147 | end.new 148 | 149 | assert_nil instance.call(one: 1, two: 2.2, three: "3") 150 | 151 | error = assert_raises(Strict::MethodCallError) do 152 | instance.call(one: 1, two: 2.2, three: 3) 153 | end 154 | 155 | assert_match(/three/, error.message) 156 | 157 | error = assert_raises(Strict::MethodCallError) do 158 | instance.call(one: 1, two: 2.2) 159 | end 160 | 161 | assert_match(/three/, error.message) 162 | 163 | error = assert_raises(Strict::MethodCallError) do 164 | instance.call(one: 1, two: 2.2, three: "3", four: 4) 165 | end 166 | 167 | assert_match(/four/, error.message) 168 | end 169 | 170 | it "invalidates return values" do 171 | instance = Class.new do 172 | extend Strict::Method 173 | 174 | sig do 175 | one Anything() 176 | returns String 177 | end 178 | def call(one) 179 | one 180 | end 181 | end.new 182 | 183 | assert_equal "1", instance.call("1") 184 | 185 | error = assert_raises(Strict::MethodReturnError) do 186 | instance.call(1) 187 | end 188 | 189 | assert_match(/1/, error.message) 190 | end 191 | 192 | it "ensures sigs align with methods" do 193 | assert_raises(Strict::MethodDefinitionError) do 194 | Class.new do 195 | extend Strict::Method 196 | 197 | sig do 198 | one Anything() 199 | end 200 | def call(one, two) 201 | one + two 202 | end 203 | end 204 | end 205 | 206 | assert_raises(Strict::MethodDefinitionError) do 207 | Class.new do 208 | extend Strict::Method 209 | 210 | sig do 211 | one Anything() 212 | two Anything() 213 | end 214 | def call(one) 215 | one 216 | end 217 | end 218 | end 219 | end 220 | 221 | describe "instance methods" do 222 | it "only strictly validates methods declared with a sig" do 223 | klass = Class.new do 224 | extend Strict::Method 225 | 226 | def sigless(baz, bat) 227 | baz + bat 228 | end 229 | 230 | sig do 231 | baz Integer 232 | bat Integer 233 | returns Integer 234 | end 235 | def sigged(baz, bat) 236 | baz + bat 237 | end 238 | end 239 | instance = klass.new 240 | 241 | assert_empty klass.strict_class_methods.keys 242 | assert_equal [:sigged], klass.strict_instance_methods.keys 243 | assert_equal 3, instance.sigless(1, 2) 244 | assert_equal "12", instance.sigless("1", "2") 245 | 246 | assert_equal 3, instance.sigged(1, 2) 247 | assert_raises(Strict::MethodCallError) do 248 | assert_equal "12", instance.sigged("1", "2") 249 | end 250 | end 251 | end 252 | 253 | describe "self. class methods" do 254 | it "only strictly validates methods declared with a sig" do 255 | klass = Class.new do 256 | extend Strict::Method 257 | 258 | def self.sigless(baz, bat) 259 | baz + bat 260 | end 261 | 262 | sig do 263 | baz Integer 264 | bat Integer 265 | returns Integer 266 | end 267 | def self.sigged(baz, bat) 268 | baz + bat 269 | end 270 | end 271 | 272 | assert_equal [:sigged], klass.strict_class_methods.keys 273 | assert_empty klass.strict_instance_methods.keys 274 | assert_equal 3, klass.sigless(1, 2) 275 | assert_equal "12", klass.sigless("1", "2") 276 | 277 | assert_equal 3, klass.sigged(1, 2) 278 | assert_raises(Strict::MethodCallError) do 279 | assert_equal "12", klass.sigged("1", "2") 280 | end 281 | end 282 | end 283 | 284 | describe "class << self methods" do 285 | it "only strictly validates methods declared with a sig" do 286 | klass = Class.new do 287 | extend Strict::Method 288 | 289 | class << self 290 | def sigless(baz, bat) 291 | baz + bat 292 | end 293 | 294 | sig do 295 | baz Integer 296 | bat Integer 297 | returns Integer 298 | end 299 | def sigged(baz, bat) 300 | baz + bat 301 | end 302 | end 303 | end 304 | 305 | assert_equal [:sigged], klass.strict_class_methods.keys 306 | assert_empty klass.strict_instance_methods.keys 307 | assert_equal 3, klass.sigless(1, 2) 308 | assert_equal "12", klass.sigless("1", "2") 309 | 310 | assert_equal 3, klass.sigged(1, 2) 311 | assert_raises(Strict::MethodCallError) do 312 | assert_equal "12", klass.sigged("1", "2") 313 | end 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /test/strict/methods/dsl_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Methods::Dsl do 6 | describe ".run" do 7 | it "creates a configuration with valid identifiers and arguments" do 8 | configuration = Strict::Methods::Dsl.run do 9 | no_arguments 10 | _underscore_identifier 11 | question? 12 | unsafe! 13 | default_with_value default: 1 14 | default_with_callable default: -> { 1 } 15 | default_value default_value: 1 16 | default_generator default_generator: -> { 1 } 17 | coerce coerce: ->(value) { "coerced #{value}" } 18 | coerce_array coerce: ToArray() 19 | coerce_array_with coerce: ToArray(with: ->(element) { element.to_s }) 20 | coerce_hash coerce: ToHash() 21 | coerce_hash_with coerce: ToHash( 22 | with_keys: ->(element) { element.to_s }, 23 | with_values: ->(element) { element.to_s } 24 | ) 25 | all_of AllOf(Enumerable, Comparable) 26 | any_of AnyOf(Integer, String, nil) 27 | anything Anything() 28 | array_of ArrayOf(Anything()) 29 | boolean Boolean() 30 | hash_of HashOf(Integer => String) 31 | range_of RangeOf(Numeric) 32 | returns Boolean() 33 | end 34 | parameters = configuration.parameters.to_h { |p| [p.name, p] } 35 | 36 | assert_equal Strict::Validators::Anything.instance, parameters.fetch(:no_arguments).validator 37 | refute_nil parameters.fetch(:_underscore_identifier) 38 | refute_nil parameters.fetch(:question?) 39 | refute_nil parameters.fetch(:unsafe!) 40 | assert_equal 1, parameters.fetch(:default_with_value).default_generator.call 41 | assert_equal 1, parameters.fetch(:default_with_callable).default_generator.call 42 | assert_equal 1, parameters.fetch(:default_value).default_generator.call 43 | assert_equal 1, parameters.fetch(:default_generator).default_generator.call 44 | assert_equal "coerced value", parameters.fetch(:coerce).coerce("value") 45 | assert_equal [[:one, 1]], parameters.fetch(:coerce_array).coerce({ one: 1 }) 46 | assert_equal %w[1 2], parameters.fetch(:coerce_array_with).coerce([1, 2]) 47 | assert_equal({ one: 1 }, parameters.fetch(:coerce_hash).coerce([[:one, 1]])) 48 | assert_equal({ "one" => "1" }, parameters.fetch(:coerce_hash_with).coerce([[:one, 1]])) 49 | assert_instance_of Strict::Validators::AllOf, parameters.fetch(:all_of).validator 50 | assert_instance_of Strict::Validators::AnyOf, parameters.fetch(:any_of).validator 51 | assert_instance_of Strict::Validators::Anything, parameters.fetch(:anything).validator 52 | assert_instance_of Strict::Validators::ArrayOf, parameters.fetch(:array_of).validator 53 | assert_instance_of Strict::Validators::Boolean, parameters.fetch(:boolean).validator 54 | assert_instance_of Strict::Validators::HashOf, parameters.fetch(:hash_of).validator 55 | assert_instance_of Strict::Validators::RangeOf, parameters.fetch(:range_of).validator 56 | assert_instance_of Strict::Validators::Boolean, configuration.returns.validator 57 | end 58 | 59 | it "allows overwriting parameters" do 60 | configuration = Strict::Methods::Dsl.run do 61 | foo String 62 | foo Integer 63 | end 64 | 65 | assert_equal %i[foo], configuration.parameters.map(&:name) 66 | assert_equal [Integer], configuration.parameters.map(&:validator) 67 | end 68 | 69 | it "allows manually creating parameters" do 70 | configuration = Strict::Methods::Dsl.run do 71 | strict_parameter :if 72 | end 73 | 74 | assert_equal [:if], configuration.parameters.map(&:name) 75 | end 76 | 77 | it "allows conflicting returns parameters and declarations" do 78 | configuration = Strict::Methods::Dsl.run do 79 | strict_parameter :returns, Integer 80 | returns Boolean() 81 | end 82 | parameters = configuration.parameters.to_h { |p| [p.name, p] } 83 | 84 | assert_equal Integer, parameters.fetch(:returns).validator 85 | assert_instance_of Strict::Validators::Boolean, configuration.returns.validator 86 | end 87 | 88 | it "defines returns as anything when not specified" do 89 | configuration = Strict::Methods::Dsl.run do 90 | foo String 91 | end 92 | 93 | assert_instance_of Strict::Validators::Anything, configuration.returns.validator 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/strict/object_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ObjectClass 6 | include Strict::Object 7 | 8 | attributes do 9 | foo Integer 10 | bar String, coerce: :some_coercer 11 | baz String, default: "some string" 12 | end 13 | 14 | def self.some_coercer(value) 15 | value.to_s 16 | end 17 | end 18 | 19 | describe Strict::Object do 20 | it "exposes the configuration on the class" do 21 | assert_instance_of Strict::Attributes::Configuration, ObjectClass.strict_attributes 22 | assert_equal %i[foo bar baz], ObjectClass.strict_attributes.map(&:name) 23 | end 24 | 25 | it "exposes writer methods" do 26 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 27 | instance.foo = 2 28 | 29 | assert_equal 2, instance.foo 30 | end 31 | 32 | it "exposes reader methods" do 33 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 34 | 35 | assert_equal 1, instance.foo 36 | end 37 | 38 | it "does not allow invalid arguments at initialization" do 39 | error = assert_raises(Strict::InitializationError) do 40 | ObjectClass.new(foo: "1", bar: "2", baz: "3") 41 | end 42 | 43 | assert_match(/foo/, error.message) 44 | end 45 | 46 | it "coerces arguments that can be coerced at initialization" do 47 | instance = ObjectClass.new(foo: 1, bar: 2, baz: "3") 48 | 49 | assert_equal "2", instance.bar 50 | end 51 | 52 | it "does not require optional attributes at initialization" do 53 | instance = ObjectClass.new(foo: 1, bar: "2") 54 | 55 | assert_equal "some string", instance.baz 56 | end 57 | 58 | it "requires mandatory attributes at initialization" do 59 | error = assert_raises(Strict::InitializationError) do 60 | ObjectClass.new(foo: 1, baz: "3") 61 | end 62 | 63 | assert_match(/bar/, error.message) 64 | end 65 | 66 | it "does not allow additional attributes at initialization" do 67 | error = assert_raises(Strict::InitializationError) do 68 | ObjectClass.new(foo: 1, bar: "2", baz: "3", bat: "uh oh") 69 | end 70 | 71 | assert_match(/bat/, error.message) 72 | end 73 | 74 | it "aggregates errors at initialization" do 75 | error = assert_raises(Strict::InitializationError) do 76 | ObjectClass.new(foo: "1", baz: "3", bat: "uh oh") 77 | end 78 | 79 | assert_match(/foo/, error.message) 80 | assert_match(/bar/, error.message) 81 | assert_match(/bat/, error.message) 82 | end 83 | 84 | it "does not allow invalid arguments at assignment" do 85 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 86 | 87 | error = assert_raises(Strict::AssignmentError) do 88 | instance.foo = "1" 89 | end 90 | 91 | assert_match(/foo/, error.message) 92 | end 93 | 94 | it "coerces arguments that can be coerced at assignment" do 95 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 96 | instance.bar = 3 97 | 98 | assert_equal "3", instance.bar 99 | end 100 | 101 | it "turns into a hash of attributes" do 102 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 103 | 104 | assert_equal({ foo: 1, bar: "2", baz: "3" }, instance.to_h) 105 | end 106 | 107 | it "can be inspected" do 108 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 109 | 110 | assert_equal "#", instance.inspect 111 | end 112 | 113 | it "can be pretty printed" do 114 | instance = ObjectClass.new(foo: 1, bar: "2", baz: "3") 115 | output = StringIO.new 116 | PP.pp(instance, output, 5) 117 | 118 | assert_equal <<~OUTPUT, output.string 119 | # 123 | OUTPUT 124 | end 125 | 126 | it "exposes a coercer" do 127 | instance = ObjectClass.coercer.call(foo: 1, bar: "2", baz: "3") 128 | 129 | assert_instance_of ObjectClass, instance 130 | assert_equal 1, instance.foo 131 | assert_equal "2", instance.bar 132 | assert_equal "3", instance.baz 133 | 134 | instance = ObjectClass.coercer.call("1") 135 | 136 | assert_equal "1", instance 137 | 138 | assert_raises(Strict::InitializationError) do 139 | ObjectClass.coercer.call({}) 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/strict/parameter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Parameter do 6 | describe ".make" do 7 | it "has defaults when only given a name" do 8 | parameter = Strict::Parameter.make("attr_name") 9 | 10 | assert_equal :attr_name, parameter.name 11 | assert_equal Strict::Validators::Anything.instance, parameter.validator 12 | assert_equal Strict::Parameter::NOT_PROVIDED, parameter.default_generator 13 | refute parameter.coercer 14 | refute_predicate parameter, :optional? 15 | end 16 | 17 | it "accepts a combination of all arguments" do 18 | parameter = Strict::Parameter.make( 19 | :attr_name, 20 | Strict::Validators::Boolean.instance, 21 | coerce: ->(value) { value + 1 }, 22 | default: 1 23 | ) 24 | 25 | assert_equal :attr_name, parameter.name 26 | assert_equal Strict::Validators::Boolean.instance, parameter.validator 27 | refute_equal Strict::Parameter::NOT_PROVIDED, parameter.default_generator 28 | assert_equal 1, parameter.default_generator.call 29 | assert parameter.coercer 30 | assert_predicate parameter, :optional? 31 | end 32 | 33 | it "accepts a validator" do 34 | parameter = Strict::Parameter.make(:attr_name, Strict::Validators::Boolean.instance) 35 | 36 | assert_equal Strict::Validators::Boolean.instance, parameter.validator 37 | end 38 | 39 | it "accepts a coerce value" do 40 | parameter = Strict::Parameter.make(:attr_name, coerce: ->(value) { value + 1 }) 41 | 42 | assert parameter.coercer 43 | end 44 | 45 | it "accepts a value for 'default'" do 46 | parameter = Strict::Parameter.make(:attr_name, default: 1) 47 | 48 | refute_equal Strict::Parameter::NOT_PROVIDED, parameter.default_generator 49 | assert_equal 1, parameter.default_generator.call 50 | assert_predicate parameter, :optional? 51 | end 52 | 53 | it "accepts a callable for 'default'" do 54 | parameter = Strict::Parameter.make(:attr_name, default: -> { 1 }) 55 | 56 | refute_equal Strict::Parameter::NOT_PROVIDED, parameter.default_generator 57 | assert_equal 1, parameter.default_generator.call 58 | assert_predicate parameter, :optional? 59 | end 60 | 61 | it "accepts a value for 'default_value'" do 62 | parameter = Strict::Parameter.make(:attr_name, default_value: -> { 1 }) 63 | 64 | refute_equal Strict::Parameter::NOT_PROVIDED, parameter.default_generator 65 | assert_equal 1, parameter.default_generator.call.call 66 | assert_predicate parameter, :optional? 67 | end 68 | 69 | it "accepts a callable for 'default_generator'" do 70 | parameter = Strict::Parameter.make(:attr_name, default_generator: -> { 1 }) 71 | 72 | refute_equal Strict::Parameter::NOT_PROVIDED, parameter.default_generator 73 | assert_equal 1, parameter.default_generator.call 74 | assert_predicate parameter, :optional? 75 | end 76 | 77 | it "does not accept multiple defaults" do 78 | assert_raises(ArgumentError) do 79 | Strict::Parameter.make(:attr_name, default: 1, default_value: 1) 80 | end 81 | end 82 | end 83 | 84 | describe "#valid?" do 85 | it "uses the validator to check if the value is valid" do 86 | parameter = Strict::Parameter.make(:attr_name, Strict::Validators::Boolean.instance) 87 | 88 | assert parameter.valid?(true) 89 | assert parameter.valid?(false) 90 | refute parameter.valid?(nil) 91 | refute parameter.valid?(1) 92 | 93 | parameter = Strict::Parameter.make( 94 | :attr_name, 95 | Strict::Validators::AnyOf.new(Strict::Validators::Boolean.instance, nil) 96 | ) 97 | 98 | assert parameter.valid?(true) 99 | assert parameter.valid?(false) 100 | assert parameter.valid?(nil) 101 | refute parameter.valid?(1) 102 | end 103 | 104 | it "does not call the validator if sampling indicates not to" do 105 | validator = Class.new do 106 | attr_accessor :called 107 | 108 | def initialize 109 | @called = false 110 | end 111 | 112 | def ===(value) 113 | self.called = true 114 | Strict::Validators::Boolean.instance === value 115 | end 116 | end.new 117 | parameter = Strict::Parameter.make(:attr_name, validator) 118 | 119 | refute validator.called 120 | Strict.with_overrides(sample_rate: 0) do 121 | assert parameter.valid?(true) 122 | refute validator.called 123 | assert parameter.valid?(false) 124 | refute validator.called 125 | assert parameter.valid?(nil) 126 | refute validator.called 127 | assert parameter.valid?(1) 128 | refute validator.called 129 | end 130 | 131 | Strict.with_overrides(sample_rate: 1) do 132 | refute parameter.valid?(nil) 133 | assert validator.called 134 | end 135 | end 136 | end 137 | 138 | describe "#coerce" do 139 | it "returns the value if coercion is not enabled" do 140 | parameter = Strict::Parameter.make(:attr_name, coerce: false) 141 | 142 | assert_equal "value", parameter.coerce("value") 143 | end 144 | 145 | it "does not support .coerce_attr_name coercion" do 146 | parameter = Strict::Parameter.make(:attr_name, coerce: true) 147 | 148 | assert_raises(NoMethodError) do 149 | parameter.coerce("value") 150 | end 151 | end 152 | 153 | it "does not support coercion methods is passed" do 154 | parameter = Strict::Parameter.make(:attr_name, coerce: :some_method) 155 | 156 | assert_raises(NoMethodError) do 157 | parameter.coerce("value") 158 | end 159 | end 160 | 161 | it "calls the callable if one is passed" do 162 | parameter = Strict::Parameter.make(:attr_name, coerce: ->(value) { "coerced #{value}" }) 163 | 164 | assert_equal "coerced value", parameter.coerce("value") 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /test/strict/reader/attributes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ReaderClass 6 | extend Strict::Reader::Attributes 7 | 8 | attributes do 9 | foo Integer 10 | bar String, coerce: :some_coercer 11 | baz String, default: "some string" 12 | end 13 | 14 | def self.some_coercer(value) 15 | value.to_s 16 | end 17 | end 18 | 19 | describe Strict::Reader::Attributes do 20 | it "exposes the configuration on the class" do 21 | assert_instance_of Strict::Attributes::Configuration, ReaderClass.strict_attributes 22 | assert_equal %i[foo bar baz], ReaderClass.strict_attributes.map(&:name) 23 | end 24 | 25 | it "does not expose writer methods" do 26 | instance = ReaderClass.new(foo: 1, bar: "2", baz: "3") 27 | 28 | assert_raises(NoMethodError) do 29 | instance.foo = 1 30 | end 31 | end 32 | 33 | it "exposes reader methods" do 34 | instance = ReaderClass.new(foo: 1, bar: "2", baz: "3") 35 | 36 | assert_equal 1, instance.foo 37 | end 38 | 39 | it "does not allow invalid arguments" do 40 | error = assert_raises(Strict::InitializationError) do 41 | ReaderClass.new(foo: "1", bar: "2", baz: "3") 42 | end 43 | 44 | assert_match(/foo/, error.message) 45 | end 46 | 47 | it "coerces arguments that can be coerced" do 48 | instance = ReaderClass.new(foo: 1, bar: 2, baz: "3") 49 | 50 | assert_equal "2", instance.bar 51 | end 52 | 53 | it "does not require optional attributes" do 54 | instance = ReaderClass.new(foo: 1, bar: "2") 55 | 56 | assert_equal "some string", instance.baz 57 | end 58 | 59 | it "requires mandatory attributes" do 60 | error = assert_raises(Strict::InitializationError) do 61 | ReaderClass.new(foo: 1, baz: "3") 62 | end 63 | 64 | assert_match(/bar/, error.message) 65 | end 66 | 67 | it "does not allow additional attributes" do 68 | error = assert_raises(Strict::InitializationError) do 69 | ReaderClass.new(foo: 1, bar: "2", baz: "3", bat: "uh oh") 70 | end 71 | 72 | assert_match(/bat/, error.message) 73 | end 74 | 75 | it "aggregates errors" do 76 | error = assert_raises(Strict::InitializationError) do 77 | ReaderClass.new(foo: "1", baz: "3", bat: "uh oh") 78 | end 79 | 80 | assert_match(/foo/, error.message) 81 | assert_match(/bar/, error.message) 82 | assert_match(/bat/, error.message) 83 | end 84 | 85 | it "turns into a hash of attributes" do 86 | instance = ReaderClass.new(foo: 1, bar: "2", baz: "3") 87 | 88 | assert_equal({ foo: 1, bar: "2", baz: "3" }, instance.to_h) 89 | end 90 | 91 | it "can be inspected" do 92 | instance = ReaderClass.new(foo: 1, bar: "2", baz: "3") 93 | 94 | assert_equal "#", instance.inspect 95 | end 96 | 97 | it "can be pretty printed" do 98 | instance = ReaderClass.new(foo: 1, bar: "2", baz: "3") 99 | output = StringIO.new 100 | PP.pp(instance, output, 5) 101 | 102 | assert_equal <<~OUTPUT, output.string 103 | # 107 | OUTPUT 108 | end 109 | 110 | it "exposes a coercer" do 111 | instance = ReaderClass.coercer.call(foo: 1, bar: "2", baz: "3") 112 | 113 | assert_instance_of ReaderClass, instance 114 | assert_equal 1, instance.foo 115 | assert_equal "2", instance.bar 116 | assert_equal "3", instance.baz 117 | 118 | instance = ReaderClass.coercer.call("1") 119 | 120 | assert_equal "1", instance 121 | 122 | assert_raises(Strict::InitializationError) do 123 | ReaderClass.coercer.call({}) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/strict/return_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Return do 6 | describe ".make" do 7 | it "has defaults for the validator and coercion" do 8 | returns = Strict::Return.make 9 | 10 | assert_equal Strict::Validators::Anything.instance, returns.validator 11 | refute returns.coercer 12 | end 13 | 14 | it "accepts a combination of all arguments" do 15 | returns = Strict::Return.make(Strict::Validators::Boolean.instance, coerce: ->(value) { value + 1 }) 16 | 17 | assert_equal Strict::Validators::Boolean.instance, returns.validator 18 | assert returns.coercer 19 | end 20 | 21 | it "accepts a validator" do 22 | returns = Strict::Return.make(Strict::Validators::Boolean.instance) 23 | 24 | assert_equal Strict::Validators::Boolean.instance, returns.validator 25 | end 26 | 27 | it "accepts a coerce value" do 28 | returns = Strict::Return.make(coerce: ->(value) { value + 1 }) 29 | 30 | assert returns.coercer 31 | end 32 | 33 | it "does not accept a value for 'default'" do 34 | assert_raises(ArgumentError) do 35 | Strict::Return.make(default: 1) 36 | end 37 | end 38 | 39 | it "does not accept a value for 'default_value'" do 40 | assert_raises(ArgumentError) do 41 | Strict::Return.make(default_value: 1) 42 | end 43 | end 44 | 45 | it "does not accept a value for 'default_generator'" do 46 | assert_raises(ArgumentError) do 47 | Strict::Return.make(default_generator: -> { 1 }) 48 | end 49 | end 50 | end 51 | 52 | describe "#valid?" do 53 | it "uses the validator to check if the value is valid" do 54 | returns = Strict::Return.make(Strict::Validators::Boolean.instance) 55 | 56 | assert returns.valid?(true) 57 | assert returns.valid?(false) 58 | refute returns.valid?(nil) 59 | refute returns.valid?(1) 60 | 61 | returns = Strict::Return.make(Strict::Validators::AnyOf.new(Strict::Validators::Boolean.instance, nil)) 62 | 63 | assert returns.valid?(true) 64 | assert returns.valid?(false) 65 | assert returns.valid?(nil) 66 | refute returns.valid?(1) 67 | end 68 | 69 | it "does not call the validator if sampling indicates not to" do 70 | validator = Class.new do 71 | attr_accessor :called 72 | 73 | def initialize 74 | @called = false 75 | end 76 | 77 | def ===(value) 78 | self.called = true 79 | Strict::Validators::Boolean.instance === value 80 | end 81 | end.new 82 | returns = Strict::Return.make(validator) 83 | 84 | refute validator.called 85 | Strict.with_overrides(sample_rate: 0) do 86 | assert returns.valid?(true) 87 | refute validator.called 88 | assert returns.valid?(false) 89 | refute validator.called 90 | assert returns.valid?(nil) 91 | refute validator.called 92 | assert returns.valid?(1) 93 | refute validator.called 94 | end 95 | 96 | Strict.with_overrides(sample_rate: 1) do 97 | refute returns.valid?(nil) 98 | assert validator.called 99 | end 100 | end 101 | end 102 | 103 | describe "#coerce" do 104 | it "returns the value if coercion is not enabled" do 105 | returns = Strict::Return.make(coerce: false) 106 | 107 | assert_equal "value", returns.coerce("value") 108 | end 109 | 110 | it "does not support .coerce_attr_name coercion" do 111 | returns = Strict::Return.make(coerce: true) 112 | 113 | assert_raises(NoMethodError) do 114 | returns.coerce("value") 115 | end 116 | end 117 | 118 | it "does not support coercion methods is passed" do 119 | returns = Strict::Return.make(coerce: :some_method) 120 | 121 | assert_raises(NoMethodError) do 122 | returns.coerce("value") 123 | end 124 | end 125 | 126 | it "calls the callable if one is passed" do 127 | returns = Strict::Return.make(coerce: ->(value) { "coerced #{value}" }) 128 | 129 | assert_equal "coerced value", returns.coerce("value") 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/strict/validators/all_of_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::AllOf do 6 | describe "#===" do 7 | before do 8 | @value = 1 9 | @validates = Module.new do 10 | def self.===(value) 11 | value == 1 12 | end 13 | end 14 | @invalidates = Module.new do 15 | def self.===(value) 16 | value == 2 17 | end 18 | end 19 | end 20 | 21 | it "validates when all subvalidators validate" do 22 | all_of = Strict::Validators::AllOf.new(@validates, @validates, @validates) 23 | 24 | assert_operator all_of, :===, @value 25 | end 26 | 27 | it "does not validate when some subvalidators validate" do 28 | all_of = Strict::Validators::AllOf.new(@validates, @invalidates, @validates) 29 | 30 | refute_operator all_of, :===, @value 31 | end 32 | 33 | it "does not validate when no subvalidators validate" do 34 | all_of = Strict::Validators::AllOf.new(@invalidates, @invalidates, @invalidates) 35 | 36 | refute_operator all_of, :===, @value 37 | end 38 | end 39 | 40 | describe "#to_s" do 41 | it "is meaningful" do 42 | all_of = Strict::Validators::AllOf.new(1, "2", nil, Integer) 43 | 44 | assert_equal "AllOf(1, \"2\", nil, Integer)", all_of.to_s 45 | end 46 | end 47 | 48 | describe "#inspect" do 49 | it "is meaningful" do 50 | all_of = Strict::Validators::AllOf.new(1, "2", nil, Integer) 51 | 52 | assert_equal "AllOf(1, \"2\", nil, Integer)", all_of.inspect 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/strict/validators/any_of_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::AnyOf do 6 | describe "#===" do 7 | before do 8 | @value = 1 9 | @validates = Module.new do 10 | def self.===(value) 11 | value == 1 12 | end 13 | end 14 | @invalidates = Module.new do 15 | def self.===(value) 16 | value == 2 17 | end 18 | end 19 | end 20 | 21 | it "validates when all subvalidators validate" do 22 | any_of = Strict::Validators::AnyOf.new(@validates, @validates, @validates) 23 | 24 | assert_operator any_of, :===, @value 25 | end 26 | 27 | it "validates when some subvalidators validate" do 28 | any_of = Strict::Validators::AnyOf.new(@validates, @invalidates, @validates) 29 | 30 | assert_operator any_of, :===, @value 31 | end 32 | 33 | it "does not validate when no subvalidators validate" do 34 | any_of = Strict::Validators::AnyOf.new(@invalidates, @invalidates, @invalidates) 35 | 36 | refute_operator any_of, :===, @value 37 | end 38 | end 39 | 40 | describe "#to_s" do 41 | it "is meaningful" do 42 | any_of = Strict::Validators::AnyOf.new(1, "2", nil, Integer) 43 | 44 | assert_equal "AnyOf(1, \"2\", nil, Integer)", any_of.to_s 45 | end 46 | end 47 | 48 | describe "#inspect" do 49 | it "is meaningful" do 50 | any_of = Strict::Validators::AnyOf.new(1, "2", nil, Integer) 51 | 52 | assert_equal "AnyOf(1, \"2\", nil, Integer)", any_of.inspect 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/strict/validators/anything_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::Anything do 6 | describe "#===" do 7 | it "validates anything" do 8 | anything = Strict::Validators::Anything.instance 9 | 10 | assert_operator anything, :===, 1 11 | assert_operator anything, :===, true 12 | assert_operator anything, :===, {} 13 | assert_operator anything, :===, "something" 14 | assert_operator anything, :===, Strict 15 | end 16 | end 17 | 18 | describe "#to_s" do 19 | it "is meaningful" do 20 | anything = Strict::Validators::Anything.instance 21 | 22 | assert_equal "Anything()", anything.to_s 23 | end 24 | end 25 | 26 | describe "#inspect" do 27 | it "is meaningful" do 28 | anything = Strict::Validators::Anything.instance 29 | 30 | assert_equal "Anything()", anything.inspect 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/strict/validators/array_of_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::ArrayOf do 6 | describe "#===" do 7 | before do 8 | @array_of = Strict::Validators::ArrayOf.new(Integer) 9 | end 10 | 11 | it "validates arrays with all elements validateing the validator" do 12 | assert_operator @array_of, :===, [] 13 | assert_operator @array_of, :===, [1] 14 | assert_operator @array_of, :===, [1, 2] 15 | end 16 | 17 | it "does not validate arrays when elements do not validate the validator" do 18 | refute_operator @array_of, :===, [""] 19 | refute_operator @array_of, :===, [1, ""] 20 | refute_operator @array_of, :===, ["", 1] 21 | end 22 | 23 | it "does not validate objects which are not arrays" do 24 | refute_operator @array_of, :===, (0..10) 25 | end 26 | end 27 | 28 | describe "#to_s" do 29 | it "is meaningful" do 30 | array_of = Strict::Validators::ArrayOf.new("2") 31 | 32 | assert_equal "ArrayOf(\"2\")", array_of.to_s 33 | end 34 | end 35 | 36 | describe "#inspect" do 37 | it "is meaningful" do 38 | array_of = Strict::Validators::ArrayOf.new("2") 39 | 40 | assert_equal "ArrayOf(\"2\")", array_of.inspect 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/strict/validators/boolean_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::Boolean do 6 | describe "#===" do 7 | before do 8 | @boolean = Strict::Validators::Boolean.instance 9 | end 10 | 11 | it "validates true" do 12 | assert_operator @boolean, :===, true 13 | end 14 | 15 | it "validates false" do 16 | assert_operator @boolean, :===, false 17 | end 18 | 19 | it "does not validate objects that are not booleans" do 20 | refute_operator @boolean, :===, 1 21 | refute_operator @boolean, :===, "string" 22 | end 23 | end 24 | 25 | describe "#to_s" do 26 | it "is meaningful" do 27 | boolean = Strict::Validators::Boolean.instance 28 | 29 | assert_equal "Boolean()", boolean.to_s 30 | end 31 | end 32 | 33 | describe "#inspect" do 34 | it "is meaningful" do 35 | boolean = Strict::Validators::Boolean.instance 36 | 37 | assert_equal "Boolean()", boolean.inspect 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/strict/validators/hash_of_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::HashOf do 6 | describe "#===" do 7 | before do 8 | @hash_of = Strict::Validators::HashOf.new(Integer, String) 9 | end 10 | 11 | it "validates the entries (both key and value) of the hash" do 12 | assert_operator @hash_of, :===, {} 13 | assert_operator @hash_of, :===, { 1 => "one" } 14 | assert_operator @hash_of, :===, { 1 => "one", 2 => "two" } 15 | end 16 | 17 | it "does not validate when a key does not validate" do 18 | refute_operator @hash_of, :===, { "one" => "one" } 19 | refute_operator @hash_of, :===, { 1 => "one", "two" => "two" } 20 | end 21 | 22 | it "does not validate when a value does not validate" do 23 | refute_operator @hash_of, :===, { 1 => 1 } 24 | refute_operator @hash_of, :===, { 1 => "one", 2 => 2 } 25 | end 26 | 27 | it "does not validate objects that are not hashes" do 28 | refute_operator @hash_of, :===, [] 29 | refute_operator @hash_of, :===, [[1, "one"]] 30 | end 31 | end 32 | 33 | describe "#to_s" do 34 | it "is meaningful" do 35 | hash_of = Strict::Validators::HashOf.new("2", "3") 36 | 37 | assert_equal "HashOf(\"2\" => \"3\")", hash_of.to_s 38 | end 39 | end 40 | 41 | describe "#inspect" do 42 | it "is meaningful" do 43 | hash_of = Strict::Validators::HashOf.new("2", "3") 44 | 45 | assert_equal "HashOf(\"2\" => \"3\")", hash_of.inspect 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/strict/validators/range_of_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict::Validators::RangeOf do 6 | describe "#===" do 7 | before do 8 | @range_of = Strict::Validators::RangeOf.new(Integer) 9 | end 10 | 11 | it "validates ranges with elements that validate the element validator" do 12 | assert_operator @range_of, :===, (0..10) 13 | end 14 | 15 | it "validates endless ranges with elements that validate the element validator" do 16 | assert_operator @range_of, :===, (0..) 17 | end 18 | 19 | it "validates beginless ranges with elements that validate the element validator" do 20 | assert_operator @range_of, :===, (..10) 21 | end 22 | 23 | it "does not validate ranges where the beginning does not validate" do 24 | refute_operator @range_of, :===, (0.0..10) 25 | end 26 | 27 | it "does not validate ranges where the end does not validate" do 28 | refute_operator @range_of, :===, (0..10.0) 29 | end 30 | 31 | it "does not validate ranges with elements that do not validate the element validator" do 32 | refute_operator @range_of, :===, ("a".."d") 33 | end 34 | 35 | it "does not validate endless ranges with elements that do not validate the element validator" do 36 | refute_operator @range_of, :===, ("a"..) 37 | end 38 | 39 | it "does not validate beginless ranges with elements that do not validate the element validator" do 40 | refute_operator @range_of, :===, (.."d") 41 | end 42 | 43 | it "does not validate objects that are not ranges" do 44 | refute_operator @range_of, :===, [0, 1, 2, 3, 4] 45 | end 46 | end 47 | 48 | describe "#to_s" do 49 | it "is meaningful" do 50 | range_of = Strict::Validators::RangeOf.new("2") 51 | 52 | assert_equal "RangeOf(\"2\")", range_of.to_s 53 | end 54 | end 55 | 56 | describe "#inspect" do 57 | it "is meaningful" do 58 | range_of = Strict::Validators::RangeOf.new("2") 59 | 60 | assert_equal "RangeOf(\"2\")", range_of.inspect 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/strict/value_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ValueClass 6 | include Strict::Value 7 | 8 | attributes do 9 | foo Integer 10 | bar String, coerce: :some_coercer 11 | baz String, default: "some string" 12 | end 13 | 14 | def self.some_coercer(value) 15 | value.to_s 16 | end 17 | end 18 | 19 | describe Strict::Value do 20 | before do 21 | @other_value_class = Class.new do 22 | include Strict::Value 23 | 24 | attributes do 25 | foo Integer 26 | bar String, coerce: :some_coercer 27 | baz String, default: "some string" 28 | end 29 | 30 | def self.some_coercer(value) 31 | value.to_s 32 | end 33 | end 34 | end 35 | 36 | it "exposes the configuration on the class" do 37 | assert_instance_of Strict::Attributes::Configuration, ValueClass.strict_attributes 38 | assert_equal %i[foo bar baz], ValueClass.strict_attributes.map(&:name) 39 | end 40 | 41 | it "does not expose writer methods" do 42 | instance = ValueClass.new(foo: 1, bar: "2", baz: "3") 43 | 44 | assert_raises(NoMethodError) do 45 | instance.foo = 1 46 | end 47 | end 48 | 49 | it "exposes reader methods" do 50 | instance = ValueClass.new(foo: 1, bar: "2", baz: "3") 51 | 52 | assert_equal 1, instance.foo 53 | end 54 | 55 | it "does not allow invalid arguments" do 56 | error = assert_raises(Strict::InitializationError) do 57 | ValueClass.new(foo: "1", bar: "2", baz: "3") 58 | end 59 | 60 | assert_match(/foo/, error.message) 61 | end 62 | 63 | it "coerces arguments that can be coerced" do 64 | instance = ValueClass.new(foo: 1, bar: 2, baz: "3") 65 | 66 | assert_equal "2", instance.bar 67 | end 68 | 69 | it "does not require optional attributes" do 70 | instance = ValueClass.new(foo: 1, bar: "2") 71 | 72 | assert_equal "some string", instance.baz 73 | end 74 | 75 | it "requires mandatory attributes" do 76 | error = assert_raises(Strict::InitializationError) do 77 | ValueClass.new(foo: 1, baz: "3") 78 | end 79 | 80 | assert_match(/bar/, error.message) 81 | end 82 | 83 | it "does not allow additional attributes" do 84 | error = assert_raises(Strict::InitializationError) do 85 | ValueClass.new(foo: 1, bar: "2", baz: "3", bat: "uh oh") 86 | end 87 | 88 | assert_match(/bat/, error.message) 89 | end 90 | 91 | it "aggregates errors" do 92 | error = assert_raises(Strict::InitializationError) do 93 | ValueClass.new(foo: "1", baz: "3", bat: "uh oh") 94 | end 95 | 96 | assert_match(/foo/, error.message) 97 | assert_match(/bar/, error.message) 98 | assert_match(/bat/, error.message) 99 | end 100 | 101 | it "implements equality" do 102 | value_instance_one = ValueClass.new(foo: 1, bar: "2", baz: "3") 103 | value_instance_two = ValueClass.new(foo: 1, bar: "2", baz: "3") 104 | value_instance_three = ValueClass.new(foo: 1, bar: "2", baz: "4") 105 | other_value_instance_one = @other_value_class.new(foo: 1, bar: "2", baz: "3") 106 | 107 | assert_equal value_instance_one, value_instance_one # rubocop:disable Minitest/UselessAssertion 108 | assert_equal value_instance_one, value_instance_two 109 | assert_equal value_instance_two, value_instance_one 110 | refute_equal value_instance_three, value_instance_one 111 | refute_equal value_instance_one, value_instance_three 112 | refute_equal value_instance_one, other_value_instance_one 113 | end 114 | 115 | it "is hashable" do 116 | value_instance_one = ValueClass.new(foo: 1, bar: "2", baz: "3") 117 | value_instance_two = ValueClass.new(foo: 1, bar: "2", baz: "3") 118 | value_instance_three = ValueClass.new(foo: 1, bar: "2", baz: "4") 119 | other_value_instance_one = @other_value_class.new(foo: 1, bar: "2", baz: "3") 120 | 121 | hash = {} 122 | hash[value_instance_one] = 1 123 | hash[value_instance_two] = 2 124 | hash[value_instance_three] = 3 125 | hash[other_value_instance_one] = 4 126 | 127 | assert_equal 2, hash[value_instance_one] 128 | assert_equal 2, hash[value_instance_two] 129 | assert_equal 3, hash[value_instance_three] 130 | assert_equal 4, hash[other_value_instance_one] 131 | end 132 | 133 | it "is clonable" do 134 | instance = ValueClass.new(foo: 1, bar: "2", baz: "3") 135 | cloned = instance.with(foo: 2) 136 | 137 | assert_equal 1, instance.foo 138 | assert_equal 2, cloned.foo 139 | assert_equal "2", cloned.bar 140 | assert_equal "3", cloned.baz 141 | 142 | assert_raises(Strict::InitializationError) do 143 | instance.with(foo: "1") 144 | end 145 | end 146 | 147 | it "turns into a hash of attributes" do 148 | instance = ValueClass.new(foo: 1, bar: "2", baz: "3") 149 | 150 | assert_equal({ foo: 1, bar: "2", baz: "3" }, instance.to_h) 151 | end 152 | 153 | it "can be inspected" do 154 | instance = ValueClass.new(foo: 1, bar: "2", baz: "3") 155 | 156 | assert_equal "#", instance.inspect 157 | end 158 | 159 | it "can be pretty printed" do 160 | instance = ValueClass.new(foo: 1, bar: "2", baz: "3") 161 | output = StringIO.new 162 | PP.pp(instance, output, 5) 163 | 164 | assert_equal <<~OUTPUT, output.string 165 | # 169 | OUTPUT 170 | end 171 | 172 | it "exposes a coercer" do 173 | instance = ValueClass.coercer.call(foo: 1, bar: "2", baz: "3") 174 | 175 | assert_instance_of ValueClass, instance 176 | assert_equal 1, instance.foo 177 | assert_equal "2", instance.bar 178 | assert_equal "3", instance.baz 179 | 180 | instance = ValueClass.coercer.call("1") 181 | 182 | assert_equal "1", instance 183 | 184 | assert_raises(Strict::InitializationError) do 185 | ValueClass.coercer.call({}) 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /test/strict_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | describe Strict do 6 | it "has a version number" do 7 | refute_nil Strict::VERSION 8 | end 9 | 10 | it "can be configured" do 11 | refute_nil Strict.configuration 12 | original_random = Strict.configuration.random 13 | 14 | Strict.configure do |c| 15 | c.random = Random.new 16 | end 17 | 18 | refute_equal original_random, Strict.configuration.random 19 | 20 | assert_equal 1, Strict.configuration.sample_rate 21 | Strict.with_overrides(sample_rate: 0) do 22 | current_random = Strict.configuration.random 23 | error = assert_raises(Strict::Error) do 24 | Strict.configure do |c| 25 | c.random = Random.new 26 | end 27 | end 28 | 29 | assert_equal current_random, Strict.configuration.random 30 | assert_match(/cannot reconfigure overridden configuration/, error.message) 31 | 32 | assert_equal 0, Strict.configuration.sample_rate 33 | Strict.with_overrides(sample_rate: 0.5) do 34 | assert_in_delta 0.5, Strict.configuration.sample_rate 35 | end 36 | assert_equal 0, Strict.configuration.sample_rate 37 | end 38 | assert_equal 1, Strict.configuration.sample_rate 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "strict" 5 | 6 | require "minitest/autorun" 7 | require "minitest-spec-context" 8 | require "debug" 9 | --------------------------------------------------------------------------------