├── .devcontainer ├── Dockerfile ├── base.Dockerfile └── devcontainer.json ├── .github └── workflows │ ├── ci.yml │ └── rubocop.yml ├── .gitignore ├── .rubocop-https---raw-githubusercontent-com-rails-rails-main--rubocop-yml ├── .rubocop.yml ├── CONTRIBUTING.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── activeresource.gemspec ├── examples └── performance.rb ├── lib ├── active_resource.rb ├── active_resource │ ├── active_job_serializer.rb │ ├── associations.rb │ ├── associations │ │ └── builder │ │ │ ├── association.rb │ │ │ ├── belongs_to.rb │ │ │ ├── has_many.rb │ │ │ └── has_one.rb │ ├── base.rb │ ├── callbacks.rb │ ├── collection.rb │ ├── connection.rb │ ├── custom_methods.rb │ ├── exceptions.rb │ ├── formats.rb │ ├── formats │ │ ├── json_format.rb │ │ └── xml_format.rb │ ├── http_mock.rb │ ├── inheriting_hash.rb │ ├── log_subscriber.rb │ ├── railtie.rb │ ├── reflection.rb │ ├── schema.rb │ ├── singleton.rb │ ├── threadsafe_attributes.rb │ ├── validations.rb │ └── version.rb └── activeresource.rb └── test ├── abstract_unit.rb ├── cases ├── active_job_serializer_test.rb ├── association_test.rb ├── associations │ └── builder │ │ ├── belongs_to_test.rb │ │ ├── has_many_test.rb │ │ └── has_one_test.rb ├── authorization_test.rb ├── base │ ├── custom_methods_test.rb │ ├── equality_test.rb │ ├── load_test.rb │ └── schema_test.rb ├── base_errors_test.rb ├── base_test.rb ├── callbacks_test.rb ├── collection_test.rb ├── connection_test.rb ├── finder_test.rb ├── format_test.rb ├── http_mock_test.rb ├── inheritence_test.rb ├── inheriting_hash_test.rb ├── log_subscriber_test.rb ├── reflection_test.rb └── validations_test.rb ├── fixtures ├── address.rb ├── beast.rb ├── comment.rb ├── customer.rb ├── inventory.rb ├── person.rb ├── pet.rb ├── post.rb ├── product.rb ├── project.rb ├── proxy.rb ├── sound.rb ├── street_address.rb ├── subscription_plan.rb └── weather.rb ├── setter_trap.rb ├── singleton_test.rb └── threadsafe_attributes_test.rb /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} 4 | 5 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 6 | ARG NODE_VERSION="none" 7 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 8 | 9 | # [Optional] Uncomment this section to install additional OS packages. 10 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 11 | # && apt-get -y install --no-install-recommends 12 | 13 | # [Optional] Uncomment this line to install additional gems. 14 | # RUN gem install 15 | 16 | # [Optional] Uncomment this line to install global node packages. 17 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/base.Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM ruby:${VARIANT} 4 | 5 | # Copy library scripts to execute 6 | COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/ 7 | 8 | # [Option] Install zsh 9 | ARG INSTALL_ZSH="true" 10 | # [Option] Upgrade OS packages to their latest versions 11 | ARG UPGRADE_PACKAGES="true" 12 | # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. 13 | ARG USERNAME=vscode 14 | ARG USER_UID=1000 15 | ARG USER_GID=$USER_UID 16 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 17 | # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131 18 | && apt-get purge -y imagemagick imagemagick-6-common \ 19 | # Install common packages, non-root user, rvm, core build tools 20 | && bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ 21 | && bash /tmp/library-scripts/ruby-debian.sh "none" "${USERNAME}" "true" "true" \ 22 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* 23 | 24 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 25 | ARG NODE_VERSION="none" 26 | ENV NVM_DIR=/usr/local/share/nvm 27 | ENV NVM_SYMLINK_CURRENT=true \ 28 | PATH=${NVM_DIR}/current/bin:${PATH} 29 | RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \ 30 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 31 | 32 | # Remove library scripts for final image 33 | RUN rm -rf /tmp/library-scripts 34 | 35 | # [Optional] Uncomment this section to install additional OS packages. 36 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 37 | # && apt-get -y install --no-install-recommends 38 | 39 | # [Optional] Uncomment this line to install additional gems. 40 | # RUN gem install 41 | 42 | # [Optional] Uncomment this line to install global node packages. 43 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/ruby 3 | { 4 | "name": "Ruby", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 9 | // Append -bullseye or -buster to pin to an OS version. 10 | // Use -bullseye variants on local on arm64/Apple Silicon. 11 | "VARIANT": "3-bullseye", 12 | // Options 13 | "NODE_VERSION": "lts/*" 14 | } 15 | }, 16 | 17 | // Set *default* container specific settings.json values on container create. 18 | "settings": {}, 19 | 20 | // Add the IDs of extensions you want installed when the container is created. 21 | "extensions": [ 22 | "rebornix.Ruby" 23 | ], 24 | 25 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 26 | // "forwardPorts": [], 27 | 28 | // Use 'postCreateCommand' to run commands after the container is created. 29 | // "postCreateCommand": "ruby --version", 30 | 31 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 32 | "remoteUser": "vscode", 33 | "features": { 34 | "github-cli": "latest" 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - ruby: "2.7" 13 | env: 14 | BRANCH: 7-0-stable 15 | experimental: false 16 | - ruby: "3.0" 17 | env: 18 | BRANCH: 7-0-stable 19 | experimental: false 20 | - ruby: "3.1" 21 | env: 22 | BRANCH: 7-0-stable 23 | experimental: false 24 | - ruby: "3.2" 25 | env: 26 | BRANCH: 7-0-stable 27 | experimental: false 28 | - ruby: "3.3" 29 | env: 30 | BRANCH: 7-0-stable 31 | 32 | - ruby: "2.7" 33 | env: 34 | BRANCH: 7-1-stable 35 | experimental: false 36 | - ruby: "3.0" 37 | env: 38 | BRANCH: 7-1-stable 39 | experimental: false 40 | - ruby: "3.1" 41 | env: 42 | BRANCH: 7-1-stable 43 | experimental: false 44 | - ruby: "3.2" 45 | env: 46 | BRANCH: 7-1-stable 47 | experimental: false 48 | - ruby: "3.3" 49 | env: 50 | BRANCH: 7-1-stable 51 | 52 | - ruby: "3.1" 53 | env: 54 | BRANCH: 7-2-stable 55 | experimental: false 56 | - ruby: "3.2" 57 | env: 58 | BRANCH: 7-2-stable 59 | experimental: false 60 | - ruby: "3.3" 61 | env: 62 | BRANCH: 7-2-stable 63 | 64 | - ruby: "3.2" 65 | env: 66 | BRANCH: 8-0-stable 67 | experimental: false 68 | - ruby: "3.3" 69 | env: 70 | BRANCH: 8-0-stable 71 | 72 | - ruby: "3.2" 73 | env: 74 | BRANCH: main 75 | experimental: true 76 | - ruby: "3.3" 77 | env: 78 | BRANCH: main 79 | experimental: true 80 | - ruby: head 81 | env: 82 | BRANCH: main 83 | experimental: true 84 | steps: 85 | - uses: actions/checkout@v4 86 | - name: Set up Ruby 87 | uses: ruby/setup-ruby@v1 88 | env: ${{ matrix.env }} 89 | with: 90 | ruby-version: ${{ matrix.ruby }} 91 | bundler-cache: true 92 | - name: Run tests 93 | run: bundle exec rake 94 | env: ${{ matrix.env }} 95 | continue-on-error: ${{ matrix.experimental }} 96 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Set up Ruby 3.0 13 | uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: 3.0 16 | bundler-cache: true 17 | 18 | - name: Run RuboCop 19 | run: bundle exec rubocop --parallel 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't put *.swp, *.bak, etc here; those belong in a global ~/.gitignore. 2 | # Check out http://help.github.com/ignore-files/ for how to set that up. 3 | 4 | debug.log 5 | .Gemfile 6 | /.bundle 7 | /.ruby-version 8 | *.lock 9 | /pkg 10 | /dist 11 | /doc/rdoc 12 | /*/doc 13 | /*/test/tmp 14 | /RDOC_MAIN.rdoc 15 | -------------------------------------------------------------------------------- /.rubocop-https---raw-githubusercontent-com-rails-rails-main--rubocop-yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-minitest 3 | - rubocop-packaging 4 | - rubocop-performance 5 | - rubocop-rails 6 | 7 | AllCops: 8 | TargetRubyVersion: 2.7 9 | # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop 10 | # to ignore them, so only the ones explicitly set in this file are enabled. 11 | DisabledByDefault: true 12 | SuggestExtensions: false 13 | Exclude: 14 | - '**/tmp/**/*' 15 | - '**/templates/**/*' 16 | - '**/vendor/**/*' 17 | - 'actionpack/lib/action_dispatch/journey/parser.rb' 18 | - 'actionmailbox/test/dummy/**/*' 19 | - 'actiontext/test/dummy/**/*' 20 | - '**/node_modules/**/*' 21 | 22 | Performance: 23 | Exclude: 24 | - '**/test/**/*' 25 | 26 | # Prefer assert_not over assert ! 27 | Rails/AssertNot: 28 | Include: 29 | - '**/test/**/*' 30 | 31 | # Prefer assert_not_x over refute_x 32 | Rails/RefuteMethods: 33 | Include: 34 | - '**/test/**/*' 35 | 36 | Rails/IndexBy: 37 | Enabled: true 38 | 39 | Rails/IndexWith: 40 | Enabled: true 41 | 42 | # Prefer &&/|| over and/or. 43 | Style/AndOr: 44 | Enabled: true 45 | 46 | # Align `when` with `case`. 47 | Layout/CaseIndentation: 48 | Enabled: true 49 | 50 | Layout/ClosingHeredocIndentation: 51 | Enabled: true 52 | 53 | Layout/ClosingParenthesisIndentation: 54 | Enabled: true 55 | 56 | # Align comments with method definitions. 57 | Layout/CommentIndentation: 58 | Enabled: true 59 | 60 | Layout/ElseAlignment: 61 | Enabled: true 62 | 63 | # Align `end` with the matching keyword or starting expression except for 64 | # assignments, where it should be aligned with the LHS. 65 | Layout/EndAlignment: 66 | Enabled: true 67 | EnforcedStyleAlignWith: variable 68 | AutoCorrect: true 69 | 70 | Layout/EndOfLine: 71 | Enabled: true 72 | 73 | Layout/EmptyLineAfterMagicComment: 74 | Enabled: true 75 | 76 | Layout/EmptyLinesAroundAccessModifier: 77 | Enabled: true 78 | EnforcedStyle: only_before 79 | 80 | Layout/EmptyLinesAroundBlockBody: 81 | Enabled: true 82 | 83 | # In a regular class definition, no empty lines around the body. 84 | Layout/EmptyLinesAroundClassBody: 85 | Enabled: true 86 | 87 | # In a regular method definition, no empty lines around the body. 88 | Layout/EmptyLinesAroundMethodBody: 89 | Enabled: true 90 | 91 | # In a regular module definition, no empty lines around the body. 92 | Layout/EmptyLinesAroundModuleBody: 93 | Enabled: true 94 | 95 | # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. 96 | Style/HashSyntax: 97 | Enabled: true 98 | 99 | # Method definitions after `private` or `protected` isolated calls need one 100 | # extra level of indentation. 101 | Layout/IndentationConsistency: 102 | Enabled: true 103 | EnforcedStyle: indented_internal_methods 104 | 105 | # Two spaces, no tabs (for indentation). 106 | Layout/IndentationWidth: 107 | Enabled: true 108 | 109 | Layout/LeadingCommentSpace: 110 | Enabled: true 111 | 112 | Layout/SpaceAfterColon: 113 | Enabled: true 114 | 115 | Layout/SpaceAfterComma: 116 | Enabled: true 117 | 118 | Layout/SpaceAfterSemicolon: 119 | Enabled: true 120 | 121 | Layout/SpaceAroundEqualsInParameterDefault: 122 | Enabled: true 123 | 124 | Layout/SpaceAroundKeyword: 125 | Enabled: true 126 | 127 | Layout/SpaceAroundOperators: 128 | Enabled: true 129 | 130 | Layout/SpaceBeforeComma: 131 | Enabled: true 132 | 133 | Layout/SpaceBeforeComment: 134 | Enabled: true 135 | 136 | Layout/SpaceBeforeFirstArg: 137 | Enabled: true 138 | 139 | Style/DefWithParentheses: 140 | Enabled: true 141 | 142 | # Defining a method with parameters needs parentheses. 143 | Style/MethodDefParentheses: 144 | Enabled: true 145 | 146 | Style/ExplicitBlockArgument: 147 | Enabled: true 148 | 149 | Style/FrozenStringLiteralComment: 150 | Enabled: true 151 | EnforcedStyle: always 152 | Exclude: 153 | - 'actionview/test/**/*.builder' 154 | - 'actionview/test/**/*.ruby' 155 | - 'actionpack/test/**/*.builder' 156 | - 'actionpack/test/**/*.ruby' 157 | - 'activestorage/db/migrate/**/*.rb' 158 | - 'activestorage/db/update_migrate/**/*.rb' 159 | - 'actionmailbox/db/migrate/**/*.rb' 160 | - 'actiontext/db/migrate/**/*.rb' 161 | 162 | Style/RedundantFreeze: 163 | Enabled: true 164 | 165 | # Use `foo {}` not `foo{}`. 166 | Layout/SpaceBeforeBlockBraces: 167 | Enabled: true 168 | 169 | # Use `foo { bar }` not `foo {bar}`. 170 | Layout/SpaceInsideBlockBraces: 171 | Enabled: true 172 | EnforcedStyleForEmptyBraces: space 173 | 174 | # Use `{ a: 1 }` not `{a:1}`. 175 | Layout/SpaceInsideHashLiteralBraces: 176 | Enabled: true 177 | 178 | Layout/SpaceInsideParens: 179 | Enabled: true 180 | 181 | # Check quotes usage according to lint rule below. 182 | Style/StringLiterals: 183 | Enabled: true 184 | EnforcedStyle: double_quotes 185 | 186 | # Detect hard tabs, no hard tabs. 187 | Layout/IndentationStyle: 188 | Enabled: true 189 | 190 | # Empty lines should not have any spaces. 191 | Layout/TrailingEmptyLines: 192 | Enabled: true 193 | 194 | # No trailing whitespace. 195 | Layout/TrailingWhitespace: 196 | Enabled: true 197 | 198 | # Use quotes for string literals when they are enough. 199 | Style/RedundantPercentQ: 200 | Enabled: true 201 | 202 | Lint/AmbiguousOperator: 203 | Enabled: true 204 | 205 | Lint/AmbiguousRegexpLiteral: 206 | Enabled: true 207 | 208 | Lint/DuplicateRequire: 209 | Enabled: true 210 | 211 | Lint/DuplicateMethods: 212 | Enabled: true 213 | 214 | Lint/ErbNewArguments: 215 | Enabled: true 216 | 217 | # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. 218 | Lint/RequireParentheses: 219 | Enabled: true 220 | 221 | Lint/RedundantStringCoercion: 222 | Enabled: true 223 | 224 | Lint/UriEscapeUnescape: 225 | Enabled: true 226 | 227 | Lint/UselessAssignment: 228 | Enabled: true 229 | 230 | Lint/DeprecatedClassMethods: 231 | Enabled: true 232 | 233 | Style/ParenthesesAroundCondition: 234 | Enabled: true 235 | 236 | Style/HashTransformKeys: 237 | Enabled: true 238 | 239 | Style/HashTransformValues: 240 | Enabled: true 241 | 242 | Style/RedundantBegin: 243 | Enabled: true 244 | 245 | Style/RedundantReturn: 246 | Enabled: true 247 | AllowMultipleReturnValues: true 248 | 249 | Style/RedundantRegexpEscape: 250 | Enabled: true 251 | 252 | Style/Semicolon: 253 | Enabled: true 254 | AllowAsExpressionSeparator: true 255 | 256 | # Prefer Foo.method over Foo::method 257 | Style/ColonMethodCall: 258 | Enabled: true 259 | 260 | Style/TrivialAccessors: 261 | Enabled: true 262 | 263 | Performance/BindCall: 264 | Enabled: true 265 | 266 | Performance/FlatMap: 267 | Enabled: true 268 | 269 | Performance/MapCompact: 270 | Enabled: true 271 | 272 | Performance/SelectMap: 273 | Enabled: true 274 | 275 | Performance/RedundantMerge: 276 | Enabled: true 277 | 278 | Performance/StartWith: 279 | Enabled: true 280 | 281 | Performance/EndWith: 282 | Enabled: true 283 | 284 | Performance/RegexpMatch: 285 | Enabled: true 286 | 287 | Performance/ReverseEach: 288 | Enabled: true 289 | 290 | Performance/StringReplacement: 291 | Enabled: true 292 | 293 | Performance/UnfreezeString: 294 | Enabled: true 295 | 296 | Performance/DeletePrefix: 297 | Enabled: true 298 | 299 | Performance/DeleteSuffix: 300 | Enabled: true 301 | 302 | Minitest/UnreachableAssertion: 303 | Enabled: true 304 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: https://raw.githubusercontent.com/rails/rails/main/.rubocop.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.6 5 | DisabledByDefault: true 6 | 7 | Rails/IndexBy: 8 | Enabled: false # until we support Rails version 6.0 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to Active Resource 2 | ===================== 3 | 4 | [![Documentation Status](http://inch-ci.org/github/rails/activeresource.svg?branch=main)](http://inch-ci.org/github/rails/activeresource) 5 | 6 | Active Resource is work of [many contributors](https://github.com/rails/activeresource/graphs/contributors). You're encouraged to submit [pull requests](https://github.com/rails/activeresource/pulls), [propose features and discuss issues](https://github.com/rails/activeresource/issues). 7 | 8 | #### Fork the Project 9 | 10 | Fork the [project on Github](https://github.com/rails/activeresource) and check out your copy. 11 | 12 | ``` 13 | git clone https://github.com/contributor/activeresource.git 14 | cd activeresource 15 | git remote add upstream https://github.com/rails/activeresource.git 16 | ``` 17 | 18 | #### Create a Topic Branch 19 | 20 | Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. 21 | 22 | ``` 23 | git checkout main 24 | git pull upstream main 25 | git checkout -b my-feature-branch 26 | ``` 27 | 28 | #### Bundle Install and Test 29 | 30 | Ensure that you can build the project and run tests. 31 | 32 | ``` 33 | bundle install 34 | bundle exec rake test 35 | ``` 36 | 37 | #### Write Tests 38 | 39 | Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add to [test](test). 40 | 41 | We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. 42 | 43 | #### Write Code 44 | 45 | Implement your feature or bug fix. 46 | 47 | Make sure that `bundle exec rake test` completes without errors. 48 | 49 | #### Write Documentation 50 | 51 | Document any external behavior in the [README](README.md). 52 | 53 | #### Commit Changes 54 | 55 | Make sure git knows your name and email address: 56 | 57 | ``` 58 | git config --global user.name "Your Name" 59 | git config --global user.email "contributor@example.com" 60 | ``` 61 | 62 | Writing good commit logs is important. A commit log should describe what changed and why. 63 | 64 | ``` 65 | git add ... 66 | git commit 67 | ``` 68 | 69 | #### Push 70 | 71 | ``` 72 | git push origin my-feature-branch 73 | ``` 74 | 75 | #### Make a Pull Request 76 | 77 | Go to https://github.com/contributor/activeresource and select your feature branch. Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. 78 | 79 | #### Rebase 80 | 81 | If you've been working on a change for a while, rebase with upstream/main. 82 | 83 | ``` 84 | git fetch upstream 85 | git rebase upstream/main 86 | git push origin my-feature-branch -f 87 | ``` 88 | 89 | #### Check on Your Pull Request 90 | 91 | Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. 92 | 93 | #### Be Patient 94 | 95 | It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! 96 | 97 | #### Thank You 98 | 99 | Please do know that we really appreciate and value your time and work. We love you, really. 100 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo| "https://github.com/#{repo}" } 6 | 7 | branch = ENV.fetch("BRANCH", "main") 8 | gem "activesupport", github: "rails/rails", branch: branch 9 | gem "activemodel", github: "rails/rails", branch: branch 10 | gem "activejob", github: "rails/rails", branch: branch 11 | 12 | gem "rubocop" 13 | gem "rubocop-minitest" 14 | gem "rubocop-packaging" 15 | gem "rubocop-performance" 16 | gem "rubocop-rails" 17 | 18 | gem "minitest-bisect" 19 | 20 | gemspec 21 | 22 | platform :mri do 23 | group :test do 24 | gem "ruby-prof" 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2016 David Heinemeier Hansson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Active Resource 2 | 3 | Active Resource (ARes) connects business objects and Representational State Transfer (REST) 4 | web services. It implements object-relational mapping for REST web services to provide transparent 5 | proxying capabilities between a client (Active Resource) and a RESTful service (which is provided by 6 | Simply RESTful routing in `ActionController::Resources`). 7 | 8 | ## Philosophy 9 | 10 | Active Resource attempts to provide a coherent wrapper object-relational mapping for REST 11 | web services. It follows the same philosophy as Active Record, in that one of its prime aims 12 | is to reduce the amount of code needed to map to these resources. This is made possible 13 | by relying on a number of code- and protocol-based conventions that make it easy for Active Resource 14 | to infer complex relations and structures. These conventions are outlined in detail in the documentation 15 | for `ActiveResource::Base`. 16 | 17 | ## Overview 18 | 19 | Model classes are mapped to remote REST resources by Active Resource much the same way Active Record maps 20 | model classes to database tables. When a request is made to a remote resource, a REST JSON request is 21 | generated, transmitted, and the result received and serialized into a usable Ruby object. 22 | 23 | ## Download and installation 24 | 25 | The latest version of Active Resource can be installed with RubyGems: 26 | 27 | ``` 28 | gem install activeresource 29 | ``` 30 | 31 | Or added to a Gemfile: 32 | 33 | ```ruby 34 | gem 'activeresource' 35 | ``` 36 | 37 | Source code can be downloaded on GitHub 38 | 39 | * https://github.com/rails/activeresource/tree/main 40 | 41 | ### Configuration and Usage 42 | 43 | Putting Active Resource to use is very similar to Active Record. It's as simple as creating a model class 44 | that inherits from `ActiveResource::Base` and providing a `site` class variable to it: 45 | 46 | ```ruby 47 | class Person < ActiveResource::Base 48 | self.site = "http://api.people.com:3000" 49 | end 50 | ``` 51 | 52 | Now the Person class is REST enabled and can invoke REST services very similarly to how Active Record invokes 53 | life cycle methods that operate against a persistent store. 54 | 55 | ```ruby 56 | # Find a person with id = 1 57 | tyler = Person.find(1) 58 | Person.exists?(1) # => true 59 | ``` 60 | 61 | As you can see, the methods are quite similar to Active Record's methods for dealing with database 62 | records. But rather than dealing directly with a database record, you're dealing with HTTP resources 63 | (which may or may not be database records). 64 | 65 | Connection settings (`site`, `headers`, `user`, `password`, `bearer_token`, `proxy`) and the connections 66 | themselves are store in thread-local variables to make them thread-safe, so you can also set these 67 | dynamically, even in a multi-threaded environment, for instance: 68 | 69 | ```ruby 70 | ActiveResource::Base.site = api_site_for(request) 71 | ``` 72 | ### Authentication 73 | 74 | Active Resource supports the token based authentication provided by Rails through the 75 | `ActionController::HttpAuthentication::Token` class using custom headers. 76 | 77 | ```ruby 78 | class Person < ActiveResource::Base 79 | self.headers['Authorization'] = 'Token token="abcd"' 80 | end 81 | ``` 82 | 83 | You can also set any specific HTTP header using the same way. As mentioned above, headers are 84 | thread-safe, so you can set headers dynamically, even in a multi-threaded environment: 85 | 86 | ```ruby 87 | ActiveResource::Base.headers['Authorization'] = current_session_api_token 88 | ``` 89 | 90 | Active Resource supports 2 options for HTTP authentication today. 91 | 92 | 1. Basic 93 | ```ruby 94 | class Person < ActiveResource::Base 95 | self.user = 'my@email.com' 96 | self.password = '123' 97 | end 98 | # username: my@email.com password: 123 99 | ``` 100 | 101 | 2. Bearer Token 102 | ```ruby 103 | class Person < ActiveResource::Base 104 | self.auth_type = :bearer 105 | self.bearer_token = 'my-token123' 106 | end 107 | # Bearer my-token123 108 | ``` 109 | 110 | ### Protocol 111 | 112 | Active Resource is built on a standard JSON or XML format for requesting and submitting resources 113 | over HTTP. It mirrors the RESTful routing built into Action Controller but will also work with any 114 | other REST service that properly implements the protocol. REST uses HTTP, but unlike "typical" web 115 | applications, it makes use of all the verbs available in the HTTP specification: 116 | 117 | * GET requests are used for finding and retrieving resources. 118 | * POST requests are used to create new resources. 119 | * PUT requests are used to update existing resources. 120 | * DELETE requests are used to delete resources. 121 | 122 | For more information on how this protocol works with Active Resource, see the `ActiveResource::Base` documentation; 123 | for more general information on REST web services, see the article 124 | [here](http://en.wikipedia.org/wiki/Representational_State_Transfer). 125 | 126 | ### Find 127 | 128 | Find requests use the GET method and expect the JSON form of whatever resource/resources is/are 129 | being requested. So, for a request for a single element, the JSON of that item is expected in 130 | response: 131 | 132 | ```ruby 133 | # Expects a response of 134 | # 135 | # {"id":1,"first":"Tyler","last":"Durden"} 136 | # 137 | # for GET http://api.people.com:3000/people/1.json 138 | # 139 | tyler = Person.find(1) 140 | ``` 141 | 142 | The JSON document that is received is used to build a new object of type Person, with each 143 | JSON element becoming an attribute on the object. 144 | 145 | ```ruby 146 | tyler.is_a? Person # => true 147 | tyler.last # => 'Durden' 148 | ``` 149 | 150 | Any complex element (one that contains other elements) becomes its own object: 151 | 152 | ```ruby 153 | # With this response: 154 | # {"id":1,"first":"Tyler","address":{"street":"Paper St.","state":"CA"}} 155 | # 156 | # for GET http://api.people.com:3000/people/1.json 157 | # 158 | tyler = Person.find(1) 159 | tyler.address # => 160 | tyler.address.street # => 'Paper St.' 161 | ``` 162 | 163 | Collections can also be requested in a similar fashion 164 | 165 | ```ruby 166 | # Expects a response of 167 | # 168 | # [ 169 | # {"id":1,"first":"Tyler","last":"Durden"}, 170 | # {"id":2,"first":"Tony","last":"Stark",} 171 | # ] 172 | # 173 | # for GET http://api.people.com:3000/people.json 174 | # 175 | people = Person.all 176 | people.first # => 'Tyler' ...> 177 | people.last # => 'Tony' ...> 178 | ``` 179 | 180 | ### Create 181 | 182 | Creating a new resource submits the JSON form of the resource as the body of the request and expects 183 | a 'Location' header in the response with the RESTful URL location of the newly created resource. The 184 | id of the newly created resource is parsed out of the Location response header and automatically set 185 | as the id of the ARes object. 186 | 187 | ```ruby 188 | # {"first":"Tyler","last":"Durden"} 189 | # 190 | # is submitted as the body on 191 | # 192 | # if include_root_in_json is not set or set to false => {"first":"Tyler"} 193 | # if include_root_in_json is set to true => {"person":{"first":"Tyler"}} 194 | # 195 | # POST http://api.people.com:3000/people.json 196 | # 197 | # when save is called on a new Person object. An empty response is 198 | # is expected with a 'Location' header value: 199 | # 200 | # Response (201): Location: http://api.people.com:3000/people/2 201 | # 202 | tyler = Person.new(:first => 'Tyler') 203 | tyler.new? # => true 204 | tyler.save # => true 205 | tyler.new? # => false 206 | tyler.id # => 2 207 | ``` 208 | 209 | ### Update 210 | 211 | 'save' is also used to update an existing resource and follows the same protocol as creating a resource 212 | with the exception that no response headers are needed -- just an empty response when the update on the 213 | server side was successful. 214 | 215 | ```ruby 216 | # {"first":"Tyler"} 217 | # 218 | # is submitted as the body on 219 | # 220 | # if include_root_in_json is not set or set to false => {"first":"Tyler"} 221 | # if include_root_in_json is set to true => {"person":{"first":"Tyler"}} 222 | # 223 | # PUT http://api.people.com:3000/people/1.json 224 | # 225 | # when save is called on an existing Person object. An empty response is 226 | # is expected with code (204) 227 | # 228 | tyler = Person.find(1) 229 | tyler.first # => 'Tyler' 230 | tyler.first = 'Tyson' 231 | tyler.save # => true 232 | ``` 233 | 234 | ### Delete 235 | 236 | Destruction of a resource can be invoked as a class and instance method of the resource. 237 | 238 | ```ruby 239 | # A request is made to 240 | # 241 | # DELETE http://api.people.com:3000/people/1.json 242 | # 243 | # for both of these forms. An empty response with 244 | # is expected with response code (200) 245 | # 246 | tyler = Person.find(1) 247 | tyler.destroy # => true 248 | tyler.exists? # => false 249 | Person.delete(2) # => true 250 | Person.exists?(2) # => false 251 | ``` 252 | 253 | ### Associations 254 | 255 | Relationships between resources can be declared using the standard association syntax 256 | that should be familiar to anyone who uses Active Record. For example, using the 257 | class definition below: 258 | 259 | ```ruby 260 | class Post < ActiveResource::Base 261 | self.site = "http://blog.io" 262 | has_many :comments 263 | end 264 | 265 | post = Post.find(1) # issues GET http://blog.io/posts/1.json 266 | comments = post.comments # issues GET http://blog.io/comments.json?post_id=1 267 | ``` 268 | 269 | In this case, the `Comment` model would have to be implemented as Active Resource, too. 270 | 271 | If you control the server, you may wish to include nested resources thus avoiding a 272 | second network request. Given the resource above, if the response includes comments 273 | in the response, they will be automatically loaded into the Active Resource object. 274 | The server-side model can be adjusted as follows to include comments in the response. 275 | 276 | ```ruby 277 | class Post < ActiveRecord::Base 278 | has_many :comments 279 | 280 | def as_json(options) 281 | super.merge(:include=>[:comments]) 282 | end 283 | end 284 | ``` 285 | 286 | ### Logging 287 | 288 | Active Resource instruments the event `request.active_resource` when doing a request 289 | to the remote service. You can subscribe to it by doing: 290 | 291 | ```ruby 292 | ActiveSupport::Notifications.subscribe('request.active_resource') do |name, start, finish, id, payload| 293 | ``` 294 | 295 | The `payload` is a `Hash` with the following keys: 296 | 297 | * `method` as a `Symbol` 298 | * `request_uri` as a `String` 299 | * `result` as an `Net::HTTPResponse` 300 | 301 | ## License 302 | 303 | Active Resource is released under the MIT license: 304 | 305 | * http://www.opensource.org/licenses/MIT 306 | 307 | ## Contributing to Active Resource 308 | 309 | Active Resource is work of many contributors. You're encouraged to submit pull requests, propose 310 | features and discuss issues. 311 | 312 | See [CONTRIBUTING](https://github.com/rails/activeresource/blob/main/CONTRIBUTING.md). 313 | 314 | ## Support 315 | 316 | Full API documentation is available at 317 | 318 | * http://rubydoc.info/gems/activeresource 319 | 320 | Bug reports and feature requests can be filed with the rest for the Ruby on Rails project here: 321 | 322 | * https://github.com/rails/activeresource/issues 323 | 324 | You can find more usage information in the ActiveResource::Base documentation. 325 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | require "rake/testtask" 5 | require "bundler" 6 | Bundler::GemHelper.install_tasks 7 | 8 | desc "Default Task" 9 | task default: [ :test ] 10 | 11 | # Run the unit tests 12 | 13 | Rake::TestTask.new { |t| 14 | t.libs << "test" 15 | t.pattern = "test/**/*_test.rb" 16 | t.warning = true 17 | t.verbose = true 18 | } 19 | 20 | namespace :test do 21 | task :isolated do 22 | ruby = File.join(*RbConfig::CONFIG.values_at("bindir", "RUBY_INSTALL_NAME")) 23 | activesupport_path = "#{File.dirname(__FILE__)}/../activesupport/lib" 24 | Dir.glob("test/**/*_test.rb").all? do |file| 25 | sh(ruby, "-w", "-Ilib:test:#{activesupport_path}", file) 26 | end || raise("Failures") 27 | end 28 | end 29 | 30 | task :lines do 31 | lines, codelines, total_lines, total_codelines = 0, 0, 0, 0 32 | 33 | FileList["lib/active_resource/**/*.rb"].each do |file_name| 34 | next if /vendor/.match?(file_name) 35 | f = File.open(file_name) 36 | 37 | while line = f.gets 38 | lines += 1 39 | next if /^\s*$/.match?(line) 40 | next if /^\s*#/.match?(line) 41 | codelines += 1 42 | end 43 | puts "L: #{sprintf("%4d", lines)}, LOC #{sprintf("%4d", codelines)} | #{file_name}" 44 | 45 | total_lines += lines 46 | total_codelines += codelines 47 | 48 | lines, codelines = 0, 0 49 | end 50 | 51 | puts "Total: Lines #{total_lines}, LOC #{total_codelines}" 52 | end 53 | -------------------------------------------------------------------------------- /activeresource.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("../lib", __FILE__) 4 | require "active_resource/version" 5 | 6 | Gem::Specification.new do |s| 7 | version = ActiveResource::VERSION::STRING 8 | s.platform = Gem::Platform::RUBY 9 | s.name = "activeresource" 10 | s.version = version 11 | s.summary = "REST modeling framework (part of Rails)." 12 | s.description = "REST on Rails. Wrap your RESTful web app with Ruby classes and work with them like Active Record models." 13 | s.license = "MIT" 14 | 15 | s.author = "David Heinemeier Hansson" 16 | s.email = "david@loudthinking.com" 17 | s.homepage = "http://www.rubyonrails.org" 18 | 19 | s.metadata = { 20 | "bug_tracker_uri" => "https://github.com/rails/activeresource/issues", 21 | "changelog_uri" => "https://github.com/rails/activeresource/releases/tag/v#{version}", 22 | "documentation_uri" => "http://rubydoc.info/gems/activeresource", 23 | "source_code_uri" => "https://github.com/rails/activeresource/tree/v#{version}", 24 | "rubygems_mfa_required" => "true", 25 | } 26 | 27 | s.files = Dir["MIT-LICENSE", "README.md", "lib/**/*"] 28 | s.require_path = "lib" 29 | 30 | s.required_ruby_version = ">= 2.6.0" 31 | 32 | s.add_dependency("activesupport", ">= 6.0") 33 | s.add_dependency("activemodel", ">= 6.0") 34 | s.add_dependency("activemodel-serializers-xml", "~> 1.0") 35 | 36 | s.add_development_dependency("rake") 37 | s.add_development_dependency("mocha", ">= 0.13.0") 38 | s.add_development_dependency("rexml") 39 | end 40 | -------------------------------------------------------------------------------- /examples/performance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" 4 | require "active_resource" 5 | require "benchmark" 6 | 7 | TIMES = (ENV["N"] || 10_000).to_i 8 | 9 | # deep nested resource 10 | attrs = { 11 | id: 1, 12 | name: "Luis", 13 | age: 21, 14 | friends: [ 15 | { 16 | name: "JK", 17 | age: 24, 18 | colors: ["red", "green", "blue"], 19 | brothers: [ 20 | { 21 | name: "Mateo", 22 | age: 35, 23 | children: [{ name: "Edith", age: 5 }, { name: "Martha", age: 4 }] 24 | }, 25 | { 26 | name: "Felipe", 27 | age: 33, 28 | children: [{ name: "Bryan", age: 1 }, { name: "Luke", age: 0 }] 29 | } 30 | ] 31 | }, 32 | { 33 | name: "Eduardo", 34 | age: 20, 35 | colors: [], 36 | brothers: [ 37 | { 38 | name: "Sebas", 39 | age: 23, 40 | children: [{ name: "Andres", age: 0 }, { name: "Jorge", age: 2 }] 41 | }, 42 | { 43 | name: "Elsa", 44 | age: 19, 45 | children: [{ name: "Natacha", age: 1 }] 46 | }, 47 | { 48 | name: "Milena", 49 | age: 16, 50 | children: [] 51 | } 52 | ] 53 | } 54 | ] 55 | } 56 | 57 | class Customer < ActiveResource::Base 58 | self.site = "http://37s.sunrise.i:3000" 59 | end 60 | 61 | module Nested 62 | class Customer < ActiveResource::Base 63 | self.site = "http://37s.sunrise.i:3000" 64 | end 65 | end 66 | 67 | Benchmark.bm(40) do |x| 68 | x.report("Model.new (instantiation)") { TIMES.times { Customer.new } } 69 | x.report("Nested::Model.new (instantiation)") { TIMES.times { Nested::Customer.new } } 70 | x.report("Model.new (setting attributes)") { TIMES.times { Customer.new attrs } } 71 | x.report("Nested::Model.new (setting attributes)") { TIMES.times { Nested::Customer.new attrs } } 72 | end 73 | -------------------------------------------------------------------------------- /lib/active_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | #-- 4 | # Copyright (c) 2006-2012 David Heinemeier Hansson 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining 7 | # a copy of this software and associated documentation files (the 8 | # "Software"), to deal in the Software without restriction, including 9 | # without limitation the rights to use, copy, modify, merge, publish, 10 | # distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so, subject to 12 | # the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be 15 | # included in all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | #++ 25 | 26 | require "uri" 27 | 28 | require "active_support" 29 | require "active_model" 30 | require "active_resource/exceptions" 31 | require "active_resource/version" 32 | 33 | module ActiveResource 34 | extend ActiveSupport::Autoload 35 | 36 | URI_PARSER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::RFC2396_Parser.new 37 | 38 | autoload :Base 39 | autoload :Callbacks 40 | autoload :Connection 41 | autoload :CustomMethods 42 | autoload :Formats 43 | autoload :HttpMock 44 | autoload :Schema 45 | autoload :Singleton 46 | autoload :InheritingHash 47 | autoload :Validations 48 | autoload :Collection 49 | 50 | if ActiveSupport::VERSION::STRING >= "7.1" 51 | def self.deprecator 52 | @deprecator ||= ActiveSupport::Deprecation.new(VERSION::STRING, "ActiveResource") 53 | end 54 | else 55 | def self.deprecator 56 | ActiveSupport::Deprecation 57 | end 58 | end 59 | end 60 | 61 | require "active_resource/railtie" if defined?(Rails.application) 62 | -------------------------------------------------------------------------------- /lib/active_resource/active_job_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | class ActiveJobSerializer < ActiveJob::Serializers::ObjectSerializer 5 | def serialize(resource) 6 | super( 7 | "class" => resource.class.name, 8 | "persisted" => resource.persisted?, 9 | "prefix_options" => resource.prefix_options.as_json, 10 | "attributes" => resource.attributes.as_json 11 | ) 12 | end 13 | 14 | def deserialize(hash) 15 | hash["class"].constantize.new(hash["attributes"]).tap do |resource| 16 | resource.persisted = hash["persisted"] 17 | resource.prefix_options = hash["prefix_options"] 18 | end 19 | end 20 | 21 | private 22 | def klass 23 | ActiveResource::Base 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/active_resource/associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource::Associations 4 | module Builder 5 | autoload :Association, "active_resource/associations/builder/association" 6 | autoload :HasMany, "active_resource/associations/builder/has_many" 7 | autoload :HasOne, "active_resource/associations/builder/has_one" 8 | autoload :BelongsTo, "active_resource/associations/builder/belongs_to" 9 | end 10 | 11 | 12 | 13 | # Specifies a one-to-many association. 14 | # 15 | # === Options 16 | # [:class_name] 17 | # Specify the class name of the association. This class name would 18 | # be used for resolving the association class. 19 | # 20 | # ==== Example for [:class_name] - option 21 | # GET /posts/123.json delivers following response body: 22 | # { 23 | # title: "ActiveResource now has associations", 24 | # body: "Lorem Ipsum" 25 | # comments: [ 26 | # { 27 | # content: "..." 28 | # }, 29 | # { 30 | # content: "..." 31 | # } 32 | # ] 33 | # } 34 | # ==== 35 | # 36 | # has_many :comments, :class_name => 'myblog/comment' 37 | # Would resolve those comments into the Myblog::Comment class. 38 | # 39 | # If the response body does not contain an attribute matching the association name 40 | # a request sent to the index action under the current resource. 41 | # For the example above, if the comments are not present the requested path would be: 42 | # GET /posts/123/comments.xml 43 | def has_many(name, options = {}) 44 | Builder::HasMany.build(self, name, options) 45 | end 46 | 47 | # Specifies a one-to-one association. 48 | # 49 | # === Options 50 | # [:class_name] 51 | # Specify the class name of the association. This class name would 52 | # be used for resolving the association class. 53 | # 54 | # ==== Example for [:class_name] - option 55 | # GET /posts/1.json delivers following response body: 56 | # { 57 | # title: "ActiveResource now has associations", 58 | # body: "Lorem Ipsum", 59 | # author: { 60 | # name: "Gabby Blogger", 61 | # } 62 | # } 63 | # ==== 64 | # 65 | # has_one :author, :class_name => 'myblog/author' 66 | # Would resolve this author into the Myblog::Author class. 67 | # 68 | # If the response body does not contain an attribute matching the association name 69 | # a request is sent to a singleton path under the current resource. 70 | # For example, if a Product class has_one :inventory calling Product#inventory 71 | # will generate a request on /products/:product_id/inventory.json. 72 | # 73 | def has_one(name, options = {}) 74 | Builder::HasOne.build(self, name, options) 75 | end 76 | 77 | # Specifies a one-to-one association with another class. This class should only be used 78 | # if this class contains the foreign key. 79 | # 80 | # Methods will be added for retrieval and query for a single associated object, for which 81 | # this object holds an id: 82 | # 83 | # [association(force_reload = false)] 84 | # Returns the associated object. +nil+ is returned if the foreign key is +nil+. 85 | # Throws a ActiveResource::ResourceNotFound exception if the foreign key is not +nil+ 86 | # and the resource is not found. 87 | # 88 | # (+association+ is replaced with the symbol passed as the first argument, so 89 | # belongs_to :post would add among others post.nil?. 90 | # 91 | # === Example 92 | # 93 | # A Comment class declares belongs_to :post, which will add: 94 | # * Comment#post (similar to Post.find(post_id)) 95 | # The declaration can also include an options hash to specialize the behavior of the association. 96 | # 97 | # === Options 98 | # [:class_name] 99 | # Specify the class name for the association. Use it only if that name can't be inferred from association name. 100 | # So belongs_to :post will by default be linked to the Post class, but if the real class name is Article, 101 | # you'll have to specify it with this option. 102 | # [:foreign_key] 103 | # Specify the foreign key used for the association. By default this is guessed to be the name 104 | # of the association with an "_id" suffix. So a class that defines a belongs_to :post 105 | # association will use "post_id" as the default :foreign_key. Similarly, 106 | # belongs_to :article, :class_name => "Post" will use a foreign key 107 | # of "article_id". 108 | # 109 | # Option examples: 110 | # belongs_to :customer, :class_name => 'User' 111 | # Creates a belongs_to association called customer which is represented through the User class. 112 | # 113 | # belongs_to :customer, :foreign_key => 'user_id' 114 | # Creates a belongs_to association called customer which would be resolved by the foreign_key user_id instead of customer_id 115 | # 116 | def belongs_to(name, options = {}) 117 | Builder::BelongsTo.build(self, name, options) 118 | end 119 | 120 | # Defines the belongs_to association finder method 121 | def defines_belongs_to_finder_method(reflection) 122 | method_name = reflection.name 123 | ivar_name = :"@#{method_name}" 124 | 125 | if method_defined?(method_name) 126 | instance_variable_set(ivar_name, nil) 127 | remove_method(method_name) 128 | end 129 | 130 | define_method(method_name) do 131 | if instance_variable_defined?(ivar_name) 132 | instance_variable_get(ivar_name) 133 | elsif attributes.include?(method_name) 134 | attributes[method_name] 135 | elsif association_id = send(reflection.foreign_key) 136 | instance_variable_set(ivar_name, reflection.klass.find(association_id)) 137 | end 138 | end 139 | end 140 | 141 | def defines_has_many_finder_method(reflection) 142 | method_name = reflection.name 143 | ivar_name = :"@#{method_name}" 144 | 145 | define_method(method_name) do 146 | if instance_variable_defined?(ivar_name) 147 | instance_variable_get(ivar_name) 148 | elsif attributes.include?(method_name) 149 | attributes[method_name] 150 | elsif !new_record? 151 | instance_variable_set(ivar_name, reflection.klass.find(:all, params: { "#{self.class.element_name}_id": self.id })) 152 | else 153 | instance_variable_set(ivar_name, self.class.collection_parser.new) 154 | end 155 | end 156 | end 157 | 158 | # Defines the has_one association 159 | def defines_has_one_finder_method(reflection) 160 | method_name = reflection.name 161 | ivar_name = :"@#{method_name}" 162 | 163 | define_method(method_name) do 164 | if instance_variable_defined?(ivar_name) 165 | instance_variable_get(ivar_name) 166 | elsif attributes.include?(method_name) 167 | attributes[method_name] 168 | elsif reflection.klass.respond_to?(:singleton_name) 169 | instance_variable_set(ivar_name, reflection.klass.find(params: { "#{self.class.element_name}_id": self.id })) 170 | else 171 | instance_variable_set(ivar_name, reflection.klass.find(:one, from: "/#{self.class.collection_name}/#{self.id}/#{method_name}#{self.class.format_extension}")) 172 | end 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/active_resource/associations/builder/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource::Associations::Builder 4 | class Association # :nodoc: 5 | # providing a Class-Variable, which will have a different store of subclasses 6 | class_attribute :valid_options 7 | self.valid_options = [:class_name] 8 | 9 | # would identify subclasses of association 10 | class_attribute :macro 11 | 12 | attr_reader :model, :name, :options, :klass 13 | 14 | def self.build(model, name, options) 15 | new(model, name, options).build 16 | end 17 | 18 | def initialize(model, name, options) 19 | @model, @name, @options = model, name, options 20 | end 21 | 22 | def build 23 | validate_options 24 | model.create_reflection(self.class.macro, name, options) 25 | end 26 | 27 | private 28 | def validate_options 29 | options.assert_valid_keys(self.class.valid_options) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/active_resource/associations/builder/belongs_to.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource::Associations::Builder 4 | class BelongsTo < Association 5 | self.valid_options += [:foreign_key] 6 | 7 | self.macro = :belongs_to 8 | 9 | def build 10 | validate_options 11 | reflection = model.create_reflection(self.class.macro, name, options) 12 | model.defines_belongs_to_finder_method(reflection) 13 | reflection 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/active_resource/associations/builder/has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource::Associations::Builder 4 | class HasMany < Association 5 | self.macro = :has_many 6 | 7 | def build 8 | validate_options 9 | model.create_reflection(self.class.macro, name, options).tap do |reflection| 10 | model.defines_has_many_finder_method(reflection) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/active_resource/associations/builder/has_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource::Associations::Builder 4 | class HasOne < Association 5 | self.macro = :has_one 6 | 7 | def build 8 | validate_options 9 | model.create_reflection(self.class.macro, name, options).tap do |reflection| 10 | model.defines_has_one_finder_method(reflection) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/active_resource/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/array/wrap" 4 | 5 | module ActiveResource 6 | module Callbacks 7 | extend ActiveSupport::Concern 8 | 9 | CALLBACKS = [ 10 | :before_validation, :after_validation, :before_save, :around_save, :after_save, 11 | :before_create, :around_create, :after_create, :before_update, :around_update, 12 | :after_update, :before_destroy, :around_destroy, :after_destroy 13 | ] 14 | 15 | included do 16 | extend ActiveModel::Callbacks 17 | include ActiveModel::Validations::Callbacks 18 | 19 | define_model_callbacks :save, :create, :update, :destroy 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/active_resource/collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | require "active_support/inflector" 5 | 6 | module ActiveResource # :nodoc: 7 | class Collection # :nodoc: 8 | SELF_DEFINE_METHODS = [:to_a, :collect!, :map!, :all?] 9 | include Enumerable 10 | delegate :to_yaml, :all?, *(Array.instance_methods(false) - SELF_DEFINE_METHODS), to: :to_a 11 | 12 | # The array of actual elements returned by index actions 13 | attr_accessor :elements, :resource_class, :original_params 14 | 15 | # ActiveResource::Collection is a wrapper to handle parsing index responses that 16 | # do not directly map to Rails conventions. 17 | # 18 | # You can define a custom class that inherits from ActiveResource::Collection 19 | # in order to to set the elements instance. 20 | # 21 | # GET /posts.json delivers following response body: 22 | # { 23 | # posts: [ 24 | # { 25 | # title: "ActiveResource now has associations", 26 | # body: "Lorem Ipsum" 27 | # }, 28 | # {...} 29 | # ], 30 | # next_page: "/posts.json?page=2" 31 | # } 32 | # 33 | # A Post class can be setup to handle it with: 34 | # 35 | # class Post < ActiveResource::Base 36 | # self.site = "http://example.com" 37 | # self.collection_parser = PostCollection 38 | # end 39 | # 40 | # And the collection parser: 41 | # 42 | # class PostCollection < ActiveResource::Collection 43 | # attr_accessor :next_page 44 | # def initialize(parsed = {}) 45 | # @elements = parsed['posts'] 46 | # @next_page = parsed['next_page'] 47 | # end 48 | # end 49 | # 50 | # The result from a find method that returns multiple entries will now be a 51 | # PostParser instance. ActiveResource::Collection includes Enumerable and 52 | # instances can be iterated over just like an array. 53 | # @posts = Post.find(:all) # => PostCollection:xxx 54 | # @posts.next_page # => "/posts.json?page=2" 55 | # @posts.map(&:id) # =>[1, 3, 5 ...] 56 | # 57 | # The initialize method will receive the ActiveResource::Formats parsed result 58 | # and should set @elements. 59 | def initialize(elements = []) 60 | @elements = elements 61 | end 62 | 63 | def to_a 64 | elements 65 | end 66 | 67 | def collect! 68 | return elements unless block_given? 69 | set = [] 70 | each { |o| set << yield(o) } 71 | @elements = set 72 | self 73 | end 74 | alias map! collect! 75 | 76 | def first_or_create(attributes = {}) 77 | first || resource_class.create(original_params.update(attributes)) 78 | rescue NoMethodError 79 | raise "Cannot create resource from resource type: #{resource_class.inspect}" 80 | end 81 | 82 | def first_or_initialize(attributes = {}) 83 | first || resource_class.new(original_params.update(attributes)) 84 | rescue NoMethodError 85 | raise "Cannot build resource from resource type: #{resource_class.inspect}" 86 | end 87 | 88 | def where(clauses = {}) 89 | raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash 90 | new_clauses = original_params.merge(clauses) 91 | resource_class.where(new_clauses) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/active_resource/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/benchmark" 4 | require "active_support/core_ext/object/inclusion" 5 | require "net/https" 6 | require "date" 7 | require "time" 8 | 9 | module ActiveResource 10 | # Class to handle connections to remote web services. 11 | # This class is used by ActiveResource::Base to interface with REST 12 | # services. 13 | class Connection 14 | HTTP_FORMAT_HEADER_NAMES = { get: "Accept", 15 | put: "Content-Type", 16 | post: "Content-Type", 17 | patch: "Content-Type", 18 | delete: "Accept", 19 | head: "Accept" 20 | } 21 | 22 | attr_reader :site, :user, :password, :bearer_token, :auth_type, :timeout, :open_timeout, :read_timeout, :proxy, :ssl_options 23 | attr_accessor :format, :logger 24 | 25 | class << self 26 | def requests 27 | @@requests ||= [] 28 | end 29 | end 30 | 31 | # The +site+ parameter is required and will set the +site+ 32 | # attribute to the URI for the remote resource service. 33 | def initialize(site, format = ActiveResource::Formats::JsonFormat, logger: nil) 34 | raise ArgumentError, "Missing site URI" unless site 35 | @proxy = @user = @password = @bearer_token = nil 36 | self.site = site 37 | self.format = format 38 | self.logger = logger 39 | end 40 | 41 | # Set URI for remote service. 42 | def site=(site) 43 | @site = site.is_a?(URI) ? site : URI.parse(site) 44 | @ssl_options ||= {} if @site.is_a?(URI::HTTPS) 45 | @user = URI_PARSER.unescape(@site.user) if @site.user 46 | @password = URI_PARSER.unescape(@site.password) if @site.password 47 | end 48 | 49 | # Set the proxy for remote service. 50 | def proxy=(proxy) 51 | @proxy = proxy.is_a?(URI) ? proxy : URI.parse(proxy) 52 | end 53 | 54 | # Sets the user for remote service. 55 | attr_writer :user 56 | 57 | # Sets the password for remote service. 58 | attr_writer :password 59 | 60 | # Sets the bearer token for remote service. 61 | attr_writer :bearer_token 62 | 63 | # Sets the auth type for remote service. 64 | def auth_type=(auth_type) 65 | @auth_type = legitimize_auth_type(auth_type) 66 | end 67 | 68 | # Sets the number of seconds after which HTTP requests to the remote service should time out. 69 | attr_writer :timeout 70 | 71 | # Sets the number of seconds after which HTTP connects to the remote service should time out. 72 | attr_writer :open_timeout 73 | 74 | # Sets the number of seconds after which HTTP read requests to the remote service should time out. 75 | attr_writer :read_timeout 76 | 77 | # Hash of options applied to Net::HTTP instance when +site+ protocol is 'https'. 78 | attr_writer :ssl_options 79 | 80 | # Executes a GET request. 81 | # Used to get (find) resources. 82 | def get(path, headers = {}) 83 | with_auth { request(:get, path, build_request_headers(headers, :get, self.site.merge(path))) } 84 | end 85 | 86 | # Executes a DELETE request (see HTTP protocol documentation if unfamiliar). 87 | # Used to delete resources. 88 | def delete(path, headers = {}) 89 | with_auth { request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path))) } 90 | end 91 | 92 | # Executes a PATCH request (see HTTP protocol documentation if unfamiliar). 93 | # Used to update resources. 94 | def patch(path, body = "", headers = {}) 95 | with_auth { request(:patch, path, body.to_s, build_request_headers(headers, :patch, self.site.merge(path))) } 96 | end 97 | 98 | # Executes a PUT request (see HTTP protocol documentation if unfamiliar). 99 | # Used to update resources. 100 | def put(path, body = "", headers = {}) 101 | with_auth { request(:put, path, body.to_s, build_request_headers(headers, :put, self.site.merge(path))) } 102 | end 103 | 104 | # Executes a POST request. 105 | # Used to create new resources. 106 | def post(path, body = "", headers = {}) 107 | with_auth { request(:post, path, body.to_s, build_request_headers(headers, :post, self.site.merge(path))) } 108 | end 109 | 110 | # Executes a HEAD request. 111 | # Used to obtain meta-information about resources, such as whether they exist and their size (via response headers). 112 | def head(path, headers = {}) 113 | with_auth { request(:head, path, build_request_headers(headers, :head, self.site.merge(path))) } 114 | end 115 | 116 | private 117 | # Makes a request to the remote service. 118 | def request(method, path, *arguments) 119 | result = ActiveSupport::Notifications.instrument("request.active_resource") do |payload| 120 | payload[:method] = method 121 | payload[:request_uri] = "#{site.scheme}://#{site.host}:#{site.port}#{path}" 122 | payload[:result] = http.send(method, path, *arguments) 123 | end 124 | handle_response(result) 125 | rescue Timeout::Error => e 126 | raise TimeoutError.new(e.message) 127 | rescue OpenSSL::SSL::SSLError => e 128 | raise SSLError.new(e.message) 129 | end 130 | 131 | # Handles response and error codes from the remote service. 132 | def handle_response(response) 133 | case response.code.to_i 134 | when 301, 302, 303, 307 135 | raise(Redirection.new(response)) 136 | when 200...400 137 | response 138 | when 400 139 | raise(BadRequest.new(response)) 140 | when 401 141 | raise(UnauthorizedAccess.new(response)) 142 | when 402 143 | raise(PaymentRequired.new(response)) 144 | when 403 145 | raise(ForbiddenAccess.new(response)) 146 | when 404 147 | raise(ResourceNotFound.new(response)) 148 | when 405 149 | raise(MethodNotAllowed.new(response)) 150 | when 409 151 | raise(ResourceConflict.new(response)) 152 | when 410 153 | raise(ResourceGone.new(response)) 154 | when 412 155 | raise(PreconditionFailed.new(response)) 156 | when 422 157 | raise(ResourceInvalid.new(response)) 158 | when 429 159 | raise(TooManyRequests.new(response)) 160 | when 401...500 161 | raise(ClientError.new(response)) 162 | when 500...600 163 | raise(ServerError.new(response)) 164 | else 165 | raise(ConnectionError.new(response, "Unknown response code: #{response.code}")) 166 | end 167 | end 168 | 169 | # Creates new Net::HTTP instance for communication with the 170 | # remote service and resources. 171 | def http 172 | configure_http(new_http) 173 | end 174 | 175 | def new_http 176 | if @proxy 177 | user = URI_PARSER.unescape(@proxy.user) if @proxy.user 178 | password = URI_PARSER.unescape(@proxy.password) if @proxy.password 179 | Net::HTTP.new(@site.host, @site.port, @proxy.host, @proxy.port, user, password) 180 | else 181 | Net::HTTP.new(@site.host, @site.port) 182 | end 183 | end 184 | 185 | def configure_http(http) 186 | apply_ssl_options(http).tap do |https| 187 | # Net::HTTP timeouts default to 60 seconds. 188 | if defined? @timeout 189 | https.open_timeout = @timeout 190 | https.read_timeout = @timeout 191 | end 192 | https.open_timeout = @open_timeout if defined?(@open_timeout) 193 | https.read_timeout = @read_timeout if defined?(@read_timeout) 194 | end 195 | end 196 | 197 | def apply_ssl_options(http) 198 | http.tap do |https| 199 | # Skip config if site is already a https:// URI. 200 | if defined? @ssl_options 201 | http.use_ssl = true 202 | 203 | # All the SSL options have corresponding http settings. 204 | @ssl_options.each { |key, value| http.send "#{key}=", value } 205 | end 206 | end 207 | end 208 | 209 | def default_header 210 | @default_header ||= {} 211 | end 212 | 213 | # Builds headers for request to remote service. 214 | def build_request_headers(headers, http_method, uri) 215 | authorization_header(http_method, uri).update(default_header).update(http_format_header(http_method)).update(headers.to_hash) 216 | end 217 | 218 | def response_auth_header 219 | @response_auth_header ||= "" 220 | end 221 | 222 | def with_auth 223 | retried ||= false 224 | yield 225 | rescue UnauthorizedAccess => e 226 | raise if retried || auth_type != :digest 227 | @response_auth_header = e.response["WWW-Authenticate"] 228 | retried = true 229 | retry 230 | end 231 | 232 | def authorization_header(http_method, uri) 233 | if @user || @password 234 | if auth_type == :digest 235 | { "Authorization" => digest_auth_header(http_method, uri) } 236 | else 237 | { "Authorization" => "Basic " + ["#{@user}:#{@password}"].pack("m").delete("\r\n") } 238 | end 239 | elsif @bearer_token 240 | { "Authorization" => "Bearer #{@bearer_token}" } 241 | else 242 | {} 243 | end 244 | end 245 | 246 | def digest_auth_header(http_method, uri) 247 | params = extract_params_from_response 248 | 249 | request_uri = uri.path 250 | request_uri << "?#{uri.query}" if uri.query 251 | 252 | ha1 = Digest::MD5.hexdigest("#{@user}:#{params['realm']}:#{@password}") 253 | ha2 = Digest::MD5.hexdigest("#{http_method.to_s.upcase}:#{request_uri}") 254 | 255 | params["cnonce"] = client_nonce 256 | request_digest = Digest::MD5.hexdigest([ha1, params["nonce"], "0", params["cnonce"], params["qop"], ha2].join(":")) 257 | "Digest #{auth_attributes_for(uri, request_digest, params)}" 258 | end 259 | 260 | def client_nonce 261 | Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535))) 262 | end 263 | 264 | def extract_params_from_response 265 | params = {} 266 | if response_auth_header =~ /^(\w+) (.*)/ 267 | $2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 } 268 | end 269 | params 270 | end 271 | 272 | def auth_attributes_for(uri, request_digest, params) 273 | auth_attrs = 274 | [ 275 | %Q(username="#{@user}"), 276 | %Q(realm="#{params['realm']}"), 277 | %Q(qop="#{params['qop']}"), 278 | %Q(uri="#{uri.path}"), 279 | %Q(nonce="#{params['nonce']}"), 280 | 'nc="0"', 281 | %Q(cnonce="#{params['cnonce']}"), 282 | %Q(response="#{request_digest}")] 283 | 284 | auth_attrs << %Q(opaque="#{params['opaque']}") unless params["opaque"].blank? 285 | auth_attrs.join(", ") 286 | end 287 | 288 | def http_format_header(http_method) 289 | { HTTP_FORMAT_HEADER_NAMES[http_method] => format.mime_type } 290 | end 291 | 292 | def legitimize_auth_type(auth_type) 293 | return :basic if auth_type.nil? 294 | auth_type = auth_type.to_sym 295 | auth_type.in?([:basic, :digest, :bearer]) ? auth_type : :basic 296 | end 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /lib/active_resource/custom_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/object/blank" 4 | 5 | module ActiveResource 6 | # A module to support custom REST methods and sub-resources, allowing you to break out 7 | # of the "default" REST methods with your own custom resource requests. For example, 8 | # say you use Rails to expose a REST service and configure your routes with: 9 | # 10 | # map.resources :people, :new => { :register => :post }, 11 | # :member => { :promote => :put, :deactivate => :delete } 12 | # :collection => { :active => :get } 13 | # 14 | # This route set creates routes for the following HTTP requests: 15 | # 16 | # POST /people/new/register.json # PeopleController.register 17 | # PATCH/PUT /people/1/promote.json # PeopleController.promote with :id => 1 18 | # DELETE /people/1/deactivate.json # PeopleController.deactivate with :id => 1 19 | # GET /people/active.json # PeopleController.active 20 | # 21 | # Using this module, Active Resource can use these custom REST methods just like the 22 | # standard methods. 23 | # 24 | # class Person < ActiveResource::Base 25 | # self.site = "https://37s.sunrise.com" 26 | # end 27 | # 28 | # Person.new(:name => 'Ryan').post(:register) # POST /people/new/register.json 29 | # # => { :id => 1, :name => 'Ryan' } 30 | # 31 | # Person.find(1).put(:promote, :position => 'Manager') # PUT /people/1/promote.json 32 | # Person.find(1).delete(:deactivate) # DELETE /people/1/deactivate.json 33 | # 34 | # Person.get(:active) # GET /people/active.json 35 | # # => [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}] 36 | # 37 | module CustomMethods 38 | extend ActiveSupport::Concern 39 | 40 | included do 41 | class << self 42 | alias :orig_delete :delete 43 | 44 | # Invokes a GET to a given custom REST method. For example: 45 | # 46 | # Person.get(:active) # GET /people/active.json 47 | # # => [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}] 48 | # 49 | # Person.get(:active, :awesome => true) # GET /people/active.json?awesome=true 50 | # # => [{:id => 1, :name => 'Ryan'}] 51 | # 52 | # Note: the objects returned from this method are not automatically converted 53 | # into ActiveResource::Base instances - they are ordinary Hashes. If you are expecting 54 | # ActiveResource::Base instances, use the find class method with the 55 | # :from option. For example: 56 | # 57 | # Person.find(:all, :from => :active) 58 | def get(custom_method_name, options = {}) 59 | hashified = format.decode(connection.get(custom_method_collection_url(custom_method_name, options), headers).body) 60 | derooted = Formats.remove_root(hashified) 61 | derooted.is_a?(Array) ? derooted.map { |e| Formats.remove_root(e) } : derooted 62 | end 63 | 64 | def post(custom_method_name, options = {}, body = "") 65 | connection.post(custom_method_collection_url(custom_method_name, options), body, headers) 66 | end 67 | 68 | def patch(custom_method_name, options = {}, body = "") 69 | connection.patch(custom_method_collection_url(custom_method_name, options), body, headers) 70 | end 71 | 72 | def put(custom_method_name, options = {}, body = "") 73 | connection.put(custom_method_collection_url(custom_method_name, options), body, headers) 74 | end 75 | 76 | def delete(custom_method_name, options = {}) 77 | # Need to jump through some hoops to retain the original class 'delete' method 78 | if custom_method_name.is_a?(Symbol) 79 | connection.delete(custom_method_collection_url(custom_method_name, options), headers) 80 | else 81 | orig_delete(custom_method_name, options) 82 | end 83 | end 84 | end 85 | end 86 | 87 | module ClassMethods 88 | def custom_method_collection_url(method_name, options = {}) 89 | prefix_options, query_options = split_options(options) 90 | "#{prefix(prefix_options)}#{collection_name}/#{method_name}#{format_extension}#{query_string(query_options)}" 91 | end 92 | end 93 | 94 | def get(method_name, options = {}) 95 | self.class.format.decode(connection.get(custom_method_element_url(method_name, options), self.class.headers).body) 96 | end 97 | 98 | def post(method_name, options = {}, body = nil) 99 | request_body = body.blank? ? encode : body 100 | if new? 101 | connection.post(custom_method_new_element_url(method_name, options), request_body, self.class.headers) 102 | else 103 | connection.post(custom_method_element_url(method_name, options), request_body, self.class.headers) 104 | end 105 | end 106 | 107 | def patch(method_name, options = {}, body = "") 108 | connection.patch(custom_method_element_url(method_name, options), body, self.class.headers) 109 | end 110 | 111 | def put(method_name, options = {}, body = "") 112 | connection.put(custom_method_element_url(method_name, options), body, self.class.headers) 113 | end 114 | 115 | def delete(method_name, options = {}) 116 | connection.delete(custom_method_element_url(method_name, options), self.class.headers) 117 | end 118 | 119 | 120 | private 121 | def custom_method_element_url(method_name, options = {}) 122 | "#{self.class.prefix(prefix_options)}#{self.class.collection_name}/#{URI.encode_www_form_component(id.to_s)}/#{method_name}#{self.class.format_extension}#{self.class.__send__(:query_string, options)}" 123 | end 124 | 125 | def custom_method_new_element_url(method_name, options = {}) 126 | "#{self.class.prefix(prefix_options)}#{self.class.collection_name}/new/#{method_name}#{self.class.format_extension}#{self.class.__send__(:query_string, options)}" 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/active_resource/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | class ConnectionError < StandardError # :nodoc: 5 | attr_reader :response 6 | 7 | def initialize(response, message = nil) 8 | @response = response 9 | @message = message 10 | end 11 | 12 | def to_s 13 | return @message if @message 14 | 15 | message = +"Failed." 16 | message << " Response code = #{response.code}." if response.respond_to?(:code) 17 | message << " Response message = #{response.message}." if response.respond_to?(:message) 18 | message 19 | end 20 | end 21 | 22 | # Raised when a Timeout::Error occurs. 23 | class TimeoutError < ConnectionError 24 | def initialize(message) 25 | @message = message 26 | end 27 | def to_s; @message ; end 28 | end 29 | 30 | # Raised when a OpenSSL::SSL::SSLError occurs. 31 | class SSLError < ConnectionError 32 | def initialize(message) 33 | @message = message 34 | end 35 | def to_s; @message ; end 36 | end 37 | 38 | # 3xx Redirection 39 | class Redirection < ConnectionError # :nodoc: 40 | def to_s 41 | response["Location"] ? "#{super} => #{response['Location']}" : super 42 | end 43 | end 44 | 45 | class MissingPrefixParam < ArgumentError # :nodoc: 46 | end 47 | 48 | # 4xx Client Error 49 | class ClientError < ConnectionError # :nodoc: 50 | end 51 | 52 | # 400 Bad Request 53 | class BadRequest < ClientError # :nodoc: 54 | end 55 | 56 | # 401 Unauthorized 57 | class UnauthorizedAccess < ClientError # :nodoc: 58 | end 59 | 60 | # 402 Payment Required 61 | class PaymentRequired < ClientError # :nodoc: 62 | end 63 | 64 | # 403 Forbidden 65 | class ForbiddenAccess < ClientError # :nodoc: 66 | end 67 | 68 | # 404 Not Found 69 | class ResourceNotFound < ClientError # :nodoc: 70 | end 71 | 72 | # 409 Conflict 73 | class ResourceConflict < ClientError # :nodoc: 74 | end 75 | 76 | # 410 Gone 77 | class ResourceGone < ClientError # :nodoc: 78 | end 79 | 80 | # 412 Precondition Failed 81 | class PreconditionFailed < ClientError # :nodoc: 82 | end 83 | 84 | # 429 Too Many Requests 85 | class TooManyRequests < ClientError # :nodoc: 86 | end 87 | 88 | # 5xx Server Error 89 | class ServerError < ConnectionError # :nodoc: 90 | end 91 | 92 | # 405 Method Not Allowed 93 | class MethodNotAllowed < ClientError # :nodoc: 94 | def allowed_methods 95 | @response["Allow"].split(",").map { |verb| verb.strip.downcase.to_sym } 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/active_resource/formats.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | module Formats 5 | autoload :XmlFormat, "active_resource/formats/xml_format" 6 | autoload :JsonFormat, "active_resource/formats/json_format" 7 | 8 | # Lookup the format class from a mime type reference symbol. Example: 9 | # 10 | # ActiveResource::Formats[:xml] # => ActiveResource::Formats::XmlFormat 11 | # ActiveResource::Formats[:json] # => ActiveResource::Formats::JsonFormat 12 | def self.[](mime_type_reference) 13 | ActiveResource::Formats.const_get(ActiveSupport::Inflector.camelize(mime_type_reference.to_s) + "Format") 14 | end 15 | 16 | def self.remove_root(data) 17 | if data.is_a?(Hash) && data.keys.size == 1 && data.values.first.is_a?(Enumerable) 18 | data.values.first 19 | else 20 | data 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_resource/formats/json_format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/json" 4 | 5 | module ActiveResource 6 | module Formats 7 | module JsonFormat 8 | extend self 9 | 10 | def extension 11 | "json" 12 | end 13 | 14 | def mime_type 15 | "application/json" 16 | end 17 | 18 | def encode(hash, options = nil) 19 | ActiveSupport::JSON.encode(hash, options) 20 | end 21 | 22 | def decode(json) 23 | return nil if json.nil? 24 | Formats.remove_root(ActiveSupport::JSON.decode(json)) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/active_resource/formats/xml_format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/hash/conversions" 4 | 5 | module ActiveResource 6 | module Formats 7 | module XmlFormat 8 | extend self 9 | 10 | def extension 11 | "xml" 12 | end 13 | 14 | def mime_type 15 | "application/xml" 16 | end 17 | 18 | def encode(hash, options = {}) 19 | hash.to_xml(options) 20 | end 21 | 22 | def decode(xml) 23 | Formats.remove_root(Hash.from_xml(xml)) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/active_resource/inheriting_hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | class InheritingHash < Hash 5 | def initialize(parent_hash = {}) 6 | # Default hash value must be nil, which allows fallback lookup on parent hash 7 | super(nil) 8 | @parent_hash = parent_hash 9 | end 10 | 11 | def [](key) 12 | super || @parent_hash[key] 13 | end 14 | 15 | # Merges the flattened parent hash (if it's an InheritingHash) 16 | # with ourself 17 | def to_hash 18 | @parent_hash.to_hash.merge(self) 19 | end 20 | 21 | # So we can see the merged object in IRB or the Rails console 22 | def pretty_print(pp) 23 | pp.pp_hash to_hash 24 | end 25 | 26 | def inspect 27 | to_hash.inspect 28 | end 29 | 30 | def to_s 31 | inspect 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/active_resource/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | class LogSubscriber < ActiveSupport::LogSubscriber 5 | def request(event) 6 | result = event.payload[:result] 7 | 8 | # When result is nil, the connection could not even be initiated 9 | # with the server, so we log an internal synthetic error response (523). 10 | code = result.try(:code) || 523 # matches CloudFlare's convention 11 | message = result.try(:message) || "ActiveResource connection error" 12 | body = result.try(:body) || "" 13 | 14 | log_level_method = code.to_i < 400 ? :info : :error 15 | 16 | send log_level_method, "#{event.payload[:method].to_s.upcase} #{event.payload[:request_uri]}" 17 | send log_level_method, "--> %d %s %d (%.1fms)" % [code, message, body.to_s.length, event.duration] 18 | end 19 | 20 | def logger 21 | ActiveResource::Base.logger 22 | end 23 | end 24 | end 25 | 26 | ActiveResource::LogSubscriber.attach_to :active_resource 27 | -------------------------------------------------------------------------------- /lib/active_resource/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_resource" 4 | require "rails" 5 | 6 | module ActiveResource 7 | class Railtie < Rails::Railtie 8 | config.active_resource = ActiveSupport::OrderedOptions.new 9 | 10 | initializer "active_resource.set_configs" do |app| 11 | ActiveSupport.on_load(:active_resource) do 12 | app.config.active_resource.each do |k, v| 13 | send "#{k}=", v 14 | end 15 | end 16 | end 17 | 18 | initializer "active_resource.add_active_job_serializer" do |app| 19 | if app.config.try(:active_job).try(:custom_serializers) 20 | require "active_resource/active_job_serializer" 21 | app.config.active_job.custom_serializers << ActiveResource::ActiveJobSerializer 22 | end 23 | end 24 | 25 | initializer "active_resource.deprecator" do |app| 26 | if app.respond_to?(:deprecators) 27 | app.deprecators[:active_resource] = ActiveResource.deprecator 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/active_resource/reflection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/class/attribute" 4 | require "active_support/core_ext/module/deprecation" 5 | 6 | module ActiveResource 7 | # = Active Resource reflection 8 | # 9 | # Associations in ActiveResource would be used to resolve nested attributes 10 | # in a response with correct classes. 11 | # Now they could be specified over Associations with the options :class_name 12 | module Reflection # :nodoc: 13 | extend ActiveSupport::Concern 14 | 15 | included do 16 | class_attribute :reflections 17 | self.reflections = {} 18 | end 19 | 20 | module ClassMethods 21 | def create_reflection(macro, name, options) 22 | reflection = AssociationReflection.new(macro, name, options) 23 | self.reflections = self.reflections.merge(name => reflection) 24 | reflection 25 | end 26 | end 27 | 28 | 29 | class AssociationReflection 30 | def initialize(macro, name, options) 31 | @macro, @name, @options = macro, name, options 32 | end 33 | 34 | # Returns the name of the macro. 35 | # 36 | # has_many :clients returns :clients 37 | attr_reader :name 38 | 39 | # Returns the macro type. 40 | # 41 | # has_many :clients returns :has_many 42 | attr_reader :macro 43 | 44 | # Returns the hash of options used for the macro. 45 | # 46 | # has_many :clients returns +{}+ 47 | attr_reader :options 48 | 49 | # Returns the class for the macro. 50 | # 51 | # has_many :clients returns the Client class 52 | def klass 53 | @klass ||= class_name.constantize 54 | end 55 | 56 | # Returns the class name for the macro. 57 | # 58 | # has_many :clients returns 'Client' 59 | def class_name 60 | @class_name ||= derive_class_name 61 | end 62 | 63 | # Returns the foreign_key for the macro. 64 | def foreign_key 65 | @foreign_key ||= derive_foreign_key 66 | end 67 | 68 | private 69 | def derive_class_name 70 | options[:class_name] ? options[:class_name].to_s.camelize : name.to_s.classify 71 | end 72 | 73 | def derive_foreign_key 74 | options[:foreign_key] ? options[:foreign_key].to_s : "#{name.to_s.downcase}_id" 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/active_resource/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource # :nodoc: 4 | class Schema # :nodoc: 5 | # attributes can be known to be one of these types. They are easy to 6 | # cast to/from. 7 | KNOWN_ATTRIBUTE_TYPES = %w( string text integer float decimal datetime timestamp time date binary boolean ) 8 | 9 | # An array of attribute definitions, representing the attributes that 10 | # have been defined. 11 | attr_accessor :attrs 12 | 13 | # The internals of an Active Resource Schema are very simple - 14 | # unlike an Active Record TableDefinition (on which it is based). 15 | # It provides a set of convenience methods for people to define their 16 | # schema using the syntax: 17 | # schema do 18 | # string :foo 19 | # integer :bar 20 | # end 21 | # 22 | # The schema stores the name and type of each attribute. That is then 23 | # read out by the schema method to populate the schema of the actual 24 | # resource. 25 | def initialize 26 | @attrs = {} 27 | end 28 | 29 | def attribute(name, type, options = {}) 30 | raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || Schema::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s) 31 | 32 | the_type = type.to_s 33 | # TODO: add defaults 34 | # the_attr = [type.to_s] 35 | # the_attr << options[:default] if options.has_key? :default 36 | @attrs[name.to_s] = the_type 37 | self 38 | end 39 | 40 | # The following are the attribute types supported by Active Resource 41 | # migrations. 42 | KNOWN_ATTRIBUTE_TYPES.each do |attr_type| 43 | # def string(*args) 44 | # options = args.extract_options! 45 | # attr_names = args 46 | # 47 | # attr_names.each { |name| attribute(name, 'string', options) } 48 | # end 49 | class_eval <<-EOV, __FILE__, __LINE__ + 1 50 | # frozen_string_literal: true 51 | def #{attr_type}(*args) 52 | options = args.extract_options! 53 | attr_names = args 54 | 55 | attr_names.each { |name| attribute(name, '#{attr_type}', options) } 56 | end 57 | EOV 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/active_resource/singleton.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | module Singleton 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | attr_writer :singleton_name 9 | 10 | def singleton_name 11 | @singleton_name ||= model_name.element 12 | end 13 | 14 | # Gets the singleton path for the object. If the +query_options+ parameter is omitted, Rails 15 | # will split from the \prefix options. 16 | # 17 | # ==== Options 18 | # * +prefix_options+ - A \hash to add a \prefix to the request for nested URLs (e.g., :account_id => 19 19 | # would yield a URL like /accounts/19/purchases.json). 20 | # 21 | # * +query_options+ - A \hash to add items to the query string for the request. 22 | # 23 | # ==== Examples 24 | # Weather.singleton_path 25 | # # => /weather.json 26 | # 27 | # class Inventory < ActiveResource::Base 28 | # self.site = "https://37s.sunrise.com" 29 | # self.prefix = "/products/:product_id/" 30 | # end 31 | # 32 | # Inventory.singleton_path(:product_id => 5) 33 | # # => /products/5/inventory.json 34 | # 35 | # Inventory.singleton_path({:product_id => 5}, {:sold => true}) 36 | # # => /products/5/inventory.json?sold=true 37 | # 38 | def singleton_path(prefix_options = {}, query_options = nil) 39 | check_prefix_options(prefix_options) 40 | 41 | prefix_options, query_options = split_options(prefix_options) if query_options.nil? 42 | "#{prefix(prefix_options)}#{singleton_name}#{format_extension}#{query_string(query_options)}" 43 | end 44 | 45 | # Core method for finding singleton resources. 46 | # 47 | # ==== Arguments 48 | # Takes a single argument of options 49 | # 50 | # ==== Options 51 | # * :params - Sets the query and \prefix (nested URL) parameters. 52 | # 53 | # ==== Examples 54 | # Weather.find 55 | # # => GET /weather.json 56 | # 57 | # Weather.find(:params => {:degrees => 'fahrenheit'}) 58 | # # => GET /weather.json?degrees=fahrenheit 59 | # 60 | # == Failure or missing data 61 | # A failure to find the requested object raises a ResourceNotFound exception. 62 | # 63 | # Inventory.find 64 | # # => raises ResourceNotFound 65 | def find(options = {}) 66 | find_singleton(options) 67 | end 68 | 69 | private 70 | # Find singleton resource 71 | def find_singleton(options) 72 | prefix_options, query_options = split_options(options[:params]) 73 | 74 | path = singleton_path(prefix_options, query_options) 75 | resp = self.format.decode(self.connection.get(path, self.headers).body) 76 | instantiate_record(resp, prefix_options) 77 | end 78 | end 79 | # Deletes the resource from the remote service. 80 | # 81 | # ==== Examples 82 | # weather = Weather.find 83 | # weather.destroy 84 | # Weather.find # 404 (Resource Not Found) 85 | def destroy 86 | connection.delete(singleton_path, self.class.headers) 87 | end 88 | 89 | 90 | protected 91 | # Update the resource on the remote service 92 | def update 93 | connection.put(singleton_path(prefix_options), encode, self.class.headers).tap do |response| 94 | load_attributes_from_response(response) 95 | end 96 | end 97 | 98 | # Create (i.e. \save to the remote service) the \new resource. 99 | def create 100 | connection.post(singleton_path, encode, self.class.headers).tap do |response| 101 | self.id = id_from_response(response) 102 | load_attributes_from_response(response) 103 | end 104 | end 105 | 106 | private 107 | def singleton_path(options = nil) 108 | self.class.singleton_path(options || prefix_options) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/active_resource/threadsafe_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/object/duplicable" 4 | 5 | module ThreadsafeAttributes 6 | def self.included(klass) 7 | klass.extend(ClassMethods) 8 | end 9 | 10 | module ClassMethods 11 | def threadsafe_attribute(*attrs) 12 | main_thread = Thread.main # remember this, because it could change after forking 13 | 14 | attrs.each do |attr| 15 | define_method attr do 16 | get_threadsafe_attribute(attr, main_thread) 17 | end 18 | 19 | define_method "#{attr}=" do |value| 20 | set_threadsafe_attribute(attr, value, main_thread) 21 | end 22 | 23 | define_method "#{attr}_defined?" do 24 | threadsafe_attribute_defined?(attr, main_thread) 25 | end 26 | end 27 | end 28 | end 29 | 30 | private 31 | def get_threadsafe_attribute(name, main_thread) 32 | if threadsafe_attribute_defined_by_thread?(name, Thread.current) 33 | get_threadsafe_attribute_by_thread(name, Thread.current) 34 | elsif threadsafe_attribute_defined_by_thread?(name, main_thread) 35 | value = get_threadsafe_attribute_by_thread(name, main_thread) 36 | value = value.dup if value.duplicable? 37 | set_threadsafe_attribute_by_thread(name, value, Thread.current) 38 | value 39 | end 40 | end 41 | 42 | def set_threadsafe_attribute(name, value, main_thread) 43 | set_threadsafe_attribute_by_thread(name, value, Thread.current) 44 | unless threadsafe_attribute_defined_by_thread?(name, main_thread) 45 | set_threadsafe_attribute_by_thread(name, value, main_thread) 46 | end 47 | end 48 | 49 | def threadsafe_attribute_defined?(name, main_thread) 50 | threadsafe_attribute_defined_by_thread?(name, Thread.current) || ((Thread.current != main_thread) && threadsafe_attribute_defined_by_thread?(name, main_thread)) 51 | end 52 | 53 | def get_threadsafe_attribute_by_thread(name, thread) 54 | thread.thread_variable_get "active.resource.#{name}.#{self.object_id}" 55 | end 56 | 57 | def set_threadsafe_attribute_by_thread(name, value, thread) 58 | thread.thread_variable_set "active.resource.#{name}.#{self.object_id}.defined", true 59 | thread.thread_variable_set "active.resource.#{name}.#{self.object_id}", value 60 | end 61 | 62 | def threadsafe_attribute_defined_by_thread?(name, thread) 63 | thread.thread_variable_get "active.resource.#{name}.#{self.object_id}.defined" 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/active_resource/validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/array/wrap" 4 | require "active_support/core_ext/object/blank" 5 | 6 | module ActiveResource 7 | class ResourceInvalid < ClientError # :nodoc: 8 | end 9 | 10 | # Active Resource validation is reported to and from this object, which is used by Base#save 11 | # to determine whether the object in a valid state to be saved. See usage example in Validations. 12 | class Errors < ActiveModel::Errors 13 | # Grabs errors from an array of messages (like ActiveRecord::Validations). 14 | # The second parameter directs the errors cache to be cleared (default) 15 | # or not (by passing true). 16 | def from_array(messages, save_cache = false) 17 | clear unless save_cache 18 | humanized_attributes = Hash[@base.known_attributes.map { |attr_name| [attr_name.humanize, attr_name] }] 19 | messages.each do |message| 20 | attr_message = humanized_attributes.keys.sort_by { |a| -a.length }.detect do |attr_name| 21 | if message[0, attr_name.size + 1] == "#{attr_name} " 22 | add humanized_attributes[attr_name], message[(attr_name.size + 1)..-1] 23 | end 24 | end 25 | add(:base, message) if attr_message.nil? 26 | end 27 | end 28 | 29 | # Grabs errors from a hash of attribute => array of errors elements 30 | # The second parameter directs the errors cache to be cleared (default) 31 | # or not (by passing true) 32 | # 33 | # Unrecognized attribute names will be humanized and added to the record's 34 | # base errors. 35 | def from_hash(messages, save_cache = false) 36 | clear unless save_cache 37 | 38 | messages.each do |(key, errors)| 39 | errors.each do |error| 40 | if @base.known_attributes.include?(key) 41 | add key, error 42 | elsif key == "base" 43 | add(:base, error) 44 | else 45 | # reporting an error on an attribute not in attributes 46 | # format and add them to base 47 | add(:base, "#{key.humanize} #{error}") 48 | end 49 | end 50 | end 51 | end 52 | 53 | # Grabs errors from a json response. 54 | def from_json(json, save_cache = false) 55 | decoded = ActiveSupport::JSON.decode(json) || {} rescue {} 56 | if decoded.kind_of?(Hash) && (decoded.has_key?("errors") || decoded.empty?) 57 | errors = decoded["errors"] || {} 58 | if errors.kind_of?(Array) 59 | # 3.2.1-style with array of strings 60 | ActiveResource.deprecator.warn("Returning errors as an array of strings is deprecated.") 61 | from_array errors, save_cache 62 | else 63 | # 3.2.2+ style 64 | from_hash errors, save_cache 65 | end 66 | else 67 | # <3.2-style respond_with - lacks 'errors' key 68 | ActiveResource.deprecator.warn('Returning errors as a hash without a root "errors" key is deprecated.') 69 | from_hash decoded, save_cache 70 | end 71 | end 72 | 73 | # Grabs errors from an XML response. 74 | def from_xml(xml, save_cache = false) 75 | array = Array.wrap(Hash.from_xml(xml)["errors"]["error"]) rescue [] 76 | from_array array, save_cache 77 | end 78 | end 79 | 80 | # Module to support validation and errors with Active Resource objects. The module overrides 81 | # Base#save to rescue ActiveResource::ResourceInvalid exceptions and parse the errors returned 82 | # in the web service response. The module also adds an +errors+ collection that mimics the interface 83 | # of the errors provided by ActiveModel::Errors. 84 | # 85 | # ==== Example 86 | # 87 | # Consider a Person resource on the server requiring both a +first_name+ and a +last_name+ with a 88 | # validates_presence_of :first_name, :last_name declaration in the model: 89 | # 90 | # person = Person.new(:first_name => "Jim", :last_name => "") 91 | # person.save # => false (server returns an HTTP 422 status code and errors) 92 | # person.valid? # => false 93 | # person.errors.empty? # => false 94 | # person.errors.count # => 1 95 | # person.errors.full_messages # => ["Last name can't be empty"] 96 | # person.errors[:last_name] # => ["can't be empty"] 97 | # person.last_name = "Halpert" 98 | # person.save # => true (and person is now saved to the remote service) 99 | # 100 | module Validations 101 | extend ActiveSupport::Concern 102 | include ActiveModel::Validations 103 | 104 | included do 105 | alias_method :save_without_validation, :save 106 | alias_method :save, :save_with_validation 107 | end 108 | 109 | # Validate a resource and save (POST) it to the remote web service. 110 | # If any local validations fail - the save (POST) will not be attempted. 111 | def save_with_validation(options = {}) 112 | perform_validation = options[:validate] != false 113 | 114 | # clear the remote validations so they don't interfere with the local 115 | # ones. Otherwise we get an endless loop and can never change the 116 | # fields so as to make the resource valid. 117 | @remote_errors = nil 118 | if perform_validation && valid? || !perform_validation 119 | save_without_validation 120 | true 121 | else 122 | false 123 | end 124 | rescue ResourceInvalid => error 125 | # cache the remote errors because every call to valid? clears 126 | # all errors. We must keep a copy to add these back after local 127 | # validations. 128 | @remote_errors = error 129 | load_remote_errors(@remote_errors, true) 130 | false 131 | end 132 | 133 | 134 | # Loads the set of remote errors into the object's Errors based on the 135 | # content-type of the error-block received. 136 | def load_remote_errors(remote_errors, save_cache = false) # :nodoc: 137 | case self.class.format 138 | when ActiveResource::Formats[:xml] 139 | errors.from_xml(remote_errors.response.body, save_cache) 140 | when ActiveResource::Formats[:json] 141 | errors.from_json(remote_errors.response.body, save_cache) 142 | end 143 | end 144 | 145 | # Checks for errors on an object (i.e., is resource.errors empty?). 146 | # 147 | # Runs all the specified local validations and returns true if no errors 148 | # were added, otherwise false. 149 | # Runs local validations (eg those on your Active Resource model), and 150 | # also any errors returned from the remote system the last time we 151 | # saved. 152 | # Remote errors can only be cleared by trying to re-save the resource. 153 | # 154 | # ==== Examples 155 | # my_person = Person.create(params[:person]) 156 | # my_person.valid? 157 | # # => true 158 | # 159 | # my_person.errors.add('login', 'can not be empty') if my_person.login == '' 160 | # my_person.valid? 161 | # # => false 162 | # 163 | def valid?(context = nil) 164 | run_callbacks :validate do 165 | super 166 | load_remote_errors(@remote_errors, true) if defined?(@remote_errors) && @remote_errors.present? 167 | errors.empty? 168 | end 169 | end 170 | 171 | # Returns the Errors object that holds all information about attribute error messages. 172 | def errors 173 | @errors ||= Errors.new(self) 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/active_resource/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveResource 4 | module VERSION # :nodoc: 5 | MAJOR = 6 6 | MINOR = 1 7 | TINY = 4 8 | PRE = nil 9 | 10 | STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/activeresource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_resource" 4 | -------------------------------------------------------------------------------- /test/abstract_unit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" unless defined? Gem 4 | require "bundler/setup" 5 | 6 | lib = File.expand_path("#{File.dirname(__FILE__)}/../lib") 7 | $:.unshift(lib) unless $:.include?("lib") || $:.include?(lib) 8 | 9 | require "minitest/autorun" 10 | require "active_resource" 11 | require "active_support" 12 | require "active_support/test_case" 13 | require "setter_trap" 14 | require "active_support/logger" 15 | require "base64" 16 | 17 | ActiveSupport::TestCase.test_order = :random if ActiveSupport::TestCase.respond_to?(:test_order=) 18 | ActiveResource::Base.logger = ActiveSupport::Logger.new("#{File.dirname(__FILE__)}/debug.log") 19 | ActiveResource::Base.include_root_in_json = true 20 | 21 | def setup_response 22 | matz_hash = { "person" => { id: 1, name: "Matz" } } 23 | 24 | @default_request_headers = { "Content-Type" => "application/json" } 25 | @matz = matz_hash.to_json 26 | @matz_xml = matz_hash.to_xml 27 | @david = { person: { id: 2, name: "David" } }.to_json 28 | @greg = { person: { id: 3, name: "Greg" } }.to_json 29 | @addy = { address: { id: 1, street: "12345 Street", country: "Australia" } }.to_json 30 | @rick = { person: { name: "Rick", age: 25 } }.to_json 31 | @joe = { person: { id: 6, name: "Joe", likes_hats: true } }.to_json 32 | @people = { people: [ { person: { id: 1, name: "Matz" } }, { person: { id: 2, name: "David" } }] }.to_json 33 | @people_david = { people: [ { person: { id: 2, name: "David" } }] }.to_json 34 | @addresses = { addresses: [{ address: { id: 1, street: "12345 Street", country: "Australia" } }] }.to_json 35 | @post = { id: 1, title: "Hello World", body: "Lorem Ipsum" }.to_json 36 | @posts = [{ id: 1, title: "Hello World", body: "Lorem Ipsum" }, { id: 2, title: "Second Post", body: "Lorem Ipsum" }].to_json 37 | @comments = [{ id: 1, post_id: 1, content: "Interesting post" }, { id: 2, post_id: 1, content: "I agree" }].to_json 38 | @pets = [{ id: 1, name: "Max" }, { id: 2, name: "Daisy" }].to_json 39 | 40 | # - deep nested resource - 41 | # - Luis (Customer) 42 | # - JK (Customer::Friend) 43 | # - Mateo (Customer::Friend::Brother) 44 | # - Edith (Customer::Friend::Brother::Child) 45 | # - Martha (Customer::Friend::Brother::Child) 46 | # - Felipe (Customer::Friend::Brother) 47 | # - Bryan (Customer::Friend::Brother::Child) 48 | # - Luke (Customer::Friend::Brother::Child) 49 | # - Eduardo (Customer::Friend) 50 | # - Sebas (Customer::Friend::Brother) 51 | # - Andres (Customer::Friend::Brother::Child) 52 | # - Jorge (Customer::Friend::Brother::Child) 53 | # - Elsa (Customer::Friend::Brother) 54 | # - Natacha (Customer::Friend::Brother::Child) 55 | # - Milena (Customer::Friend::Brother) 56 | # 57 | @luis = { 58 | customer: { 59 | id: 1, 60 | name: "Luis", 61 | friends: [{ 62 | name: "JK", 63 | brothers: [ 64 | { 65 | name: "Mateo", 66 | children: [{ name: "Edith" }, { name: "Martha" }] 67 | }, { 68 | name: "Felipe", 69 | children: [{ name: "Bryan" }, { name: "Luke" }] 70 | } 71 | ] 72 | }, { 73 | name: "Eduardo", 74 | brothers: [ 75 | { 76 | name: "Sebas", 77 | children: [{ name: "Andres" }, { name: "Jorge" }] 78 | }, { 79 | name: "Elsa", 80 | children: [{ name: "Natacha" }] 81 | }, { 82 | name: "Milena", 83 | children: [] 84 | } 85 | ] 86 | }], 87 | enemies: [{ name: "Joker" }], 88 | mother: { name: "Ingeborg" } 89 | } 90 | }.to_json 91 | 92 | @startup_sound = { 93 | sound: { 94 | name: "Mac Startup Sound", author: { name: "Jim Reekes" } 95 | } 96 | }.to_json 97 | 98 | @product = { id: 1, name: "Rails book" }.to_json 99 | @inventory = { status: "Sold Out", total: 10, used: 10 }.to_json 100 | 101 | ActiveResource::HttpMock.respond_to do |mock| 102 | mock.get "/people/1.json", {}, @matz 103 | mock.get "/people/1.xml", {}, @matz_xml 104 | mock.get "/people/2.xml", {}, @david 105 | mock.get "/people/Greg.json", {}, @greg 106 | mock.get "/people/6.json", {}, @joe 107 | mock.get "/people/4.json", { "key" => "value" }, nil, 404 108 | mock.put "/people/1.json", {}, nil, 204 109 | mock.delete "/people/1.json", {}, nil, 200 110 | mock.delete "/people/2.xml", {}, nil, 400 111 | mock.get "/people/99.json", {}, nil, 404 112 | mock.post "/people.json", {}, @rick, 201, "Location" => "/people/5.xml" 113 | mock.get "/people.json", {}, @people 114 | mock.get "/people/1/addresses.json", {}, @addresses 115 | mock.get "/people/1/addresses/1.json", {}, @addy 116 | mock.get "/people/1/addresses/2.xml", {}, nil, 404 117 | mock.get "/people/2/addresses.json", {}, nil, 404 118 | mock.get "/people/2/addresses/1.xml", {}, nil, 404 119 | mock.get "/people/Greg/addresses/1.json", {}, @addy 120 | mock.put "/people/1/addresses/1.json", {}, nil, 204 121 | mock.delete "/people/1/addresses/1.json", {}, nil, 200 122 | mock.post "/people/1/addresses.json", {}, nil, 201, "Location" => "/people/1/addresses/5" 123 | mock.get "/people/1/addresses/99.json", {}, nil, 404 124 | mock.get "/people//addresses.xml", {}, nil, 404 125 | mock.get "/people//addresses/1.xml", {}, nil, 404 126 | mock.put "/people//addresses/1.xml", {}, nil, 404 127 | mock.delete "/people//addresses/1.xml", {}, nil, 404 128 | mock.post "/people//addresses.xml", {}, nil, 404 129 | mock.head "/people/1.json", {}, nil, 200 130 | mock.head "/people/Greg.json", {}, nil, 200 131 | mock.head "/people/99.json", {}, nil, 404 132 | mock.head "/people/1/addresses/1.json", {}, nil, 200 133 | mock.head "/people/1/addresses/2.json", {}, nil, 404 134 | mock.head "/people/2/addresses/1.json", {}, nil, 404 135 | mock.head "/people/Greg/addresses/1.json", {}, nil, 200 136 | # customer 137 | mock.get "/customers/1.json", {}, @luis 138 | # sound 139 | mock.get "/sounds/1.json", {}, @startup_sound 140 | # post 141 | mock.get "/posts.json", {}, @posts 142 | mock.get "/posts/1.json", {}, @post 143 | mock.get "/posts/1/comments.json", {}, @comments 144 | # products 145 | mock.get "/products/1.json", { "Accept" => "application/json", "X-Inherited-Header" => "present" }, @product 146 | mock.get "/products/1/inventory.json", {}, @inventory 147 | # pets 148 | mock.get "/people/1/pets.json", {}, @pets 149 | end 150 | 151 | Person.user = nil 152 | Person.password = nil 153 | end 154 | -------------------------------------------------------------------------------- /test/cases/active_job_serializer_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/project" 6 | require "fixtures/person" 7 | require "fixtures/product" 8 | require "active_job" 9 | require "active_job/arguments" 10 | require "active_resource/active_job_serializer" 11 | 12 | class ActiveJobSerializerTest < ActiveSupport::TestCase 13 | setup do 14 | @klass = ActiveResource::ActiveJobSerializer 15 | end 16 | 17 | def test_serialize 18 | project = Project.new(id: 1, name: "Ruby on Rails") 19 | project.prefix_options[:person_id] = 1 20 | project_json = { 21 | _aj_serialized: @klass.name, 22 | class: project.class.name, 23 | persisted: project.persisted?, 24 | prefix_options: project.prefix_options, 25 | attributes: project.attributes 26 | }.as_json 27 | serialized_json = @klass.serialize(project) 28 | 29 | assert_equal project_json, serialized_json 30 | end 31 | 32 | def test_deserialize 33 | person = Person.new(id: 2, name: "David") 34 | person.persisted = true 35 | person_json = { 36 | _aj_serialized: @klass.name, 37 | class: person.class.name, 38 | persisted: person.persisted?, 39 | prefix_options: person.prefix_options, 40 | attributes: person.attributes 41 | }.as_json 42 | deserialized_object = @klass.deserialize(person_json) 43 | 44 | assert_equal person, deserialized_object 45 | end 46 | 47 | def test_serialize? 48 | product = Product.new(id: 3, name: "Chunky Bacon") 49 | 50 | assert @klass.serialize?(product) 51 | assert_not @klass.serialize?("not a resource") 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/cases/association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/person" 6 | require "fixtures/beast" 7 | require "fixtures/customer" 8 | 9 | 10 | class AssociationTest < ActiveSupport::TestCase 11 | def setup 12 | @klass = ActiveResource::Associations::Builder::Association 13 | @reflection = ActiveResource::Reflection::AssociationReflection.new :belongs_to, :customer, {} 14 | end 15 | 16 | 17 | def test_validations_for_instance 18 | object = @klass.new(Person, :customers, {}) 19 | assert_equal({}, object.send(:validate_options)) 20 | end 21 | 22 | def test_instance_build 23 | object = @klass.new(Person, :customers, {}) 24 | assert_kind_of ActiveResource::Reflection::AssociationReflection, object.build 25 | end 26 | 27 | def test_valid_options 28 | assert @klass.build(Person, :customers, class_name: "Client") 29 | 30 | assert_raise ArgumentError do 31 | @klass.build(Person, :customers, soo_invalid: true) 32 | end 33 | end 34 | 35 | def test_association_class_build 36 | assert_kind_of ActiveResource::Reflection::AssociationReflection, @klass.build(Person, :customers, {}) 37 | end 38 | 39 | def test_has_many 40 | External::Person.send(:has_many, :people) 41 | assert_equal 1, External::Person.reflections.select { |name, reflection| reflection.macro.eql?(:has_many) }.count 42 | end 43 | 44 | def test_has_many_on_new_record 45 | Post.send(:has_many, :topics) 46 | Topic.stubs(:find).returns([:unexpected_response]) 47 | assert_equal [], Post.new.topics.to_a 48 | end 49 | 50 | def test_has_one 51 | External::Person.send(:has_one, :customer) 52 | assert_equal 1, External::Person.reflections.select { |name, reflection| reflection.macro.eql?(:has_one) }.count 53 | end 54 | 55 | def test_belongs_to 56 | External::Person.belongs_to(:Customer) 57 | assert_equal 1, External::Person.reflections.select { |name, reflection| reflection.macro.eql?(:belongs_to) }.count 58 | end 59 | 60 | def test_defines_belongs_to_finder_method_with_instance_variable_cache 61 | Person.defines_belongs_to_finder_method(@reflection) 62 | 63 | person = Person.new 64 | assert_not person.instance_variable_defined?(:@customer) 65 | person.stubs(:customer_id).returns(2) 66 | Customer.expects(:find).with(2).once() 67 | 2.times { person.customer } 68 | assert person.instance_variable_defined?(:@customer) 69 | end 70 | 71 | def test_belongs_to_with_finder_key 72 | Person.defines_belongs_to_finder_method(@reflection) 73 | 74 | person = Person.new 75 | person.stubs(:customer_id).returns(1) 76 | Customer.expects(:find).with(1).once() 77 | person.customer 78 | end 79 | 80 | def test_belongs_to_with_nil_finder_key 81 | Person.defines_belongs_to_finder_method(@reflection) 82 | 83 | person = Person.new 84 | person.stubs(:customer_id).returns(nil) 85 | Customer.expects(:find).with(nil).never() 86 | person.customer 87 | end 88 | 89 | def test_inverse_associations_do_not_create_circular_dependencies 90 | code = <<-CODE 91 | class Park < ActiveResource::Base 92 | has_many :trails 93 | end 94 | 95 | class Trail < ActiveResource::Base 96 | belongs_to :park 97 | end 98 | CODE 99 | 100 | assert_nothing_raised do 101 | eval code 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/cases/associations/builder/belongs_to_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/person" 6 | require "fixtures/beast" 7 | require "fixtures/customer" 8 | 9 | 10 | class ActiveResource::Associations::Builder::BelongsToTest < ActiveSupport::TestCase 11 | def setup 12 | @klass = ActiveResource::Associations::Builder::BelongsTo 13 | end 14 | 15 | 16 | def test_validations_for_instance 17 | object = @klass.new(Person, :customer, {}) 18 | assert_equal({}, object.send(:validate_options)) 19 | end 20 | 21 | def test_instance_build 22 | object = @klass.new(Person, :customer, {}) 23 | Person.expects(:defines_belongs_to_finder_method).with(kind_of(ActiveResource::Reflection::AssociationReflection)) 24 | 25 | reflection = object.build 26 | 27 | assert_kind_of ActiveResource::Reflection::AssociationReflection, reflection 28 | assert_equal :customer, reflection.name 29 | assert_equal Customer, reflection.klass 30 | assert_equal "customer_id", reflection.foreign_key 31 | end 32 | 33 | 34 | def test_valid_options 35 | assert @klass.build(Person, :customer, class_name: "Person") 36 | assert @klass.build(Person, :customer, foreign_key: "person_id") 37 | 38 | assert_raise ArgumentError do 39 | @klass.build(Person, :customer, soo_invalid: true) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/cases/associations/builder/has_many_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/person" 6 | require "fixtures/street_address" 7 | 8 | class ActiveResource::Associations::Builder::HasManyTest < ActiveSupport::TestCase 9 | def setup 10 | @klass = ActiveResource::Associations::Builder::HasMany 11 | end 12 | 13 | def test_validations_for_instance 14 | object = @klass.new(Person, :street_address, {}) 15 | assert_equal({}, object.send(:validate_options)) 16 | end 17 | 18 | def test_instance_build 19 | object = @klass.new(Person, :street_address, {}) 20 | Person.expects(:defines_has_many_finder_method).with(kind_of(ActiveResource::Reflection::AssociationReflection)) 21 | 22 | reflection = object.build 23 | 24 | assert_kind_of ActiveResource::Reflection::AssociationReflection, reflection 25 | assert_equal :street_address, reflection.name 26 | assert_equal StreetAddress, reflection.klass 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/cases/associations/builder/has_one_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/product" 6 | require "fixtures/inventory" 7 | 8 | class ActiveResource::Associations::Builder::HasOneTest < ActiveSupport::TestCase 9 | def setup 10 | @klass = ActiveResource::Associations::Builder::HasOne 11 | end 12 | 13 | def test_validations_for_instance 14 | object = @klass.new(Product, :inventory, {}) 15 | assert_equal({}, object.send(:validate_options)) 16 | end 17 | 18 | def test_instance_build 19 | object = @klass.new(Product, :inventory, {}) 20 | Product.expects(:defines_has_one_finder_method).with(kind_of(ActiveResource::Reflection::AssociationReflection)) 21 | 22 | reflection = object.build 23 | 24 | assert_kind_of ActiveResource::Reflection::AssociationReflection, reflection 25 | assert_equal :inventory, reflection.name 26 | assert_equal Inventory, reflection.klass 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/cases/authorization_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "base64" 4 | require "abstract_unit" 5 | 6 | class AuthorizationTest < ActiveSupport::TestCase 7 | Response = Struct.new(:code) 8 | 9 | def setup 10 | @conn = ActiveResource::Connection.new("http://localhost") 11 | @matz = { person: { id: 1, name: "Matz" } }.to_json 12 | @david = { person: { id: 2, name: "David" } }.to_json 13 | @authenticated_conn = ActiveResource::Connection.new("http://david:test123@localhost") 14 | @basic_authorization_request_header = { "Authorization" => "Basic ZGF2aWQ6dGVzdDEyMw==" } 15 | @jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 16 | @bearer_token_authorization_request_header = { "Authorization" => "Bearer #{@jwt}" } 17 | end 18 | 19 | private 20 | def decode(response) 21 | @authenticated_conn.format.decode(response.body) 22 | end 23 | end 24 | 25 | class BasicAuthorizationTest < AuthorizationTest 26 | def setup 27 | super 28 | @authenticated_conn.auth_type = :basic 29 | 30 | ActiveResource::HttpMock.respond_to do |mock| 31 | mock.get "/people/2.json", @basic_authorization_request_header, @david 32 | mock.get "/people/1.json", @basic_authorization_request_header, nil, 401, "WWW-Authenticate" => "i_should_be_ignored" 33 | mock.get "/people/3.json", @bearer_token_authorization_request_header, @david 34 | mock.put "/people/2.json", @basic_authorization_request_header, nil, 204 35 | mock.delete "/people/2.json", @basic_authorization_request_header, nil, 200 36 | mock.post "/people/2/addresses.json", @basic_authorization_request_header, nil, 201, "Location" => "/people/1/addresses/5" 37 | mock.head "/people/2.json", @basic_authorization_request_header, nil, 200 38 | end 39 | end 40 | 41 | def test_get 42 | david = decode(@authenticated_conn.get("/people/2.json")) 43 | assert_equal "David", david["name"] 44 | end 45 | 46 | def test_post 47 | response = @authenticated_conn.post("/people/2/addresses.json") 48 | assert_equal "/people/1/addresses/5", response["Location"] 49 | end 50 | 51 | def test_put 52 | response = @authenticated_conn.put("/people/2.json") 53 | assert_equal 204, response.code 54 | end 55 | 56 | def test_delete 57 | response = @authenticated_conn.delete("/people/2.json") 58 | assert_equal 200, response.code 59 | end 60 | 61 | def test_head 62 | response = @authenticated_conn.head("/people/2.json") 63 | assert_equal 200, response.code 64 | end 65 | 66 | def test_retry_on_401_doesnt_happen_with_basic_auth 67 | assert_raise(ActiveResource::UnauthorizedAccess) { @authenticated_conn.get("/people/1.json") } 68 | assert_equal "", @authenticated_conn.send(:response_auth_header) 69 | end 70 | 71 | def test_raises_invalid_request_on_unauthorized_requests 72 | assert_raise(ActiveResource::InvalidRequestError) { @conn.get("/people/2.json") } 73 | assert_raise(ActiveResource::InvalidRequestError) { @conn.post("/people/2/addresses.json") } 74 | assert_raise(ActiveResource::InvalidRequestError) { @conn.put("/people/2.json") } 75 | assert_raise(ActiveResource::InvalidRequestError) { @conn.delete("/people/2.json") } 76 | assert_raise(ActiveResource::InvalidRequestError) { @conn.head("/people/2.json") } 77 | end 78 | 79 | 80 | def test_authorization_header 81 | authorization_header = @authenticated_conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 82 | assert_equal @basic_authorization_request_header["Authorization"], authorization_header["Authorization"] 83 | authorization = authorization_header["Authorization"].to_s.split 84 | 85 | assert_equal "Basic", authorization[0] 86 | assert_equal ["david", "test123"], ::Base64.decode64(authorization[1]).split(":")[0..1] 87 | end 88 | 89 | def test_authorization_header_with_username_but_no_password 90 | @conn = ActiveResource::Connection.new("http://david:@localhost") 91 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 92 | authorization = authorization_header["Authorization"].to_s.split 93 | 94 | assert_equal "Basic", authorization[0] 95 | assert_equal ["david"], ::Base64.decode64(authorization[1]).split(":")[0..1] 96 | end 97 | 98 | def test_authorization_header_with_password_but_no_username 99 | @conn = ActiveResource::Connection.new("http://:test123@localhost") 100 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 101 | authorization = authorization_header["Authorization"].to_s.split 102 | 103 | assert_equal "Basic", authorization[0] 104 | assert_equal ["", "test123"], ::Base64.decode64(authorization[1]).split(":")[0..1] 105 | end 106 | 107 | def test_authorization_header_with_decoded_credentials_from_url 108 | @conn = ActiveResource::Connection.new("http://my%40email.com:%31%32%33@localhost") 109 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 110 | authorization = authorization_header["Authorization"].to_s.split 111 | 112 | assert_equal "Basic", authorization[0] 113 | assert_equal ["my@email.com", "123"], ::Base64.decode64(authorization[1]).split(":")[0..1] 114 | end 115 | 116 | def test_authorization_header_explicitly_setting_username_and_password 117 | @authenticated_conn = ActiveResource::Connection.new("http://@localhost") 118 | @authenticated_conn.user = "david" 119 | @authenticated_conn.password = "test123" 120 | authorization_header = @authenticated_conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 121 | assert_equal @basic_authorization_request_header["Authorization"], authorization_header["Authorization"] 122 | authorization = authorization_header["Authorization"].to_s.split 123 | 124 | assert_equal "Basic", authorization[0] 125 | assert_equal ["david", "test123"], ::Base64.decode64(authorization[1]).split(":")[0..1] 126 | end 127 | 128 | def test_authorization_header_explicitly_setting_username_but_no_password 129 | @conn = ActiveResource::Connection.new("http://@localhost") 130 | @conn.user = "david" 131 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 132 | authorization = authorization_header["Authorization"].to_s.split 133 | 134 | assert_equal "Basic", authorization[0] 135 | assert_equal ["david"], ::Base64.decode64(authorization[1]).split(":")[0..1] 136 | end 137 | 138 | def test_authorization_header_explicitly_setting_password_but_no_username 139 | @conn = ActiveResource::Connection.new("http://@localhost") 140 | @conn.password = "test123" 141 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 142 | authorization = authorization_header["Authorization"].to_s.split 143 | 144 | assert_equal "Basic", authorization[0] 145 | assert_equal ["", "test123"], ::Base64.decode64(authorization[1]).split(":")[0..1] 146 | end 147 | 148 | def test_authorization_header_if_credentials_supplied_and_auth_type_is_basic 149 | authorization_header = @authenticated_conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 150 | assert_equal @basic_authorization_request_header["Authorization"], authorization_header["Authorization"] 151 | authorization = authorization_header["Authorization"].to_s.split 152 | 153 | assert_equal "Basic", authorization[0] 154 | assert_equal ["david", "test123"], ::Base64.decode64(authorization[1]).split(":")[0..1] 155 | end 156 | 157 | def test_authorization_header_explicitly_setting_jwt_and_auth_type_is_bearer 158 | @conn = ActiveResource::Connection.new("http://localhost") 159 | @conn.auth_type = :bearer 160 | @conn.bearer_token = @jwt 161 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/3.json")) 162 | assert_equal @bearer_token_authorization_request_header["Authorization"], authorization_header["Authorization"] 163 | authorization = authorization_header["Authorization"].to_s.split 164 | 165 | assert_equal "Bearer", authorization[0] 166 | assert_equal @jwt, authorization[1] 167 | end 168 | 169 | def test_authorization_header_if_no_jwt_and_auth_type_is_bearer 170 | @conn = ActiveResource::Connection.new("http://localhost") 171 | @conn.auth_type = :bearer 172 | authorization_header = @conn.__send__(:authorization_header, :get, URI.parse("/people/3.json")) 173 | assert_nil authorization_header["Authorization"] 174 | end 175 | 176 | def test_client_nonce_is_not_nil 177 | assert_not_nil ActiveResource::Connection.new("http://david:test123@localhost").send(:client_nonce) 178 | end 179 | end 180 | 181 | class DigestAuthorizationTest < AuthorizationTest 182 | def setup 183 | super 184 | @authenticated_conn.auth_type = :digest 185 | 186 | # Make client nonce deterministic 187 | def @authenticated_conn.client_nonce; "i-am-a-client-nonce" end 188 | 189 | @nonce = "MTI0OTUxMzc4NzpjYWI3NDM3NDNmY2JmODU4ZjQ2ZjcwNGZkMTJiMjE0NA==" 190 | 191 | ActiveResource::HttpMock.respond_to do |mock| 192 | mock.get "/people/2.json", { "Authorization" => blank_digest_auth_header("/people/2.json", "fad396f6a34aeba28e28b9b96ddbb671") }, nil, 401, "WWW-Authenticate" => response_digest_auth_header 193 | mock.get "/people/2.json", { "Authorization" => request_digest_auth_header("/people/2.json", "c064d5ba8891a25290c76c8c7d31fb7b") }, @david, 200 194 | mock.get "/people/1.json", { "Authorization" => request_digest_auth_header("/people/1.json", "f9c0b594257bb8422af4abd429c5bb70") }, @matz, 200 195 | 196 | mock.put "/people/2.json", { "Authorization" => blank_digest_auth_header("/people/2.json", "50a685d814f94665b9d160fbbaa3958a") }, nil, 401, "WWW-Authenticate" => response_digest_auth_header 197 | mock.put "/people/2.json", { "Authorization" => request_digest_auth_header("/people/2.json", "5a75cde841122d8e0f20f8fd1f98a743") }, nil, 204 198 | 199 | mock.delete "/people/2.json", { "Authorization" => blank_digest_auth_header("/people/2.json", "846f799107eab5ca4285b909ee299a33") }, nil, 401, "WWW-Authenticate" => response_digest_auth_header 200 | mock.delete "/people/2.json", { "Authorization" => request_digest_auth_header("/people/2.json", "9f5b155224edbbb69fd99d8ce094681e") }, nil, 200 201 | 202 | mock.post "/people/2/addresses.json", { "Authorization" => blank_digest_auth_header("/people/2/addresses.json", "6984d405ff3d9ed07bbf747dcf16afb0") }, nil, 401, "WWW-Authenticate" => response_digest_auth_header 203 | mock.post "/people/2/addresses.json", { "Authorization" => request_digest_auth_header("/people/2/addresses.json", "4bda6a28dbf930b5af9244073623bd04") }, nil, 201, "Location" => "/people/1/addresses/5" 204 | 205 | mock.head "/people/2.json", { "Authorization" => blank_digest_auth_header("/people/2.json", "15e5ed84ba5c4cfcd5c98a36c2e4f421") }, nil, 401, "WWW-Authenticate" => response_digest_auth_header 206 | mock.head "/people/2.json", { "Authorization" => request_digest_auth_header("/people/2.json", "d4c6d2bcc8717abb2e2ccb8c49ee6a91") }, nil, 200 207 | end 208 | end 209 | 210 | def test_authorization_header_if_credentials_supplied_and_auth_type_is_digest 211 | authorization_header = @authenticated_conn.__send__(:authorization_header, :get, URI.parse("/people/2.json")) 212 | assert_equal blank_digest_auth_header("/people/2.json", "fad396f6a34aeba28e28b9b96ddbb671"), authorization_header["Authorization"] 213 | end 214 | 215 | def test_authorization_header_with_query_string_if_auth_type_is_digest 216 | authorization_header = @authenticated_conn.__send__(:authorization_header, :get, URI.parse("/people/2.json?only=name")) 217 | assert_equal blank_digest_auth_header("/people/2.json?only=name", "f8457b0b5d21b6b80737a386217afb24"), authorization_header["Authorization"] 218 | end 219 | 220 | def test_get_with_digest_auth_handles_initial_401_response_and_retries 221 | response = @authenticated_conn.get("/people/2.json") 222 | assert_equal "David", decode(response)["name"] 223 | end 224 | 225 | def test_post_with_digest_auth_handles_initial_401_response_and_retries 226 | response = @authenticated_conn.post("/people/2/addresses.json") 227 | assert_equal "/people/1/addresses/5", response["Location"] 228 | assert_equal 201, response.code 229 | end 230 | 231 | def test_put_with_digest_auth_handles_initial_401_response_and_retries 232 | response = @authenticated_conn.put("/people/2.json") 233 | assert_equal 204, response.code 234 | end 235 | 236 | def test_delete_with_digest_auth_handles_initial_401_response_and_retries 237 | response = @authenticated_conn.delete("/people/2.json") 238 | assert_equal 200, response.code 239 | end 240 | 241 | def test_head_with_digest_auth_handles_initial_401_response_and_retries 242 | response = @authenticated_conn.head("/people/2.json") 243 | assert_equal 200, response.code 244 | end 245 | 246 | def test_get_with_digest_auth_caches_nonce 247 | response = @authenticated_conn.get("/people/2.json") 248 | assert_equal "David", decode(response)["name"] 249 | 250 | # There is no mock for this request with a non-cached nonce. 251 | response = @authenticated_conn.get("/people/1.json") 252 | assert_equal "Matz", decode(response)["name"] 253 | end 254 | 255 | def test_raises_invalid_request_on_unauthorized_requests_with_digest_auth 256 | @conn.auth_type = :digest 257 | assert_raise(ActiveResource::InvalidRequestError) { @conn.get("/people/2.json") } 258 | assert_raise(ActiveResource::InvalidRequestError) { @conn.post("/people/2/addresses.json") } 259 | assert_raise(ActiveResource::InvalidRequestError) { @conn.put("/people/2.json") } 260 | assert_raise(ActiveResource::InvalidRequestError) { @conn.delete("/people/2.json") } 261 | assert_raise(ActiveResource::InvalidRequestError) { @conn.head("/people/2.json") } 262 | end 263 | 264 | private 265 | def blank_digest_auth_header(uri, response) 266 | %Q(Digest username="david", realm="", qop="", uri="#{uri}", nonce="", nc="0", cnonce="i-am-a-client-nonce", response="#{response}") 267 | end 268 | 269 | def request_digest_auth_header(uri, response) 270 | %Q(Digest username="david", realm="RailsTestApp", qop="auth", uri="#{uri}", nonce="#{@nonce}", nc="0", cnonce="i-am-a-client-nonce", response="#{response}", opaque="ef6dfb078ba22298d366f99567814ffb") 271 | end 272 | 273 | def response_digest_auth_header 274 | %Q(Digest realm="RailsTestApp", qop="auth", algorithm=MD5, nonce="#{@nonce}", opaque="ef6dfb078ba22298d366f99567814ffb") 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /test/cases/base/custom_methods_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | require "fixtures/street_address" 6 | require "active_support/core_ext/hash/conversions" 7 | 8 | class CustomMethodsTest < ActiveSupport::TestCase 9 | def setup 10 | @matz = { person: { id: 1, name: "Matz" } }.to_json 11 | @matz_deep = { person: { id: 1, name: "Matz", other: "other" } }.to_json 12 | @matz_array = { people: [{ person: { id: 1, name: "Matz" } }] }.to_json 13 | @ryan = { person: { name: "Ryan" } }.to_json 14 | @addy = { address: { id: 1, street: "12345 Street" } }.to_json 15 | @addy_deep = { address: { id: 1, street: "12345 Street", zip: "27519" } }.to_json 16 | 17 | ActiveResource::HttpMock.respond_to do |mock| 18 | mock.get "/people/1.json", {}, @matz 19 | mock.get "/people/1/shallow.json", {}, @matz 20 | mock.get "/people/1/deep.json", {}, @matz_deep 21 | mock.get "/people/retrieve.json?name=Matz", {}, @matz_array 22 | mock.get "/people/managers.json", {}, @matz_array 23 | mock.post "/people/hire.json?name=Matz", {}, nil, 201 24 | mock.put "/people/1/promote.json?position=Manager", {}, nil, 204 25 | mock.put "/people/promote.json?name=Matz", {}, nil, 204, {} 26 | mock.put "/people/sort.json?by=name", {}, nil, 204 27 | mock.delete "/people/deactivate.json?name=Matz", {}, nil, 200 28 | mock.delete "/people/1/deactivate.json", {}, nil, 200 29 | mock.post "/people/new/register.json", {}, @ryan, 201, "Location" => "/people/5.json" 30 | mock.post "/people/1/register.json", {}, @matz, 201 31 | mock.get "/people/1/addresses/1.json", {}, @addy 32 | mock.get "/people/1/addresses/1/deep.json", {}, @addy_deep 33 | mock.put "/people/1/addresses/1/normalize_phone.json?locale=US", {}, nil, 204 34 | mock.put "/people/1/addresses/sort.json?by=name", {}, nil, 204 35 | mock.post "/people/1/addresses/new/link.json", {}, { address: { street: "12345 Street" } }.to_json, 201, "Location" => "/people/1/addresses/2.json" 36 | end 37 | 38 | Person.user = nil 39 | Person.password = nil 40 | end 41 | 42 | def teardown 43 | ActiveResource::HttpMock.reset! 44 | end 45 | 46 | def test_custom_collection_method 47 | # GET 48 | assert_equal([{ "id" => 1, "name" => "Matz" }], Person.get(:retrieve, name: "Matz")) 49 | 50 | # POST 51 | assert_equal(ActiveResource::Response.new("", 201, {}), Person.post(:hire, name: "Matz")) 52 | 53 | # PUT 54 | assert_equal ActiveResource::Response.new("", 204, {}), 55 | Person.put(:promote, { name: "Matz" }, "atestbody") 56 | assert_equal ActiveResource::Response.new("", 204, {}), Person.put(:sort, by: "name") 57 | 58 | # DELETE 59 | Person.delete :deactivate, name: "Matz" 60 | 61 | # Nested resource 62 | assert_equal ActiveResource::Response.new("", 204, {}), StreetAddress.put(:sort, person_id: 1, by: "name") 63 | end 64 | 65 | def test_custom_element_method 66 | # Test GET against an element URL 67 | assert_equal Person.find(1).get(:shallow), "id" => 1, "name" => "Matz" 68 | assert_equal Person.find(1).get(:deep), "id" => 1, "name" => "Matz", "other" => "other" 69 | 70 | # Test PUT against an element URL 71 | assert_equal ActiveResource::Response.new("", 204, {}), Person.find(1).put(:promote, { position: "Manager" }, "body") 72 | 73 | # Test DELETE against an element URL 74 | assert_equal ActiveResource::Response.new("", 200, {}), Person.find(1).delete(:deactivate) 75 | 76 | # With nested resources 77 | assert_equal StreetAddress.find(1, params: { person_id: 1 }).get(:deep), 78 | "id" => 1, "street" => "12345 Street", "zip" => "27519" 79 | assert_equal ActiveResource::Response.new("", 204, {}), 80 | StreetAddress.find(1, params: { person_id: 1 }).put(:normalize_phone, locale: "US") 81 | end 82 | 83 | def test_custom_new_element_method 84 | # Test POST against a new element URL 85 | ryan = Person.new(name: "Ryan") 86 | assert_equal ActiveResource::Response.new(@ryan, 201, "Location" => "/people/5.json"), ryan.post(:register) 87 | expected_request = ActiveResource::Request.new(:post, "/people/new/register.json", @ryan) 88 | assert_equal expected_request.body, ActiveResource::HttpMock.requests.first.body 89 | 90 | # Test POST against a nested collection URL 91 | addy = StreetAddress.new(street: "123 Test Dr.", person_id: 1) 92 | assert_equal ActiveResource::Response.new({ address: { street: "12345 Street" } }.to_json, 93 | 201, "Location" => "/people/1/addresses/2.json"), 94 | addy.post(:link) 95 | 96 | matz = Person.find(1) 97 | assert_equal ActiveResource::Response.new(@matz, 201), matz.post(:register) 98 | end 99 | 100 | def test_find_custom_resources 101 | assert_equal "Matz", Person.find(:all, from: :managers).first.name 102 | end 103 | 104 | def test_paths_with_format 105 | path_with_format = "/people/active.json" 106 | 107 | ActiveResource::HttpMock.respond_to do |mock| 108 | mock.get path_with_format, {}, @matz 109 | mock.post path_with_format, {}, nil 110 | mock.patch path_with_format, {}, nil 111 | mock.delete path_with_format, {}, nil 112 | mock.put path_with_format, {}, nil 113 | end 114 | 115 | [:get, :post, :delete, :patch, :put].each_with_index do |method, index| 116 | Person.send(method, :active) 117 | expected_request = ActiveResource::Request.new(method, path_with_format) 118 | assert_equal expected_request.path, ActiveResource::HttpMock.requests[index].path 119 | assert_equal expected_request.method, ActiveResource::HttpMock.requests[index].method 120 | end 121 | end 122 | 123 | def test_paths_without_format 124 | ActiveResource::Base.include_format_in_path = false 125 | path_without_format = "/people/active" 126 | 127 | ActiveResource::HttpMock.respond_to do |mock| 128 | mock.get path_without_format, {}, @matz 129 | mock.post path_without_format, {}, nil 130 | mock.patch path_without_format, {}, nil 131 | mock.delete path_without_format, {}, nil 132 | mock.put path_without_format, {}, nil 133 | end 134 | 135 | [:get, :post, :delete, :patch, :put].each_with_index do |method, index| 136 | Person.send(method, :active) 137 | expected_request = ActiveResource::Request.new(method, path_without_format) 138 | assert_equal expected_request.path, ActiveResource::HttpMock.requests[index].path 139 | assert_equal expected_request.method, ActiveResource::HttpMock.requests[index].method 140 | end 141 | ensure 142 | ActiveResource::Base.include_format_in_path = true 143 | end 144 | 145 | def test_custom_element_method_identifier_encoding 146 | luis = { person: { id: "luís", name: "Luís" } }.to_json 147 | 148 | ActiveResource::HttpMock.respond_to do |mock| 149 | mock.get "/people/lu%C3%ADs.json", {}, luis 150 | mock.put "/people/lu%C3%ADs/deactivate.json", {}, luis, 204 151 | end 152 | 153 | assert_equal ActiveResource::Response.new(luis, 204), Person.find("luís").put(:deactivate) 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /test/cases/base/equality_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | require "fixtures/street_address" 6 | 7 | class BaseEqualityTest < ActiveSupport::TestCase 8 | def setup 9 | @new = Person.new 10 | @one = Person.new(id: 1) 11 | @two = Person.new(id: 2) 12 | @street = StreetAddress.new(id: 2) 13 | end 14 | 15 | def test_should_equal_self 16 | assert @new == @new, "@new == @new" 17 | assert @one == @one, "@one == @one" 18 | end 19 | 20 | def test_shouldnt_equal_new_resource 21 | assert @new != @one, "@new != @one" 22 | assert @one != @new, "@one != @new" 23 | end 24 | 25 | def test_shouldnt_equal_different_class 26 | assert @two != @street, "person != street_address with same id" 27 | assert @street != @two, "street_address != person with same id" 28 | end 29 | 30 | def test_eql_should_alias_equals_operator 31 | assert_equal @new == @new, @new.eql?(@new) 32 | assert_equal @new == @one, @new.eql?(@one) 33 | 34 | assert_equal @one == @one, @one.eql?(@one) 35 | assert_equal @one == @new, @one.eql?(@new) 36 | 37 | assert_equal @one == @street, @one.eql?(@street) 38 | end 39 | 40 | def test_hash_should_be_id_hash 41 | [@new, @one, @two, @street].each do |resource| 42 | assert_equal resource.id.hash, resource.hash 43 | end 44 | end 45 | 46 | def test_with_prefix_options 47 | assert_equal @one == @one, @one.eql?(@one) 48 | assert_equal @one == @one.dup, @one.eql?(@one.dup) 49 | new_one = @one.dup 50 | new_one.prefix_options = { foo: "bar" } 51 | assert_not_equal @one, new_one 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/cases/base/load_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | require "fixtures/street_address" 6 | require "active_support/core_ext/hash/conversions" 7 | 8 | module Highrise 9 | class Note < ActiveResource::Base 10 | self.site = "http://37s.sunrise.i:3000" 11 | end 12 | 13 | class Comment < ActiveResource::Base 14 | self.site = "http://37s.sunrise.i:3000" 15 | end 16 | 17 | module Deeply 18 | module Nested 19 | class Note < ActiveResource::Base 20 | self.site = "http://37s.sunrise.i:3000" 21 | end 22 | 23 | class Comment < ActiveResource::Base 24 | self.site = "http://37s.sunrise.i:3000" 25 | end 26 | 27 | module TestDifferentLevels 28 | class Note < ActiveResource::Base 29 | self.site = "http://37s.sunrise.i:3000" 30 | end 31 | end 32 | end 33 | end 34 | end 35 | 36 | 37 | class BaseLoadTest < ActiveSupport::TestCase 38 | class FakeParameters 39 | def initialize(attributes) 40 | @attributes = attributes 41 | end 42 | 43 | def to_hash 44 | @attributes 45 | end 46 | end 47 | 48 | def setup 49 | @matz = { id: 1, name: "Matz" } 50 | 51 | @first_address = { address: { id: 1, street: "12345 Street" } } 52 | @addresses = [@first_address, { address: { id: 2, street: "67890 Street" } }] 53 | @addresses_from_json = { street_addresses: @addresses } 54 | @addresses_from_json_single = { street_addresses: [ @first_address ] } 55 | 56 | @deep = { id: 1, street: { 57 | id: 1, state: { id: 1, name: "Oregon", 58 | notable_rivers: [ 59 | { id: 1, name: "Willamette" }, 60 | { id: 2, name: "Columbia", rafted_by: @matz }], 61 | postal_codes: [ 97018, 1234567890 ], 62 | dates: [ Time.now ], 63 | votes: [ true, false, true ], 64 | places: [ "Columbia City", "Unknown" ] } } } 65 | 66 | 67 | # List of books formatted as [{timestamp_of_publication => name}, ...] 68 | @books = { books: [ 69 | { 1009839600 => "Ruby in a Nutshell" }, 70 | { 1199142000 => "The Ruby Programming Language" } 71 | ] } 72 | 73 | @books_date = { books: [ 74 | { Time.at(1009839600) => "Ruby in a Nutshell" }, 75 | { Time.at(1199142000) => "The Ruby Programming Language" } 76 | ] } 77 | 78 | @complex_books = { 79 | books: { 80 | "Complex.String&-Character*|=_+()!~": { 81 | isbn: 1009839690, 82 | author: "Frank Smith" 83 | }, 84 | "16Candles": { 85 | isbn: 1199142400, 86 | author: "John Hughes" 87 | } 88 | } 89 | } 90 | 91 | @person = Person.new 92 | end 93 | 94 | def test_load_hash_with_integers_as_keys_creates_stringified_attributes 95 | Person.__send__(:remove_const, :Book) if Person.const_defined?(:Book) 96 | assert_not Person.const_defined?(:Book), "Books shouldn't exist until autocreated" 97 | assert_nothing_raised { @person.load(@books) } 98 | assert_equal @books[:books].map { |book| book.stringify_keys }, @person.books.map(&:attributes) 99 | end 100 | 101 | def test_load_hash_with_dates_as_keys_creates_stringified_attributes 102 | Person.__send__(:remove_const, :Book) if Person.const_defined?(:Book) 103 | assert_not Person.const_defined?(:Book), "Books shouldn't exist until autocreated" 104 | assert_nothing_raised { @person.load(@books_date) } 105 | assert_equal @books_date[:books].map { |book| book.stringify_keys }, @person.books.map(&:attributes) 106 | end 107 | 108 | def test_load_hash_with_unacceptable_constant_characters_creates_unknown_resource 109 | Person.__send__(:remove_const, :Books) if Person.const_defined?(:Books) 110 | assert_not Person.const_defined?(:Books), "Books shouldn't exist until autocreated" 111 | assert_nothing_raised { @person.load(@complex_books) } 112 | assert Person::Books.const_defined?(:UnnamedResource), "UnnamedResource should have been autocreated" 113 | @person.books.attributes.keys.each { |key| assert_kind_of Person::Books::UnnamedResource, @person.books.attributes[key] } 114 | end 115 | 116 | def test_load_expects_hash 117 | assert_raise(ArgumentError) { @person.load nil } 118 | assert_raise(ArgumentError) { @person.load '' } 119 | end 120 | 121 | def test_load_simple_hash 122 | assert_equal Hash.new, @person.attributes 123 | assert_equal @matz.stringify_keys, @person.load(@matz).attributes 124 | end 125 | 126 | def test_load_object_with_implicit_conversion_to_hash 127 | assert_equal @matz.stringify_keys, @person.load(FakeParameters.new(@matz)).attributes 128 | end 129 | 130 | def test_after_load_attributes_are_accessible 131 | assert_equal Hash.new, @person.attributes 132 | assert_equal @matz.stringify_keys, @person.load(@matz).attributes 133 | assert_equal @matz[:name], @person.attributes["name"] 134 | end 135 | 136 | def test_after_load_attributes_are_accessible_via_indifferent_access 137 | assert_equal Hash.new, @person.attributes 138 | assert_equal @matz.stringify_keys, @person.load(@matz).attributes 139 | assert_equal @matz[:name], @person.attributes["name"] 140 | assert_equal @matz[:name], @person.attributes[:name] 141 | end 142 | 143 | def test_load_one_with_existing_resource 144 | address = @person.load(street_address: @first_address.values.first).street_address 145 | assert_kind_of StreetAddress, address 146 | assert_equal @first_address.values.first.stringify_keys, address.attributes 147 | end 148 | 149 | def test_load_one_with_unknown_resource 150 | address = silence_warnings { @person.load(@first_address).address } 151 | assert_kind_of Person::Address, address 152 | assert_equal @first_address.values.first.stringify_keys, address.attributes 153 | end 154 | 155 | def test_load_one_with_unknown_resource_from_anonymous_subclass 156 | subclass = Class.new(Person).tap { |c| c.element_name = "person" } 157 | address = silence_warnings { subclass.new.load(@first_address).address } 158 | assert_kind_of subclass::Address, address 159 | end 160 | 161 | def test_load_collection_with_existing_resource 162 | addresses = @person.load(@addresses_from_json).street_addresses 163 | assert_kind_of Array, addresses 164 | addresses.each { |address| assert_kind_of StreetAddress, address } 165 | assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes) 166 | end 167 | 168 | def test_load_collection_with_unknown_resource 169 | Person.__send__(:remove_const, :Address) if Person.const_defined?(:Address) 170 | assert_not Person.const_defined?(:Address), "Address shouldn't exist until autocreated" 171 | addresses = silence_warnings { @person.load(addresses: @addresses).addresses } 172 | assert Person.const_defined?(:Address), "Address should have been autocreated" 173 | addresses.each { |address| assert_kind_of Person::Address, address } 174 | assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes) 175 | end 176 | 177 | def test_load_collection_with_single_existing_resource 178 | addresses = @person.load(@addresses_from_json_single).street_addresses 179 | assert_kind_of Array, addresses 180 | addresses.each { |address| assert_kind_of StreetAddress, address } 181 | assert_equal [ @first_address.values.first ].map(&:stringify_keys), addresses.map(&:attributes) 182 | end 183 | 184 | def test_load_collection_with_single_unknown_resource 185 | Person.__send__(:remove_const, :Address) if Person.const_defined?(:Address) 186 | assert_not Person.const_defined?(:Address), "Address shouldn't exist until autocreated" 187 | addresses = silence_warnings { @person.load(addresses: [ @first_address ]).addresses } 188 | assert Person.const_defined?(:Address), "Address should have been autocreated" 189 | addresses.each { |address| assert_kind_of Person::Address, address } 190 | assert_equal [ @first_address.values.first ].map(&:stringify_keys), addresses.map(&:attributes) 191 | end 192 | 193 | def test_recursively_loaded_collections 194 | person = @person.load(@deep) 195 | assert_equal @deep[:id], person.id 196 | 197 | street = person.street 198 | assert_kind_of Person::Street, street 199 | assert_equal @deep[:street][:id], street.id 200 | 201 | state = street.state 202 | assert_kind_of Person::Street::State, state 203 | assert_equal @deep[:street][:state][:id], state.id 204 | 205 | rivers = state.notable_rivers 206 | assert_kind_of Array, rivers 207 | assert_kind_of Person::Street::State::NotableRiver, rivers.first 208 | assert_equal @deep[:street][:state][:notable_rivers].first[:id], rivers.first.id 209 | assert_equal @matz[:id], rivers.last.rafted_by.id 210 | 211 | postal_codes = state.postal_codes 212 | assert_kind_of Array, postal_codes 213 | assert_equal 2, postal_codes.size 214 | assert_kind_of Integer, postal_codes.first 215 | assert_equal @deep[:street][:state][:postal_codes].first, postal_codes.first 216 | assert_kind_of Numeric, postal_codes.last 217 | assert_equal @deep[:street][:state][:postal_codes].last, postal_codes.last 218 | 219 | places = state.places 220 | assert_kind_of Array, places 221 | assert_kind_of String, places.first 222 | assert_equal @deep[:street][:state][:places].first, places.first 223 | 224 | dates = state.dates 225 | assert_kind_of Array, dates 226 | assert_kind_of Time, dates.first 227 | assert_equal @deep[:street][:state][:dates].first, dates.first 228 | 229 | votes = state.votes 230 | assert_kind_of Array, votes 231 | assert_kind_of TrueClass, votes.first 232 | assert_equal @deep[:street][:state][:votes].first, votes.first 233 | end 234 | 235 | def test_nested_collections_within_the_same_namespace 236 | n = Highrise::Note.new(comments: [{ comment: { name: "1" } }]) 237 | assert_kind_of Highrise::Comment, n.comments.first 238 | end 239 | 240 | def test_nested_collections_within_deeply_nested_namespace 241 | n = Highrise::Deeply::Nested::Note.new(comments: [{ name: "1" }]) 242 | assert_kind_of Highrise::Deeply::Nested::Comment, n.comments.first 243 | end 244 | 245 | def test_nested_collections_in_different_levels_of_namespaces 246 | n = Highrise::Deeply::Nested::TestDifferentLevels::Note.new(comments: [{ name: "1" }]) 247 | assert_kind_of Highrise::Deeply::Nested::Comment, n.comments.first 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /test/cases/base_errors_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | 6 | class BaseErrorsTest < ActiveSupport::TestCase 7 | def setup 8 | ActiveResource::HttpMock.respond_to do |mock| 9 | mock.post "/people.xml", {}, %q(Age can't be blankKnown attribute can't be blankName can't be blankName must start with a letterPerson quota full for today.Phone work can't be blankPhone is not valid), 422, "Content-Type" => "application/xml; charset=utf-8" 10 | mock.post "/people.json", {}, %q({"errors":{"age":["can't be blank"],"known_attribute":["can't be blank"],"name":["can't be blank", "must start with a letter"],"person":["quota full for today."],"phone_work":["can't be blank"],"phone":["is not valid"]}}), 422, "Content-Type" => "application/json; charset=utf-8" 11 | end 12 | end 13 | 14 | def test_should_mark_as_invalid 15 | [ :json, :xml ].each do |format| 16 | invalid_user_using_format(format) do 17 | assert_not @person.valid? 18 | end 19 | end 20 | end 21 | 22 | def test_should_parse_json_and_xml_errors 23 | [ :json, :xml ].each do |format| 24 | invalid_user_using_format(format) do 25 | assert_kind_of ActiveResource::Errors, @person.errors 26 | assert_equal 7, @person.errors.size 27 | end 28 | end 29 | end 30 | 31 | def test_should_parse_json_errors_when_no_errors_key 32 | ActiveResource::HttpMock.respond_to do |mock| 33 | mock.post "/people.json", {}, "{}", 422, "Content-Type" => "application/json; charset=utf-8" 34 | end 35 | 36 | invalid_user_using_format(:json) do 37 | assert_kind_of ActiveResource::Errors, @person.errors 38 | assert_equal 0, @person.errors.size 39 | end 40 | end 41 | 42 | def test_should_parse_errors_to_individual_attributes 43 | [ :json, :xml ].each do |format| 44 | invalid_user_using_format(format) do 45 | assert @person.errors[:name].any? 46 | assert_equal ["can't be blank"], @person.errors[:age] 47 | assert_equal ["can't be blank", "must start with a letter"], @person.errors[:name] 48 | assert_equal ["can't be blank"], @person.errors[:phone_work] 49 | assert_equal ["is not valid"], @person.errors[:phone] 50 | assert_equal ["Person quota full for today."], @person.errors[:base] 51 | end 52 | end 53 | end 54 | 55 | def test_should_parse_errors_to_known_attributes 56 | [ :json, :xml ].each do |format| 57 | invalid_user_using_format(format) do 58 | assert_equal ["can't be blank"], @person.errors[:known_attribute] 59 | end 60 | end 61 | end 62 | 63 | def test_should_iterate_over_errors 64 | [ :json, :xml ].each do |format| 65 | invalid_user_using_format(format) do 66 | errors = [] 67 | if ActiveSupport.gem_version >= Gem::Version.new("6.1.x") 68 | @person.errors.each { |error| errors << [error.attribute, error.message] } 69 | else 70 | @person.errors.each { |attribute, message| errors << [attribute, message] } 71 | end 72 | assert errors.include?([:name, "can't be blank"]) 73 | end 74 | end 75 | end 76 | 77 | def test_should_iterate_over_full_errors 78 | [ :json, :xml ].each do |format| 79 | invalid_user_using_format(format) do 80 | errors = [] 81 | @person.errors.to_a.each { |message| errors << message } 82 | assert errors.include?("Name can't be blank") 83 | end 84 | end 85 | end 86 | 87 | def test_should_format_full_errors 88 | [ :json, :xml ].each do |format| 89 | invalid_user_using_format(format) do 90 | full = @person.errors.full_messages 91 | assert full.include?("Age can't be blank") 92 | assert full.include?("Name can't be blank") 93 | assert full.include?("Name must start with a letter") 94 | assert full.include?("Person quota full for today.") 95 | assert full.include?("Phone is not valid") 96 | assert full.include?("Phone work can't be blank") 97 | end 98 | end 99 | end 100 | 101 | def test_should_mark_as_invalid_when_content_type_is_unavailable_in_response_header 102 | ActiveResource::HttpMock.respond_to do |mock| 103 | mock.post "/people.xml", {}, %q(Age can't be blankName can't be blankName must start with a letterPerson quota full for today.Phone work can't be blankPhone is not valid), 422, {} 104 | mock.post "/people.json", {}, %q({"errors":{"age":["can't be blank"],"name":["can't be blank", "must start with a letter"],"person":["quota full for today."],"phone_work":["can't be blank"],"phone":["is not valid"]}}), 422, {} 105 | end 106 | 107 | [ :json, :xml ].each do |format| 108 | invalid_user_using_format(format) do 109 | assert_not @person.valid? 110 | end 111 | end 112 | end 113 | 114 | def test_should_parse_json_string_errors_with_an_errors_key 115 | ActiveResource::HttpMock.respond_to do |mock| 116 | mock.post "/people.json", {}, %q({"errors":["Age can't be blank", "Name can't be blank", "Name must start with a letter", "Person quota full for today.", "Phone work can't be blank", "Phone is not valid"]}), 422, "Content-Type" => "application/json; charset=utf-8" 117 | end 118 | 119 | assert_deprecated(/as an array/, ActiveResource.deprecator) do 120 | invalid_user_using_format(:json) do 121 | assert @person.errors[:name].any? 122 | assert_equal ["can't be blank"], @person.errors[:age] 123 | assert_equal ["can't be blank", "must start with a letter"], @person.errors[:name] 124 | assert_equal ["is not valid"], @person.errors[:phone] 125 | assert_equal ["can't be blank"], @person.errors[:phone_work] 126 | assert_equal ["Person quota full for today."], @person.errors[:base] 127 | end 128 | end 129 | end 130 | 131 | def test_should_parse_3_1_style_json_errors 132 | ActiveResource::HttpMock.respond_to do |mock| 133 | mock.post "/people.json", {}, %q({"age":["can't be blank"],"name":["can't be blank", "must start with a letter"],"person":["quota full for today."],"phone_work":["can't be blank"],"phone":["is not valid"]}), 422, "Content-Type" => "application/json; charset=utf-8" 134 | end 135 | 136 | assert_deprecated(/without a root/, ActiveResource.deprecator) do 137 | invalid_user_using_format(:json) do 138 | assert @person.errors[:name].any? 139 | assert_equal ["can't be blank"], @person.errors[:age] 140 | assert_equal ["can't be blank", "must start with a letter"], @person.errors[:name] 141 | assert_equal ["is not valid"], @person.errors[:phone] 142 | assert_equal ["can't be blank"], @person.errors[:phone_work] 143 | assert_equal ["Person quota full for today."], @person.errors[:base] 144 | end 145 | end 146 | end 147 | 148 | private 149 | def invalid_user_using_format(mime_type_reference) 150 | previous_format = Person.format 151 | previous_schema = Person.schema 152 | 153 | Person.format = mime_type_reference 154 | Person.schema = { "known_attribute" => "string" } 155 | @person = Person.new(name: "", age: "", phone: "", phone_work: "") 156 | assert_equal false, @person.save 157 | 158 | yield 159 | ensure 160 | Person.format = previous_format 161 | Person.schema = previous_schema 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /test/cases/callbacks_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "active_support/core_ext/hash/conversions" 5 | 6 | class Developer < ActiveResource::Base 7 | self.site = "http://37s.sunrise.i:3000" 8 | 9 | class << self 10 | def callback_string(callback_method) 11 | "history << [#{callback_method.to_sym.inspect}, :string]" 12 | end 13 | 14 | def callback_proc(callback_method) 15 | Proc.new { |model| model.history << [callback_method, :proc] } 16 | end 17 | 18 | def define_callback_method(callback_method) 19 | define_method(callback_method) do 20 | self.history << [callback_method, :method] 21 | end 22 | send(callback_method, :"#{callback_method}") 23 | end 24 | 25 | def callback_object(callback_method) 26 | klass = Class.new 27 | klass.send(:define_method, callback_method) do |model| 28 | model.history << [callback_method, :object] 29 | end 30 | klass.new 31 | end 32 | end 33 | 34 | ActiveResource::Callbacks::CALLBACKS.each do |callback_method| 35 | next if callback_method.to_s =~ /^around_/ 36 | define_callback_method(callback_method) 37 | send(callback_method, callback_proc(callback_method)) 38 | send(callback_method, callback_object(callback_method)) 39 | send(callback_method) { |model| model.history << [callback_method, :block] } 40 | end 41 | 42 | def history 43 | @history ||= [] 44 | end 45 | end 46 | 47 | class CallbacksTest < ActiveSupport::TestCase 48 | def setup 49 | @developer_attrs = { id: 1, name: "Guillermo", salary: 100_000 } 50 | @developer = { "developer" => @developer_attrs }.to_json 51 | ActiveResource::HttpMock.respond_to do |mock| 52 | mock.post "/developers.json", {}, @developer, 201, "Location" => "/developers/1.json" 53 | mock.get "/developers/1.json", {}, @developer 54 | mock.put "/developers/1.json", {}, nil, 204 55 | mock.delete "/developers/1.json", {}, nil, 200 56 | end 57 | end 58 | 59 | def test_valid? 60 | developer = Developer.new 61 | developer.valid? 62 | assert_equal [ 63 | [ :before_validation, :method ], 64 | [ :before_validation, :proc ], 65 | [ :before_validation, :object ], 66 | [ :before_validation, :block ], 67 | [ :after_validation, :method ], 68 | [ :after_validation, :proc ], 69 | [ :after_validation, :object ], 70 | [ :after_validation, :block ], 71 | ], developer.history 72 | end 73 | 74 | def test_create 75 | developer = Developer.create(@developer_attrs) 76 | assert_equal [ 77 | [ :before_validation, :method ], 78 | [ :before_validation, :proc ], 79 | [ :before_validation, :object ], 80 | [ :before_validation, :block ], 81 | [ :after_validation, :method ], 82 | [ :after_validation, :proc ], 83 | [ :after_validation, :object ], 84 | [ :after_validation, :block ], 85 | [ :before_save, :method ], 86 | [ :before_save, :proc ], 87 | [ :before_save, :object ], 88 | [ :before_save, :block ], 89 | [ :before_create, :method ], 90 | [ :before_create, :proc ], 91 | [ :before_create, :object ], 92 | [ :before_create, :block ], 93 | [ :after_create, :method ], 94 | [ :after_create, :proc ], 95 | [ :after_create, :object ], 96 | [ :after_create, :block ], 97 | [ :after_save, :method ], 98 | [ :after_save, :proc ], 99 | [ :after_save, :object ], 100 | [ :after_save, :block ] 101 | ], developer.history 102 | end 103 | 104 | def test_update 105 | developer = Developer.find(1) 106 | developer.save 107 | assert_equal [ 108 | [ :before_validation, :method ], 109 | [ :before_validation, :proc ], 110 | [ :before_validation, :object ], 111 | [ :before_validation, :block ], 112 | [ :after_validation, :method ], 113 | [ :after_validation, :proc ], 114 | [ :after_validation, :object ], 115 | [ :after_validation, :block ], 116 | [ :before_save, :method ], 117 | [ :before_save, :proc ], 118 | [ :before_save, :object ], 119 | [ :before_save, :block ], 120 | [ :before_update, :method ], 121 | [ :before_update, :proc ], 122 | [ :before_update, :object ], 123 | [ :before_update, :block ], 124 | [ :after_update, :method ], 125 | [ :after_update, :proc ], 126 | [ :after_update, :object ], 127 | [ :after_update, :block ], 128 | [ :after_save, :method ], 129 | [ :after_save, :proc ], 130 | [ :after_save, :object ], 131 | [ :after_save, :block ] 132 | ], developer.history 133 | end 134 | 135 | def test_destroy 136 | developer = Developer.find(1) 137 | developer.destroy 138 | assert_equal [ 139 | [ :before_destroy, :method ], 140 | [ :before_destroy, :proc ], 141 | [ :before_destroy, :object ], 142 | [ :before_destroy, :block ], 143 | [ :after_destroy, :method ], 144 | [ :after_destroy, :proc ], 145 | [ :after_destroy, :object ], 146 | [ :after_destroy, :block ] 147 | ], developer.history 148 | end 149 | 150 | def test_delete 151 | developer = Developer.find(1) 152 | Developer.delete(developer.id) 153 | assert_equal [], developer.history 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /test/cases/collection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | class CollectionTest < ActiveSupport::TestCase 6 | def setup 7 | @collection = ActiveResource::Collection.new 8 | end 9 | end 10 | 11 | class BasicCollectionTest < CollectionTest 12 | def test_collection_respond_to_collect! 13 | assert @collection.respond_to?(:collect!) 14 | end 15 | 16 | def test_collection_respond_to_map! 17 | assert @collection.respond_to?(:map!) 18 | end 19 | 20 | def test_collection_respond_to_first_or_create 21 | assert @collection.respond_to?(:first_or_create) 22 | end 23 | 24 | def test_collection_respond_to_first_or_initialize 25 | assert @collection.respond_to?(:first_or_initialize) 26 | end 27 | 28 | def test_first_or_create_without_resource_class_raises_error 29 | assert_raise(RuntimeError) { @collection.first_or_create } 30 | end 31 | 32 | def test_first_or_initialize_without_resource_class_raises_error 33 | assert_raise(RuntimeError) { @collection.first_or_initialize } 34 | end 35 | 36 | def test_collect_bang_modifies_elements 37 | elements = %w(a b c) 38 | @collection.elements = elements 39 | results = @collection.collect! { |i| i + "!" } 40 | assert_equal results.to_a, elements.collect! { |i| i + "!" } 41 | end 42 | 43 | def test_collect_bang_returns_collection 44 | @collection.elements = %w(a) 45 | results = @collection.collect! { |i| i + "!" } 46 | assert_kind_of ActiveResource::Collection, results 47 | end 48 | 49 | def respond_to_where 50 | assert @collection.respond_to?(:where) 51 | end 52 | end 53 | 54 | class PaginatedCollection < ActiveResource::Collection 55 | attr_accessor :next_page 56 | def initialize(parsed = {}) 57 | @elements = parsed["results"] 58 | @next_page = parsed["next_page"] 59 | end 60 | end 61 | 62 | class PaginatedPost < ActiveResource::Base 63 | self.site = "http://37s.sunrise.i:3000" 64 | self.collection_parser = "PaginatedCollection" 65 | end 66 | 67 | class ReduxCollection < ActiveResource::Base 68 | self.site = "http://37s.sunrise.i:3000" 69 | self.collection_parser = PaginatedCollection 70 | end 71 | 72 | 73 | class CollectionInheritanceTest < ActiveSupport::TestCase 74 | def setup 75 | @post = { id: 1, title: "Awesome" } 76 | @posts_hash = { "results" => [@post], :next_page => "/paginated_posts.json?page=2" } 77 | @posts = @posts_hash.to_json 78 | @posts2 = { "results" => [@post.merge(id: 2)], :next_page => nil }.to_json 79 | 80 | @empty_posts = { "results" => [], :next_page => nil }.to_json 81 | @new_post = { id: nil, title: nil }.to_json 82 | ActiveResource::HttpMock.respond_to do |mock| 83 | mock.get "/paginated_posts.json", {}, @posts 84 | mock.get "/paginated_posts/new.json", {}, @new_post 85 | mock.get "/paginated_posts.json?page=2", {}, @posts 86 | mock.get "/paginated_posts.json?title=test", {}, @empty_posts 87 | mock.get "/paginated_posts.json?page=2&title=Awesome", {}, @posts 88 | mock.post "/paginated_posts.json", {}, nil 89 | end 90 | end 91 | 92 | def test_setting_collection_parser 93 | assert_kind_of PaginatedCollection, PaginatedPost.find(:all) 94 | end 95 | 96 | def test_setting_collection_parser_resource_class 97 | assert_equal PaginatedPost, PaginatedPost.where(page: 2).resource_class 98 | end 99 | 100 | def test_setting_collection_parser_original_params 101 | assert_equal({ page: 2 }, PaginatedPost.where(page: 2).original_params) 102 | end 103 | 104 | def test_custom_accessor 105 | assert_equal PaginatedPost.find(:all).next_page, @posts_hash[:next_page] 106 | end 107 | 108 | def test_first_or_create 109 | post = PaginatedPost.where(title: "test").first_or_create 110 | assert post.valid? 111 | end 112 | 113 | def test_first_or_initialize 114 | post = PaginatedPost.where(title: "test").first_or_initialize 115 | assert post.valid? 116 | end 117 | 118 | def test_where 119 | posts = PaginatedPost.where(page: 2) 120 | next_posts = posts.where(title: "Awesome") 121 | assert_kind_of PaginatedCollection, next_posts 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/cases/connection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | class ConnectionTest < ActiveSupport::TestCase 6 | ResponseCodeStub = Struct.new(:code) 7 | RedirectResponseStub = Struct.new(:code, :Location) 8 | 9 | def setup 10 | @conn = ActiveResource::Connection.new("http://localhost") 11 | matz = { person: { id: 1, name: "Matz" } } 12 | david = { person: { id: 2, name: "David" } } 13 | @people = { people: [ matz, david ] }.to_json 14 | @people_single = { "people-single-elements" => [ matz ] }.to_json 15 | @people_empty = { "people-empty-elements" => [ ] }.to_json 16 | @matz = matz.to_json 17 | @david = david.to_json 18 | @header = { "key" => "value" }.freeze 19 | 20 | @default_request_headers = { "Content-Type" => "application/json" } 21 | ActiveResource::HttpMock.respond_to do |mock| 22 | mock.get "/people/2.json", @header, @david 23 | mock.get "/people.json", {}, @people 24 | mock.get "/people_single_elements.json", {}, @people_single 25 | mock.get "/people_empty_elements.json", {}, @people_empty 26 | mock.get "/people/1.json", {}, @matz 27 | mock.put "/people/1.json", {}, nil, 204 28 | mock.put "/people/2.json", {}, @header, 204 29 | mock.delete "/people/1.json", {}, nil, 200 30 | mock.delete "/people/2.json", @header, nil, 200 31 | mock.post "/people.json", {}, nil, 201, "Location" => "/people/5.json" 32 | mock.post "/members.json", {}, @header, 201, "Location" => "/people/6.json" 33 | mock.head "/people/1.json", {}, nil, 200 34 | end 35 | end 36 | 37 | def test_same_logger_as_base 38 | old_logger = ActiveResource::Base.logger 39 | ActiveResource::Base.logger = original_logger = Object.new 40 | old_site = ActiveResource::Base.site 41 | 42 | ActiveResource::Base.site = "http://localhost" 43 | assert_equal original_logger, ActiveResource::Base.connection.logger 44 | 45 | ActiveResource::Base.logger = Logger.new(STDOUT) 46 | assert_equal ActiveResource::Base.logger, ActiveResource::Base.connection.logger 47 | ensure 48 | ActiveResource::Base.logger = old_logger 49 | ActiveResource::Base.site = old_site 50 | end 51 | 52 | def test_handle_response 53 | # 2xx and 3xx are valid responses. 54 | [200, 299, 300, 399].each do |code| 55 | expected = ResponseCodeStub.new(code) 56 | assert_equal expected, handle_response(expected) 57 | end 58 | 59 | # 301 is moved permanently (redirect) 60 | assert_redirect_raises 301 61 | 62 | # 302 is found (redirect) 63 | assert_redirect_raises 302 64 | 65 | # 303 is see other (redirect) 66 | assert_redirect_raises 303 67 | 68 | # 307 is temporary redirect 69 | assert_redirect_raises 307 70 | 71 | # 400 is a bad request (e.g. malformed URI or missing request parameter) 72 | assert_response_raises ActiveResource::BadRequest, 400 73 | 74 | # 401 is an unauthorized request 75 | assert_response_raises ActiveResource::UnauthorizedAccess, 401 76 | 77 | # 402 is a payment required error. 78 | assert_response_raises ActiveResource::PaymentRequired, 402 79 | 80 | # 403 is a forbidden request (and authorizing will not help) 81 | assert_response_raises ActiveResource::ForbiddenAccess, 403 82 | 83 | # 404 is a missing resource. 84 | assert_response_raises ActiveResource::ResourceNotFound, 404 85 | 86 | # 405 is a method not allowed error 87 | assert_response_raises ActiveResource::MethodNotAllowed, 405 88 | 89 | # 409 is an optimistic locking error 90 | assert_response_raises ActiveResource::ResourceConflict, 409 91 | 92 | # 410 is a removed resource 93 | assert_response_raises ActiveResource::ResourceGone, 410 94 | 95 | # 412 is a precondition failed 96 | assert_response_raises ActiveResource::PreconditionFailed, 412 97 | 98 | # 422 is a validation error 99 | assert_response_raises ActiveResource::ResourceInvalid, 422 100 | 101 | # 429 is too many requests 102 | assert_response_raises ActiveResource::TooManyRequests, 429 103 | 104 | # 4xx are client errors. 105 | [402, 499].each do |code| 106 | assert_response_raises ActiveResource::ClientError, code 107 | end 108 | 109 | # 5xx are server errors. 110 | [500, 599].each do |code| 111 | assert_response_raises ActiveResource::ServerError, code 112 | end 113 | 114 | # Others are unknown. 115 | [199, 600].each do |code| 116 | assert_response_raises ActiveResource::ConnectionError, code 117 | end 118 | end 119 | 120 | ResponseHeaderStub = Struct.new(:code, :message, "Allow") 121 | def test_should_return_allowed_methods_for_method_no_allowed_exception 122 | handle_response ResponseHeaderStub.new(405, "HTTP Failed...", "GET, POST") 123 | rescue ActiveResource::MethodNotAllowed => e 124 | assert_equal "Failed. Response code = 405. Response message = HTTP Failed....", e.message 125 | assert_equal [:get, :post], e.allowed_methods 126 | end 127 | 128 | def test_initialize_raises_argument_error_on_missing_site 129 | assert_raise(ArgumentError) { ActiveResource::Connection.new(nil) } 130 | end 131 | 132 | def test_site_accessor_accepts_uri_or_string_argument 133 | site = URI.parse("http://localhost") 134 | 135 | assert_raise(URI::InvalidURIError) { @conn.site = nil } 136 | 137 | assert_nothing_raised { @conn.site = "http://localhost" } 138 | assert_equal site, @conn.site 139 | 140 | assert_nothing_raised { @conn.site = site } 141 | assert_equal site, @conn.site 142 | end 143 | 144 | def test_proxy_accessor_accepts_uri_or_string_argument 145 | proxy = URI.parse("http://proxy_user:proxy_password@proxy.local:4242") 146 | 147 | assert_nothing_raised { @conn.proxy = "http://proxy_user:proxy_password@proxy.local:4242" } 148 | assert_equal proxy, @conn.proxy 149 | 150 | assert_nothing_raised { @conn.proxy = proxy } 151 | assert_equal proxy, @conn.proxy 152 | end 153 | 154 | def test_proxy_accessor_accepts_uri_or_string_argument_with_special_characters 155 | user = "proxy_;{(,!$%_user" 156 | password = "proxy_;:{(,!$%_password" 157 | 158 | encoded_user = URI.encode_www_form_component(user) # "proxy_;%7B(,!$%25_user" 159 | encoded_password = URI.encode_www_form_component(password) # "proxy_;:%7B(,!$%25_password" 160 | 161 | proxy = URI.parse("http://#{encoded_user}:#{encoded_password}@proxy.local:4242") 162 | 163 | assert_nothing_raised { @conn.proxy = "http://#{encoded_user}:#{encoded_password}@proxy.local:4242" } 164 | assert_equal user, @conn.send(:new_http).proxy_user 165 | assert_equal password, @conn.send(:new_http).proxy_pass 166 | 167 | assert_nothing_raised { @conn.proxy = proxy } 168 | assert_equal user, @conn.send(:new_http).proxy_user 169 | assert_equal password, @conn.send(:new_http).proxy_pass 170 | end 171 | 172 | def test_timeout_accessor 173 | @conn.timeout = 5 174 | assert_equal 5, @conn.timeout 175 | end 176 | 177 | def test_get 178 | matz = decode(@conn.get("/people/1.json")) 179 | assert_equal "Matz", matz["name"] 180 | end 181 | 182 | def test_head 183 | response = @conn.head("/people/1.json") 184 | assert response.body.blank? 185 | assert_equal 200, response.code 186 | end 187 | 188 | def test_get_with_header 189 | david = decode(@conn.get("/people/2.json", @header)) 190 | assert_equal "David", david["name"] 191 | end 192 | 193 | def test_get_collection 194 | people = decode(@conn.get("/people.json")) 195 | assert_equal "Matz", people[0]["person"]["name"] 196 | assert_equal "David", people[1]["person"]["name"] 197 | end 198 | 199 | def test_get_collection_single 200 | people = decode(@conn.get("/people_single_elements.json")) 201 | assert_equal "Matz", people[0]["person"]["name"] 202 | end 203 | 204 | def test_get_collection_empty 205 | people = decode(@conn.get("/people_empty_elements.json")) 206 | assert_equal [], people 207 | end 208 | 209 | def test_post 210 | response = @conn.post("/people.json") 211 | assert_equal "/people/5.json", response["Location"] 212 | end 213 | 214 | def test_post_with_header 215 | response = @conn.post("/members.json", @header) 216 | assert_equal "/people/6.json", response["Location"] 217 | end 218 | 219 | def test_put 220 | response = @conn.put("/people/1.json") 221 | assert_equal 204, response.code 222 | end 223 | 224 | def test_put_with_header 225 | response = @conn.put("/people/2.json", @header) 226 | assert_equal 204, response.code 227 | end 228 | 229 | def test_delete 230 | response = @conn.delete("/people/1.json") 231 | assert_equal 200, response.code 232 | end 233 | 234 | def test_delete_with_header 235 | response = @conn.delete("/people/2.json", @header) 236 | assert_equal 200, response.code 237 | end 238 | 239 | def test_timeout 240 | @http = mock("new Net::HTTP") 241 | @conn.expects(:http).returns(@http) 242 | @http.expects(:get).raises(Timeout::Error, "execution expired") 243 | assert_raise(ActiveResource::TimeoutError) { @conn.get("/people_timeout.json") } 244 | end 245 | 246 | def test_setting_timeout 247 | http = Net::HTTP.new("") 248 | 249 | [10, 20].each do |timeout| 250 | @conn.timeout = timeout 251 | @conn.send(:configure_http, http) 252 | assert_equal timeout, http.open_timeout 253 | assert_equal timeout, http.read_timeout 254 | end 255 | end 256 | 257 | def test_accept_http_header 258 | @http = mock("new Net::HTTP") 259 | @conn.expects(:http).returns(@http) 260 | path = "/people/1.xml" 261 | @http.expects(:get).with(path, "Accept" => "application/xhtml+xml").returns(ActiveResource::Response.new(@matz, 200, "Content-Type" => "text/xhtml")) 262 | assert_nothing_raised { @conn.get(path, "Accept" => "application/xhtml+xml") } 263 | end 264 | 265 | def test_ssl_options_get_applied_to_http 266 | http = Net::HTTP.new("") 267 | @conn.site = "https://secure" 268 | @conn.ssl_options = { verify_mode: OpenSSL::SSL::VERIFY_PEER } 269 | @conn.send(:configure_http, http) 270 | 271 | assert http.use_ssl? 272 | assert_equal http.verify_mode, OpenSSL::SSL::VERIFY_PEER 273 | end 274 | 275 | def test_ssl_options_get_applied_to_https_urls_without_explicitly_setting_ssl_options 276 | http = Net::HTTP.new("") 277 | @conn.site = "https://secure" 278 | assert @conn.send(:configure_http, http).use_ssl? 279 | end 280 | 281 | def test_ssl_error 282 | http = Net::HTTP.new("") 283 | @conn.expects(:http).returns(http) 284 | http.expects(:get).raises(OpenSSL::SSL::SSLError, "Expired certificate") 285 | assert_raise(ActiveResource::SSLError) { @conn.get("/people/1.json") } 286 | end 287 | 288 | def test_auth_type_can_be_string 289 | @conn.auth_type = "digest" 290 | assert_equal(:digest, @conn.auth_type) 291 | end 292 | 293 | def test_auth_type_defaults_to_basic 294 | @conn.auth_type = nil 295 | assert_equal(:basic, @conn.auth_type) 296 | end 297 | 298 | def test_auth_type_ignores_nonsensical_values 299 | @conn.auth_type = :wibble 300 | assert_equal(:basic, @conn.auth_type) 301 | end 302 | 303 | def test_disable_net_connection_by_default_when_http_mock_is_available 304 | assert_equal(ActiveResource::HttpMock, @conn.send(:http).class) 305 | end 306 | 307 | def test_enable_net_connection 308 | @conn.send(:http) 309 | keep_net_connection_status do 310 | ActiveResource::HttpMock.enable_net_connection! 311 | assert @conn.send(:http).kind_of?(Net::HTTP) 312 | end 313 | end 314 | 315 | def test_disable_net_connection 316 | keep_net_connection_status do 317 | ActiveResource::HttpMock.enable_net_connection! 318 | @conn.send(:http) 319 | ActiveResource::HttpMock.disable_net_connection! 320 | assert @conn.send(:http).kind_of?(ActiveResource::HttpMock) 321 | end 322 | end 323 | 324 | def keep_net_connection_status 325 | old = ActiveResource::HttpMock.net_connection_enabled? 326 | begin 327 | yield 328 | ensure 329 | if old 330 | ActiveResource::HttpMock.enable_net_connection! 331 | else 332 | ActiveResource::HttpMock.disable_net_connection! 333 | end 334 | end 335 | end 336 | 337 | protected 338 | def assert_response_raises(klass, code) 339 | assert_raise(klass, "Expected response code #{code} to raise #{klass}") do 340 | handle_response ResponseCodeStub.new(code) 341 | end 342 | end 343 | 344 | def assert_redirect_raises(code) 345 | assert_raise(ActiveResource::Redirection, "Expected response code #{code} to raise ActiveResource::Redirection") do 346 | handle_response RedirectResponseStub.new(code, "http://example.com/") 347 | end 348 | end 349 | 350 | def handle_response(response) 351 | @conn.__send__(:handle_response, response) 352 | end 353 | 354 | def decode(response) 355 | @conn.format.decode(response.body) 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /test/cases/finder_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | require "fixtures/customer" 6 | require "fixtures/street_address" 7 | require "fixtures/beast" 8 | require "fixtures/proxy" 9 | require "fixtures/pet" 10 | require "active_support/core_ext/hash/conversions" 11 | 12 | class FinderTest < ActiveSupport::TestCase 13 | def setup 14 | setup_response # find me in abstract_unit 15 | end 16 | 17 | def test_find_by_id 18 | matz = Person.find(1) 19 | assert_kind_of Person, matz 20 | assert_equal "Matz", matz.name 21 | assert matz.name? 22 | end 23 | 24 | def test_find_by_id_with_custom_prefix 25 | addy = StreetAddress.find(1, params: { person_id: 1 }) 26 | assert_kind_of StreetAddress, addy 27 | assert_equal "12345 Street", addy.street 28 | end 29 | 30 | def test_find_all 31 | all = Person.find(:all) 32 | assert_equal 2, all.size 33 | assert_kind_of Person, all.first 34 | assert_equal "Matz", all.first.name 35 | assert_equal "David", all.last.name 36 | end 37 | 38 | def test_all 39 | all = Person.all 40 | assert_equal 2, all.size 41 | assert_kind_of Person, all.first 42 | assert_equal "Matz", all.first.name 43 | assert_equal "David", all.last.name 44 | end 45 | 46 | def test_all_with_params 47 | all = StreetAddress.all(params: { person_id: 1 }) 48 | assert_equal 1, all.size 49 | assert_kind_of StreetAddress, all.first 50 | end 51 | 52 | def test_where 53 | people = Person.where 54 | assert_equal 2, people.size 55 | assert_kind_of Person, people.first 56 | assert_equal "Matz", people.first.name 57 | assert_equal "David", people.last.name 58 | end 59 | 60 | def test_where_with_clauses 61 | addresses = StreetAddress.where(person_id: 1) 62 | assert_equal 1, addresses.size 63 | assert_kind_of StreetAddress, addresses.first 64 | end 65 | 66 | def test_where_with_clause_in 67 | ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?id%5B%5D=2", {}, @people_david } 68 | people = Person.where(id: [2]) 69 | assert_equal 1, people.size 70 | assert_kind_of Person, people.first 71 | assert_equal "David", people.first.name 72 | end 73 | 74 | def test_where_with_invalid_clauses 75 | error = assert_raise(ArgumentError) { Person.where(nil) } 76 | assert_equal "expected a clauses Hash, got nil", error.message 77 | end 78 | 79 | def test_find_first 80 | matz = Person.find(:first) 81 | assert_kind_of Person, matz 82 | assert_equal "Matz", matz.name 83 | end 84 | 85 | def test_first 86 | matz = Person.first 87 | assert_kind_of Person, matz 88 | assert_equal "Matz", matz.name 89 | end 90 | 91 | def test_first_with_params 92 | addy = StreetAddress.first(params: { person_id: 1 }) 93 | assert_kind_of StreetAddress, addy 94 | assert_equal "12345 Street", addy.street 95 | end 96 | 97 | def test_find_last 98 | david = Person.find(:last) 99 | assert_kind_of Person, david 100 | assert_equal "David", david.name 101 | end 102 | 103 | def test_last 104 | david = Person.last 105 | assert_kind_of Person, david 106 | assert_equal "David", david.name 107 | end 108 | 109 | def test_last_with_params 110 | addy = StreetAddress.last(params: { person_id: 1 }) 111 | assert_kind_of StreetAddress, addy 112 | assert_equal "12345 Street", addy.street 113 | end 114 | 115 | def test_find_by_id_not_found 116 | assert_raise(ActiveResource::ResourceNotFound) { Person.find(99) } 117 | assert_raise(ActiveResource::ResourceNotFound) { StreetAddress.find(99, params: { person_id: 1 }) } 118 | end 119 | 120 | def test_find_all_sub_objects 121 | all = StreetAddress.find(:all, params: { person_id: 1 }) 122 | assert_equal 1, all.size 123 | assert_kind_of StreetAddress, all.first 124 | assert_equal ({ person_id: 1 }), all.first.prefix_options 125 | end 126 | 127 | def test_find_all_sub_objects_not_found 128 | assert_nothing_raised do 129 | StreetAddress.find(:all, params: { person_id: 2 }) 130 | end 131 | end 132 | 133 | def test_find_all_by_from 134 | ActiveResource::HttpMock.respond_to { |m| m.get "/companies/1/people.json", {}, @people_david } 135 | 136 | people = Person.find(:all, from: "/companies/1/people.json") 137 | assert_equal 1, people.size 138 | assert_equal "David", people.first.name 139 | end 140 | 141 | def test_find_all_by_from_with_options 142 | ActiveResource::HttpMock.respond_to { |m| m.get "/companies/1/people.json", {}, @people_david } 143 | 144 | people = Person.find(:all, from: "/companies/1/people.json") 145 | assert_equal 1, people.size 146 | assert_equal "David", people.first.name 147 | end 148 | 149 | def test_find_all_by_from_with_prefix 150 | ActiveResource::HttpMock.respond_to { |m| m.get "/dogs.json", {}, @pets } 151 | 152 | pets = Pet.find(:all, from: "/dogs.json", params: { person_id: 1 }) 153 | assert_equal 2, pets.size 154 | assert_equal "Max", pets.first.name 155 | assert_equal ({ person_id: 1 }), pets.first.prefix_options 156 | 157 | assert_equal "Daisy", pets.second.name 158 | assert_equal ({ person_id: 1 }), pets.second.prefix_options 159 | end 160 | 161 | def test_find_all_by_symbol_from 162 | ActiveResource::HttpMock.respond_to { |m| m.get "/people/managers.json", {}, @people_david } 163 | 164 | people = Person.find(:all, from: :managers) 165 | assert_equal 1, people.size 166 | assert_equal "David", people.first.name 167 | end 168 | 169 | def test_find_all_by_symbol_from_with_prefix 170 | ActiveResource::HttpMock.respond_to { |m| m.get "/people/1/pets/dogs.json", {}, @pets } 171 | 172 | pets = Pet.find(:all, from: :dogs, params: { person_id: 1 }) 173 | assert_equal 2, pets.size 174 | assert_equal "Max", pets.first.name 175 | assert_equal ({ person_id: 1 }), pets.first.prefix_options 176 | 177 | assert_equal "Daisy", pets.second.name 178 | assert_equal ({ person_id: 1 }), pets.second.prefix_options 179 | end 180 | 181 | def test_find_first_or_last_not_found 182 | ActiveResource::HttpMock.respond_to { |m| m.get "/people.json", {}, "", 404 } 183 | 184 | assert_nothing_raised { Person.find(:first) } 185 | assert_nothing_raised { Person.find(:last) } 186 | end 187 | 188 | def test_find_single_by_from 189 | ActiveResource::HttpMock.respond_to { |m| m.get "/companies/1/manager.json", {}, @david } 190 | 191 | david = Person.find(:one, from: "/companies/1/manager.json") 192 | assert_equal "David", david.name 193 | end 194 | 195 | def test_find_single_by_symbol_from 196 | ActiveResource::HttpMock.respond_to { |m| m.get "/people/leader.json", {}, @david } 197 | 198 | david = Person.find(:one, from: :leader) 199 | assert_equal "David", david.name 200 | end 201 | 202 | def test_find_identifier_encoding 203 | ActiveResource::HttpMock.respond_to { |m| m.get "/people/%3F.json", {}, @david } 204 | 205 | david = Person.find("?") 206 | 207 | assert_equal "David", david.name 208 | end 209 | 210 | def test_find_identifier_encoding_for_path_traversal 211 | ActiveResource::HttpMock.respond_to { |m| m.get "/people/..%2F.json", {}, @david } 212 | 213 | david = Person.find("../") 214 | 215 | assert_equal "David", david.name 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /test/cases/format_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | require "fixtures/street_address" 6 | 7 | class FormatTest < ActiveSupport::TestCase 8 | def setup 9 | @matz = { id: 1, name: "Matz" } 10 | @david = { id: 2, name: "David" } 11 | 12 | @programmers = [ @matz, @david ] 13 | end 14 | 15 | def test_http_format_header_name 16 | [:get, :head].each do |verb| 17 | header_name = ActiveResource::Connection::HTTP_FORMAT_HEADER_NAMES[verb] 18 | assert_equal "Accept", header_name 19 | end 20 | 21 | [:patch, :put, :post].each do |verb| 22 | header_name = ActiveResource::Connection::HTTP_FORMAT_HEADER_NAMES[verb] 23 | assert_equal "Content-Type", header_name 24 | end 25 | end 26 | 27 | def test_formats_on_single_element 28 | [ :json, :xml ].each do |format| 29 | using_format(Person, format) do 30 | ActiveResource::HttpMock.respond_to.get "/people/1.#{format}", { "Accept" => ActiveResource::Formats[format].mime_type }, ActiveResource::Formats[format].encode(@david) 31 | assert_equal @david[:name], Person.find(1).name 32 | end 33 | end 34 | end 35 | 36 | def test_formats_on_collection 37 | [ :json, :xml ].each do |format| 38 | using_format(Person, format) do 39 | ActiveResource::HttpMock.respond_to.get "/people.#{format}", { "Accept" => ActiveResource::Formats[format].mime_type }, ActiveResource::Formats[format].encode(@programmers) 40 | remote_programmers = Person.find(:all) 41 | assert_equal 2, remote_programmers.size 42 | assert remote_programmers.find { |p| p.name == "David" } 43 | end 44 | end 45 | end 46 | 47 | def test_formats_on_custom_collection_method 48 | [ :json, :xml ].each do |format| 49 | using_format(Person, format) do 50 | ActiveResource::HttpMock.respond_to.get "/people/retrieve.#{format}?name=David", { "Accept" => ActiveResource::Formats[format].mime_type }, ActiveResource::Formats[format].encode([@david]) 51 | remote_programmers = Person.get(:retrieve, name: "David") 52 | assert_equal 1, remote_programmers.size 53 | assert_equal @david[:id], remote_programmers[0]["id"] 54 | assert_equal @david[:name], remote_programmers[0]["name"] 55 | end 56 | end 57 | end 58 | 59 | def test_formats_on_custom_element_method 60 | [:json, :xml].each do |format| 61 | using_format(Person, format) do 62 | david = (format == :json ? { person: @david } : @david) 63 | ActiveResource::HttpMock.respond_to do |mock| 64 | mock.get "/people/2.#{format}", { "Accept" => ActiveResource::Formats[format].mime_type }, ActiveResource::Formats[format].encode(david) 65 | mock.get "/people/2/shallow.#{format}", { "Accept" => ActiveResource::Formats[format].mime_type }, ActiveResource::Formats[format].encode(david) 66 | end 67 | 68 | remote_programmer = Person.find(2).get(:shallow) 69 | assert_equal @david[:id], remote_programmer["id"] 70 | assert_equal @david[:name], remote_programmer["name"] 71 | end 72 | 73 | ryan_hash = { name: "Ryan" } 74 | ryan_hash = (format == :json ? { person: ryan_hash } : ryan_hash) 75 | ryan = ActiveResource::Formats[format].encode(ryan_hash) 76 | using_format(Person, format) do 77 | remote_ryan = Person.new(name: "Ryan") 78 | ActiveResource::HttpMock.respond_to.post "/people.#{format}", { "Content-Type" => ActiveResource::Formats[format].mime_type }, ryan, 201, "Location" => "/people/5.#{format}" 79 | remote_ryan.save 80 | 81 | remote_ryan = Person.new(name: "Ryan") 82 | ActiveResource::HttpMock.respond_to.post "/people/new/register.#{format}", { "Content-Type" => ActiveResource::Formats[format].mime_type }, ryan, 201, "Location" => "/people/5.#{format}" 83 | assert_equal ActiveResource::Response.new(ryan, 201, "Location" => "/people/5.#{format}"), remote_ryan.post(:register) 84 | end 85 | end 86 | end 87 | 88 | def test_setting_format_before_site 89 | resource = Class.new(ActiveResource::Base) 90 | resource.format = :json 91 | resource.site = "http://37s.sunrise.i:3000" 92 | assert_equal ActiveResource::Formats[:json], resource.connection.format 93 | end 94 | 95 | def test_serialization_of_nested_resource 96 | address = { street: "12345 Street" } 97 | person = { name: "Rus", address: address } 98 | 99 | [:json, :xml].each do |format| 100 | encoded_person = ActiveResource::Formats[format].encode(person) 101 | assert_match(/12345 Street/, encoded_person) 102 | remote_person = Person.new(person.update(address: StreetAddress.new(address))) 103 | assert_kind_of StreetAddress, remote_person.address 104 | using_format(Person, format) do 105 | ActiveResource::HttpMock.respond_to.post "/people.#{format}", { "Content-Type" => ActiveResource::Formats[format].mime_type }, encoded_person, 201, "Location" => "/people/5.#{format}" 106 | remote_person.save 107 | end 108 | end 109 | end 110 | 111 | def test_removing_root 112 | matz = { name: "Matz" } 113 | matz_with_root = { person: matz } 114 | 115 | # On Array 116 | people = [ matz ] 117 | assert_equal ActiveResource::Formats.remove_root(people), [ matz ] 118 | 119 | # On Hash with no root 120 | person = matz 121 | assert_equal ActiveResource::Formats.remove_root(person), matz 122 | 123 | # On Hash with root 124 | person = matz_with_root 125 | assert_equal ActiveResource::Formats.remove_root(person), matz 126 | end 127 | 128 | private 129 | def using_format(klass, mime_type_reference) 130 | previous_format = klass.format 131 | klass.format = mime_type_reference 132 | 133 | yield 134 | ensure 135 | klass.format = previous_format 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/cases/http_mock_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "active_support/core_ext/object/inclusion" 5 | 6 | class HttpMockTest < ActiveSupport::TestCase 7 | setup do 8 | @http = ActiveResource::HttpMock.new("http://example.com") 9 | end 10 | 11 | FORMAT_HEADER = ActiveResource::Connection::HTTP_FORMAT_HEADER_NAMES 12 | 13 | [:post, :patch, :put, :get, :delete, :head].each do |method| 14 | test "responds to simple #{method} request" do 15 | ActiveResource::HttpMock.respond_to do |mock| 16 | mock.send(method, "/people/1", { FORMAT_HEADER[method] => "application/json" }, "Response") 17 | end 18 | 19 | assert_equal "Response", request(method, "/people/1", FORMAT_HEADER[method] => "application/json").body 20 | end 21 | 22 | test "adds format header by default to #{method} request" do 23 | ActiveResource::HttpMock.respond_to do |mock| 24 | mock.send(method, "/people/1", {}, "Response") 25 | end 26 | 27 | assert_equal "Response", request(method, "/people/1", FORMAT_HEADER[method] => "application/json").body 28 | end 29 | 30 | test "respond only when headers match header by default to #{method} request" do 31 | ActiveResource::HttpMock.respond_to do |mock| 32 | mock.send(method, "/people/1", { "X-Header" => "X" }, "Response") 33 | end 34 | 35 | assert_equal "Response", request(method, "/people/1", "X-Header" => "X").body 36 | assert_raise(ActiveResource::InvalidRequestError) { request(method, "/people/1") } 37 | end 38 | 39 | test "does not overwrite format header to #{method} request" do 40 | ActiveResource::HttpMock.respond_to do |mock| 41 | mock.send(method, "/people/1", { FORMAT_HEADER[method] => "application/json" }, "Response") 42 | end 43 | 44 | assert_equal "Response", request(method, "/people/1", FORMAT_HEADER[method] => "application/json").body 45 | end 46 | 47 | test "ignores format header when there is only one response to same url in a #{method} request" do 48 | ActiveResource::HttpMock.respond_to do |mock| 49 | mock.send(method, "/people/1", {}, "Response") 50 | end 51 | 52 | assert_equal "Response", request(method, "/people/1", FORMAT_HEADER[method] => "application/json").body 53 | assert_equal "Response", request(method, "/people/1", FORMAT_HEADER[method] => "application/xml").body 54 | end 55 | 56 | test "responds correctly when format header is given to #{method} request" do 57 | ActiveResource::HttpMock.respond_to do |mock| 58 | mock.send(method, "/people/1", { FORMAT_HEADER[method] => "application/xml" }, "XML") 59 | mock.send(method, "/people/1", { FORMAT_HEADER[method] => "application/json" }, "Json") 60 | end 61 | 62 | assert_equal "XML", request(method, "/people/1", FORMAT_HEADER[method] => "application/xml").body 63 | assert_equal "Json", request(method, "/people/1", FORMAT_HEADER[method] => "application/json").body 64 | end 65 | 66 | test "raises InvalidRequestError if no response found for the #{method} request" do 67 | ActiveResource::HttpMock.respond_to do |mock| 68 | mock.send(method, "/people/1", { FORMAT_HEADER[method] => "application/json" }, "json") 69 | end 70 | 71 | assert_raise(::ActiveResource::InvalidRequestError) do 72 | request(method, "/people/1", FORMAT_HEADER[method] => "application/xml") 73 | end 74 | end 75 | end 76 | 77 | test "allows you to send in pairs directly to the respond_to method" do 78 | matz = { person: { id: 1, name: "Matz" } }.to_json 79 | 80 | create_matz = ActiveResource::Request.new(:post, "/people.json", matz, {}) 81 | created_response = ActiveResource::Response.new("", 201, "Location" => "/people/1.json") 82 | get_matz = ActiveResource::Request.new(:get, "/people/1.json", nil) 83 | ok_response = ActiveResource::Response.new(matz, 200, {}) 84 | 85 | pairs = { create_matz => created_response, get_matz => ok_response } 86 | 87 | ActiveResource::HttpMock.respond_to(pairs) 88 | assert_equal 2, ActiveResource::HttpMock.responses.length 89 | assert_equal "", ActiveResource::HttpMock.responses.assoc(create_matz)[1].body 90 | assert_equal matz, ActiveResource::HttpMock.responses.assoc(get_matz)[1].body 91 | end 92 | 93 | test "resets all mocked responses on each call to respond_to with a block by default" do 94 | ActiveResource::HttpMock.respond_to do |mock| 95 | mock.send(:get, "/people/1", {}, "JSON1") 96 | end 97 | assert_equal 1, ActiveResource::HttpMock.responses.length 98 | 99 | ActiveResource::HttpMock.respond_to do |mock| 100 | mock.send(:get, "/people/2", {}, "JSON2") 101 | end 102 | assert_equal 1, ActiveResource::HttpMock.responses.length 103 | end 104 | 105 | test "resets all mocked responses on each call to respond_to by passing pairs by default" do 106 | ActiveResource::HttpMock.respond_to do |mock| 107 | mock.send(:get, "/people/1", {}, "JSON1") 108 | end 109 | assert_equal 1, ActiveResource::HttpMock.responses.length 110 | 111 | matz = { person: { id: 1, name: "Matz" } }.to_json 112 | get_matz = ActiveResource::Request.new(:get, "/people/1.json", nil) 113 | ok_response = ActiveResource::Response.new(matz, 200, {}) 114 | ActiveResource::HttpMock.respond_to(get_matz => ok_response) 115 | 116 | assert_equal 1, ActiveResource::HttpMock.responses.length 117 | end 118 | 119 | test "allows you to add new responses to the existing responses by calling a block" do 120 | ActiveResource::HttpMock.respond_to do |mock| 121 | mock.send(:get, "/people/1", {}, "JSON1") 122 | end 123 | assert_equal 1, ActiveResource::HttpMock.responses.length 124 | 125 | ActiveResource::HttpMock.respond_to(false) do |mock| 126 | mock.send(:get, "/people/2", {}, "JSON2") 127 | end 128 | assert_equal 2, ActiveResource::HttpMock.responses.length 129 | end 130 | 131 | test "allows you to add new responses to the existing responses by passing pairs" do 132 | ActiveResource::HttpMock.respond_to do |mock| 133 | mock.send(:get, "/people/1", {}, "JSON1") 134 | end 135 | assert_equal 1, ActiveResource::HttpMock.responses.length 136 | 137 | matz = { person: { id: 1, name: "Matz" } }.to_json 138 | get_matz = ActiveResource::Request.new(:get, "/people/1.json", nil) 139 | ok_response = ActiveResource::Response.new(matz, 200, {}) 140 | ActiveResource::HttpMock.respond_to({ get_matz => ok_response }, false) 141 | 142 | assert_equal 2, ActiveResource::HttpMock.responses.length 143 | end 144 | 145 | test "allows you to replace the existing response with the same request by calling a block" do 146 | ActiveResource::HttpMock.respond_to do |mock| 147 | mock.send(:get, "/people/1", {}, "JSON1") 148 | end 149 | assert_equal 1, ActiveResource::HttpMock.responses.length 150 | 151 | ActiveResource::HttpMock.respond_to(false) do |mock| 152 | mock.send(:get, "/people/1", {}, "JSON2") 153 | end 154 | assert_equal 1, ActiveResource::HttpMock.responses.length 155 | end 156 | 157 | test "allows you to replace the existing response with the same request by passing pairs" do 158 | ActiveResource::HttpMock.respond_to do |mock| 159 | mock.send(:get, "/people/1", {}, "JSON1") 160 | end 161 | assert_equal 1, ActiveResource::HttpMock.responses.length 162 | 163 | matz = { person: { id: 1, name: "Matz" } }.to_json 164 | get_matz = ActiveResource::Request.new(:get, "/people/1", nil) 165 | ok_response = ActiveResource::Response.new(matz, 200, {}) 166 | 167 | ActiveResource::HttpMock.respond_to({ get_matz => ok_response }, false) 168 | assert_equal 1, ActiveResource::HttpMock.responses.length 169 | end 170 | 171 | test "do not replace the response with the same path but different method by calling a block" do 172 | ActiveResource::HttpMock.respond_to do |mock| 173 | mock.send(:get, "/people/1", {}, "JSON1") 174 | end 175 | assert_equal 1, ActiveResource::HttpMock.responses.length 176 | 177 | ActiveResource::HttpMock.respond_to(false) do |mock| 178 | mock.send(:put, "/people/1", {}, "JSON2") 179 | end 180 | assert_equal 2, ActiveResource::HttpMock.responses.length 181 | end 182 | 183 | test "do not replace the response with the same path but different method by passing pairs" do 184 | ActiveResource::HttpMock.respond_to do |mock| 185 | mock.send(:get, "/people/1", {}, "JSON1") 186 | end 187 | assert_equal 1, ActiveResource::HttpMock.responses.length 188 | 189 | put_matz = ActiveResource::Request.new(:put, "/people/1", nil) 190 | ok_response = ActiveResource::Response.new("", 200, {}) 191 | 192 | ActiveResource::HttpMock.respond_to({ put_matz => ok_response }, false) 193 | assert_equal 2, ActiveResource::HttpMock.responses.length 194 | end 195 | 196 | test "omits query parameters from the URL when options[:omit_query_params] is true" do 197 | ActiveResource::HttpMock.respond_to do |mock| 198 | mock.get("/endpoint", {}, "Response", 200, {}, { omit_query_in_path: true }) 199 | end 200 | 201 | response = request(:get, "/endpoint?param1=value1¶m2=value2") 202 | 203 | assert_equal "Response", response.body 204 | end 205 | 206 | def request(method, path, headers = {}, body = nil) 207 | if method.in?([:patch, :put, :post]) 208 | @http.send(method, path, body, headers) 209 | else 210 | @http.send(method, path, headers) 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /test/cases/inheritence_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/product" 6 | 7 | class InheritenceTest < ActiveSupport::TestCase 8 | def test_sub_class_retains_ancestor_headers 9 | ActiveResource::HttpMock.respond_to do |mock| 10 | mock.get "/sub_products/1.json", 11 | { "Accept" => "application/json", "X-Inherited-Header" => "present" }, 12 | { id: 1, name: "Sub Product" }.to_json, 13 | 200 14 | end 15 | 16 | sub_product = SubProduct.find(1) 17 | assert_equal "SubProduct", sub_product.class.to_s 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/cases/inheriting_hash_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InheritingHashTest < ActiveSupport::TestCase 4 | def setup 5 | @parent = ActiveResource::InheritingHash.new({ override_me: "foo", parent_key: "parent_value" }) 6 | @child = ActiveResource::InheritingHash.new(@parent) 7 | @child[:override_me] = "bar" 8 | @child[:child_only] = "baz" 9 | end 10 | 11 | def test_child_key_overrides_parent_key 12 | assert_equal "bar", @child[:override_me] 13 | end 14 | 15 | def test_parent_key_available_on_lookup 16 | assert_equal "parent_value", @child[:parent_key] 17 | end 18 | 19 | def test_conversion_to_regular_hash_includes_parent_keys 20 | hash = @child.to_hash 21 | 22 | assert_equal 3, hash.keys.length 23 | assert_equal "parent_value", hash[:parent_key] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/cases/log_subscriber_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/person" 5 | require "active_support/log_subscriber/test_helper" 6 | require "active_resource/log_subscriber" 7 | require "active_support/core_ext/hash/conversions" 8 | 9 | class LogSubscriberTest < ActiveSupport::TestCase 10 | include ActiveSupport::LogSubscriber::TestHelper 11 | 12 | def setup 13 | super 14 | 15 | @matz = { person: { id: 1, name: "Matz" } }.to_json 16 | ActiveResource::HttpMock.respond_to do |mock| 17 | mock.get "/people/1.json", {}, @matz 18 | mock.get "/people/2.json", {}, nil, 404 19 | mock.get "/people/3.json", {}, nil, 502 20 | end 21 | 22 | ActiveResource::LogSubscriber.attach_to :active_resource 23 | end 24 | 25 | def set_logger(logger) 26 | ActiveResource::Base.logger = logger 27 | end 28 | 29 | def test_request_notification 30 | Person.find(1) 31 | wait 32 | assert_equal 2, @logger.logged(:info).size 33 | assert_equal "GET http://37s.sunrise.i:3000/people/1.json", @logger.logged(:info)[0] 34 | assert_match(/--> 200 200 33/, @logger.logged(:info)[1]) 35 | end 36 | 37 | def test_failure_error_log 38 | Person.find(2) 39 | rescue 40 | wait 41 | assert_equal 2, @logger.logged(:error).size 42 | assert_equal "GET http://37s.sunrise.i:3000/people/2.json", @logger.logged(:error)[0] 43 | assert_match(/--> 404 404 0/, @logger.logged(:error)[1]) 44 | end 45 | 46 | def test_server_error_log 47 | Person.find(3) 48 | rescue 49 | wait 50 | assert_equal 2, @logger.logged(:error).size 51 | assert_equal "GET http://37s.sunrise.i:3000/people/3.json", @logger.logged(:error)[0] 52 | assert_match(/--> 502 502 0/, @logger.logged(:error)[1]) 53 | end 54 | 55 | def test_connection_failure 56 | Person.find(99) 57 | rescue 58 | wait 59 | assert_equal 2, @logger.logged(:error).size 60 | assert_equal "GET http://37s.sunrise.i:3000/people/99.json", @logger.logged(:error)[0] 61 | assert_match(/--> 523 ActiveResource connection error 0/, @logger.logged(:error)[1]) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/cases/reflection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | require "fixtures/person" 6 | require "fixtures/customer" 7 | 8 | 9 | 10 | class ReflectionTest < ActiveSupport::TestCase 11 | def test_correct_class_attributes 12 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, {}) 13 | assert_equal :people, object.name 14 | assert_equal :test, object.macro 15 | assert_equal({}, object.options) 16 | end 17 | 18 | def test_correct_class_name_matching_without_class_name 19 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, {}) 20 | assert_equal Person, object.klass 21 | end 22 | 23 | def test_correct_class_name_matching_as_string 24 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, class_name: "Person") 25 | assert_equal Person, object.klass 26 | end 27 | 28 | def test_correct_class_name_matching_as_symbol 29 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, class_name: :person) 30 | assert_equal Person, object.klass 31 | end 32 | 33 | def test_correct_class_name_matching_as_class 34 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, class_name: Person) 35 | assert_equal Person, object.klass 36 | end 37 | 38 | def test_correct_class_name_matching_as_string_with_namespace 39 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, class_name: "external/person") 40 | assert_equal External::Person, object.klass 41 | end 42 | 43 | def test_correct_class_name_matching_as_plural_string_with_namespace 44 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, class_name: "external/profile_data") 45 | assert_equal External::ProfileData, object.klass 46 | end 47 | 48 | def test_foreign_key_method_with_no_foreign_key_option 49 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :person, {}) 50 | assert_equal "person_id", object.foreign_key 51 | end 52 | 53 | def test_foreign_key_method_with_with_foreign_key_option 54 | object = ActiveResource::Reflection::AssociationReflection.new(:test, :people, foreign_key: "client_id") 55 | assert_equal "client_id", object.foreign_key 56 | end 57 | 58 | def test_creation_of_reflection 59 | Person.reflections = {} 60 | object = Person.create_reflection(:test, :people, {}) 61 | assert_equal ActiveResource::Reflection::AssociationReflection, object.class 62 | assert_equal 1, Person.reflections.count 63 | assert_equal Person, Person.reflections[:people].klass 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/cases/validations_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/project" 5 | require "active_support/core_ext/hash/conversions" 6 | 7 | # The validations are tested thoroughly under ActiveModel::Validations 8 | # This test case simply makes sure that they are all accessible by 9 | # Active Resource objects. 10 | class ValidationsTest < ActiveSupport::TestCase 11 | VALID_PROJECT_HASH = { name: "My Project", description: "A project" } 12 | def setup 13 | @my_proj = { "person" => VALID_PROJECT_HASH }.to_json 14 | ActiveResource::HttpMock.respond_to do |mock| 15 | mock.post "/projects.json", {}, @my_proj, 201, "Location" => "/projects/5.json" 16 | end 17 | end 18 | 19 | def test_validates_presence_of 20 | p = new_project(name: nil) 21 | assert_not p.valid?, "should not be a valid record without name" 22 | assert_not p.save, "should not have saved an invalid record" 23 | assert_equal ["can't be blank"], p.errors[:name], "should have an error on name" 24 | 25 | p.name = "something" 26 | 27 | assert p.save, "should have saved after fixing the validation, but had: #{p.errors.inspect}" 28 | end 29 | 30 | def test_fails_save! 31 | p = new_project(name: nil) 32 | assert_raise(ActiveResource::ResourceInvalid) { p.save! } 33 | end 34 | 35 | def test_save_without_validation 36 | p = new_project(name: nil) 37 | assert_not p.save 38 | assert p.save(validate: false) 39 | end 40 | 41 | def test_validate_callback 42 | # we have a callback ensuring the description is longer than three letters 43 | p = new_project(description: "a") 44 | assert_not p.valid?, "should not be a valid record when it fails a validation callback" 45 | assert_not p.save, "should not have saved an invalid record" 46 | assert_equal ["must be greater than three letters long"], p.errors[:description], "should be an error on description" 47 | 48 | # should now allow this description 49 | p.description = "abcd" 50 | assert p.save, "should have saved after fixing the validation, but had: #{p.errors.inspect}" 51 | end 52 | 53 | def test_client_side_validation_maximum 54 | project = Project.new(description: "123456789012345") 55 | assert_not project.valid? 56 | assert_equal ["is too long (maximum is 10 characters)"], project.errors[:description] 57 | end 58 | 59 | def test_invalid_method 60 | p = new_project 61 | 62 | assert_not p.invalid? 63 | end 64 | 65 | def test_validate_bang_method 66 | p = new_project(name: nil) 67 | 68 | assert_raise(ActiveModel::ValidationError) { p.validate! } 69 | end 70 | 71 | protected 72 | # quickie helper to create a new project with all the required 73 | # attributes. 74 | # Pass in any params you specifically want to override 75 | def new_project(opts = {}) 76 | Project.new(VALID_PROJECT_HASH.merge(opts)) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/fixtures/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # turns everything into the same object 4 | class AddressXMLFormatter 5 | include ActiveResource::Formats::XmlFormat 6 | 7 | def decode(xml) 8 | data = ActiveResource::Formats::XmlFormat.decode(xml) 9 | # process address fields 10 | data.each do |address| 11 | address["city_state"] = "#{address['city']}, #{address['state']}" 12 | end 13 | data 14 | end 15 | end 16 | 17 | class AddressResource < ActiveResource::Base 18 | self.element_name = "address" 19 | self.format = AddressXMLFormatter.new 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/beast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BeastResource < ActiveResource::Base 4 | self.site = "http://beast.caboo.se" 5 | site.user = "foo" 6 | site.password = "bar" 7 | end 8 | 9 | class Forum < BeastResource 10 | # taken from BeastResource 11 | # self.site = 'http://beast.caboo.se' 12 | end 13 | 14 | class Topic < BeastResource 15 | self.site += "/forums/:forum_id" 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Comment < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000/posts/:post_id" 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/customer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Customer < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000" 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/inventory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Inventory < ActiveResource::Base 4 | include ActiveResource::Singleton 5 | self.site = "http://37s.sunrise.i:3000" 6 | self.prefix = "/products/:product_id/" 7 | 8 | schema do 9 | integer :total 10 | integer :used 11 | 12 | string :status 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/person.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Person < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000" 5 | end 6 | 7 | module External 8 | class Person < ActiveResource::Base 9 | self.site = "http://atq.caffeine.intoxication.it" 10 | end 11 | 12 | class ProfileData < ActiveResource::Base 13 | self.site = "http://external.profile.data.nl" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/pet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Pet < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000" 5 | self.prefix = "/people/:person_id/" 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Post < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000" 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/product.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Product < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000" 5 | # X-Inherited-Header is for testing that any subclasses 6 | # include the headers of this class 7 | self.headers["X-Inherited-Header"] = "present" 8 | end 9 | 10 | class SubProduct < Product 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/project.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # used to test validations 4 | class Project < ActiveResource::Base 5 | self.site = "http://37s.sunrise.i:3000" 6 | schema do 7 | string :email 8 | string :name 9 | end 10 | 11 | validates :name, presence: true 12 | validates :description, presence: false, length: { maximum: 10 } 13 | validate :description_greater_than_three_letters 14 | 15 | # to test the validate *callback* works 16 | def description_greater_than_three_letters 17 | errors.add :description, "must be greater than three letters long" if description.length < 3 unless description.blank? 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/fixtures/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProxyResource < ActiveResource::Base 4 | self.site = "http://localhost" 5 | self.proxy = "http://user:password@proxy.local:3000" 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/sound.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Asset 4 | class Sound < ActiveResource::Base 5 | self.site = "http://37s.sunrise.i:3000" 6 | end 7 | end 8 | 9 | # to test namespacing in a module 10 | class Author 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/street_address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class StreetAddress < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000/people/:person_id" 5 | self.element_name = "address" 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/subscription_plan.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SubscriptionPlan < ActiveResource::Base 4 | self.site = "http://37s.sunrise.i:3000" 5 | self.element_name = "plan" 6 | self.primary_key = :code 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/weather.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Weather < ActiveResource::Base 4 | include ActiveResource::Singleton 5 | self.site = "http://37s.sunrise.i:3000" 6 | 7 | schema do 8 | string :status 9 | string :temperature 10 | end 11 | end 12 | 13 | class WeatherDashboard < ActiveResource::Base 14 | include ActiveResource::Singleton 15 | self.site = "http://37s.sunrise.i:3000" 16 | self.singleton_name = "dashboard" 17 | 18 | schema do 19 | string :status 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/setter_trap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SetterTrap < BasicObject 4 | class << self 5 | def rollback_sets(obj) 6 | trapped = new(obj) 7 | yield(trapped).tap { trapped.rollback_sets } 8 | end 9 | end 10 | 11 | def initialize(obj) 12 | @cache = {} 13 | @obj = obj 14 | end 15 | 16 | def respond_to?(method) 17 | @obj.respond_to?(method) 18 | end 19 | 20 | def method_missing(method, *args, &proc) 21 | @cache[method] ||= @obj.send($`) if method.to_s =~ /=$/ 22 | @obj.send method, *args, &proc 23 | end 24 | 25 | def rollback_sets 26 | @cache.each { |k, v| @obj.send k, v } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/singleton_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | require "fixtures/weather" 5 | require "fixtures/inventory" 6 | 7 | class SingletonTest < ActiveSupport::TestCase 8 | def setup_weather 9 | weather = { status: "Sunny", temperature: 67 } 10 | ActiveResource::HttpMock.respond_to do |mock| 11 | mock.get "/weather.json", {}, weather.to_json 12 | mock.get "/weather.json?degrees=fahrenheit", {}, weather.merge(temperature: 100).to_json 13 | mock.post "/weather.json", {}, weather.to_json, 201, "Location" => "/weather.json" 14 | mock.delete "/weather.json", {}, nil 15 | mock.put "/weather.json", {}, nil, 204 16 | end 17 | end 18 | 19 | def setup_weather_not_found 20 | ActiveResource::HttpMock.respond_to do |mock| 21 | mock.get "/weather.json", {}, nil, 404 22 | end 23 | end 24 | 25 | def setup_inventory 26 | inventory = { status: "Sold Out", total: 10, used: 10 }.to_json 27 | 28 | ActiveResource::HttpMock.respond_to do |mock| 29 | mock.get "/products/5/inventory.json", {}, inventory 30 | end 31 | end 32 | 33 | def test_custom_singleton_name 34 | assert_equal "dashboard", WeatherDashboard.singleton_name 35 | end 36 | 37 | def test_singleton_path 38 | assert_equal "/weather.json", Weather.singleton_path 39 | end 40 | 41 | def test_singleton_path_with_parameters 42 | assert_equal "/weather.json?degrees=fahrenheit", Weather.singleton_path(degrees: "fahrenheit") 43 | assert_equal "/weather.json?degrees=false", Weather.singleton_path(degrees: false) 44 | assert_equal "/weather.json?degrees=", Weather.singleton_path(degrees: nil) 45 | 46 | assert_equal "/weather.json?degrees=fahrenheit", Weather.singleton_path("degrees" => "fahrenheit") 47 | 48 | # Use include? because ordering of param hash is not guaranteed 49 | path = Weather.singleton_path(degrees: "fahrenheit", lunar: true) 50 | assert path.include?("weather.json") 51 | assert path.include?("degrees=fahrenheit") 52 | assert path.include?("lunar=true") 53 | 54 | path = Weather.singleton_path(days: ["monday", "saturday and sunday", nil, false]) 55 | assert_equal "/weather.json?days%5B%5D=monday&days%5B%5D=saturday+and+sunday&days%5B%5D=&days%5B%5D=false", path 56 | 57 | path = Inventory.singleton_path(product_id: 5) 58 | assert_equal "/products/5/inventory.json", path 59 | 60 | path = Inventory.singleton_path({ product_id: 5 }, { sold: true }) 61 | assert_equal "/products/5/inventory.json?sold=true", path 62 | end 63 | 64 | def test_find_singleton 65 | setup_weather 66 | weather = Weather.send(:find_singleton, Hash.new) 67 | assert_not_nil weather 68 | assert_equal "Sunny", weather.status 69 | assert_equal 67, weather.temperature 70 | end 71 | 72 | def test_find 73 | setup_weather 74 | weather = Weather.find 75 | assert_not_nil weather 76 | assert_equal "Sunny", weather.status 77 | assert_equal 67, weather.temperature 78 | end 79 | 80 | def test_find_with_param_options 81 | setup_inventory 82 | inventory = Inventory.find(params: { product_id: 5 }) 83 | 84 | assert_not_nil inventory 85 | assert_equal "Sold Out", inventory.status 86 | assert_equal 10, inventory.used 87 | assert_equal 10, inventory.total 88 | assert_equal({ product_id: 5 }, inventory.prefix_options) 89 | end 90 | 91 | def test_find_with_query_options 92 | setup_weather 93 | 94 | weather = Weather.find(params: { degrees: "fahrenheit" }) 95 | assert_not_nil weather 96 | assert_equal "Sunny", weather.status 97 | assert_equal 100, weather.temperature 98 | end 99 | 100 | def test_not_found 101 | setup_weather_not_found 102 | 103 | assert_raise ActiveResource::ResourceNotFound do 104 | Weather.find 105 | end 106 | end 107 | 108 | def test_create_singleton 109 | setup_weather 110 | weather = Weather.create(status: "Sunny", temperature: 67) 111 | assert_not_nil weather 112 | assert_equal "Sunny", weather.status 113 | assert_equal 67, weather.temperature 114 | end 115 | 116 | def test_destroy 117 | setup_weather 118 | 119 | # First Create the Weather 120 | weather = Weather.create(status: "Sunny", temperature: 67) 121 | assert_not_nil weather 122 | 123 | # Now Destroy it 124 | weather.destroy 125 | end 126 | 127 | def test_update 128 | setup_weather 129 | 130 | # First Create the Weather 131 | weather = Weather.create(status: "Sunny", temperature: 67) 132 | assert_not_nil weather 133 | 134 | # Then update it 135 | weather.status = "Rainy" 136 | weather.save 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/threadsafe_attributes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_unit" 4 | 5 | class ThreadsafeAttributesTest < ActiveSupport::TestCase 6 | class TestClass 7 | include ThreadsafeAttributes 8 | threadsafe_attribute :safeattr 9 | end 10 | 11 | setup do 12 | @tester = TestClass.new 13 | end 14 | 15 | test "#threadsafe attributes work in a single thread" do 16 | assert_not @tester.safeattr_defined? 17 | assert_nil @tester.safeattr 18 | @tester.safeattr = "a value" 19 | assert @tester.safeattr_defined? 20 | assert_equal "a value", @tester.safeattr 21 | end 22 | 23 | test "#threadsafe attributes inherit the value of the main thread" do 24 | @tester.safeattr = "a value" 25 | Thread.new do 26 | assert @tester.safeattr_defined? 27 | assert_equal "a value", @tester.safeattr 28 | end.join 29 | assert_equal "a value", @tester.safeattr 30 | end 31 | 32 | test "#changing a threadsafe attribute in a thread does not affect the main thread" do 33 | @tester.safeattr = "a value" 34 | Thread.new do 35 | @tester.safeattr = "a new value" 36 | assert_equal "a new value", @tester.safeattr 37 | end.join 38 | assert_equal "a value", @tester.safeattr 39 | end 40 | 41 | test "#threadsafe attributes inherit the value of the main thread when value is nil/false" do 42 | @tester.safeattr = false 43 | Thread.new do 44 | assert @tester.safeattr_defined? 45 | assert_equal false, @tester.safeattr 46 | end.join 47 | assert_equal false, @tester.safeattr 48 | end 49 | 50 | test "#changing a threadsafe attribute in a thread sets an equal value for the main thread, if no value has been set" do 51 | assert_not @tester.safeattr_defined? 52 | assert_nil @tester.safeattr 53 | Thread.new do 54 | @tester.safeattr = "value from child" 55 | assert_equal "value from child", @tester.safeattr 56 | end.join 57 | assert @tester.safeattr_defined? 58 | assert_equal "value from child", @tester.safeattr 59 | end 60 | 61 | test "#threadsafe attributes can retrieve non-duplicable from main thread" do 62 | @tester.safeattr = :symbol_1 63 | Thread.new do 64 | assert_equal :symbol_1, @tester.safeattr 65 | end.join 66 | end 67 | 68 | test "#threadsafe attributes work in fibers" do 69 | @tester.safeattr = :symbol_1 70 | Fiber.new do 71 | assert_equal :symbol_1, @tester.safeattr 72 | end.resume 73 | end 74 | 75 | unless RUBY_PLATFORM == "java" 76 | test "threadsafe attributes can be accessed after forking within a thread" do 77 | reader, writer = IO.pipe 78 | @tester.safeattr = "a value" 79 | Thread.new do 80 | fork do 81 | reader.close 82 | writer.print(@tester.safeattr) 83 | writer.close 84 | end 85 | end.join 86 | writer.close 87 | assert_equal "a value", reader.read 88 | reader.close 89 | end 90 | end 91 | end 92 | --------------------------------------------------------------------------------