├── .codeclimate.yml ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ └── FEATURE_REQUEST.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── versionci.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── .tool-versions ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── decanter.gemspec ├── lib ├── decanter.rb ├── decanter │ ├── base.rb │ ├── collection_detection.rb │ ├── configuration.rb │ ├── core.rb │ ├── exceptions.rb │ ├── extensions.rb │ ├── parser.rb │ ├── parser │ │ ├── array_parser.rb │ │ ├── base.rb │ │ ├── boolean_parser.rb │ │ ├── compose_parser.rb │ │ ├── core.rb │ │ ├── date_parser.rb │ │ ├── datetime_parser.rb │ │ ├── float_parser.rb │ │ ├── hash_parser.rb │ │ ├── integer_parser.rb │ │ ├── pass_parser.rb │ │ ├── phone_parser.rb │ │ ├── string_parser.rb │ │ ├── utils.rb │ │ └── value_parser.rb │ ├── railtie.rb │ └── version.rb └── generators │ ├── decanter │ ├── install_generator.rb │ └── templates │ │ └── initializer.rb │ └── rails │ ├── decanter_generator.rb │ ├── parser_generator.rb │ ├── resource_override.rb │ └── templates │ ├── decanter.rb.erb │ └── parser.rb.erb ├── migration-guides ├── v3.0.0.md ├── v4.0.0.md └── v5.0.0.md └── spec ├── decanter ├── decanter_collection_detection_spec.rb ├── decanter_core_spec.rb ├── decanter_extensions_spec.rb ├── decanter_spec.rb └── parser │ ├── array_parser_spec.rb │ ├── boolean_parser_spec.rb │ ├── date_parser_spec.rb │ ├── datetime_parser_spec.rb │ ├── float_parser_spec.rb │ ├── hash_parser_spec.rb │ ├── integer_parser_spec.rb │ ├── parser_spec.rb │ ├── pass_parser_spec.rb │ ├── phone_parser_spec.rb │ ├── string_parser_spec.rb │ └── value_parser_spec.rb └── spec_helper.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | rubocop: 3 | enabled: true 4 | checks: 5 | Rubocop/Style/Documentation: 6 | enabled: false 7 | Rubocop/Metrics/LineLength: 8 | enabled: false 9 | Rubocop/Rails/Validation: 10 | enabled: false 11 | Rubocop/Style/IndentationConsistency: 12 | enabled: false 13 | Rubocop/Style/EmptyLines: 14 | enabled: false 15 | Rubocop/Style/ClassAndModuleChildren: 16 | enabled: false 17 | Rubocop/Style/AccessorMethodName: 18 | enabled: false 19 | golint: 20 | enabled: true 21 | gofmt: 22 | enabled: true 23 | eslint: 24 | enabled: true 25 | csslint: 26 | enabled: true 27 | brakeman: 28 | enabled: false 29 | bundler-audit: 30 | enabled: true 31 | ratings: 32 | paths: 33 | - lib/** 34 | - "**.rb" 35 | exclude_paths: 36 | - bin/**/* 37 | - spec/**/* 38 | - coverage/**/* 39 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | 3 | - @nicoledow 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug with Decanter 4 | labels: bug 5 | --- 6 | 7 | 12 | 13 | ## Environment 14 | 15 | - `decanter` version: 16 | - `ruby` version: 17 | - `rails` version: 18 | 19 | ## Expected Behavior 20 | 21 | ## Current Behavior 22 | 23 | ## Steps to Reproduce 24 | 28 | 29 | ## Suggested Solution 30 | 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a feature for Decanter 4 | labels: enhancement 5 | --- 6 | 7 | ## Feature Description 8 | 9 | ## Suggested Solution 10 | 14 | 15 | ## Alternatives Considered / Existing Workarounds 16 | 17 | ## Additional Context 18 | 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Items Addressed 2 | 3 | 4 | ## Author Checklist 5 | - [ ] Add unit test(s) 6 | - [ ] Update documentation (if necessary) 7 | - [ ] Update version in `version.rb` following [versioning guidelines](https://github.com/LaunchPadLab/opex-public/blob/master/gists/gem-guidelines.md#pull-requests-and-deployments) 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/versionci.yml: -------------------------------------------------------------------------------- 1 | name: Version CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: ["3.2.0", "3.2.7", "3.3.0", "3.3.7"] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: ${{ matrix.ruby }} 19 | bundler-cache: true 20 | - run: bundle exec rspec 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | .env 3 | .DS_Store 4 | # Builds for push 5 | decanter-*.gem -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.5 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | cache: 4 | bundler: true 5 | 6 | script: 7 | - bundle exec rspec 8 | 9 | notifications: 10 | email: false 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [conor@launchpadlab.com](mailto:conor@launchpadlab.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing! This project follows our [Gem guidelines](https://github.com/LaunchPadLab/opex-public/blob/master/gists/gem-guidelines.md). 4 | 5 | ## Code of Conduct 6 | 7 | We expect all participants to adhere to our [Code of Conduct](CODE_OF_CONDUCT.md). 8 | 9 | ## How Can I Contribute? 10 | 11 | There are several ways to contribute to this project, including (but not limited to): 12 | 13 | - Reporting bugs 14 | - Requesting features 15 | - Responding to bug reports / feature requests 16 | - Submitting PR(s) that address issues labeled with "help wanted" 17 | - Updating documentation 18 | 19 | Before making a contribution, search through the existing [issues](https://github.com/LaunchPadLab/decanter/issues) and [pull requests](https://github.com/LaunchPadLab/decanter/pulls) to see if your item has already been raised or addressed. If there is an existing **open** issue that impacts you, it is best practice to upvote (👍) the initial comment as opposed to adding an additional comment voicing your support. The latter creates noise for the LaunchPad Lab team and makes it difficult for us to quickly and effectively rank issues according to the community's priority. 20 | 21 | ## Getting Started Locally 22 | 1. Fork and clone the repo 23 | 1. Run `bundle` to install dependencies 24 | 1. Run `rspec` to run unit tests 25 | 1. Load development changes in a local application in your Gemfile 26 | ```ruby 27 | gem "decanter", path: "/path/to/repo" 28 | ``` 29 | 30 | ## Proposing a Change 31 | For any non-trivial change, we prefer an issue to be created first. This helps us (and you!) save time and keep track of conversations and decisions made regarding the project. 32 | 33 | ### Sending a Pull Request 34 | If this is your first Pull Request, we recommend learning how to navigate GitHub via this free video tutorial: [How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 35 | 36 | The LaunchPad Lab team monitors this repository for pull requests. Once a pull request has been created from a forked repository that **meets all the guidelines** outlined in the [Pull Request Checklist](.github/PULL_REQUEST_TEMPLATE.md), we will review the request and either merge it, request changes, or close it with an explanation. We aim to respond to pull requests within 48 hours. 37 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in decanter.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | decanter (5.0.0) 5 | actionpack (>= 7.1.3.2) 6 | activesupport 7 | rails (>= 7.1.3.2) 8 | rails-html-sanitizer (>= 1.0.4) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (7.1.3.2) 14 | actionpack (= 7.1.3.2) 15 | activesupport (= 7.1.3.2) 16 | nio4r (~> 2.0) 17 | websocket-driver (>= 0.6.1) 18 | zeitwerk (~> 2.6) 19 | actionmailbox (7.1.3.2) 20 | actionpack (= 7.1.3.2) 21 | activejob (= 7.1.3.2) 22 | activerecord (= 7.1.3.2) 23 | activestorage (= 7.1.3.2) 24 | activesupport (= 7.1.3.2) 25 | mail (>= 2.7.1) 26 | net-imap 27 | net-pop 28 | net-smtp 29 | actionmailer (7.1.3.2) 30 | actionpack (= 7.1.3.2) 31 | actionview (= 7.1.3.2) 32 | activejob (= 7.1.3.2) 33 | activesupport (= 7.1.3.2) 34 | mail (~> 2.5, >= 2.5.4) 35 | net-imap 36 | net-pop 37 | net-smtp 38 | rails-dom-testing (~> 2.2) 39 | actionpack (7.1.3.2) 40 | actionview (= 7.1.3.2) 41 | activesupport (= 7.1.3.2) 42 | nokogiri (>= 1.8.5) 43 | racc 44 | rack (>= 2.2.4) 45 | rack-session (>= 1.0.1) 46 | rack-test (>= 0.6.3) 47 | rails-dom-testing (~> 2.2) 48 | rails-html-sanitizer (~> 1.6) 49 | actiontext (7.1.3.2) 50 | actionpack (= 7.1.3.2) 51 | activerecord (= 7.1.3.2) 52 | activestorage (= 7.1.3.2) 53 | activesupport (= 7.1.3.2) 54 | globalid (>= 0.6.0) 55 | nokogiri (>= 1.8.5) 56 | actionview (7.1.3.2) 57 | activesupport (= 7.1.3.2) 58 | builder (~> 3.1) 59 | erubi (~> 1.11) 60 | rails-dom-testing (~> 2.2) 61 | rails-html-sanitizer (~> 1.6) 62 | activejob (7.1.3.2) 63 | activesupport (= 7.1.3.2) 64 | globalid (>= 0.3.6) 65 | activemodel (7.1.3.2) 66 | activesupport (= 7.1.3.2) 67 | activerecord (7.1.3.2) 68 | activemodel (= 7.1.3.2) 69 | activesupport (= 7.1.3.2) 70 | timeout (>= 0.4.0) 71 | activestorage (7.1.3.2) 72 | actionpack (= 7.1.3.2) 73 | activejob (= 7.1.3.2) 74 | activerecord (= 7.1.3.2) 75 | activesupport (= 7.1.3.2) 76 | marcel (~> 1.0) 77 | activesupport (7.1.3.2) 78 | base64 79 | bigdecimal 80 | concurrent-ruby (~> 1.0, >= 1.0.2) 81 | connection_pool (>= 2.2.5) 82 | drb 83 | i18n (>= 1.6, < 2) 84 | minitest (>= 5.1) 85 | mutex_m 86 | tzinfo (~> 2.0) 87 | base64 (0.2.0) 88 | bigdecimal (3.1.7) 89 | builder (3.2.4) 90 | concurrent-ruby (1.2.3) 91 | connection_pool (2.4.1) 92 | crass (1.0.6) 93 | date (3.4.1) 94 | diff-lcs (1.5.1) 95 | docile (1.1.5) 96 | dotenv (3.1.1) 97 | drb (2.2.1) 98 | erubi (1.12.0) 99 | globalid (1.2.1) 100 | activesupport (>= 6.1) 101 | i18n (1.14.4) 102 | concurrent-ruby (~> 1.0) 103 | io-console (0.7.2) 104 | irb (1.13.0) 105 | rdoc (>= 4.0.0) 106 | reline (>= 0.4.2) 107 | json (2.7.2) 108 | loofah (2.22.0) 109 | crass (~> 1.0.2) 110 | nokogiri (>= 1.12.0) 111 | mail (2.8.1) 112 | mini_mime (>= 0.1.1) 113 | net-imap 114 | net-pop 115 | net-smtp 116 | marcel (1.0.4) 117 | mini_mime (1.1.5) 118 | minitest (5.22.3) 119 | mutex_m (0.2.0) 120 | net-imap (0.5.6) 121 | date 122 | net-protocol 123 | net-pop (0.1.2) 124 | net-protocol 125 | net-protocol (0.2.2) 126 | timeout 127 | net-smtp (0.5.1) 128 | net-protocol 129 | nio4r (2.7.4) 130 | nokogiri (1.16.4-arm64-darwin) 131 | racc (~> 1.4) 132 | nokogiri (1.16.4-x86_64-linux) 133 | racc (~> 1.4) 134 | psych (5.1.2) 135 | stringio 136 | racc (1.7.3) 137 | rack (3.0.10) 138 | rack-session (2.0.0) 139 | rack (>= 3.0.0) 140 | rack-test (2.1.0) 141 | rack (>= 1.3) 142 | rackup (2.1.0) 143 | rack (>= 3) 144 | webrick (~> 1.8) 145 | rails (7.1.3.2) 146 | actioncable (= 7.1.3.2) 147 | actionmailbox (= 7.1.3.2) 148 | actionmailer (= 7.1.3.2) 149 | actionpack (= 7.1.3.2) 150 | actiontext (= 7.1.3.2) 151 | actionview (= 7.1.3.2) 152 | activejob (= 7.1.3.2) 153 | activemodel (= 7.1.3.2) 154 | activerecord (= 7.1.3.2) 155 | activestorage (= 7.1.3.2) 156 | activesupport (= 7.1.3.2) 157 | bundler (>= 1.15.0) 158 | railties (= 7.1.3.2) 159 | rails-dom-testing (2.2.0) 160 | activesupport (>= 5.0.0) 161 | minitest 162 | nokogiri (>= 1.6) 163 | rails-html-sanitizer (1.6.0) 164 | loofah (~> 2.21) 165 | nokogiri (~> 1.14) 166 | railties (7.1.3.2) 167 | actionpack (= 7.1.3.2) 168 | activesupport (= 7.1.3.2) 169 | irb 170 | rackup (>= 1.0.0) 171 | rake (>= 12.2) 172 | thor (~> 1.0, >= 1.2.2) 173 | zeitwerk (~> 2.6) 174 | rake (12.3.3) 175 | rdoc (6.6.3.1) 176 | psych (>= 4.0.0) 177 | reline (0.5.5) 178 | io-console (~> 0.5) 179 | rspec-core (3.9.3) 180 | rspec-support (~> 3.9.3) 181 | rspec-expectations (3.9.4) 182 | diff-lcs (>= 1.2.0, < 2.0) 183 | rspec-support (~> 3.9.0) 184 | rspec-mocks (3.9.1) 185 | diff-lcs (>= 1.2.0, < 2.0) 186 | rspec-support (~> 3.9.0) 187 | rspec-rails (3.9.1) 188 | actionpack (>= 3.0) 189 | activesupport (>= 3.0) 190 | railties (>= 3.0) 191 | rspec-core (~> 3.9.0) 192 | rspec-expectations (~> 3.9.0) 193 | rspec-mocks (~> 3.9.0) 194 | rspec-support (~> 3.9.0) 195 | rspec-support (3.9.4) 196 | simplecov (0.15.1) 197 | docile (~> 1.1.0) 198 | json (>= 1.8, < 3) 199 | simplecov-html (~> 0.10.0) 200 | simplecov-html (0.10.2) 201 | stringio (3.1.0) 202 | thor (1.3.1) 203 | timeout (0.4.3) 204 | tzinfo (2.0.6) 205 | concurrent-ruby (~> 1.0) 206 | webrick (1.8.1) 207 | websocket-driver (0.7.7) 208 | base64 209 | websocket-extensions (>= 0.1.0) 210 | websocket-extensions (0.1.5) 211 | zeitwerk (2.6.13) 212 | 213 | PLATFORMS 214 | arm64-darwin-22 215 | arm64-darwin-23 216 | x86_64-linux 217 | 218 | DEPENDENCIES 219 | bundler (~> 2.4.22) 220 | decanter! 221 | dotenv 222 | rake (~> 12.0) 223 | rspec-rails (~> 3.9) 224 | simplecov (~> 0.15.1) 225 | 226 | BUNDLED WITH 227 | 2.4.22 228 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 TODO: Write your name 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Decanter 2 | 3 | Decanter is a Ruby gem that makes it easy to transform incoming data before it hits the model. You can think of Decanter as the opposite of Active Model Serializers (AMS). While AMS transforms your outbound data into a format that your frontend consumes, Decanter transforms your incoming data into a format that your backend consumes. 4 | 5 | ```ruby 6 | gem 'decanter', '~> 5.0' 7 | ``` 8 | 9 | ## Migration Guides 10 | 11 | - [v3.0.0](migration-guides/v3.0.0.md) 12 | 13 | ## Contents 14 | 15 | - [Basic Usage](#basic-usage) 16 | - [Decanters](#decanters) 17 | - [Generators](#generators) 18 | - [Decanting Collections](#decanting-collections) 19 | - [Nested resources](#nested-resources) 20 | - [Default parsers](#default-parsers) 21 | - [Parser options](#parser-options) 22 | - [Exceptions](#exceptions) 23 | - [Advanced usage](#advanced-usage) 24 | - [Custom parsers](#custom-parsers) 25 | - [Squashing inputs](#squashing-inputs) 26 | - [Chaining parsers](#chaining-parsers) 27 | - [Requiring params](#requiring-params) 28 | - [Global configuration](#global-configuration) 29 | - [Contributing](#contributing) 30 | 31 | ## Basic Usage 32 | 33 | ### Decanters 34 | 35 | Declare a `Decanter` for a model: 36 | 37 | ```ruby 38 | # app/decanters/trip_decanter.rb 39 | 40 | class TripDecanter < Decanter::Base 41 | input :name, :string 42 | input :start_date, :date 43 | input :end_date, :date 44 | end 45 | ``` 46 | 47 | Then, transform incoming params in your controller using `Decanter#decant`: 48 | 49 | ```rb 50 | # app/controllers/trips_controller.rb 51 | 52 | def create 53 | trip_params = params.require(:trip) # or params[:trip] if you are not using Strong Parameters 54 | decanted_trip_params = TripDecanter.decant(trip_params) 55 | @trip = Trip.new(decanted_trip_params) 56 | 57 | # ...any response logic 58 | end 59 | 60 | ``` 61 | 62 | ### Generators 63 | 64 | Decanter comes with custom generators for creating `Decanter` and `Parser` files: 65 | 66 | #### Decanters 67 | 68 | ``` 69 | rails g decanter Trip name:string start_date:date end_date:date 70 | 71 | # Creates app/decanters/trip_decanter.rb: 72 | class TripDecanter < Decanter::Base 73 | input :name, :string 74 | input :start_date, :date 75 | input :end_date, :date 76 | end 77 | ``` 78 | 79 | #### Parsers 80 | 81 | ``` 82 | rails g parser TruncatedString 83 | 84 | # Creates lib/decanter/parsers/truncated_string_parser.rb: 85 | class TruncatedStringParser < Decanter::Parser::ValueParser 86 | parser do |value, options| 87 | value 88 | end 89 | end 90 | ``` 91 | 92 | [Learn more about using custom parsers](#custom-parsers) 93 | 94 | #### Resources 95 | 96 | When using the Rails resource generator in a project that includes Decanter, a decanter will be automatically created for the new resource: 97 | 98 | ``` 99 | rails g resource Trip name:string start_date:date end_date:date 100 | 101 | # Creates app/decanters/trip_decanter.rb: 102 | class TripDecanter < Decanter::Base 103 | input :name, :string 104 | input :start_date, :date 105 | input :end_date, :date 106 | end 107 | ``` 108 | 109 | ### Decanting Collections 110 | 111 | Decanter can decant a collection of a resource, applying the patterns used in the [fast JSON API gem](https://github.com/Netflix/fast_jsonapi#collection-serialization): 112 | 113 | ```rb 114 | # app/controllers/trips_controller.rb 115 | 116 | def create 117 | trip_params = { 118 | trips: [ 119 | { name: 'Disney World', start_date: '12/24/2018', end_date: '12/28/2018' }, 120 | { name: 'Yosemite', start_date: '5/1/2017', end_date: '5/4/2017' } 121 | ] 122 | } 123 | decanted_trip_params = TripDecanter.decant(trip_params[:trips]) 124 | Trip.create(decanted_trip_params) # bulk create trips with decanted params 125 | end 126 | ``` 127 | 128 | #### Control Over Decanting Collections 129 | 130 | You can use the `is_collection` option for explicit control over decanting collections. 131 | 132 | `decanted_trip_params = TripDecanter.decant(trip_params[:trips], is_collection: true)` 133 | 134 | If this option is not provided, autodetect logic is used to determine if the providing incoming params holds a single object or collection of objects. 135 | 136 | - `nil` or not provided: will try to autodetect single vs collection 137 | - `true` will always treat the incoming params args as _collection_ 138 | - `false` will always treat incoming params args as _single object_ 139 | - `truthy` will raise an error 140 | 141 | ### Nested resources 142 | 143 | Decanters can declare relationships using `ActiveRecord`-style declarators: 144 | 145 | ```ruby 146 | class TripDecanter < Decanter::Base 147 | has_many :destinations 148 | end 149 | ``` 150 | 151 | This decanter will look up and apply the corresponding `DestinationDecanter` whenever necessary to transform nested resources. 152 | 153 | ### Default parsers 154 | 155 | Decanter comes with the following parsers out of the box: 156 | 157 | - `:boolean` 158 | - `:date` 159 | - `:date_time` 160 | - `:float` 161 | - `:integer` 162 | - `:pass` 163 | - `:phone` 164 | - `:string` 165 | - `:array` 166 | 167 | Note: these parsers are designed to operate on a single value, except for `:array`. This parser expects an array, and will use the `parse_each` option to call a given parser on each of its elements: 168 | 169 | ```ruby 170 | input :ids, :array, parse_each: :integer 171 | ``` 172 | 173 | ### Parser options 174 | 175 | Some parsers can receive options that modify their behavior. These options are passed in as named arguments to `input`: 176 | 177 | **Example:** 178 | 179 | ```ruby 180 | input :start_date, :date, parse_format: '%Y-%m-%d' 181 | ``` 182 | 183 | **Available Options:** 184 | | Parser | Option | Default | Notes 185 | | ----------- | ----------- | -----------| ----------- 186 | | `ArrayParser` | `parse_each`| N/A | Accepts a parser type, then uses that parser to parse each element in the array. If this option is not defined, each element is simply returned. 187 | | `DateParser`| `parse_format` | `'%m/%d/%Y'`| Accepts any format string accepted by Ruby's `strftime` method 188 | | `DateTimeParser` | `parse_format` | `'%m/%d/%Y %I:%M:%S %p'` | Accepts any format string accepted by Ruby's `strftime` method 189 | 190 | ### Exceptions 191 | 192 | By default, `Decanter#decant` will raise an exception when unexpected parameters are passed. To override this behavior, you can change the strict mode option to one of: 193 | 194 | - `true` (default): unhandled keys will raise an unexpected parameters exception 195 | - `false`: all parameter key-value pairs will be included in the result 196 | - `:ignore`: unhandled keys will be excluded from the decanted result 197 | 198 | ```ruby 199 | class TripDecanter < Decanter::Base 200 | strict false 201 | # ... 202 | end 203 | ``` 204 | 205 | Or explicitly ignore a key: 206 | 207 | ```rb 208 | class TripDecanter < Decanter::Base 209 | ignore :created_at, :updated_at 210 | # ... 211 | end 212 | ``` 213 | 214 | You can also disable strict mode globally using a [global configuration](#global-configuration) setting. 215 | 216 | ## Advanced Usage 217 | 218 | ### Custom Parsers 219 | 220 | To add a custom parser, first create a parser class: 221 | 222 | ```rb 223 | # app/parsers/truncated_string_parser.rb 224 | class TruncatedStringParser < Decanter::Parser::ValueParser 225 | 226 | parser do |value, options| 227 | length = options.fetch(:length, 100) 228 | value.truncate(length) 229 | end 230 | end 231 | ``` 232 | 233 | Then, use the appropriate key to look up the parser: 234 | 235 | ```ruby 236 | input :name, :truncated_string #=> TruncatedStringParser 237 | ``` 238 | 239 | #### Custom parser methods 240 | 241 | - `#parse `: (required) recieves a block for parsing a value. Block parameters are `|value, options|` for `ValueParser` and `|name, value, options|` for `HashParser`. 242 | - `#allow []`: skips parse step if the incoming value `is_a?` instance of class(es). 243 | - `#pre []`: applies the given parser(s) before parsing the value. 244 | 245 | #### Custom parser base classes 246 | 247 | - `Decanter::Parser::ValueParser`: subclasses are expected to return a single value. 248 | - `Decanter::Parser::HashParser`: subclasses are expected to return a hash of keys and values. 249 | 250 | ### Squashing inputs 251 | 252 | Sometimes, you may want to take several inputs and combine them into one finished input prior to sending to your model. You can achieve this with a custom parser: 253 | 254 | ```ruby 255 | class TripDecanter < Decanter::Base 256 | input [:day, :month, :year], :squash_date, key: :start_date 257 | end 258 | ``` 259 | 260 | ```ruby 261 | class SquashDateParser < Decanter::Parser::ValueParser 262 | parser do |values, options| 263 | day, month, year = values.map(&:to_i) 264 | Date.new(year, month, day) 265 | end 266 | end 267 | ``` 268 | 269 | ### Chaining parsers 270 | 271 | You can compose multiple parsers by using the `#pre` method: 272 | 273 | ```ruby 274 | class FloatPercentParser < Decanter::Parser::ValueParser 275 | 276 | pre :float 277 | 278 | parser do |val, options| 279 | val / 100 280 | end 281 | end 282 | ``` 283 | 284 | Or by declaring multiple parsers for a single input: 285 | 286 | ```ruby 287 | class SomeDecanter < Decanter::Base 288 | input :some_percent, [:float, :percent] 289 | end 290 | ``` 291 | 292 | ### Requiring params 293 | 294 | If you provide the option `:required` for an input in your decanter, an exception will be thrown if the parameter is `nil` or an empty string. 295 | 296 | ```ruby 297 | class TripDecanter < Decanter::Base 298 | input :name, :string, required: true 299 | end 300 | ``` 301 | 302 | _Note: we recommend using [Active Record validations](https://guides.rubyonrails.org/active_record_validations.html) to check for presence of an attribute, rather than using the `required` option. This method is intended for use in non-RESTful routes or cases where Active Record validations are not available._ 303 | 304 | ### Default values 305 | 306 | If you provide the option `:default_value` for an input in your decanter, the input key will be initialized with the given default value. Input keys not found in the incoming data parameters will be set to the provided default rather than ignoring the missing key. Note: `nil` and empty keys will not be overridden. 307 | 308 | ```ruby 309 | class TripDecanter < Decanter::Base 310 | input :name, :string 311 | input :destination, :string, default_value: 'Chicago' 312 | end 313 | 314 | ``` 315 | 316 | ``` 317 | TripDecanter.decant({ name: 'Vacation 2020' }) 318 | => { name: 'Vacation 2020', destination: 'Chicago' } 319 | 320 | ``` 321 | 322 | ### Global configuration 323 | 324 | You can generate a local copy of the default configuration with `rails generate decanter:install`. This will create an initializer where you can do global configuration: 325 | 326 | Setting strict mode to :ignore will log out any unhandled keys. To avoid excessive logging, the global configuration can be set to `log_unhandled_keys = false` 327 | 328 | ```ruby 329 | # ./config/initializers/decanter.rb 330 | 331 | Decanter.config do |config| 332 | config.strict = false 333 | config.log_unhandled_keys = false 334 | end 335 | ``` 336 | 337 | ## Contributing 338 | 339 | This project is maintained by developers at [LaunchPad Lab](https://launchpadlab.com/). Contributions of any kind are welcome! 340 | 341 | We aim to provide a response to incoming issues within 48 hours. However, please note that we are an active dev shop and these responses may be as simple as _"we do not have time to respond to this right now, but can address it at {x} time"_. 342 | 343 | For detailed information specific to contributing to this project, reference our [Contribution guide](CONTRIBUTING.md). 344 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "decanter" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /decanter.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'decanter/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'decanter' 7 | spec.version = Decanter::VERSION 8 | spec.authors = ['Ryan Francis', 'David Corwin'] 9 | spec.email = ['ryan@launchpadlab.com'] 10 | 11 | spec.summary = 'Form Parser for Rails' 12 | spec.description = 'Decanter aims to reduce complexity in Rails controllers by creating a place for transforming data before it hits the model and database.' 13 | spec.homepage = 'https://github.com/launchpadlab/decanter' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '>= 3.2.0' 16 | 17 | # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or 18 | # delete this section to allow pushing this gem to any host. 19 | raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.' unless spec.respond_to?(:metadata) 20 | 21 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 22 | 23 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 24 | spec.bindir = 'exe' 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ['lib'] 27 | 28 | spec.add_dependency 'rails', '>= 7.1.3.2' 29 | spec.add_dependency 'actionpack', '>= 7.1.3.2' 30 | spec.add_dependency 'activesupport' 31 | spec.add_dependency 'rails-html-sanitizer', '>= 1.0.4' 32 | 33 | spec.add_development_dependency 'bundler', '~> 2.4.22' 34 | spec.add_development_dependency 'dotenv' 35 | spec.add_development_dependency 'rake', '~> 12.0' 36 | spec.add_development_dependency 'rspec-rails', '~> 3.9' 37 | spec.add_development_dependency 'simplecov', '~> 0.15.1' 38 | end 39 | -------------------------------------------------------------------------------- /lib/decanter.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/all' 2 | 3 | module Decanter 4 | 5 | class << self 6 | 7 | def decanter_for(klass_or_sym) 8 | decanter_name = 9 | case klass_or_sym 10 | when Class 11 | klass_or_sym.name 12 | when Symbol 13 | klass_or_sym.to_s.singularize.camelize 14 | else 15 | raise ArgumentError.new("cannot lookup decanter for #{klass_or_sym} with class #{klass_or_sym.class}") 16 | end + 'Decanter' 17 | begin 18 | decanter_name.constantize 19 | rescue 20 | raise NameError.new("uninitialized constant #{decanter_name}") 21 | end 22 | end 23 | 24 | def decanter_from(klass_or_string) 25 | constant = 26 | case klass_or_string 27 | when Class 28 | klass_or_string 29 | when String 30 | begin 31 | klass_or_string.constantize 32 | rescue 33 | raise NameError.new("uninitialized constant #{klass_or_string}") 34 | end 35 | else 36 | raise ArgumentError.new("cannot find decanter from #{klass_or_string} with class #{klass_or_string.class}") 37 | end 38 | 39 | unless constant.ancestors.include? Decanter::Base 40 | raise ArgumentError.new("#{constant.name} is not a decanter") 41 | end 42 | 43 | constant 44 | end 45 | 46 | def configuration 47 | @config ||= Decanter::Configuration.new 48 | end 49 | 50 | def config 51 | yield configuration 52 | end 53 | end 54 | 55 | ActiveSupport.run_load_hooks(:decanter, self) 56 | end 57 | 58 | require 'decanter/version' 59 | require 'decanter/configuration' 60 | require 'decanter/base' 61 | require 'decanter/extensions' 62 | require 'decanter/exceptions' 63 | require 'decanter/parser' 64 | require 'decanter/railtie' if defined?(::Rails) 65 | -------------------------------------------------------------------------------- /lib/decanter/base.rb: -------------------------------------------------------------------------------- 1 | require 'decanter/core' 2 | require 'decanter/collection_detection' 3 | 4 | module Decanter 5 | class Base 6 | include Core 7 | include CollectionDetection 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/decanter/collection_detection.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module CollectionDetection 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | module ClassMethods 8 | def decant(args, **options) 9 | return super(args) unless collection?(args, options[:is_collection]) 10 | 11 | args.map { |resource| super(resource) } 12 | end 13 | 14 | private 15 | 16 | # leveraging the approach used in the [fast JSON API gem](https://github.com/Netflix/fast_jsonapi#collection-serialization) 17 | def collection?(args, collection_option = nil) 18 | raise(ArgumentError, "#{name}: Unknown collection option value: #{collection_option}") unless [true, false, nil].include? collection_option 19 | 20 | return collection_option unless collection_option.nil? 21 | 22 | args.respond_to?(:size) && !args.respond_to?(:each_pair) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/decanter/configuration.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | class Configuration 3 | 4 | attr_accessor :strict, :log_unhandled_keys 5 | 6 | def initialize 7 | @strict = true 8 | @log_unhandled_keys = true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/decanter/core.rb: -------------------------------------------------------------------------------- 1 | require 'action_controller' 2 | 3 | module Decanter 4 | module Core 5 | DEFAULT_VALUE_KEY = :default_value 6 | ACTION_CONTROLLER_PARAMETERS_CLASS_NAME = 'ActionController::Parameters' 7 | 8 | def self.included(base) 9 | base.extend(ClassMethods) 10 | end 11 | 12 | module ClassMethods 13 | def input(name, parsers = nil, **options) 14 | # Convert all input names to symbols to correctly calculate handled vs. unhandled keys 15 | input_names = [name].flatten.map(&:to_sym) 16 | 17 | if input_names.length > 1 && parsers.blank? 18 | raise ArgumentError, "#{self.name} no parser specified for input with multiple values." 19 | end 20 | 21 | handlers[input_names] = { 22 | key: options.fetch(:key, input_names.first), 23 | name: input_names, 24 | options:, 25 | parsers:, 26 | type: :input 27 | } 28 | end 29 | 30 | # Adjusting has_many to explicitly define keyword arguments 31 | def has_many(assoc, **options) 32 | handlers[assoc] = { 33 | assoc:, 34 | key: options.fetch(:key, assoc), 35 | name: assoc, 36 | options:, 37 | type: :has_many 38 | } 39 | end 40 | 41 | # Adjusting has_one similarly 42 | def has_one(assoc, **options) 43 | handlers[assoc] = { 44 | assoc:, 45 | key: options.fetch(:key, assoc), 46 | name: assoc, 47 | options:, 48 | type: :has_one 49 | } 50 | end 51 | 52 | def ignore(*args) 53 | keys_to_ignore.push(*args).map!(&:to_sym) 54 | end 55 | 56 | def strict(mode) 57 | raise(ArgumentError, "#{name}: Unknown strict value #{mode}") unless [:ignore, true, false].include? mode 58 | 59 | @strict_mode = mode 60 | end 61 | 62 | def log_unhandled_keys(mode) 63 | raise(ArgumentError, "#{name}: Unknown log_unhandled_keys value #{mode}") unless [true, 64 | false].include? mode 65 | 66 | @log_unhandled_keys_mode = mode 67 | end 68 | 69 | def decant(args) 70 | return handle_empty_args if args.blank? 71 | return empty_required_input_error unless required_input_keys_present?(args) 72 | 73 | # Convert all params passed to a decanter to a hash with indifferent access to mitigate accessor ambiguity 74 | accessible_args = to_indifferent_hash(args) 75 | {}.merge(default_keys) 76 | .merge(unhandled_keys(accessible_args)) 77 | .merge(handled_keys(accessible_args)) 78 | end 79 | 80 | def default_keys 81 | # return keys with provided default value when key is not defined within incoming args 82 | default_result = default_value_inputs 83 | .map { |input| [input[:key], input[:options][DEFAULT_VALUE_KEY]] } 84 | .to_h 85 | 86 | # parse handled default values, including keys 87 | # with defaults not already managed by handled_keys 88 | default_result.merge(handled_keys(default_result)) 89 | end 90 | 91 | def default_value_inputs 92 | handlers.values.select { |input| input[:options].key?(DEFAULT_VALUE_KEY) } 93 | end 94 | 95 | def handle_empty_args 96 | any_inputs_required? ? empty_args_error : {} 97 | end 98 | 99 | def any_inputs_required? 100 | required_inputs.any? 101 | end 102 | 103 | def required_inputs 104 | handlers.map do |h| 105 | options = h.last[:options] 106 | h.first.first if options && options[:required] 107 | end 108 | end 109 | 110 | def required_input_keys_present?(args = {}) 111 | return true unless any_inputs_required? 112 | 113 | compact_inputs = required_inputs.compact 114 | compact_inputs.all? do |input| 115 | args.keys.map(&:to_sym).include?(input) && !args[input].nil? 116 | end 117 | end 118 | 119 | def empty_required_input_error 120 | raise(MissingRequiredInputValue, 121 | 'Required inputs have been declared, but no values for those inputs were passed.') 122 | end 123 | 124 | def empty_args_error 125 | raise(ArgumentError, 'Decanter has required inputs but no values were passed') 126 | end 127 | 128 | # protected 129 | 130 | def unhandled_keys(args) 131 | unhandled_keys = args.keys.map(&:to_sym) - 132 | handlers.keys.flatten.uniq - 133 | keys_to_ignore - 134 | handlers.values 135 | .select { |handler| handler[:type] != :input } 136 | .map { |handler| "#{handler[:name]}_attributes".to_sym } 137 | 138 | return {} unless unhandled_keys.any? 139 | 140 | case strict_mode 141 | when :ignore 142 | p "#{name} ignoring unhandled keys: #{unhandled_keys.join(', ')}." if log_unhandled_keys_mode 143 | {} 144 | when true 145 | raise(UnhandledKeysError, "#{name} received unhandled keys: #{unhandled_keys.join(', ')}.") 146 | else 147 | args.select { |key| unhandled_keys.include? key.to_sym } 148 | end 149 | end 150 | 151 | def handled_keys(args) 152 | arg_keys = args.keys.map(&:to_sym) 153 | inputs, assocs = handlers.values.partition { |handler| handler[:type] == :input } 154 | {}.merge( 155 | # Inputs 156 | inputs.select { |handler| (arg_keys & handler[:name]).any? } 157 | .reduce({}) { |memo, handler| memo.merge handle_input(handler, args) } 158 | ).merge( 159 | # Associations 160 | assocs.reduce({}) { |memo, handler| memo.merge handle_association(handler, args) } 161 | ) 162 | end 163 | 164 | def handle(handler, args) 165 | values = args.values_at(*handler[:name]) 166 | values = values.length == 1 ? values.first : values 167 | send("handle_#{handler[:type]}", handler, values) 168 | end 169 | 170 | def handle_input(handler, args) 171 | values = args.values_at(*handler[:name]) 172 | values = values.length == 1 ? values.first : values 173 | parse(handler[:key], handler[:parsers], values, handler[:options]) 174 | end 175 | 176 | def handle_association(handler, args) 177 | assoc_handlers = [ 178 | handler, 179 | handler.merge({ 180 | key: handler[:options].fetch(:key, "#{handler[:name]}_attributes").to_sym, 181 | name: "#{handler[:name]}_attributes".to_sym 182 | }) 183 | ] 184 | 185 | assoc_handler_names = assoc_handlers.map { |_handler| _handler[:name] } 186 | 187 | case args.values_at(*assoc_handler_names).compact.length 188 | when 0 189 | {} 190 | when 1 191 | _handler = assoc_handlers.detect { |_handler| args.has_key?(_handler[:name]) } 192 | send("handle_#{_handler[:type]}", _handler, args[_handler[:name]]) 193 | else 194 | raise ArgumentError, "Handler #{handler[:name]} matches multiple keys: #{assoc_handler_names}." 195 | end 196 | end 197 | 198 | def handle_has_many(handler, values) 199 | decanter = decanter_for_handler(handler) 200 | if values.is_a?(Hash) 201 | parsed_values = values.map do |_index, input_values| 202 | next if input_values.nil? 203 | 204 | decanter.decant(input_values) 205 | end 206 | { handler[:key] => parsed_values } 207 | else 208 | { 209 | handler[:key] => values.compact.map { |value| decanter.decant(value) } 210 | } 211 | end 212 | end 213 | 214 | def handle_has_one(handler, values) 215 | { handler[:key] => decanter_for_handler(handler).decant(values) } 216 | end 217 | 218 | def decanter_for_handler(handler) 219 | if specified_decanter = handler[:options][:decanter] 220 | Decanter.decanter_from(specified_decanter) 221 | else 222 | Decanter.decanter_for(handler[:assoc]) 223 | end 224 | end 225 | 226 | def parse(key, parsers, value, options) 227 | return { key => value } unless parsers 228 | raise ArgumentError, "No value for required argument: #{key}" if options[:required] && value_missing?(value) 229 | 230 | parser_classes = Parser.parsers_for(parsers) 231 | Parser.compose_parsers(parser_classes).parse(key, value, options) 232 | end 233 | 234 | def handlers 235 | @handlers ||= {} 236 | end 237 | 238 | def keys_to_ignore 239 | @keys_to_ignore ||= [] 240 | end 241 | 242 | def strict_mode 243 | @strict_mode.nil? ? Decanter.configuration.strict : @strict_mode 244 | end 245 | 246 | def log_unhandled_keys_mode 247 | return !!Decanter.configuration.log_unhandled_keys if @log_unhandled_keys_mode.nil? 248 | 249 | !!@log_unhandled_keys_mode 250 | end 251 | 252 | # Helpers 253 | 254 | private 255 | 256 | def value_missing?(value) 257 | value.nil? || value == '' 258 | end 259 | 260 | def to_indifferent_hash(args) 261 | return args.to_unsafe_h if args.instance_of?(ActionController::Parameters) 262 | 263 | args.to_h.with_indifferent_access 264 | end 265 | end 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /lib/decanter/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | class Error < StandardError; end 3 | class UnhandledKeysError < Error; end 4 | class MissingRequiredInputValue < Error; end 5 | class ParseError < Error; end 6 | end 7 | -------------------------------------------------------------------------------- /lib/decanter/extensions.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Extensions 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | def decant_update(args, **options) 8 | self.attributes = self.class.decant(args, options) 9 | self.save(context: options[:context]) 10 | end 11 | 12 | def decant_update!(args, **options) 13 | self.attributes = self.class.decant(args, options) 14 | self.save!(context: options[:context]) 15 | end 16 | 17 | def decant(args, **options) 18 | self.class.decant(args, options) 19 | end 20 | 21 | module ClassMethods 22 | def decant_create(args, **options) 23 | self.new(decant(args, options)) 24 | .save(context: options[:context]) 25 | end 26 | 27 | def decant_new(args, **options) 28 | self.new(decant(args, options)) 29 | end 30 | 31 | def decant_create!(args, **options) 32 | self.new(decant(args, options)) 33 | .save!(context: options[:context]) 34 | end 35 | 36 | def decant(args, options = {}) 37 | if (specified_decanter = options[:decanter]) 38 | Decanter.decanter_from(specified_decanter) 39 | else 40 | Decanter.decanter_for(self) 41 | end.decant(args) 42 | end 43 | end 44 | 45 | module ActiveRecordExtensions 46 | def self.enable! 47 | ::ActiveRecord::Base.include(Decanter::Extensions) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/decanter/parser.rb: -------------------------------------------------------------------------------- 1 | require_relative 'parser/utils' 2 | 3 | module Decanter 4 | module Parser 5 | class << self 6 | include Utils 7 | 8 | def parsers_for(klass_or_syms) 9 | Array.wrap(klass_or_syms) 10 | .map { |klass_or_sym| klass_or_sym_to_str(klass_or_sym) } 11 | .map { |parser_str| parser_constantize(parser_str) } 12 | .map { |parser| expand(parser) } 13 | .flatten 14 | end 15 | 16 | # Composes multiple parsers into a single parser 17 | def compose_parsers(parsers) 18 | raise ArgumentError.new('expects an array') unless parsers.is_a? Array 19 | composed_parser = Class.new(Decanter::Parser::ComposeParser) 20 | composed_parser.parsers(parsers) 21 | composed_parser 22 | end 23 | 24 | private 25 | 26 | # convert from a class or symbol to a string and concat 'Parser' 27 | def klass_or_sym_to_str(klass_or_sym) 28 | case klass_or_sym 29 | when Class 30 | klass_or_sym.name 31 | when Symbol 32 | symbol_to_string(klass_or_sym) 33 | else 34 | raise ArgumentError.new("cannot lookup parser for #{klass_or_sym} with class #{klass_or_sym.class}") 35 | end.concat('Parser') 36 | end 37 | 38 | # convert from a string to a constant 39 | def parser_constantize(parser_str) 40 | # safe_constantize returns nil if match not found 41 | parser_str.safe_constantize || 42 | concat_str(parser_str).safe_constantize || 43 | raise(NameError.new("cannot find parser #{parser_str}")) 44 | end 45 | 46 | # expand to include preparsers 47 | def expand(parser) 48 | Parser.parsers_for(parser.preparsers).push(parser) 49 | end 50 | end 51 | end 52 | end 53 | 54 | require "#{File.dirname(__FILE__)}/parser/core.rb" 55 | require "#{File.dirname(__FILE__)}/parser/base.rb" 56 | require "#{File.dirname(__FILE__)}/parser/value_parser.rb" 57 | require "#{File.dirname(__FILE__)}/parser/hash_parser.rb" 58 | Dir["#{File.dirname(__FILE__)}/parser/*_parser.rb"].each { |f| require f } 59 | -------------------------------------------------------------------------------- /lib/decanter/parser/array_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class ArrayParser < Base 4 | 5 | DUMMY_VALUE_KEY = '_'.freeze 6 | 7 | parser do |val, options| 8 | next if val.nil? 9 | raise Decanter::ParseError.new 'Expects an array' unless val.is_a? Array 10 | # Fetch parser classes for provided keys 11 | parse_each = options.fetch(:parse_each, :pass) 12 | item_parsers = Parser.parsers_for(Array.wrap(parse_each)) 13 | unless item_parsers.all? { |parser| parser <= ValueParser || parser <= PassParser } 14 | raise Decanter::ParseError.new 'parser(s) for array items must subclass either ValueParser or PassParser' 15 | end 16 | # Compose supplied parsers 17 | item_parser = Parser.compose_parsers(item_parsers) 18 | # Parse all values 19 | val.map do |item| 20 | # Value parsers will expect a "key" for the value they're parsing, 21 | # so we provide a dummy one. 22 | result = item_parser.parse(DUMMY_VALUE_KEY, item, options) 23 | result[DUMMY_VALUE_KEY] 24 | end.compact 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/decanter/parser/base.rb: -------------------------------------------------------------------------------- 1 | require_relative 'core' 2 | 3 | module Decanter 4 | module Parser 5 | class Base 6 | include Core 7 | end 8 | end 9 | end 10 | 11 | -------------------------------------------------------------------------------- /lib/decanter/parser/boolean_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class BooleanParser < ValueParser 4 | 5 | allow TrueClass, FalseClass 6 | 7 | parser do |val, options| 8 | next if (val.nil? || val === '') 9 | [1, '1'].include?(val) || !!/^true$/i.match?(val.to_s) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/decanter/parser/compose_parser.rb: -------------------------------------------------------------------------------- 1 | require_relative 'core' 2 | 3 | # A parser that composes the results of multiple parsers. 4 | # Intended for internal use only. 5 | module Decanter 6 | module Parser 7 | class ComposeParser < Base 8 | 9 | def self._parse(name, value, options={}) 10 | raise Decanter::ParseError.new('Must have parsers') unless @parsers 11 | # Call each parser on the result of the previous one. 12 | initial_result = { name => value } 13 | @parsers.reduce(initial_result) do |result, parser| 14 | result.keys.reduce({}) do |acc, key| 15 | acc.merge(parser.parse(key, result[key], options)) 16 | end 17 | end 18 | end 19 | 20 | def self.parsers(parsers) 21 | @parsers = parsers 22 | end 23 | 24 | end 25 | end 26 | end 27 | 28 | -------------------------------------------------------------------------------- /lib/decanter/parser/core.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | module Core 4 | 5 | def self.included(base) 6 | base.extend(ClassMethods) 7 | end 8 | 9 | module ClassMethods 10 | 11 | def _parse(name, value, options={}) 12 | { name => @parser.call(value, options) } 13 | end 14 | 15 | # Check if allowed, parse if not 16 | def parse(name, value, options={}) 17 | if allowed?(value) 18 | { name => value } 19 | else 20 | _parse(name, value, options) 21 | end 22 | end 23 | 24 | # Define parser 25 | def parser(&block) 26 | @parser = block 27 | end 28 | 29 | # Set allowed classes 30 | def allow(*args) 31 | @allowed = args 32 | end 33 | 34 | # Set preparsers 35 | def pre(*parsers) 36 | @pre = parsers 37 | end 38 | 39 | # Get prepareer 40 | def preparsers 41 | @pre || [] 42 | end 43 | 44 | # Check for allowed classes 45 | def allowed?(value) 46 | @allowed && @allowed.any? { |allowed| value.is_a? allowed } 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/decanter/parser/date_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class DateParser < ValueParser 4 | 5 | allow Date 6 | 7 | parser do |val, options| 8 | next if (val.nil? || val === '') 9 | parse_format = options.fetch(:parse_format, '%m/%d/%Y') 10 | ::Date.strptime(val, parse_format) 11 | end 12 | end 13 | end 14 | end 15 | 16 | -------------------------------------------------------------------------------- /lib/decanter/parser/datetime_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class DateTimeParser < ValueParser 4 | 5 | allow DateTime 6 | 7 | parser do |val, options| 8 | next if (val.nil? || val === '') 9 | parse_format = options.fetch(:parse_format, '%m/%d/%Y %I:%M:%S %p') 10 | ::DateTime.strptime(val, parse_format) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/decanter/parser/float_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class FloatParser < ValueParser 4 | REGEX = /(\d|[.]|[-])/ 5 | 6 | allow Float, Integer 7 | 8 | parser do |val, options| 9 | next if (val.nil? || val === '') 10 | val.scan(REGEX).join.try(:to_f) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/decanter/parser/hash_parser.rb: -------------------------------------------------------------------------------- 1 | require_relative 'core' 2 | 3 | module Decanter 4 | module Parser 5 | class HashParser < Base 6 | def self._parse(name, value, options={}) 7 | validate_hash(@parser.call(name, value, options)) 8 | end 9 | 10 | private 11 | def self.validate_hash(parsed) 12 | parsed.is_a?(Hash) ? parsed : 13 | raise(ArgumentError.new("Result of HashParser #{self.name} was #{parsed} when it must be a hash.")) 14 | end 15 | end 16 | end 17 | end 18 | 19 | -------------------------------------------------------------------------------- /lib/decanter/parser/integer_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class IntegerParser < ValueParser 4 | REGEX = /(\d|[.]|[-])/ 5 | 6 | allow Integer 7 | 8 | parser do |val, options| 9 | next if (val.nil? || val === '') 10 | val.is_a?(Float) ? 11 | val.to_i : 12 | val.scan(REGEX).join.try(:to_i) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/decanter/parser/pass_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class PassParser < Base 4 | 5 | parser do |val, options| 6 | next if (val.nil? || val == '') 7 | val 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/decanter/parser/phone_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class PhoneParser < ValueParser 4 | REGEX = /\d/ 5 | 6 | allow Integer 7 | 8 | parser do |val, options| 9 | next if (val.nil? || val === '') 10 | val.scan(REGEX).join.to_s 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/decanter/parser/string_parser.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | class StringParser < ValueParser 4 | parser do |val, options| 5 | next if (val.nil? || val === '') 6 | next val if val.is_a? String 7 | val.to_s 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/decanter/parser/utils.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Parser 3 | module Utils 4 | # extract string transformation strategies 5 | def symbol_to_string(klass_or_sym) 6 | if singular_class_present?(klass_or_sym) 7 | singularize_and_camelize_str(klass_or_sym) 8 | else 9 | camelize_str(klass_or_sym) 10 | end 11 | end 12 | 13 | def singular_class_present?(klass_or_sym) 14 | parser_str = singularize_and_camelize_str(klass_or_sym) 15 | concat_str(parser_str).safe_constantize.present? 16 | end 17 | 18 | def singularize_and_camelize_str(klass_or_sym) 19 | klass_or_sym.to_s.singularize.camelize 20 | end 21 | 22 | def camelize_str(klass_or_sym) 23 | klass_or_sym.to_s.camelize 24 | end 25 | 26 | def concat_str(parser_str) 27 | 'Decanter::Parser::'.concat(parser_str) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/decanter/parser/value_parser.rb: -------------------------------------------------------------------------------- 1 | require_relative 'core' 2 | 3 | module Decanter 4 | module Parser 5 | class ValueParser < Base 6 | 7 | def self._parse(name, value, options={}) 8 | self.validate_singularity(value) 9 | super(name, value, options) 10 | end 11 | 12 | private 13 | 14 | def self.validate_singularity(value) 15 | raise Decanter::ParseError.new 'Expects a single value' if value.is_a? Array 16 | end 17 | end 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /lib/decanter/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'decanter' 2 | 3 | class Decanter::Railtie < Rails::Railtie 4 | 5 | initializer 'decanter.active_record' do 6 | ActiveSupport.on_load :active_record do 7 | require 'decanter/extensions' 8 | Decanter::Extensions::ActiveRecordExtensions.enable! 9 | end 10 | end 11 | 12 | initializer 'decanter.parser.autoload', :before => :set_autoload_paths do |app| 13 | app.config.autoload_paths << Rails.root.join("lib/decanter/parsers") 14 | end 15 | 16 | generators do |app| 17 | Rails::Generators.configure!(app.config.generators) 18 | Rails::Generators.hidden_namespaces.uniq! 19 | require 'generators/rails/resource_override' 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/decanter/version.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | VERSION = '5.0.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/generators/decanter/install_generator.rb: -------------------------------------------------------------------------------- 1 | module Decanter 2 | module Generators 3 | class InstallGenerator < Rails::Generators::Base 4 | source_root File.expand_path('../templates', __FILE__) 5 | 6 | desc "Creates a Decanter initializer in your application." 7 | 8 | def copy_initializer 9 | copy_file "initializer.rb", "config/initializers/decanter.rb" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/decanter/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | Decanter.config do |config| 2 | config.strict = true 3 | end 4 | -------------------------------------------------------------------------------- /lib/generators/rails/decanter_generator.rb: -------------------------------------------------------------------------------- 1 | module Rails 2 | module Generators 3 | class DecanterGenerator < NamedBase 4 | source_root File.expand_path('../templates', __FILE__) 5 | check_class_collision suffix: 'Decanter' 6 | ASSOCIATION_TYPES = [:has_many, :has_one, :belongs_to] 7 | 8 | argument :attributes, type: :array, default: [], banner: 'field:type field:type' 9 | 10 | class_option :parent, type: :string, desc: 'The parent class for the generated decanter' 11 | 12 | def create_decanter_file 13 | template 'decanter.rb.erb', File.join('app/decanters', class_path, "#{file_name}_decanter.rb") 14 | end 15 | 16 | private 17 | 18 | def inputs 19 | attributes.find_all { |attr| ASSOCIATION_TYPES.exclude?(attr.type) } 20 | end 21 | 22 | def associations 23 | attributes - inputs 24 | end 25 | 26 | def parent_class_name 27 | 'Decanter::Base' 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/generators/rails/parser_generator.rb: -------------------------------------------------------------------------------- 1 | module Rails 2 | module Generators 3 | class ParserGenerator < NamedBase 4 | source_root File.expand_path('../templates', __FILE__) 5 | check_class_collision suffix: 'Parser' 6 | 7 | class_option :parent, type: :string, desc: 'The parent class for the generated parser' 8 | 9 | def create_parser_file 10 | template 'parser.rb.erb', File.join('lib/decanter/parsers', class_path, "#{file_name}_parser.rb") 11 | end 12 | 13 | private 14 | 15 | def parent_class_name 16 | 'Decanter::Parser::ValueParser' 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/rails/resource_override.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/rails/resource/resource_generator' 3 | 4 | module Rails 5 | module Generators 6 | class ResourceGenerator 7 | hook_for :decanter, default: true, type: :boolean 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/decanter.rb.erb: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name %>Decanter < <%= parent_class_name %> 3 | <% inputs.each do |input| -%> 4 | input :<%= input.name %>, :<%= input.type %> 5 | <% end -%> 6 | <% associations.each do |assoc| -%> 7 | <%= assoc.type %> :<%= assoc.name %> 8 | <% end -%> 9 | end 10 | <% end -%> 11 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/parser.rb.erb: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name %>Parser < <%= parent_class_name %> 3 | parser do |value, options| 4 | value 5 | end 6 | end 7 | <% end -%> 8 | -------------------------------------------------------------------------------- /migration-guides/v3.0.0.md: -------------------------------------------------------------------------------- 1 | # v3.0.0 Migration Guide 2 | 3 | _Note: this guide assumes you are upgrading from decanter v1 to v3. In order to migrate from v2, please downgrade to v1 first._ 4 | 5 | This version contains the following breaking changes: 6 | 7 | 1. `strict true` mode will now raise exceptions for unhandled keys. 8 | 9 | `strict true` previously displayed warnings rather than raising exceptions. To adapt to the new behavior, replace all instances of `strict true` in your app with `strict false`, and replace all instances of `strict :with_exception` to `strict true`. 10 | 11 | 2. `JoinParser` and `KeyValueSplitterParser` default parsers have been removed. 12 | 13 | If you use these parsers in your project, you should include them as custom parsers. Their source code is available on the `v1` branch. 14 | 15 | 3. All default parsers (except for `ArrayParser`) now strictly require a single value. 16 | 17 | Default parsers will now raise an exception when passed an array of values. For instance, an attribute declared with `input , :string` will expect to receive a single string rather than an array of strings. Previously, default parsers handled arrays of values in unstable and undocumented ways. In the (unlikely) event that your project was relying on the previous behavior, you can include the legacy version(s) of the parsers as custom parsers in your project. 18 | 19 | 4. Decanter exceptions have been renamed from `Decanter::Core::` to `Decanter::`. 20 | 21 | If your project relies on specific decanter exception names, make sure to rename those instances accordingly. 22 | -------------------------------------------------------------------------------- /migration-guides/v4.0.0.md: -------------------------------------------------------------------------------- 1 | # v4.0.0 Migration Guide 2 | 3 | _Note: this guide assumes you are upgrading from decanter v3 to v4._ 4 | 5 | This version contains the following breaking changes: 6 | 7 | 1. `FloatParser` and `IntegerParser` have been updated to address a bug where negative numbers were being parsed as positive. In the (unlikely) event that your project was relying on the previous behavior, you can pin the gem version to `v3.6.0` or include the legacy version(s) of the parsers as custom parsers in your project. 8 | 9 | To add a custom parser, add the new parser class to your project: 10 | 11 | ```rb 12 | # app/parsers/postive_float_parser.rb 13 | 14 | class PositiveFloatParser < Decanter::Parser::ValueParser 15 | REGEX = /(\d|[.])/ 16 | 17 | allow Float, Integer 18 | 19 | parser do |val, options| 20 | raise Decanter::ParseError.new 'Expects a single value' if val.is_a? Array 21 | next if (val.nil? || val === '') 22 | val.scan(REGEX).join.try(:to_f) 23 | end 24 | end 25 | ``` 26 | 27 | Then, use the appropriate key to look up the parser in your decanter: 28 | 29 | ```rb 30 | # app/decanters/product_decanter.rb 31 | 32 | class ProductDecanter < Decanter::Base 33 | input :price, :positive_float #=> PositiveFloatParser 34 | end 35 | ``` 36 | -------------------------------------------------------------------------------- /migration-guides/v5.0.0.md: -------------------------------------------------------------------------------- 1 | # v5.0.0 Migration Guide 2 | 3 | _Note: this guide assumes you are upgrading from decanter v4 to v5._ 4 | 5 | This version contains major updates including the upgrade to Ruby version 3.3.0. Ensure your environment is compatible with this Ruby version before proceeding. 6 | 7 | ## Major Changes 8 | 9 | - **Ruby Version Update**: Decanter now requires Ruby 3.3.0. This update brings several language [improvements and performance enhancements](https://www.ruby-lang.org/en/news/2023/12/25/ruby-3-3-0-released/). 10 | 11 | - **Rails Dependency Update**: Decanter now requires Rails 7.1.3.2. 12 | -------------------------------------------------------------------------------- /spec/decanter/decanter_collection_detection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Decanter::CollectionDetection do 4 | let(:base_decanter) { 5 | stub_const('BaseDecanter', Class.new) 6 | } 7 | 8 | let(:decanter) { 9 | stub_const('TripDecanter', base_decanter.new) 10 | TripDecanter.class_eval { include Decanter::CollectionDetection } 11 | } 12 | let(:args) { { destination: 'Hawaii' } } 13 | 14 | before(:each) { 15 | allow(base_decanter).to receive(:decant) 16 | } 17 | 18 | describe '#decant' do 19 | context 'when args are a single hash' do 20 | it 'calls decant on the entire element' do 21 | decanter.decant(args) 22 | expect(base_decanter).to have_received(:decant).once.with(args) 23 | end 24 | end 25 | 26 | context 'when no collection option is passed' do 27 | context 'and args are a collection' do 28 | let(:args) { [{ destination: 'Hawaii' }, { destination: 'Denver' }] } 29 | 30 | it 'calls decant with each element' do 31 | decanter.decant(args) 32 | expect(base_decanter).to have_received(:decant).with(args.first) 33 | expect(base_decanter).to have_received(:decant).with(args.second) 34 | end 35 | end 36 | 37 | context 'and args are not a collection' do 38 | let(:args) { { "0": [{ destination: 'Hawaii' }] } } 39 | it 'calls decant on the entire element' do 40 | decanter.decant(args) 41 | expect(base_decanter).to have_received(:decant).once.with(args) 42 | end 43 | end 44 | end 45 | 46 | context 'when the collection option is passed' do 47 | let(:fake_collection) { double('fake_collection') } 48 | let(:args) { fake_collection } 49 | 50 | before(:each) do 51 | allow(fake_collection).to receive(:map).and_yield(1).and_yield(2) 52 | end 53 | 54 | context 'and the value is true' do 55 | it 'is considered a collection' do 56 | decanter.decant(args, is_collection: true) 57 | expect(base_decanter).to have_received(:decant).with(1) 58 | expect(base_decanter).to have_received(:decant).with(2) 59 | end 60 | end 61 | 62 | context 'and the value is false' do 63 | it 'is not considered a collection' do 64 | decanter.decant(args, is_collection: false) 65 | expect(base_decanter).to have_received(:decant).once.with(args) 66 | end 67 | end 68 | 69 | context 'and the value is truthy' do 70 | it 'raises an error' do 71 | expect { decanter.decant(args, is_collection: 'yes') }.to raise_error(ArgumentError) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/decanter/decanter_core_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Decanter::Core do 4 | let(:dummy) { Class.new { include Decanter::Core } } 5 | 6 | before(:each) do 7 | Decanter::Core.class_variable_set(:@@handlers, {}) 8 | Decanter::Core.class_variable_set(:@@strict_mode, {}) 9 | end 10 | 11 | after(:each) do 12 | Decanter::Core.class_variable_set(:@@handlers, {}) 13 | Decanter::Core.class_variable_set(:@@strict_mode, {}) 14 | end 15 | 16 | describe '#input' do 17 | let(:name) { [:profile] } 18 | let(:parser) { :string } 19 | let(:options) { {} } 20 | 21 | before(:each) { dummy.input(name, parser, **options) } 22 | 23 | it 'adds a handler for the provided name' do 24 | expect(dummy.handlers.key?(name)).to be true 25 | end 26 | 27 | context 'for multiple values' do 28 | let(:name) { %i[first_name last_name] } 29 | 30 | it 'adds a handler for the provided name' do 31 | expect(dummy.handlers.has_key?(name)).to be true 32 | end 33 | 34 | it 'raises an error if multiple values are passed without a parser' do 35 | expect { dummy.input name }.to raise_error(ArgumentError) 36 | end 37 | end 38 | 39 | it 'the handler has type :input' do 40 | expect(dummy.handlers[name][:type]).to eq :input 41 | end 42 | 43 | it 'the handler has default key equal to the name' do 44 | expect(dummy.handlers[name][:key]).to eq name.first 45 | end 46 | 47 | it 'the handler has name = provided name' do 48 | expect(dummy.handlers[name][:name]).to eq name 49 | end 50 | 51 | it 'the handler passes through the options' do 52 | expect(dummy.handlers[name][:options]).to eq options 53 | end 54 | 55 | it 'the handler has parser of provided parser' do 56 | expect(dummy.handlers[name][:parsers]).to eq parser 57 | end 58 | 59 | context 'with key specified in options' do 60 | let(:options) { { key: :foo } } 61 | 62 | it 'the handler has key from options' do 63 | expect(dummy.handlers[name][:key]).to eq options[:key] 64 | end 65 | end 66 | end 67 | 68 | describe '#has_one' do 69 | let(:assoc) { :profile } 70 | let(:name) { ["#{assoc}_attributes".to_sym] } 71 | let(:options) { {} } 72 | 73 | before(:each) { dummy.has_one(assoc, **options) } 74 | 75 | it 'adds a handler for the association' do 76 | expect(dummy.handlers.has_key?(assoc)).to be true 77 | end 78 | 79 | it 'the handler has type :has_one' do 80 | expect(dummy.handlers[assoc][:type]).to eq :has_one 81 | end 82 | 83 | it 'the handler has default key :profile' do 84 | expect(dummy.handlers[assoc][:key]).to eq assoc 85 | end 86 | 87 | it 'the handler has name = assoc' do 88 | expect(dummy.handlers[assoc][:name]).to eq assoc 89 | end 90 | 91 | it 'the handler passes through the options' do 92 | expect(dummy.handlers[assoc][:options]).to eq options 93 | end 94 | 95 | it 'the handler has assoc = provided assoc' do 96 | expect(dummy.handlers[assoc][:assoc]).to eq assoc 97 | end 98 | 99 | context 'with key specified in options' do 100 | let(:options) { { key: :foo } } 101 | 102 | it 'the handler has key from options' do 103 | expect(dummy.handlers[assoc][:key]).to eq options[:key] 104 | end 105 | end 106 | end 107 | 108 | describe '#has_many' do 109 | let(:assoc) { :profile } 110 | let(:name) { ["#{assoc}_attributes".to_sym] } 111 | let(:options) { {} } 112 | 113 | before(:each) { dummy.has_many(assoc, **options) } 114 | 115 | it 'adds a handler for the assoc' do 116 | expect(dummy.handlers.has_key?(assoc)).to be true 117 | end 118 | 119 | it 'the handler has type :has_many' do 120 | expect(dummy.handlers[assoc][:type]).to eq :has_many 121 | end 122 | 123 | it 'the handler has default key :profile' do 124 | expect(dummy.handlers[assoc][:key]).to eq assoc 125 | end 126 | 127 | it 'the handler has name = assoc' do 128 | expect(dummy.handlers[assoc][:name]).to eq assoc 129 | end 130 | 131 | it 'the handler has assoc = provided assoc' do 132 | expect(dummy.handlers[assoc][:assoc]).to eq assoc 133 | end 134 | 135 | it 'the handler passes through the options' do 136 | expect(dummy.handlers[assoc][:options]).to eq options 137 | end 138 | 139 | context 'with key specified in options' do 140 | let(:options) { { key: :foo } } 141 | 142 | it 'the handler has key from options' do 143 | expect(dummy.handlers[assoc][:key]).to eq options[:key] 144 | end 145 | end 146 | end 147 | 148 | describe '#strict' do 149 | let(:mode) { true } 150 | 151 | it 'sets the strict mode' do 152 | dummy.strict mode 153 | expect(dummy.strict_mode).to eq mode 154 | end 155 | 156 | context 'for an unknown mode' do 157 | let(:mode) { :foo } 158 | 159 | it 'raises an error' do 160 | expect { dummy.strict mode }.to raise_error(ArgumentError) 161 | end 162 | end 163 | end 164 | 165 | describe '#log_unhandled_keys' do 166 | let(:mode) { false } 167 | 168 | it 'sets the @log_unhandled_keys_mode' do 169 | dummy.log_unhandled_keys(mode) 170 | expect(dummy.log_unhandled_keys_mode).to eq mode 171 | end 172 | 173 | context 'for an unknown mode' do 174 | let(:mode) { :foo } 175 | 176 | it 'raises an error' do 177 | expect { dummy.log_unhandled_keys(mode) }.to raise_error(ArgumentError) 178 | end 179 | end 180 | end 181 | 182 | describe '#parse' do 183 | context 'when a parser is not specified' do 184 | let(:parser) { double('parser', parse: nil) } 185 | 186 | before(:each) do 187 | allow(Decanter::Parser) 188 | .to receive(:parsers_for) 189 | .and_return(Array.wrap(parser)) 190 | end 191 | 192 | it 'returns the provided key and value' do 193 | expect(dummy.parse(:first_name, nil, 'bar', {})).to eq({ first_name: 'bar' }) 194 | end 195 | 196 | it 'does not call Parser.parsers_for' do 197 | dummy.parse(:first_name, nil, 'bar', {}) 198 | expect(Decanter::Parser).to_not have_received(:parsers_for) 199 | end 200 | end 201 | 202 | context 'when a parser is specified but a required value is not present' do 203 | it 'raises an argument error specifying the key' do 204 | expect { dummy.parse(:first_name, :foo, nil, { required: true }) } 205 | .to raise_error(ArgumentError, 'No value for required argument: first_name') 206 | end 207 | end 208 | 209 | context 'when one parser is specified' do 210 | let(:key) { :afloat } 211 | let(:val) { 8.0 } 212 | 213 | it 'returns the a key-value pair with the parsed value' do 214 | expect(dummy.parse(key, :float, val.to_s, {})).to eq({ key => val }) 215 | end 216 | end 217 | 218 | context 'when several parsers are specified' do 219 | let(:key) { :afloat } 220 | let(:val) { 8.0 } 221 | 222 | it 'returns the a key-value pair with the parsed value' do 223 | expect(dummy.parse(key, %i[string float], val, {})).to eq({ key => val }) 224 | end 225 | end 226 | 227 | context 'when a parser with a preparser is specified' do 228 | Object.const_set('PctParser', 229 | Class.new(Decanter::Parser::ValueParser) do 230 | def self.name 231 | 'PctParser' 232 | end 233 | end.tap do |parser| 234 | parser.pre :float 235 | parser.parser do |val, _options| 236 | val / 100 237 | end 238 | end) 239 | 240 | Object.const_set('KeyValueSplitterParser', 241 | Class.new(Decanter::Parser::HashParser) do 242 | def self.name 243 | 'KeyValueSplitterParser' 244 | end 245 | end.tap do |parser| 246 | parser.parser do |_name, val, _options| 247 | item_delimiter = ',' 248 | pair_delimiter = ':' 249 | val.split(item_delimiter).reduce({}) do |memo, pair| 250 | memo.merge(Hash[*pair.split(pair_delimiter)]) 251 | end 252 | end 253 | end) 254 | 255 | let(:key) { :afloat } 256 | let(:val) { 8.0 } 257 | 258 | it 'returns the a key-value pair with the parsed value' do 259 | expect(dummy.parse(key, %i[string pct], val, {})).to eq({ key => val / 100 }) 260 | end 261 | end 262 | 263 | context 'when a hash parser and other parsers are specified' do 264 | let(:key) { :split_it! } 265 | let(:val) { 'foo:3.45,baz:91' } 266 | 267 | it 'returns the a key-value pairs with the parsed values' do 268 | expect(dummy.parse(key, %i[key_value_splitter pct], val, {})) 269 | .to eq({ 'foo' => 0.0345, 'baz' => 0.91 }) 270 | end 271 | end 272 | end 273 | 274 | describe '#decanter_for_handler' do 275 | context 'when decanter option is specified' do 276 | let(:handler) { { options: { decanter: 'FooDecanter' } } } 277 | 278 | before(:each) { allow(Decanter).to receive(:decanter_from) } 279 | 280 | it 'calls Decanter::decanter_from with the specified decanter' do 281 | dummy.decanter_for_handler(handler) 282 | expect(Decanter).to have_received(:decanter_from).with(handler[:options][:decanter]) 283 | end 284 | end 285 | 286 | context 'when decanter option is not specified' do 287 | let(:handler) { { assoc: :foo, options: {} } } 288 | 289 | before(:each) { allow(Decanter).to receive(:decanter_for) } 290 | 291 | it 'calls Decanter::decanter_for with the assoc' do 292 | dummy.decanter_for_handler(handler) 293 | expect(Decanter).to have_received(:decanter_for).with(handler[:assoc]) 294 | end 295 | end 296 | end 297 | 298 | describe '#unhandled_keys' do 299 | let(:args) { { foo: :bar, 'baz' => 'foo' } } 300 | 301 | context 'when there are no unhandled keys' do 302 | before(:each) { allow(dummy).to receive(:handlers).and_return({ foo: { type: :input }, baz: { type: :input } }) } 303 | 304 | it 'returns an empty hash' do 305 | expect(dummy.unhandled_keys(args)).to match({}) 306 | end 307 | end 308 | 309 | context 'when there are unhandled keys' do 310 | context 'and strict mode is true' do 311 | before(:each) { allow(dummy).to receive(:handlers).and_return({}) } 312 | before(:each) { dummy.strict true } 313 | 314 | context 'when there are no ignored keys' do 315 | it 'raises an error' do 316 | expect { dummy.unhandled_keys(args) }.to raise_error(Decanter::UnhandledKeysError) 317 | end 318 | end 319 | 320 | context 'when the unhandled keys are ignored' do 321 | it 'does not raise an error' do 322 | dummy.ignore :foo, 'baz' 323 | expect { dummy.unhandled_keys(args) }.to_not raise_error(Decanter::UnhandledKeysError) 324 | end 325 | end 326 | end 327 | 328 | context 'and strict mode is :ignore' do 329 | it 'returns a hash without the unhandled keys and values' do 330 | dummy.strict :ignore 331 | expect(dummy.unhandled_keys(args)).to match({}) 332 | end 333 | 334 | it 'logs the unhandled keys' do 335 | dummy.strict :ignore 336 | expect { dummy.unhandled_keys(args) }.to output(/ignoring unhandled keys: foo, baz/).to_stdout 337 | end 338 | 339 | context 'and log_unhandled_keys mode is false' do 340 | it 'does not log the unhandled keys' do 341 | dummy.strict :ignore 342 | dummy.log_unhandled_keys false 343 | expect { dummy.unhandled_keys(args) }.not_to output(/ignoring unhandled keys: foo, baz/).to_stdout 344 | end 345 | end 346 | end 347 | 348 | context 'and strict mode is false' do 349 | it 'returns a hash with the unhandled keys and values' do 350 | dummy.strict false 351 | allow(dummy).to receive(:handlers).and_return({}) 352 | expect(dummy.unhandled_keys(args)).to match(args) 353 | end 354 | end 355 | end 356 | end 357 | 358 | describe '#handle' do 359 | let(:args) { { foo: 'hi', bar: 'bye' } } 360 | let(:name) { %i[foo bar] } 361 | let(:values) { args.values_at(*name) } 362 | let(:handler) { { type: :input, name: } } 363 | 364 | before(:each) { allow(dummy).to receive(:handle_input).and_return(:foobar) } 365 | 366 | context 'for an input' do 367 | it 'calls the handle_input with the handler and extracted values' do 368 | dummy.handle(handler, args) 369 | expect(dummy) 370 | .to have_received(:handle_input) 371 | .with(handler, values) 372 | end 373 | 374 | it 'returns the results form handle_input' do 375 | expect(dummy.handle(handler, args)).to eq :foobar 376 | end 377 | end 378 | end 379 | 380 | describe '#handle_input' do 381 | let(:name) { :name } 382 | let(:parser) { double('parser') } 383 | let(:options) { double('options') } 384 | let(:args) { { name => 'Hi', foo: 'bar' } } 385 | let(:values) { args[name] } 386 | let(:handler) { { key: name, name:, parsers: parser, options: } } 387 | 388 | before(:each) do 389 | allow(dummy).to receive(:parse) 390 | end 391 | 392 | it 'calls parse with the handler key, handler parser, values and options' do 393 | dummy.handle_input(handler, args) 394 | expect(dummy) 395 | .to have_received(:parse) 396 | .with(name, parser, values, options) 397 | end 398 | end 399 | 400 | describe '#handle_has_one' do 401 | let(:output) { { foo: 'bar' } } 402 | let(:handler) { { key: 'key', options: {} } } 403 | let(:values) { { baz: 'foo' } } 404 | let(:decanter) { double('decanter') } 405 | 406 | before(:each) do 407 | allow(decanter) 408 | .to receive(:decant) 409 | .and_return(output) 410 | 411 | allow(dummy) 412 | .to receive(:decanter_for_handler) 413 | .and_return(decanter) 414 | end 415 | 416 | it 'calls decanter_for_handler with the handler' do 417 | dummy.handle_has_one(handler, values) 418 | expect(dummy) 419 | .to have_received(:decanter_for_handler) 420 | .with(handler) 421 | end 422 | 423 | it 'calls decant with the values on the found decanter' do 424 | dummy.handle_has_one(handler, values) 425 | expect(decanter) 426 | .to have_received(:decant) 427 | .with(values) 428 | end 429 | 430 | it 'returns an array containing the key, and the decanted value' do 431 | expect(dummy.handle_has_one(handler, values)) 432 | .to match({ handler[:key] => output }) 433 | end 434 | end 435 | 436 | describe '#handle_has_many' do 437 | let(:output) { [{ foo: 'bar' }, { bar: 'foo' }] } 438 | let(:handler) { { key: 'key', options: {} } } 439 | let(:values) { [{ baz: 'foo' }, { faz: 'boo' }] } 440 | let(:decanter) { double('decanter') } 441 | 442 | before(:each) do 443 | allow(decanter) 444 | .to receive(:decant) 445 | .and_return(*output) 446 | 447 | allow(dummy) 448 | .to receive(:decanter_for_handler) 449 | .and_return(decanter) 450 | end 451 | 452 | it 'calls decanter_for_handler with the handler' do 453 | dummy.handle_has_many(handler, values) 454 | expect(dummy) 455 | .to have_received(:decanter_for_handler) 456 | .with(handler) 457 | end 458 | 459 | it 'calls decant with the each of the values on the found decanter' do 460 | dummy.handle_has_many(handler, values) 461 | expect(decanter) 462 | .to have_received(:decant) 463 | .with(values[0]) 464 | expect(decanter) 465 | .to have_received(:decant) 466 | .with(values[1]) 467 | end 468 | 469 | it 'returns an array containing the key, and an array of decanted values' do 470 | expect(dummy.handle_has_many(handler, values)) 471 | .to match({ handler[:key] => output }) 472 | end 473 | end 474 | 475 | describe '#handle_association' do 476 | let(:assoc) { :profile } 477 | let(:handler) do 478 | { 479 | assoc:, 480 | key: assoc, 481 | name: assoc, 482 | type: :has_one, 483 | options: {} 484 | } 485 | end 486 | 487 | before(:each) do 488 | allow(dummy).to receive(:handle_has_one) 489 | end 490 | 491 | context 'when there is a verbatim matching key' do 492 | let(:args) { { assoc => 'bar', :baz => 'foo' } } 493 | 494 | it 'calls handler_has_one with the handler and args' do 495 | dummy.handle_association(handler, args) 496 | expect(dummy) 497 | .to have_received(:handle_has_one) 498 | .with(handler, args[assoc]) 499 | end 500 | end 501 | 502 | context 'when there is a matching key for _attributes' do 503 | let(:args) { { "#{assoc}_attributes".to_sym => 'bar', :baz => 'foo' } } 504 | 505 | it 'calls handler_has_one with the _attributes handler and args' do 506 | dummy.handle_association(handler, args) 507 | expect(dummy) 508 | .to have_received(:handle_has_one) 509 | .with(hash_including(name: "#{assoc}_attributes".to_sym), args[:profile_attributes]) 510 | end 511 | end 512 | 513 | context 'when there is no matching key' do 514 | let(:args) { { foo: 'bar', baz: 'foo' } } 515 | 516 | it 'does not call handler_has_one' do 517 | dummy.handle_association(handler, args) 518 | expect(dummy).to_not have_received(:handle_has_one) 519 | end 520 | 521 | it 'returns an empty hash' do 522 | expect(dummy.handle_association(handler, args)).to eq({}) 523 | end 524 | end 525 | 526 | context 'when there are multiple matching keys' do 527 | let(:args) { { "#{assoc}_attributes".to_sym => 'bar', assoc => 'foo' } } 528 | 529 | it 'raises an argument error' do 530 | expect { dummy.handle_association(handler, args) } 531 | .to raise_error(ArgumentError, 532 | "Handler #{handler[:name]} matches multiple keys: [:profile, :profile_attributes].") 533 | end 534 | end 535 | end 536 | 537 | describe '#decant' do 538 | let(:args) { { foo: 'bar', baz: 'foo' } } 539 | let(:subject) { dummy.decant(args) } 540 | let(:is_required) { true } 541 | 542 | let(:input_hash) do 543 | { 544 | key: 'sky', 545 | options: { 546 | required: is_required 547 | } 548 | } 549 | end 550 | let(:handler) { [[:title], input_hash] } 551 | let(:handlers) { [handler] } 552 | 553 | before(:each) do 554 | allow(dummy).to receive(:unhandled_keys).and_return(args) 555 | allow(dummy).to receive(:handled_keys).and_return(args) 556 | end 557 | 558 | context 'with args' do 559 | context 'when strict mode is set to :ignore' do 560 | context 'and params include unhandled keys' do 561 | let(:decanter) do 562 | Class.new(Decanter::Base) do 563 | input :name, :string 564 | input :description, :string 565 | end 566 | end 567 | 568 | let(:args) { { name: 'My Trip', description: 'My Trip Description', foo: 'bar' } } 569 | 570 | it 'returns a hash with the declared key-value pairs, ignores unhandled key-value pairs' do 571 | decanter.strict :ignore 572 | decanted_params = decanter.decant(args) 573 | 574 | expect(decanted_params).not_to match(args) 575 | expect(decanted_params.keys).not_to include([:foo]) 576 | end 577 | end 578 | end 579 | 580 | context 'when inputs are required' do 581 | let(:decanter) do 582 | Class.new do 583 | include Decanter::Core 584 | input :name, :pass, required: true 585 | end 586 | end 587 | it 'should raise an exception if required values are missing' do 588 | expect { decanter.decant({ name: nil }) } 589 | .to raise_error(Decanter::MissingRequiredInputValue) 590 | end 591 | it 'should not raise an exception if required values are present' do 592 | expect { decanter.decant({ name: 'foo' }) } 593 | .not_to raise_error 594 | end 595 | it 'should treat empty arrays as present' do 596 | expect { decanter.decant({ name: [] }) } 597 | .not_to raise_error 598 | end 599 | it 'should treat empty strings as missing' do 600 | expect { decanter.decant({ name: '' }) } 601 | .to raise_error(ArgumentError) 602 | end 603 | it 'should treat blank strings as present' do 604 | expect { decanter.decant({ name: ' ' }) } 605 | .not_to raise_error 606 | end 607 | end 608 | 609 | context 'when params keys are strings' do 610 | let(:decanter) do 611 | Class.new do 612 | include Decanter::Core 613 | input :name, :string 614 | input :description, :string 615 | end 616 | end 617 | let(:args) { { 'name' => 'My Trip', 'description' => 'My Trip Description' } } 618 | it 'returns a hash with the declared key-value pairs' do 619 | decanted_params = decanter.decant(args) 620 | expect(decanted_params.with_indifferent_access).to match(args) 621 | end 622 | it 'converts all keys to symbols in the merged result' do 623 | decanted_params = decanter.decant(args) 624 | expect(decanted_params.keys).to all(be_a(Symbol)) 625 | end 626 | 627 | context 'and when inputs are strings' do 628 | let(:decanter) do 629 | Class.new do 630 | include Decanter::Core 631 | input 'name', :string 632 | input 'description', :string 633 | end 634 | end 635 | it 'returns a hash with the declared key-value pairs' do 636 | decanted_params = decanter.decant(args) 637 | expect(decanted_params.with_indifferent_access).to match(args) 638 | end 639 | end 640 | end 641 | 642 | it 'passes the args to unhandled keys' do 643 | subject 644 | expect(dummy).to have_received(:unhandled_keys).with(args) 645 | end 646 | 647 | it 'passes the args to handled keys' do 648 | subject 649 | expect(dummy).to have_received(:handled_keys).with(args) 650 | end 651 | 652 | it 'returns the merged result' do 653 | expect(subject).to eq args.merge(args) 654 | end 655 | end 656 | 657 | context 'with missing non-required args' do 658 | let(:decanter) do 659 | Class.new do 660 | include Decanter::Core 661 | input :name, :string 662 | input :description, :string 663 | end 664 | end 665 | let(:params) { { description: 'My Trip Description' } } 666 | it 'should omit missing values' do 667 | decanted_params = decanter.decant(params) 668 | # :name wasn't sent, so it shouldn't be in the result 669 | expect(decanted_params).to eq(params) 670 | end 671 | end 672 | 673 | context 'with key having a :default_value in the decanter' do 674 | let(:decanter) do 675 | Class.new do 676 | include Decanter::Core 677 | input :name, :string, default_value: 'foo' 678 | input :cost, :float, default_value: '99.99' 679 | input :description, :string 680 | end 681 | end 682 | 683 | it 'should include missing keys and their parsed default values' do 684 | params = { description: 'My Trip Description' } 685 | decanted_params = decanter.decant(params) 686 | desired_result = params.merge(name: 'foo', cost: 99.99) 687 | # :name wasn't sent, but it should have a default value of 'foo' 688 | # :cost wasn't sent, but it should have a parsed float default value of 99.99 689 | expect(decanted_params).to eq(desired_result) 690 | expect(decanted_params[:cost]).to be_kind_of(Float) 691 | end 692 | 693 | it 'should not override a (parsed) existing value' do 694 | params = { description: 'My Trip Description', name: 'bar', cost: '25.99' } 695 | decanted_params = decanter.decant(params) 696 | desired_result = params.merge(cost: 25.99) 697 | # :name has a default value of 'foo', but it was sent as and should remain 'bar' 698 | # :cost has default value of '99.99', but it was sent as '25.99' 699 | # and should have a parsed float value of 25.99 700 | expect(decanted_params).to eq(desired_result) 701 | end 702 | 703 | it 'should not override an existing nil value' do 704 | params = { description: 'My Trip Description', name: nil } 705 | decanted_params = decanter.decant(params) 706 | desired_result = params.merge(cost: 99.99) 707 | # :name has a default value of 'foo', but it was sent as and should remain nil 708 | expect(decanted_params).to eq(desired_result) 709 | end 710 | 711 | it 'should not override an existing blank value' do 712 | params = { description: 'My Trip Description', name: '', cost: '99.99' } 713 | decanted_params = decanter.decant(params) 714 | desired_result = params.merge(name: nil, cost: 99.99) 715 | # :name has a default value of 'foo', but it was sent as empty and should remain empty/nil 716 | expect(decanted_params).to eq(desired_result) 717 | end 718 | end 719 | 720 | context 'with present non-required args containing an empty value' do 721 | let(:decanter) do 722 | Class.new(Decanter::Base) do 723 | input :name, :string 724 | input :description, :string 725 | end 726 | end 727 | let(:params) { { name: '', description: 'My Trip Description' } } 728 | let(:desired_result) { { name: nil, description: 'My Trip Description' } } 729 | it 'should pass through the values' do 730 | decanted_params = decanter.decant(params) 731 | # :name should be in the result even though it was nil 732 | expect(decanted_params).to eq(desired_result) 733 | end 734 | end 735 | 736 | context 'without args' do 737 | let(:args) { nil } 738 | let(:inputs_required) { true } 739 | before(:each) do 740 | allow(dummy).to receive(:any_inputs_required?).and_return(inputs_required) 741 | end 742 | 743 | context 'when at least one input is required' do 744 | it 'should raise an exception' do 745 | expect { subject }.to raise_error(ArgumentError) 746 | end 747 | end 748 | 749 | context 'when no inputs are required' do 750 | let(:inputs_required) { false } 751 | 752 | it 'should return an empty hash' do 753 | expect(subject).to eq({}) 754 | end 755 | end 756 | end 757 | end 758 | 759 | describe 'any_inputs_required?' do 760 | let(:is_required) { true } 761 | let(:input_hash) do 762 | { 763 | key: 'foo', 764 | options: { 765 | required: is_required 766 | } 767 | } 768 | end 769 | let(:handler) { [[:title], input_hash] } 770 | let(:handlers) { [handler] } 771 | before(:each) do 772 | allow(dummy).to receive(:handlers).and_return(handlers) 773 | end 774 | 775 | context 'when required' do 776 | it 'should return true' do 777 | expect(dummy.any_inputs_required?).to be true 778 | end 779 | end 780 | 781 | context 'when not required' do 782 | let(:is_required) { false } 783 | it 'should return false' do 784 | expect(dummy.any_inputs_required?).to be false 785 | end 786 | end 787 | end 788 | 789 | describe 'required_input_keys_present?' do 790 | let(:is_required) { true } 791 | let(:args) { { "title": 'RubyConf' } } 792 | let(:input_hash) do 793 | { 794 | key: 'foo', 795 | options: { 796 | required: is_required 797 | } 798 | } 799 | end 800 | let(:handler) { [[:title], input_hash] } 801 | let(:handlers) { [handler] } 802 | before(:each) { allow(dummy).to receive(:handlers).and_return(handlers) } 803 | 804 | context 'when required args are present' do 805 | it 'should return true' do 806 | result = dummy.required_input_keys_present?(args) 807 | expect(result).to be true 808 | end 809 | end 810 | context 'when required args are not present' do 811 | let(:args) { { name: 'Bob' } } 812 | it 'should return false' do 813 | result = dummy.required_input_keys_present?(args) 814 | expect(result).to be false 815 | end 816 | end 817 | end 818 | end 819 | -------------------------------------------------------------------------------- /spec/decanter/decanter_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Decanter::Extensions do 4 | 5 | describe '#decant' do 6 | let(:args) { { } } 7 | let(:decanter) { class_double('Decanter::Base', decant: true) } 8 | 9 | context 'when a decanter is specified' do 10 | let(:options) { { decanter: 'FooDecanter' } } 11 | 12 | before(:each) do 13 | allow(Decanter).to receive(:decanter_from).and_return(decanter) 14 | end 15 | 16 | it 'calls Decanter.decanter_from with the specified decanter' do 17 | dummy_class = Class.new { include Decanter::Extensions } 18 | dummy_class.decant(args, options) 19 | expect(Decanter) 20 | .to have_received(:decanter_from) 21 | .with(options[:decanter]) 22 | end 23 | 24 | it 'calls decant on the returned decanter with the args' do 25 | dummy_class = Class.new { include Decanter::Extensions } 26 | dummy_class.decant(args, options) 27 | expect(decanter) 28 | .to have_received(:decant) 29 | .with(args) 30 | end 31 | end 32 | 33 | context 'when the decanter is not specified' do 34 | let(:options) { { } } 35 | 36 | before(:each) do 37 | allow(Decanter).to receive(:decanter_for).and_return(decanter) 38 | end 39 | 40 | it 'calls Decanter.decanter_for with self' do 41 | dummy_class = Class.new { include Decanter::Extensions } 42 | dummy_class.decant(args, options) 43 | expect(Decanter) 44 | .to have_received(:decanter_for) 45 | .with(dummy_class) 46 | end 47 | 48 | it 'calls decant on the returned decanter with the args' do 49 | dummy_class = Class.new { include Decanter::Extensions } 50 | dummy_class.decant(args, options) 51 | expect(decanter) 52 | .to have_received(:decant) 53 | .with(args) 54 | end 55 | end 56 | end 57 | 58 | context 'ActiveRecord::Persistence' do 59 | let(:dummy_class) { Class.new { include Decanter::Extensions } } 60 | let(:dummy_instance) { dummy_class.new } 61 | 62 | before(:each) do 63 | allow(dummy_class).to receive(:new).and_return(dummy_instance) 64 | allow(dummy_class).to receive(:decant) { |args| args } 65 | allow(dummy_instance).to receive(:attributes=) 66 | allow(dummy_instance).to receive(:save) 67 | allow(dummy_instance).to receive(:save!) 68 | end 69 | 70 | shared_examples 'a decanter update' do |strict| 71 | let(:args) { { foo: 'bar' } } 72 | 73 | before(:each) { strict ? dummy_instance.decant_update!(args) : dummy_instance.decant_update(args) } 74 | 75 | it 'sets the attributes on the model with the results from the decanter' do 76 | expect(dummy_instance).to have_received(:attributes=).with(args) 77 | end 78 | 79 | it "calls #{strict ? 'save!' : 'save'} on the model" do 80 | expect(dummy_instance).to have_received( strict ? :save! : :save ) 81 | end 82 | end 83 | 84 | shared_examples 'a decanter create' do |strict| 85 | let(:args) { { foo: 'bar' } } 86 | 87 | context 'with no context' do 88 | before(:each) { strict ? dummy_class.decant_create!(args) : dummy_class.decant_create(args) } 89 | 90 | it 'sets the attributes on the model with the results from the decanter' do 91 | expect(dummy_class).to have_received(:new).with(args) 92 | end 93 | 94 | it "calls #{strict ? 'save!' : 'save'} on the model" do 95 | expect(dummy_instance).to have_received( strict ? :save! : :save ) 96 | end 97 | end 98 | end 99 | 100 | describe '#decant_update' do 101 | it_behaves_like 'a decanter update' 102 | end 103 | 104 | describe '#decant_update!' do 105 | it_behaves_like 'a decanter update', true 106 | end 107 | 108 | describe '#decant_create' do 109 | it_behaves_like 'a decanter create' 110 | end 111 | 112 | describe '#decant_create!' do 113 | it_behaves_like 'a decanter create', true 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/decanter/decanter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Decanter do 4 | 5 | before(:all) do 6 | Object.const_set('FooDecanter', 7 | Class.new(Decanter::Base) do 8 | def self.name 9 | 'FooDecanter' 10 | end 11 | end 12 | ) 13 | end 14 | 15 | describe '#decanter_from' do 16 | 17 | context 'for a string' do 18 | 19 | context 'when a corresponding decanter exists' do 20 | 21 | let(:foo) { 'FooDecanter' } 22 | 23 | it 'returns the decanter' do 24 | expect(Decanter::decanter_from(foo)).to eq FooDecanter 25 | end 26 | end 27 | 28 | context 'when a corresponding class does not exist' do 29 | 30 | let(:foo) { 'FoobarDecanter' } 31 | 32 | it 'raises a name error' do 33 | expect { Decanter::decanter_from(foo) } 34 | .to raise_error(NameError, "uninitialized constant #{foo}") 35 | end 36 | end 37 | 38 | context 'when a corresponding class exists but it is not a decanter' do 39 | 40 | let(:foo) { String } 41 | 42 | it 'raises an argument error' do 43 | expect { Decanter::decanter_from(foo) } 44 | .to raise_error(ArgumentError, "#{foo.name} is not a decanter") 45 | end 46 | end 47 | end 48 | 49 | context 'for a class' do 50 | 51 | context 'when a corresponding decanter exists' do 52 | 53 | let(:foo) { FooDecanter } 54 | 55 | it 'returns the decanter' do 56 | expect(Decanter::decanter_from(foo)).to eq FooDecanter 57 | end 58 | end 59 | 60 | context 'when a corresponding class does not exist' do 61 | 62 | let(:foo) { Class.new } 63 | 64 | it 'raises a name error' do 65 | expect { Decanter::decanter_from(foo) } 66 | .to raise_error(ArgumentError, "#{foo.name} is not a decanter") 67 | end 68 | end 69 | 70 | context 'when a corresponding class exists but it is not a decanter' do 71 | 72 | let(:foo) { String } 73 | 74 | it 'raises an argument error' do 75 | expect { Decanter::decanter_from(foo) } 76 | .to raise_error(ArgumentError, "#{foo.name} is not a decanter") 77 | end 78 | end 79 | end 80 | 81 | context 'for a symbol' do 82 | 83 | let(:foo) { :foo } 84 | 85 | it 'raises an argument error' do 86 | expect { Decanter::decanter_from(foo) } 87 | .to raise_error(ArgumentError, "cannot find decanter from #{foo} with class #{foo.class}") 88 | end 89 | end 90 | end 91 | 92 | describe '#decanter_for' do 93 | 94 | context 'for a string' do 95 | 96 | let(:foo) { 'Foo' } 97 | 98 | it 'raises an argument error' do 99 | expect { Decanter::decanter_for(foo) } 100 | .to raise_error(ArgumentError, "cannot lookup decanter for #{foo} with class #{foo.class}") 101 | end 102 | end 103 | 104 | context 'for a class' do 105 | context 'when a corresponding decanter does not exist' do 106 | 107 | let(:foo) do 108 | Class.new do 109 | def self.name 110 | 'Foobar' 111 | end 112 | end 113 | end 114 | 115 | it 'raises a name error' do 116 | expect { Decanter::decanter_for(foo) } 117 | .to raise_error(NameError, "uninitialized constant #{foo.name.concat('Decanter')}") 118 | end 119 | end 120 | 121 | context 'when a corresponding decanter exists' do 122 | 123 | let(:foo) do 124 | Class.new do 125 | def self.name 126 | 'Foo' 127 | end 128 | end 129 | end 130 | 131 | it 'returns the decanter' do 132 | expect(Decanter::decanter_for(foo)).to eq FooDecanter 133 | end 134 | 135 | context 'and the class name is a frozen string' do 136 | let(:foo) { 137 | Class.new do 138 | def self.name 139 | 'Foo'.freeze # ActiveRecord classes might have a frozen name (Rails 5.2.3) 140 | end 141 | end 142 | } 143 | 144 | it 'returns the decanter' do 145 | expect(Decanter::decanter_for(foo)).to eq FooDecanter 146 | end 147 | end 148 | end 149 | end 150 | 151 | context 'for a symbol' do 152 | 153 | let(:foo) { :foobar } 154 | 155 | context 'when a corresponding decanter does not exist' do 156 | it 'raises a name error' do 157 | expect { Decanter::decanter_for(foo) } 158 | .to raise_error(NameError, "uninitialized constant #{foo.to_s.capitalize.concat('Decanter')}") 159 | end 160 | end 161 | 162 | context 'when a corresponding decanter exists' do 163 | 164 | let(:foo) { :foo } 165 | 166 | it 'returns the decanter' do 167 | expect(Decanter::decanter_for(foo)).to eq FooDecanter 168 | end 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/decanter/parser/array_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'ArrayParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::ArrayParser } 8 | 9 | describe '#parse' do 10 | context 'with an empty array' do 11 | it 'returns an empty array' do 12 | expect(parser.parse(name, [])).to match({name => []}) 13 | end 14 | end 15 | 16 | context 'with an array of "empty" values' do 17 | it 'returns an empty array' do 18 | expect(parser.parse(name, [''])).to match({name => []}) 19 | end 20 | end 21 | 22 | context 'with no parse_each option' do 23 | it 'defaults to PassParser' do 24 | expect(parser.parse(name, [1, '2'])).to match({name => [1, '2']}) 25 | end 26 | end 27 | 28 | context 'with parse_each option' do 29 | context 'with single parser' do 30 | it 'applies the parser' do 31 | expect(parser.parse(name, [1, 2, 3], parse_each: :string)).to match({name => ['1', '2', '3']}) 32 | end 33 | end 34 | 35 | context 'with multiple parsers' do 36 | it 'applies all parsers' do 37 | expect(parser.parse(name, [0, 1], parse_each: [:boolean, :string])).to eq({name => ['false', 'true']}) 38 | end 39 | end 40 | 41 | context 'with non-value parser' do 42 | let(:foo_parser) { Class.new(Decanter::Parser::HashParser) } 43 | it 'raises an exception' do 44 | # Mock parser lookup 45 | allow(Decanter::Parser) 46 | .to receive(:parsers_for) 47 | .and_return(Array.wrap(foo_parser)) 48 | expect { parser.parse(name, [1, 2, 3], parse_each: :foo) } 49 | .to raise_error(Decanter::ParseError) 50 | end 51 | end 52 | end 53 | 54 | context 'with a non-array argument' do 55 | it 'raises an exception' do 56 | expect { parser.parse(name, 123) } 57 | .to raise_error(Decanter::ParseError) 58 | end 59 | end 60 | 61 | # Note: this follows example above, 62 | # but it's still worth testing since it departs from the behavior of other parsers. 63 | context 'with empty string' do 64 | it 'raises an exception' do 65 | expect { parser.parse(name, '') } 66 | .to raise_error(Decanter::ParseError) 67 | end 68 | end 69 | 70 | context 'with nil' do 71 | it 'returns nil' do 72 | expect(parser.parse(name, nil)).to match({name => nil}) 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/decanter/parser/boolean_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'BooleanParser' do 4 | 5 | let(:parser) { Decanter::Parser::BooleanParser } 6 | 7 | describe '#parse' do 8 | 9 | trues = [ 10 | ['number', 1], 11 | ['string', 1], 12 | ['boolean', true], 13 | ['string', 'true'], 14 | ['string', 'True'], 15 | ['string', 'truE'] 16 | ] 17 | 18 | falses = [ 19 | ['number', 0], 20 | ['number', 2], 21 | ['string', '2'], 22 | ['boolean', false], 23 | ['string', 'tru'], 24 | ['string', 'not true'] 25 | ] 26 | 27 | let(:name) { :foo } 28 | 29 | context 'returns true for' do 30 | trues.each do |cond| 31 | it "#{cond[0]}: #{cond[1]}" do 32 | expect(parser.parse(name, cond[1])).to match({name => true}) 33 | end 34 | end 35 | end 36 | 37 | context 'returns false for' do 38 | falses.each do |cond| 39 | it "#{cond[0]}: #{cond[1]}" do 40 | expect(parser.parse(name, cond[1])).to match({name => false}) 41 | end 42 | end 43 | end 44 | 45 | context 'with empty string' do 46 | it 'returns nil' do 47 | expect(parser.parse(name, '')).to match({name => nil}) 48 | end 49 | end 50 | 51 | context 'with nil' do 52 | it 'returns nil' do 53 | expect(parser.parse(name, nil)).to match({name => nil}) 54 | end 55 | end 56 | 57 | context 'with array value' do 58 | it 'raises an exception' do 59 | expect { parser.parse(name, ['true']) } 60 | .to raise_error(Decanter::ParseError) 61 | expect { parser.parse(name, []) } 62 | .to raise_error(Decanter::ParseError) 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/decanter/parser/date_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'DateParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::DateParser } 8 | 9 | describe '#parse' do 10 | context 'with a valid date string of default form ' do 11 | it 'returns the date' do 12 | expect(parser.parse(name, '2/21/1990')).to match({name => Date.new(1990,2,21)}) 13 | end 14 | end 15 | 16 | context 'with an invalid date string' do 17 | it 'raises an Argument Error' do 18 | expect { parser.parse(name, '2-21-1990') } 19 | .to raise_error(ArgumentError, 'invalid date') 20 | end 21 | end 22 | 23 | context 'with a date' do 24 | it 'returns the date' do 25 | expect(parser.parse(name, Date.new(1990,2,21))).to match({name => Date.new(1990,2,21)}) 26 | end 27 | end 28 | 29 | context 'with a valid date string and custom format' do 30 | it 'returns the date' do 31 | expect(parser.parse(name, '2-21-1990', parse_format: '%m-%d-%Y')).to match({name => Date.new(1990,2,21)}) 32 | end 33 | end 34 | 35 | context 'with empty string' do 36 | it 'returns nil' do 37 | expect(parser.parse(name, '')).to match({name => nil}) 38 | end 39 | end 40 | 41 | context 'with nil' do 42 | it 'returns nil' do 43 | expect(parser.parse(name, nil)).to match({name => nil}) 44 | end 45 | end 46 | 47 | context 'with array value' do 48 | it 'raises an exception' do 49 | expect { parser.parse(name, ['2-21-1990']) } 50 | .to raise_error(Decanter::ParseError) 51 | expect { parser.parse(name, []) } 52 | .to raise_error(Decanter::ParseError) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/decanter/parser/datetime_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'DateTimeParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::DateTimeParser } 8 | 9 | describe '#parse' do 10 | context 'with a valid datetime string of default form ' do 11 | it 'returns the datetime' do 12 | expect(parser.parse(name, '2/21/1990 04:15:16 PM')).to match({name => DateTime.new(1990,2,21,16,15,16)}) 13 | end 14 | end 15 | 16 | context 'with an invalid date string' do 17 | it 'raises an Argument Error' do 18 | expect { parser.parse(name, '2-21-1990') } 19 | .to raise_error(ArgumentError, 'invalid date') 20 | end 21 | end 22 | 23 | context 'with a datetime' do 24 | it 'returns the datetime' do 25 | expect(parser.parse(name, DateTime.new(1990,2,21))).to match({name => DateTime.new(1990,2,21)}) 26 | end 27 | end 28 | 29 | context 'with a valid date string and custom format' do 30 | it 'returns the date' do 31 | expect(parser.parse(name, '2-21-1990', parse_format: '%m-%d-%Y')).to match({name => DateTime.new(1990,2,21)}) 32 | end 33 | end 34 | 35 | context 'with empty string' do 36 | it 'returns nil' do 37 | expect(parser.parse(name, '')).to match({name => nil}) 38 | end 39 | end 40 | 41 | context 'with nil' do 42 | it 'returns nil' do 43 | expect(parser.parse(name, nil)).to match({name => nil}) 44 | end 45 | end 46 | 47 | context 'with array value' do 48 | it 'raises an exception' do 49 | expect { parser.parse(name, ['2/21/1990 04:15:16 PM']) } 50 | .to raise_error(Decanter::ParseError) 51 | expect { parser.parse(name, []) } 52 | .to raise_error(Decanter::ParseError) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/decanter/parser/float_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'FloatParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::FloatParser } 8 | 9 | describe '#parse' do 10 | context 'with a string' do 11 | context 'with a positive value' do 12 | it 'returns a positive float' do 13 | expect(parser.parse(name, '1.00')).to eq({ foo: 1.00 }) 14 | end 15 | end 16 | 17 | context 'with a negative value' do 18 | it 'returns a negative float' do 19 | expect(parser.parse(name, '-1.00')).to eq({ foo: -1.00 }) 20 | end 21 | end 22 | end 23 | 24 | context 'with empty string' do 25 | it 'returns nil' do 26 | expect(parser.parse(name, '')).to eq({ foo: nil }) 27 | end 28 | end 29 | 30 | context 'with nil' do 31 | it 'returns nil' do 32 | expect(parser.parse(name, nil)).to eq({ foo: nil }) 33 | end 34 | end 35 | 36 | context 'with array value' do 37 | it 'raises an exception' do 38 | expect { parser.parse(name, ['1.00']) } 39 | .to raise_error(Decanter::ParseError) 40 | expect { parser.parse(name, []) } 41 | .to raise_error(Decanter::ParseError) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/decanter/parser/hash_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'HashParser' do 4 | 5 | let(:parser) { 6 | # Mock parser just passes value through 7 | Class.new(Decanter::Parser::HashParser) do 8 | parser do |_name, val, _options| 9 | val 10 | end 11 | end 12 | } 13 | 14 | context 'when the result is a hash' do 15 | it 'returns the hash directly' do 16 | expect(parser.parse(:first_name, { foo: 'bar' })).to eq({ foo: 'bar' }) 17 | end 18 | end 19 | 20 | context 'when the result is not a hash' do 21 | it 'raises an argument error' do 22 | expect { parser.parse(:first_name, 'bar') } 23 | .to raise_error(ArgumentError, "Result of HashParser was bar when it must be a hash.") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/decanter/parser/integer_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'IntegerParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::IntegerParser } 8 | 9 | describe '#parse' do 10 | context 'with a string' do 11 | context 'with a positive value' do 12 | it 'returns a positive integer' do 13 | expect(parser.parse(name, '1')).to eq({ foo: 1 }) 14 | end 15 | end 16 | 17 | context 'with a negative value' do 18 | it 'returns a negative integer' do 19 | expect(parser.parse(name, '-1')).to eq({ foo: -1 }) 20 | end 21 | end 22 | end 23 | 24 | context 'with empty string' do 25 | it 'returns nil' do 26 | expect(parser.parse(name, '')).to eq({ foo: nil }) 27 | end 28 | end 29 | 30 | context 'with nil' do 31 | it 'returns nil' do 32 | expect(parser.parse(name, nil)).to eq({ foo: nil }) 33 | end 34 | end 35 | 36 | context 'with array value' do 37 | it 'raises an exception' do 38 | expect { parser.parse(name, ['1']) } 39 | .to raise_error(Decanter::ParseError) 40 | expect { parser.parse(name, []) } 41 | .to raise_error(Decanter::ParseError) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/decanter/parser/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Decanter::Parser do 4 | 5 | before(:all) do 6 | 7 | Object.const_set('FooParser', 8 | Class.new(Decanter::Parser::ValueParser) do 9 | def self.name 10 | 'FooParser' 11 | end 12 | end 13 | ) 14 | 15 | Object.const_set('BarParser', 16 | Class.new(Decanter::Parser::ValueParser) do 17 | def self.name 18 | 'BarParser' 19 | end 20 | end.tap do |parser| 21 | parser.pre :date, :float 22 | end 23 | ) 24 | Object.const_set('CheckInFieldDataParser', 25 | Class.new(Decanter::Parser::ValueParser) do 26 | def self.name 27 | 'BarParser' 28 | end 29 | end.tap do |parser| 30 | parser.pre :date, :float 31 | end 32 | ) 33 | end 34 | 35 | describe '#parsers_for' do 36 | 37 | subject { Decanter::Parser.parsers_for(:bar) } 38 | 39 | let(:_data) { Decanter::Parser.parsers_for(:check_in_field_data) } 40 | 41 | it 'returns a flattened array of parsers' do 42 | expect(subject).to eq [ 43 | Decanter::Parser::DateParser, 44 | Decanter::Parser::FloatParser, 45 | BarParser 46 | ] 47 | end 48 | 49 | it 'returns Data instead of Datum' do 50 | expect(_data).to eq [ 51 | Decanter::Parser::DateParser, 52 | Decanter::Parser::FloatParser, 53 | CheckInFieldDataParser 54 | ] 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/decanter/parser/pass_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'PassParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::PassParser } 8 | 9 | describe '#parse' do 10 | it 'lets anything through' do 11 | expect(parser.parse(name, '(12)2-21/19.90')).to match({name =>'(12)2-21/19.90'}) 12 | end 13 | 14 | context 'with empty string' do 15 | it 'returns nil' do 16 | expect(parser.parse(name, '')).to match({name => nil}) 17 | end 18 | end 19 | 20 | context 'with nil' do 21 | it 'returns nil' do 22 | expect(parser.parse(name, nil)).to match({name => nil}) 23 | end 24 | end 25 | 26 | context 'with array value' do 27 | it 'returns the array value' do 28 | expect(parser.parse(name, ['123'])).to match({name => ['123']}) 29 | expect(parser.parse(name, [])).to match({name => []}) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/decanter/parser/phone_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'PhoneParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::PhoneParser } 8 | 9 | describe '#parse' do 10 | it 'strips all non-numbers from value and returns a string' do 11 | expect(parser.parse(:foo, '(12)2-21/19.90')).to match({:foo =>'122211990'}) 12 | end 13 | 14 | context 'with empty string' do 15 | it 'returns nil' do 16 | expect(parser.parse(name, '')).to match({name => nil}) 17 | end 18 | end 19 | 20 | context 'with nil' do 21 | it 'returns nil' do 22 | expect(parser.parse(name, nil)).to match({name => nil}) 23 | end 24 | end 25 | 26 | context 'with array value' do 27 | it 'raises an exception' do 28 | expect { parser.parse(name, ['(12)2-21/19.90']) } 29 | .to raise_error(Decanter::ParseError) 30 | expect { parser.parse(name, []) } 31 | .to raise_error(Decanter::ParseError) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/decanter/parser/string_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'StringParser' do 4 | 5 | let(:name) { :foo } 6 | 7 | let(:parser) { Decanter::Parser::StringParser } 8 | 9 | describe '#parse' do 10 | context 'with integer' do 11 | it 'returns string' do 12 | expect(parser.parse(name, 8)).to match({name => '8'}) 13 | end 14 | end 15 | 16 | context 'with string' do 17 | it 'returns a string' do 18 | expect(parser.parse(name, 'bar')).to match({name => 'bar'}) 19 | end 20 | end 21 | 22 | context 'with empty string' do 23 | it 'returns nil' do 24 | expect(parser.parse(name, '')).to match({name => nil}) 25 | end 26 | end 27 | 28 | context 'with nil' do 29 | it 'returns nil' do 30 | expect(parser.parse(name, nil)).to match({name => nil}) 31 | end 32 | end 33 | 34 | context 'with array value' do 35 | it 'raises an exception' do 36 | expect { parser.parse(name, [8]) } 37 | .to raise_error(Decanter::ParseError) 38 | expect { parser.parse(name, []) } 39 | .to raise_error(Decanter::ParseError) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/decanter/parser/value_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'ValueParser' do 4 | 5 | let(:parser) { 6 | # Mock parser just passes value through 7 | Class.new(Decanter::Parser::ValueParser) do 8 | parser do |val, _options| 9 | val 10 | end 11 | end 12 | } 13 | 14 | context 'when the result is a single value' do 15 | it 'returns a hash with the value keyed under the name' do 16 | expect(parser.parse(:foo, 'bar')).to match({ foo: 'bar' }) 17 | end 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'dotenv' 2 | Dotenv.load 3 | # Report Coverage to Code Climate 4 | require 'simplecov' 5 | SimpleCov.start 6 | 7 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 8 | require 'decanter' 9 | 10 | RSpec.configure do |config| 11 | config.filter_run_when_matching focus: true 12 | end 13 | --------------------------------------------------------------------------------