diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 710ebe8..84471df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,18 @@ updates: directory: "/" schedule: interval: "weekly" + groups: + actions: + patterns: ["*"] - package-ecosystem: "bundler" directory: "/" schedule: interval: "weekly" + groups: + # Lint and type tooling churns often and never affects the shipped gem. + development: + dependency-type: "development" + patterns: ["*"] + runtime: + dependency-type: "production" + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144eff7..d703eee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,27 +1,93 @@ name: CI + on: push: branches: [master] pull_request: schedule: - # Run every on Friday to ensure everything works as expected. - - cron: '0 6 * * 5' + # Weekly, to catch breakage from new Ruby or Zammad releases. + - cron: '0 6 * * 5' + workflow_dispatch: + inputs: + zammad_ref: + description: 'Zammad git ref to run the integration specs against' + default: 'develop' + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + jobs: lint: + name: RuboCop runs-on: ubuntu-latest - container: - image: zammad/zammad-ci:latest steps: - uses: actions/checkout@v7 - - name: Run lint actions - shell: bash - run: | - source /etc/profile.d/rvm.sh # ensure RVM is loaded - bundle update --bundler - bundle install -j $(nproc) - bundle exec rubocop - test: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rubocop --format github + + types: + name: Steep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec steep check + # sig/vendor is always on the load path here but never in the gem, so + # the published set is checked on its own as well. + - run: bundle exec rake rbs_published + + unit: + name: Unit specs (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Stable releases only, from required_ruby_version up to the current + # stable line. There is no 3.5: that line was abandoned after + # 3.5.0-preview1 and became 4.0. + # + # 'head' is absent because it cannot install at all, for two reasons + # outside this gem. With Gemfile.lock present, bundler honours + # `BUNDLED WITH 2.6.9`, self-downgrades from head's 4.1.0.dev and dies + # with NameError on the removed Pathname::SEPARATOR_PAT. Without the + # lockfile, a fresh resolution pulls steep -> listen -> rb-inotify -> + # ffi, which requires Ruby < 4.1.dev. + ruby: ['3.4', '4.0'] + env: + # The unit specs do not need the type-checking toolchain, and skipping it + # keeps this job fast. The `types` job installs it separately. + BUNDLE_WITHOUT: development + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run unit specs + run: bundle exec rake spec:unit + env: + COVERAGE: 'true' + + integration: + name: Integration specs (live Zammad) runs-on: ubuntu-latest + # Booting Zammad costs far more runner time than the unit suite, so there + # is no point paying for it when the unit specs are already failing. + needs: unit + # A full Zammad boot takes many minutes; without a cap a hung boot would + # occupy a runner until the six hour default expires. + timeout-minutes: 45 container: image: zammad/zammad-ci:latest services: @@ -32,26 +98,89 @@ jobs: POSTGRES_PASSWORD: zammad redis: image: redis:7 + env: + ZAMMAD_REF: ${{ inputs.zammad_ref || 'develop' }} + TEST_USER: admin@example.com + TEST_PASSWORD: test steps: - uses: actions/checkout@v7 - - name: Set up Zammad + + - name: Report the toolchain shell: bash run: | - git clone --depth 1 https://github.com/zammad/zammad.git + source /etc/profile.d/rvm.sh + # The gem requires Ruby >= 3.4; fail here with a clear message rather + # than inside a confusing bundler resolution error. + ruby -v + ruby -e 'abort "zammad-ci image ships Ruby #{RUBY_VERSION}, this gem needs >= 3.4" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")' + + - name: Boot Zammad + shell: bash + run: | + # No `set -u`: /etc/profile.d/rvm.sh reads unset variables and aborts + # under nounset. + set -eo pipefail + git clone --depth 1 --branch "$ZAMMAD_REF" https://github.com/zammad/zammad.git cd zammad - source /etc/profile.d/rvm.sh # ensure RVM is loaded + source /etc/profile.d/rvm.sh bundle config set --local frozen 'true' bundle config set --local path 'vendor' - bundle install -j $(nproc) + bundle install -j "$(nproc)" bundle exec ruby .gitlab/configure_environment.rb + # Each workflow step runs in its own shell, so Zammad's generated + # environment has to be promoted to the job environment to survive. + sed -E 's/^export +//' .gitlab/environment.env \ + | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' >> "$GITHUB_ENV" source .gitlab/environment.env RAILS_ENV=test bundle exec rake db:create cp contrib/auto_wizard_test.json auto_wizard.json bundle exec rake zammad:ci:test:start - - name: Run Ruby API integration tests + echo "TEST_URL=http://localhost:${RAILS_PORT:-3000}/" >> "$GITHUB_ENV" + + - name: Wait for Zammad to answer + shell: bash + run: | + probe="${TEST_URL%/}/api/v1/getting_started" + for attempt in $(seq 1 60); do + if curl -sSf --max-time 5 "$probe" >/dev/null 2>&1; then + echo "Zammad answered at $TEST_URL after ${attempt} attempt(s)" + exit 0 + fi + sleep 5 + done + echo "::error::Zammad never answered at $probe" + exit 1 + + - name: Install the gem's dependencies + shell: bash + run: | + source /etc/profile.d/rvm.sh + bundle install -j "$(nproc)" + + - name: Drive Zammad with this gem + shell: bash + run: | + source /etc/profile.d/rvm.sh + bundle exec ruby script/check_connection.rb + + - name: Run the integration specs + shell: bash + run: | + source /etc/profile.d/rvm.sh + bundle exec rake spec:integration + + - name: Collect Zammad logs on failure + if: failure() shell: bash run: | - source /etc/profile.d/rvm.sh # ensure RVM is loaded - bundle update --bundler - bundle install -j $(nproc) - bundle exec rspec + echo '--- zammad/log ---' + tail -n 200 zammad/log/*.log 2>/dev/null || echo 'no logs found' + + - name: Upload Zammad logs + if: failure() + uses: actions/upload-artifact@v5 + with: + name: zammad-logs + path: zammad/log/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4329d1c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +# Publishes to RubyGems via trusted publishing (OIDC), so no API key is +# stored in this repository. Configure the trusted publisher once at +# https://rubygems.org/gems/zammad_api/trusted_publishers +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + release: + name: Build and publish + runs-on: ubuntu-latest + environment: rubygems + permissions: + contents: write # create the GitHub release and push the tag commit + id-token: write # request the OIDC token for trusted publishing + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + # The tag is what triggers this workflow, and nothing downstream reads + # it: the gem is built from the gemspec, so a tag that disagrees with + # ZammadAPI::VERSION publishes a gem under a version nobody tagged and + # leaves a GitHub release pointing at one that does not exist. + - name: Check the tag against the gem version and the changelog + run: | + tagged="${GITHUB_REF_NAME#v}" + declared="$(ruby -Ilib -rzammad_api/version -e 'print ZammadAPI::VERSION')" + if [ "$tagged" != "$declared" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match ZammadAPI::VERSION ($declared)" + exit 1 + fi + if ! grep -q "^## \[${declared}\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [${declared}]' heading" + exit 1 + fi + - name: Verify the release candidate + run: bundle exec rake spec:unit rubocop steep rbs_published + - uses: rubygems/release-gem@v1 diff --git a/.gitignore b/.gitignore index 002a2a1..bb396ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /.bundle/ -/.ruby-version -/.yardoc +/.steep/ +/.yardoc/ /_yardoc/ /coverage/ /doc/ diff --git a/.overcommit.yml b/.overcommit.yml index 8553cbe..740cb16 100644 --- a/.overcommit.yml +++ b/.overcommit.yml @@ -1,33 +1,23 @@ -# Use this file to configure the Overcommit hooks you wish to use. This will -# extend the default configuration defined in: -# https://github.com/sds/overcommit/blob/master/config/default.yml +# See https://github.com/sds/overcommit#configuration # -# At the topmost level of this YAML file is a key representing type of hook -# being run (e.g. pre-commit, commit-msg, etc.). Within each type you can -# customize each hook, such as whether to only run it on certain files (via -# `include`), whether to only display output if it fails (via `quiet`), etc. -# -# For a complete list of hooks, see: -# https://github.com/sds/overcommit/tree/master/lib/overcommit/hook -# -# For a complete list of options that you can use to customize hooks, see: -# https://github.com/sds/overcommit#configuration -# -# Uncomment the following lines to make the configuration take effect. +# Install with: bundle exec overcommit --install PreCommit: - RuboCop: - enabled: true - on_warn: fail # Treat all warnings as failures -# -# TrailingWhitespace: -# enabled: true -# exclude: -# - '**/db/structure.sql' # Ignore trailing whitespace in generated files -# -#PostCheckout: -# ALL: # Special hook name that customizes all hooks of this type -# quiet: true # Change all post-checkout hooks to only display output on failure -# -# IndexTags: -# enabled: true # Generate a tags file with `ctags` each time HEAD changes + RuboCop: + enabled: true + on_warn: fail # Treat all warnings as failures + command: ['bundle', 'exec', 'rubocop'] + + TrailingWhitespace: + enabled: true + + YamlSyntax: + enabled: true + + BundleCheck: + enabled: true + + RSpec: + enabled: true + description: 'Run the unit specs' + command: ['bundle', 'exec', 'rspec', 'spec/unit'] diff --git a/.rspec b/.rspec index 8c18f1a..7a2cc1a 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,3 @@ +--require spec_helper --format documentation --color diff --git a/.rubocop.yml b/.rubocop.yml index 0ae18f9..f6254f4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,117 +1,99 @@ -# Default enabled cops -# https://github.com/bbatsov/rubocop/blob/master/config/enabled.yml - plugins: - rubocop-performance - rubocop-rake - rubocop-rspec -inherit_from: - - .rubocop_todo.yml - AllCops: NewCops: enable Exclude: - - 'bin/rails' - - 'bin/rake' - - 'bin/spring' - - 'db/schema.rb' - - 'examples/**/*' - # Match the Ruby version specified in the gemspec. - TargetRubyVersion: 3.0 + - 'pkg/**/*' + - 'vendor/**/*' + # Keep in sync with required_ruby_version in the gemspec and the CI matrix. + TargetRubyVersion: 3.4 -# Zammad StyleGuide - -Style/FrozenStringLiteralComment: - Enabled: false +# --- Zammad style guide ----------------------------------------------------- Style/NegatedIf: - Description: >- - Favor unless over if for negative conditions - (or control flow or). - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#unless-for-negatives' + Description: 'Zammad prefers an explicit `if !x` over `unless x`.' Enabled: false Style/IfUnlessModifier: - Description: >- - Favor modifier if/unless usage when you have a - single-line body. - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#if-as-a-modifier' + Description: 'Zammad allows multi-line bodies for a single condition.' Enabled: false Style/TrailingCommaInArrayLiteral: - Description: 'Checks for trailing comma in array literals.' - StyleGuide: '#no-trailing-array-commas' Enabled: false Style/TrailingCommaInHashLiteral: - Description: 'Checks for trailing comma in hash literals.' Enabled: false Style/TrailingCommaInArguments: - Description: 'Checks for trailing comma in argument lists.' - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' Enabled: false Layout/LeadingCommentSpace: - Description: 'Comments should start with a space.' - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#hash-space' Enabled: false Layout/HashAlignment: - Description: >- - Align the elements of a hash literal if they span more than - one line. Enabled: true EnforcedHashRocketStyle: table EnforcedColonStyle: table EnforcedLastArgumentHashStyle: always_inspect Style/ClassAndModuleChildren: - Description: 'Checks style of children classes and modules.' Enabled: false +Naming/PredicatePrefix: + # `has_many` is the established name for the declaration, and neither it nor + # the reader behind it is a predicate. + AllowedMethods: + - has_many + - has_many_target + Naming/MethodParameterName: - Description: >- - Checks for method parameter names that contain capital letters, - end in numbers, or do not meet a minimal length. - Enabled: true - AllowedNames: [id] + # `of` is ActiveRecord's name for a batch size, see Collection#in_batches. + AllowedNames: [id, to, of] Layout/MultilineMethodCallIndentation: - Description: >- - Checks the indentation of the method name part in method calls - that span more than one line. EnforcedStyle: indented Style/RescueStandardError: EnforcedStyle: implicit +Style/ArgumentsForwarding: + # Named parameters keep public signatures self-documenting for YARD/RBS. + UseAnonymousForwarding: false + +Naming/BlockForwarding: + EnforcedStyle: explicit + +Naming/PredicateMethod: + # Established names from the resource API. `save` reports whether the record + # was stored; the rest return true on success. + AllowedMethods: + - save + - save! + - update + - update! + - destroy + Style/Documentation: + Description: 'Public API documentation is enforced by review, not by this cop.' Enabled: false Style/PerlBackrefs: - Description: 'Avoid Perl-style regex back references.' - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-perl-regexp-last-matchers' Enabled: false Style/BlockComments: - # Keep block comments (=begin ... =end) to allow for easy copy-pasting of examples. - Description: 'Do not use block comments.' + Description: 'Block comments make examples easy to copy and paste.' Enabled: false Layout/LineLength: - Description: 'Limit lines to 80 characters.' - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#80-character-limits' Enabled: false Metrics/ClassLength: - Description: 'Avoid classes longer than 100 lines of code.' Enabled: false Metrics/MethodLength: - Description: 'Avoid methods longer than 10 lines of code.' - StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#short-methods' Enabled: false Metrics/AbcSize: @@ -123,21 +105,61 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 12 +Metrics/ParameterLists: + # Config mirrors every supported option as a keyword argument. + Max: 16 + MaxOptionalParameters: 15 + +# Broken: "String".downcase == "strinG".downcase is not the same as +# "String".casecmp("strinG"), only as "String".casecmp("strinG") == 0. +Performance/Casecmp: + Enabled: false + +# --- Specs ------------------------------------------------------------------ + RSpec/ExampleLength: - inherit_mode: - merge: - - Exclude - CountAsOne: - - 'array' - - 'hash' - - 'heredoc' - Max: 25 + CountAsOne: ['array', 'hash', 'heredoc'] + Max: 12 + # Integration specs are end-to-end scenarios against a live Zammad. + Exclude: + - 'spec/integration/**/*' + +RSpec/MultipleExpectations: + Description: 'A few request assertions per example are clearer than splitting them.' + Max: 3 + Exclude: + - 'spec/integration/**/*' + +RSpec/DescribeMethod: + Exclude: + - 'spec/integration/**/*' RSpec/NestedGroups: - Max: 6 + Max: 4 -# Broken!!!! Generates broken code since "String".downcase == "strinG".downcase is not equals "String".casecmp("strinG") but "String".casecmp("strinG") == 0 !!! -Performance/Casecmp: - Description: 'Use `casecmp` rather than `downcase ==`.' - Reference: 'https://github.com/JuanitoFatas/fast-ruby#stringcasecmp-vs-stringdowncase---code' - Enabled: false +RSpec/SpecFilePathFormat: + # Integration specs are grouped by the Zammad object they exercise, not by + # the class under test. + Exclude: + - 'spec/integration/**/*' + +RSpec/DescribeClass: + Exclude: + - 'spec/integration/**/*' + +RSpec/BeforeAfterAll: + Exclude: + - 'spec/integration/**/*' + +RSpec/LeakyLocalVariable: + # The integration specs deliberately share a record across ordered examples: + # each one asserts on what the previous step left behind. The coupling is + # pinned by `config.order = :defined` and every example that reads the record + # goes through `established!`, so a partial run says what is missing rather + # than failing on nil. + Exclude: + - 'spec/integration/**/*' + +RSpec/NoExpectationExample: + Exclude: + - 'spec/integration/**/*' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index 20ec1c6..0000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,35 +0,0 @@ -Naming/PredicateMethod: - Enabled: false - -Style/MissingRespondToMissing: - Enabled: false - -Style/StringConcatenation: - Enabled: false - -Naming/AccessorMethodName: - Enabled: false - -RSpec/MultipleExpectations: - Enabled: false - -RSpec/ExampleLength: - Enabled: false - -RSpec/SpecFilePathFormat: - Enabled: false - -RSpec/NoExpectationExample: - Enabled: false - -RSpec/DescribeMethod: - Enabled: false - -RSpec/ContextWording: - Enabled: false - -RSpec/BeforeAfterAll: - Enabled: false - -RSpec/LeakyLocalVariable: - Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..7bcbb38 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.9 diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..04abe42 --- /dev/null +++ b/.yardopts @@ -0,0 +1,9 @@ +--markup markdown +--readme README.md +--output-dir doc +--no-private +--protected +lib/**/*.rb +- +CHANGELOG.md +LICENSE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 422138f..193f029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,526 @@ +# Changelog + +## [2.0.0] - 2026-08-27 + +A breaking release that modernises the whole gem. See +[Migrating from 1.x](README.md#migrating-from-1x) for the complete before/after guide. + +### Breaking + +- Minimum Ruby version is now 3.4. +- `Client.new` takes keyword arguments, so a configuration Hash has to be splatted: + `Client.new(**config)`. A positional Hash raises `ArgumentError`. +- An unknown client option raises `ArgumentError: unknown keyword` instead of being + ignored, so an option that is misspelled or no longer supported is no longer silent. +- `logger:` takes a `Logger` rather than a boolean flag. `logger: true` used to turn on + debug output to `$stderr`; pass `Logger.new($stderr)` for the same thing. Any object + that responds to `debug` is accepted, and anything else raises `ConfigurationError`. +- `Collection#each` (`client.x.all`, `client.x.search`) now walks every page. Previously it + fetched a single page, so iterating stopped silently at 100 records for `all` and at + 10 for `search`. +- Collections are built up by chaining instead of by keyword arguments: + `all(per_page: 50)` is now `find_each(batch_size: 50)` or `page(1, of: 50)`, + `all(active: true)` is `search(...)` or `all.detect { ... }`, + `search(query: 'zammad')` is `search('zammad')`, + and `search(query: 'z', page: 2, per_page: 50)` is `search('z').page(2, of: 50)`. + `all` accepted those keywords and then discarded them, so its page size was always + 100 and its filters never reached the request; `search` did honour `page` and + `per_page`. All of them now raise `ArgumentError` rather than being accepted. +- `page(number, per_page)` with a block was replaced by `page(number, of: size)`, which + returns a new collection. `page_next` and `page_prev` were removed. +- `Collection#each_page` was renamed to `#in_batches`, which also takes the page size as + `in_batches(of: 500)`. +- `Collection#[]` was removed. It cost a request per index and ignored the page a + collection was limited to; use `first`, or `page(n, of: 1).first` for one record at an + offset. +- `Collection#per_page` and `#current_page` are no longer public. `inspect` reports both. +- There is no `per`. The page size belongs to the call that reads: `find_each(batch_size:)` + to walk, `in_batches(of:)` to batch, `page(number, of:)` for one page. Everything else + fetches as many records as the endpoint serves. +- A collection fetches the endpoint's own page size rather than a fixed 100 — 1000 on the + index endpoints, 100 on `/api/v1/tickets`, 200 on a search — so a walk spends roughly a + tenth of the round trips, each of which is a fresh TLS handshake under Faraday's default + adapter. `page(n)` without `of:` is a page of that size, so pin it with + `page(n, of: 100)` where a persisted page number has to keep meaning what it did. +- `where` rejects `page`, `per_page`, `expand`, `only_total_count` and `query` with an + `ArgumentError`. They used to be accepted and silently overridden. +- A `nil` query value raises `ArgumentError`. 1.x dropped the parameter, so + `where(owner_id: nil)` requested every ticket and the caller iterated all of them + believing they were unassigned. +- `client.on_behalf_of = 'login'` and `client.perform_on_behalf_of` were replaced by + `client.on_behalf_of('login')`, which returns a new client and also accepts a block. +- `ZammadAPI::ResourceNotFoundError` is now `ZammadAPI::UnknownResourceError`, freeing the + 404 case to be `ZammadAPI::NotFoundError`. +- `record.save` reports a validation failure as `false` and leaves the error in + `record.error`, instead of raising. `record.save!` is the raising form. Every other + failure — an expired token, a missing record, an unreachable instance — still raises from + both, because no attribute the caller can fix would change the outcome. + `client..create` uses `save!`, so it keeps raising rather than returning a + record that looks created but is not. +- A resource's class-level memos — the association proxy class and the `belongs_to` + foreign keys — are built under a lock. They were plain `@x ||=`, so two threads building + clients or reading associations at once could each build a different anonymous proxy + class for one resource, and whichever write lost was still held by the records already + built from it. Harmless under CRuby's GVL, wrong on JRuby and TruffleRuby, which is not + the contract `README` writes down. +- `record[:x] = 1` stages the attribute it names. It reached the writer dispatch as `[]=` + and was taken for an attribute literally called `[]` whose value was the index, so the + write was lost without an error and the next `save` sent `{"[]": "x"}` to Zammad. Ruby's + operators end in `=` too, so `record <= 5` invented an attribute called `<` the same way; + a writer is now recognised only by a plain attribute name. +- `record.destroy` and `record.reload` refuse a record that was never saved, instead of + acting on an id it was merely built with. `client.group.new(id: 99).destroy` reported + `new_record?` true and `persisted?` false and still sent `DELETE /api/v1/groups/99`. +- `record.id = ...` raises. The id is what addresses the record, so a staged one took + effect for every path that builds a URL from the attributes and not at all for the record + those paths then reported on: `group.id = 99; group.destroy` sent `DELETE` to group 99 + and left the record saying group 1 was the one destroyed. Zammad does not let an id be + set either, so no call that used to reach the server is lost. The constructor refuses it + too: `client.group.new(id: 5)` raises, because a new record is sent in full, so the one + spelling that reached the wire was the one nothing checked. `Resource.from_response` is + how a body Zammad served becomes a record carrying its id. +- `record.destroy` marks the record `destroyed?`, and `persisted?` answers false for one. + 1.x left a destroyed record looking live, so a later `save` went out as a `PUT` to the + deleted id and came back a 404 one call after the mistake. +- `record.attributes` and `record.changes` are deeply frozen, and `record.to_h` returns a + deep copy rather than a shallow one. Writing through either reader used to change what a + record reported without staging anything, so the next `save` did not send it, and a + nested hash from `to_h` was shared with the record. +- The `record.attributes=` writer was removed. An unknown name is an ordinary attribute + now, so `record.attributes = {name: 'Support'}` stages a change called `attributes` + that `save` sends to Zammad. Use `assign_attributes` or `update`. +- `ZammadAPI::Error` descends from `StandardError` instead of `RuntimeError`. +- `ResponseError#response` returns a `ZammadAPI::Response`, not a Faraday object, and + `#body` is the decoded payload rather than a raw JSON string. +- No Faraday exception escapes any more: an unreachable host or a timeout raises + `ZammadAPI::ConnectionError` or `ZammadAPI::TimeoutError`, so a + `rescue Faraday::ConnectionFailed` stops matching. +- `ZammadAPI::ListBase`, `ListAll` and `ListSearch` were replaced by `ZammadAPI::Collection`. +- `ZammadAPI::Log` and `ZammadAPI::JsonHelper` were removed. Pass any `Logger` via `logger:`. +- `ZammadAPI::Dispatcher` was replaced by `ZammadAPI::ResourceProxy`. +- The resources a client exposes are a fixed list. 1.x resolved `client.` to + `ZammadAPI::Resources::` through `const_get`, so a subclass of `Base` defined in + application code could be reached that way. `client.role` now raises + `UnknownResourceError`; use the raw request methods for endpoints this gem does not + model. +- The internal `new_instance` accessor was replaced by `new_record?` and `persisted?`, and + the instance-level `url` accessor by the class-level `resource_path`. Both old names now + raise `NoMethodError` as unknown attributes, naming the attributes the record does carry. +- A reader for an attribute the record does not carry raises `NoMethodError` instead of + answering `nil`. A typo read as `nil` and flowed on into whatever was written with it, + and `respond_to?` and `method` disagreed with the call throughout, so generic code that + asks before it calls was told the reader did not exist. `record[:x]`, + `record.fetch(:x, nil)` and `record.key?(:x)` are the readers for an attribute that may + legitimately be absent — which it may, because Zammad serves a reduced object where the + authenticated user may not see the whole record, and the message says so. +- `Collection#find` raises `ArgumentError` when given an id. `find` on a resource proxy is + the lookup by id; on a collection it is `Enumerable#find`, whose argument is an ifnone + callable — so `client.ticket.all.find(1)` answered with an `Enumerator`, made no request + and raised nothing. Use `client.ticket.find(1)`, or `detect { … }` for the block form. +- `TicketArticle#attachments` raises `ParseError` for attachment metadata that is not a + list of objects, where it used to die with a bare `NoMethodError` from inside the gem — + past the `rescue ZammadAPI::Error` every caller is told to write. +- `page(number, of: size)` raises `ArgumentError` when `size` is larger than the endpoint + serves, instead of quietly reducing it. A reduced page size moves the page: + `page(3, of: 500)` against `/api/v1/tickets` went out as `page=3&per_page=100` and + answered with records 201–300 rather than 1001–1500, so a job checkpointing a page + number re-read what it had already handled. `find_each(batch_size:)` and + `in_batches(of:)` are still reduced, because a batch size names how much to fetch per + request, not which records the call is about. +- `find_by` searches one string value — the longest — and raises `ArgumentError` when none + of the values is a string. Zammad matches words, so `find_by(active: true)` searched for + `"true"` and found nothing. Every other value is compared against the record, so + `find_by(email: '…', active: true)` searches the email and compares both. The values are + never joined into a single term: an instance searching without Elasticsearch matches the + term literally, through a SQL `LIKE` over each string column, so + `find_by(firstname: 'Jane', lastname: 'Doe')` asked for one column containing + `"Jane Doe"` and reported a user that exists as `nil`. +- `search` and `find_by` raise `ZammadAPI::Error` on a resource Zammad routes no search + endpoint for — `ticket_state`, `ticket_priority` and `ticket_article`. Those endpoints + answered 404, which reached `find_by` as a `NotFoundError` from a method documented to + return `nil`, so `find_by(…) || create(…)` raised instead of creating. Walk those short + lists with `all.detect { … }` instead. +- Nothing reads an `x-total-count` response header any more, because Zammad has never sent + one — from any endpoint, in any version. `Response#reported_total` was built on it, and + two guards read that: `Collection#each` would have ended a walk one request early on the + reported figure, and a `has_many` reader would have refused a list it judged truncated. + Both were dead, and the one that could have acted was the one that could have been + wrong, since it ends a walk on a figure the records do not corroborate. Zammad reports a + total in the body instead, and only where asked: `only_total_count` — which is what + `Collection#count` uses on a search — or `with_total_count` on `/search`, and `full` on + the index endpoints that render through `model_index_render`. +- A record id of `.` or `..` raises `ArgumentError`. Both are made entirely of unreserved + characters, so escaping carried them through and `find('..')` resolved one path level + up — onto the index endpoint, or through a `has_many` path onto every article on the + instance offered as one ticket's. + +### Added + +- `Collection#first` and `#take` size their own request: `all.first` is one request for one + record and `all.first(5)` one request for five, where `Enumerable` took them off the + front of a page sized for walking. The request is sized rather than the collection + limited to a page, so a read still walks on where a page comes back shorter than it was + asked for — `first(5)` answers with five records if five exist. A collection `page` + already limited keeps its own size, because that size says which records it holds. +- `client.get`, `#post`, `#put` and `#delete` take `headers:`, for an endpoint that needs + one. Names are case-insensitive and two spellings of one header are refused rather than + merged; `Authorization` and `From` are refused outright, being what the client's + credentials and `on_behalf_of` are for. +- `Test::Request#headers` records the headers a request asked for, stringified and + downcased through the real transport's own rules, so a stand-in cannot accept a header + the wire would refuse. +- `client.get`, `client.post`, `client.put` and `client.delete` reach any endpoint of the + Zammad API, including the many this gem does not model. They return a + `ZammadAPI::Response` and keep authentication, timeouts, retries, credential redaction, + JSON decoding and the error classes. Previously the only way past the seven resource + classes was to build a Faraday connection by hand. +- Request and connection timeouts (`timeout`, `open_timeout`), on by default at 60 and + 10 seconds. 1.x waited as long as the server took, so a call that used to hang now + raises `TimeoutError`. +- Automatic retry with exponential backoff for idempotent requests on connection failures, + timeouts and transient statuses. `POST` is never retried, so a failed create cannot + produce duplicate records. +- A specific error class per status: `AuthenticationError` (401), `AuthorizationError` + (403), `NotFoundError` (404), `ValidationError` (422) and `RateLimitError` (429, with + `#retry_after`). Network failures raise `ConnectionError` or `TimeoutError` instead of + leaking Faraday exceptions. +- `Collection#where`, `#page`, `#in_batches`, `#find_each`, `#count` and lazy + enumeration, plus `client.x.where(...)` as a shorthand for `all.where(...)`. `where` + accepts only parameters the endpoint reads — `sort_by` and `order_by` on a generic + index, nothing beyond paging on `/api/v1/tickets` and `/api/v1/users`, and the search + parameters on a `/search` endpoint — and raises `ArgumentError` for anything else. + Zammad drops a parameter it does not know rather than refusing it, so an attribute + filter on an index endpoint came back as the whole unfiltered list. +- A resource proxy is `Enumerable` over `all`, so `client.ticket.each`, + `client.ticket.first(5)`, `client.ticket.map`, `#find_each`, `#in_batches`, `#page`, + `#pluck` and `#count` all work without naming `all`. `client.x.find(id)` keeps + its own meaning rather than becoming `Enumerable#find`; `detect` is the block form. +- `Collection#count` costs a single request on a search endpoint, which Zammad can count + without returning the records. +- `Collection#pluck(*attributes)`, for reading one or more attributes from every record. +- `client..find_by(**params)` and `#find_by!`, which look a record up by + attribute value, and `client..exists?(id)`. `find_by` searches and then + checks the hits itself, because Zammad's index endpoints cannot filter: it returns a + record that genuinely carries the attributes asked for, or nil. What the search can + surface is Zammad's business, so `find_by(...) || create(...)` can still create a + duplicate — as writing the search out by hand would. Only the first page of hits is + examined, so a lookup costs one request whether it matches or not. +- `ResponseError` accepts a `detail:` describing a failure that has no HTTP response of its + own, so `find_by!` reads as `no record matched` rather than `no response`. Such an error + still reports the status its class is the name for, so a `NotFoundError` raised without a + request answers `404` like every other one. +- The page size is clamped to what an endpoint serves (100 for `/api/v1/tickets`, 200 for + a search, 1000 for the other index endpoints). Asking for more used to end iteration + after the first page, because Zammad capped the response and the short page read as the + end of the list. A walk also learns the size the endpoint actually serves from its first + page, so an instance that pages smaller than those figures is still walked to the end + rather than truncated. +- `find_each(batch_size:)` and `in_batches(of:)` raise when the collection is already + limited to a page. `page(3, of: 50)` and a batch size are two ways of naming the same + thing, and re-sizing the page behind the caller would hand back different records. +- `ZammadAPI::PaginationError`, raised when an endpoint answers a page with the page + before it, instead of paging forever. +- `Base#reload`, `#persisted?`, `#[]`, `#fetch`, `#to_h` and a readable `#inspect`. +- `record.update(attributes)`, `record.update!(attributes)` and + `record.assign_attributes(attributes)`. Applying a hash of changes previously meant one + writer call per attribute before `save`. +- `ssl_verify`, `proxy`, `user_agent`, `retries` and `retry_interval` client options. + The default `User-Agent` is now `zammad_api-ruby/` rather than + `Zammad API Ruby`. +- `adapter` and `middleware` client options, the seam into the Faraday stack. Swapping in a + persistent-connection adapter or adding instrumentation previously meant that the HTTP + stack was closed to callers. A Faraday error while building the connection surfaces as + `ConfigurationError`, so Faraday stays an implementation detail. +- RBS signatures in `sig/`, verified by Steep in CI. +- `require 'zammad_api/test'` ships a stand-in Zammad for testing code that calls this + client: `ZammadAPI::Test#stub` declares responses, `#client` hands back a real client + wired to them, and `#requests` records what was sent. Responses travel the same decoding, + error mapping and record building as real ones, so a stubbed 404 raises `NotFoundError`. + An unstubbed request raises rather than answering with something empty. Consumers + previously had to intercept HTTP to test against this client at all. +- `respond_to?` now answers correctly for attribute readers and resource methods. +- Records implement `deconstruct_keys`, so they can be used with `case/in` pattern + matching, including against nested attributes. `Config` and `Response` are `Data` + objects and match as well. +- `Client#with(**options)` derives a new client with changed options. The options are + re-validated and any `on_behalf_of` scope is carried over. +- `Client.from_env` builds a client from `ZAMMAD_URL`, `ZAMMAD_TOKEN`, + `ZAMMAD_HTTP_TOKEN`, `ZAMMAD_OAUTH2_TOKEN`, `ZAMMAD_USER` and `ZAMMAD_PASSWORD`, with + passed-in options winning. Every example script used to repeat the same `ENV.fetch` pair. +- `Client#me`, the user the credentials authenticate as, and `Client#version`, the version + of the Zammad instance. +- `Response#decoded(:object | :array)` validates the shape of a response body in one + place, so an unexpected payload raises `ParseError` with a consistent message instead of + failing further downstream. +- `record.related` reaches the records a record points at: `ticket.related.customer`, + `ticket.related.group`, `ticket.related.articles`, `user.related.organization`, and + `created_by` / `updated_by` on everything. Following a foreign key used to mean + `client.user.find(ticket.customer_id)` by hand. The readers sit under `related` rather + than on the record because Zammad expands an association into a name under the plain + attribute, and `ticket.customer` has to keep returning that name rather than turning + into a request. `Resource.associations` lists what a resource declares. +- Records compare as the Zammad records they came from: two records of the same kind with + the same id are equal, and `#hash` agrees, so `uniq`, `Set`, `include?` and records as + Hash keys all work. They previously compared by object identity, so the same ticket + fetched twice was two unequal records. A record with no id stays equal only to itself, + which means its first save changes its hash and a record used as a Hash key before that + save has to be rehashed after it. +- `record.to_json` and `record.as_json` render a record's attributes. `to_json` previously + fell through to `Object#to_json`, which serialized a record as the string + `"#"`. +- `Collection#empty?`, and `#size` / `#length` as names for `#count`. `Enumerable` supplies + none of the three, so `client.ticket.all.empty?` used to raise `NoMethodError`. `empty?` + costs one request and asks for a single record rather than a whole page, except on a + collection limited to one page, where the page size decides which records that page holds. + A resource proxy forwards all three. + +### Fixed + +- Credentials are no longer written to the debug log. The old transport logged + `user:password` on every client build; payload keys such as `password` and `token` are + now redacted, and `Config#inspect` redacts credentials. A `ConfigurationError` raised + while building the connection redacts the configured url and proxy out of the underlying + message too, including where that message quoted the value through `inspect` rather than + interpolating it — which is what `URI::InvalidURIError` does, for exactly the characters + that make a URL invalid. +- `on_behalf_of` no longer leaks: the old `perform_on_behalf_of` used `tap` without an + `ensure`, so an exception inside the block left the `From` header set on every later + request. +- Zammad installations served from a sub-path (`https://example.com/zammad/`) now work. + Request paths are relative, so the prefix is no longer stripped. +- Query parameters are encoded by the HTTP layer, including arrays and characters that + need escaping. +- Nested attributes inside arrays are symbolized consistently. +- A malformed or non-JSON response body no longer degrades into an empty hash that + callers then iterate as key/value pairs. +- Unknown resource names no longer resolve to unrelated Ruby classes. +- Record ids are escaped everywhere they reach a path, including the attachment download + endpoint and `has_many` association paths, so an id carrying a traversal cannot redirect + a request onto another endpoint. +- A credential carrying an unencoded `@` is redacted whole. Redaction stopped at the first + `@`, so the tail of such a password survived into `Config#inspect` and into every + `ConnectionError` message. +- `Transport#with_config` keeps the transport's own class, so a stand-in written as a + `Transport` subclass survives `client.with(...)` instead of reverting to a real HTTP one. +- `save` on a persisted record with nothing staged sends no request. The empty `PUT` it + used to issue was applied by Zammad, bumping `updated_at` and `updated_by`. +- A nested query parameter is sent as a structure rather than as its Ruby `inspect`. + `condition`, which the search endpoints narrow by and which `where` accepts, went out as + `condition=%7B%22ticket.state_id%22…`; Zammad could not parse it, dropped it, and + answered with an unnarrowed search. A `nil` is now refused at any depth, and the message + names the path to it. +- Bare socket failures are retried. `Errno::ECONNRESET` and the rest were mapped to + `ConnectionError` but were missing from the retriable list, so a transient failure + through an adapter that wraps it (net_http) was retried while the same failure through + an adapter that does not raised on the first attempt. +- `where` reads a String key as the parameter it names. Both guards compared against + Symbols, so `where('sort_by' => 'name')` was refused with a message saying the endpoint + both ignores and honours `sort_by`, and `where('page' => 2)` slipped past the + reserved-key check entirely. +- A list body that is not made of objects raises `ParseError` instead of failing later. An + unexpanded search answering `[1, 2, 3]` stored an Integer as a record's attributes, and + the first reader died with `TypeError: no implicit conversion of Symbol into Integer`. +- `destroy` clears the staged changes and the last validation error, and refuses the + association readers. A destroyed record went on reporting `changed?` and a change set + that can never be sent, and `record.related` went on requesting a record that no longer + exists — dropping the memo was not enough on its own, because the reader rebuilt one on + the next call. `record.related`, `ticket.articles` and `ticket.article(…)` all raise + `ZammadAPI::Error` for a destroyed record now, rather than reaching the server for it. +- `Config#redacted_url` no longer mangles a URL whose query string contains an `@`. + `https://host?a=b@c` was rendered as `https://[REDACTED]@c`, a host that does not exist, + in every `ConnectionError` and `TimeoutError` message. +- The RBS signatures the gem ships validate on their own. They named Faraday types that + are declared only in `sig/vendor`, which is deliberately not published, so `rbs validate` + failed for every consumer with `Could not find Faraday::Connection`. +- `ZammadAPI::Test.new` no longer builds a Faraday stack it immediately discards, which a + suite using `let(:zammad) { ZammadAPI::Test.new }` paid for once per example. +- The trailing slash a base URL is normalised with lands on the path rather than at the end + of the string. `https://host/zammad?tenant=acme` became `https://host/zammad?tenant=acme/`, + which every request was then resolved against and every `ConnectionError` printed. +- `Config` refuses a URL with a scheme and no host, and one that is not a String. Both used + to be accepted: `'https://'` failed deep inside the adapter on the first request, and a + `URI` — what `URI(...)` hands back, and it prints as the URL — died as a `NoMethodError` + past the `ConfigurationError` the constructor is documented to raise. +- `user_agent: nil` falls back to the gem's own value instead of reaching Faraday as a nil + header, which Faraday filled in with its own — so the gem silently stopped identifying + itself in the instance log an operator greps to find its requests. A `user_agent` that is + not a String raises `ConfigurationError`. +- Proxy credentials are redacted whether or not the proxy URL carries a scheme. + `proxy: 'user:secret@proxy:8080'` — the shape an `http_proxy` setting is copied out of — + rendered in `Config#inspect` in full. +- A `proxy` that is not a URL, and a `middleware` callable that raises, are reported as + `ConfigurationError`. Only `Faraday::Error` was wrapped, so these escaped as + `URI::InvalidURIError` and as whatever the callable raised, past the + `rescue ZammadAPI::ConfigurationError` around building a client. +- The debug log redacts `api_key`, `apikey`, `passwd`, `pwd` and a bare `key` as well. The + pattern matched `private_key` but not the other key spellings, and `password` but not its + short forms, so those payload values were written out in full. +- `destroyed?` is sticky. `reload` re-read a record that no longer exists and cleared the + flag on the way back, so a destroyed record came back reporting itself as `persisted?` + and its next `save` issued a `PUT` against the deleted path; a second `destroy` surfaced + Zammad's 404 rather than saying the record was already gone. `save`, `reload` and + `destroy` now all refuse a destroyed record with the same local error. +- `record.fetch` refuses more than one fallback, the way `Hash#fetch` does. `fetch(:a, :b, + :c)` — a multi-key read this has never been — was answered with `:b`. +- A collection smaller than one page costs one request rather than two. The walk confirms + the end of a short page with another request, which could only ever come back empty; it + now stops on the total the endpoint reports alongside the page, and only falls back to + confirming when the endpoint reports none. The total has to be corroborated by the page + it arrived with — the page came back short of the size requested, and exactly as many + records were seen as the total names. It is the one stop condition not derived from the + records the endpoint served, and a total that under-reports (a count taken before + permission scoping, a stale cache, a proxy rewriting the header) ended the walk early: + 100 of 150 records came back, nothing was raised, and nothing told that result apart + from a complete one. +- The test kit records a request body by value. Held by reference, a test that built one + payload, sent it, then changed it for a second call rewrote the first recorded request + and asserted against a body that never went anywhere. `Test#inspect` also reads the + recorded requests under the monitor that guards them. +- A record is persisted because Zammad answered 2xx, not because the answer parsed. The + create response was decoded before the flag went down, so a 201 carrying something other + than a JSON object — an HTML error page from an intervening proxy — raised `ParseError` + with the record still looking new. The ticket existed in Zammad while the record here did + not, and a retried `save` POSTed a second one. What that leaves behind is a record that + is persisted and has no id, and `save`, `reload` and `destroy` all refuse it by name + rather than pretending: a record built by `new` has nothing staged, so the retried `save` + took the "nothing changed, nothing to send" short circuit and returned `true` without + making a single request, for a record that may or may not be in Zammad. +- A `ConfigurationError` raised while building the connection no longer quotes the proxy + credentials. `proxy: 'http://user:pa ss@host:3128'` came back as + `URI::InvalidURIError` with the whole URL, password included, in a message that lands in + every log and exception report — the case `Config#inspect` exists to prevent, reached by + another route. +- `Config` refuses a `proxy` that is not a String, an `adapter` that cannot be a Symbol, + and an `ssl_verify` that is not a boolean. A `URI` proxy was accepted and then died as a + `NoMethodError` inside `inspect`, so the object documented as safe to log raised at the + moment something logged it; `adapter: 1` and `adapter: true` escaped the constructor as + `NoMethodError`; and `ssl_verify: 'false'` — a plausible environment read — is the + truthy string `"false"`, so verification stayed on while the caller believed otherwise. +- An `OpenSSL::SSL::SSLError` that an adapter did not wrap is mapped to `ConnectionError` + like its Faraday counterpart. Unlisted, a certificate mismatch through such an adapter — + and this gem lets a caller choose one — escaped `request` raw, past every + `rescue ZammadAPI::Error`. It is not retried: a rejected certificate is a fact about the + instance, not a transient failure. +- A resource subclassed by a caller keeps its parent's API path. Class-level state is not + inherited, so `class MyTicket < Ticket; end` inherited all nine of Ticket's associations, + its page limit and its searchability, and lost only the path — `MyTicket.resource_path` + raised "does not declare an API path" from a class that plainly did. +- The test kit answers a later page of a singly-stubbed list endpoint the way an endpoint + out of records would. A stub that kept serving the same records to every page tripped the + repeated-page guard, so the obvious `stub(:get, 'api/v1/groups', body: [...])` made every + full read of that collection raise `PaginationError`. Against a real Zammad the same code + works, because page 2 comes back empty; the stand-in was what differed. A stub that names + a `page` is still served exactly as written. +- The test kit sequences stubs within an identical query scope rather than across every + scoped stub for an endpoint. Two stubs naming different parameters both match a request + carrying all of them, and they were read as a sequence: stubbing a search once for its + records and once for its count made `count` consume the records stub, hand back an Array + where a count belonged, and then report the endpoint as unstubbed. The most specific + scope now answers, and two that are equally specific raise + `ZammadAPI::Test::AmbiguousStubError` rather than one of them being picked. +- `Collection#count` reads the total from the header when a search endpoint ignores + `only_total_count`. The probe came back as the usual page of records and was thrown away, + so the answer cost 1 + N requests instead of N. +- `update` and `update!` refuse a destroyed record before staging anything. They assigned + first and saved second, and `save!` is where the destroyed check lives, so `update` on a + destroyed record raised and left it `changed?` with a change set that can never be + sent — the state `destroy` clears the staged changes to prevent. +- Writing an attribute the record does not carry is a change, and is sent. Zammad reduces + the object it serializes for a permission-scoped client, so a key being absent says + nothing about what is stored; read as a `nil` original, `group.note = nil` compared equal + to nil, staged nothing and was still merged into the attributes. The write was dropped + without a word, `save` returned `true` having sent no request, and the record went on + reporting a key Zammad never sent it, so `changes` and `attributes` disagreed. +- `ticket.article(...)` refuses a ticket that has not been saved instead of POSTing + `ticket_id: null` and leaving the caller to read Zammad's 422 for the reason. Every other + path in the gem that needs a stored id says so locally. +- A `has_many` reader refuses a response that is one page of several, with + `ZammadAPI::PaginationError`, rather than handing back a short list. These are the one + kind of list read in a single request, because the association endpoints Zammad routes + serve the whole thing; an endpoint that started paging would have returned its first page + and nothing to say so, while `all` and `search` walk to the end. +- The documented page sizes are sizes the endpoints actually serve. Every `find_each` and + `in_batches` example used `of: 500` against `client.ticket`, which caps at 100, so each + headline example did something other than what it showed. The clamp itself stands — a + batch size says how much to fetch at a time, not which records you get, which is why + `page` refuses an oversize size and a walk reduces it. +- A destroyed record reports the attributes Zammad last served, not a write that never + left the process. `destroy` dropped the staged change set and left the writes it + described standing, so `group.name = 'B'; group.destroy` answered `changed?` with false, + `changes` with `{}` and `name` with `"B"` — with nothing left to tell a local edit apart + from a value the server gave, in the one state where it can never be saved. +- A record Zammad served without an id is no longer told it "was saved". Zammad serves a + reduced object where the authenticated user may not see the whole record, so a plain read + can hand back a persisted record with no id; the message sent the caller to look at a + save that never happened instead of at what the client may read. +- `require 'zammad_api'` no longer depends on Faraday loading `net/http` for it. + `Timeout::Error` and `SocketError` are named in `Transport`'s class body and resolved + only because the default adapter pulled `timeout` and `socket` in transitively — so a + Faraday that stopped doing that, or a slimmer adapter, turned the require into a + `NameError` before a single request. +- `middleware:` that decodes the response body — `c.response :json` is the usual one — is + refused when the client is built, before anything is sent, with a `ConfigurationError` + naming it. Faraday's parsed Hash used to become a Ruby inspect string that `JSON.parse` + refused, which killed every record built from it with a `ParseError` naming Zammad for + what the caller's stack had done. This gem parses JSON itself and hands the undecoded + bytes to attachment downloads, so once a middleware has consumed the body there is + nothing faithful left to hand back. +- Two spellings of one query parameter raise instead of silently sending whichever Hash + order put last — at any depth, and in `where` at the call that wrote it as well as on + the wire. The same goes for two spellings of one response header in `ZammadAPI::Test`. Keys are normalised + in both places, so `where('sort_by' => 'name', sort_by: 'id')` and + `{state_id: 1, 'state_id' => 2}` each collapsed into one parameter and dropped the other + value without a word — including inside `condition`, the structured parameter the search + endpoints read. +- A stubbed response from `ZammadAPI::Test` carries `content-type` the way a real one does, + and is decoded by that header rather than by the Ruby type of the stub's body. Code that + branches on `response.headers['content-type']` passed against Zammad and failed against + the stand-in, or the reverse; and a stub declaring a non-JSON type still handed back a + decoded Hash, where Zammad gives the raw string and reading a record from it raises + `ParseError`. +- `record.fetch(:missing, default) { ... }` warns the way `Hash#fetch` warns, naming the + line that made the call. A bare `Kernel#warn` reports no source location, so the warning + identified neither the call site nor the library it came from. + +### Changed + +- `Response#headers` is frozen, and each response carries its own. Writing through it + changed nothing on the wire and, in `ZammadAPI::Test`, rewrote the stub for every later + request in the example. + +- `client..destroy(id)` deletes directly instead of fetching the record first. +- Resource dispatch is explicit rather than `method_missing` plus `const_get`. +- A resource declares what its endpoint does — `searchable true`, `max_per_page 100`, + `index_query_keys :sort_by` — the way it already declared `path`, rather than by setting + `SEARCHABLE`, `MAX_PER_PAGE` and `INDEX_QUERY_KEYS`. A misspelled constant was silently + ignored and the resource kept Base's default, so `SEARCHEABLE = true` left the resource + unsearchable and every `find_by` on it raised "Zammad routes no search endpoint" with no + hint that the declaration was the problem; a misspelled declaration is a `NoMethodError` + at load. +- `client.` returns the same proxy each time rather than allocating one per call. + The proxies are built with the client and frozen, so a client stays immutable once built + and safe to share between threads without locking, as documented. Clients from `#with` + and `#on_behalf_of` get proxies of their own, so none is shared with the transport it was + derived from. +- The recursive copy behind frozen attributes, `to_h` and the test kit's recorded bodies + lives in one place (`ZammadAPI::DeepCopy`) instead of being written once per caller. +- Unit specs (`rake spec:unit`) run without a Zammad instance; the specs that need a live + server live in `spec/integration`. +- CI runs RuboCop, Steep and the unit specs on every supported stable Ruby, and publishes + releases through RubyGems trusted publishing. +- The integration job now waits for Zammad to answer before running specs, promotes + Zammad's generated CI environment into the job so it survives across steps, pins the + Zammad ref (overridable via `workflow_dispatch`), carries a timeout, and uploads Zammad's + logs on failure. It also runs `script/check_connection.rb` as a preflight, so a broken + gem-to-Zammad link fails in seconds with a readable transcript instead of 53 spec errors. +- The integration suite no longer depends on spec file order to run Zammad's auto wizard, + and tolerates an instance that is already set up. Its lifecycle examples are pinned to + definition order and say what is missing when only part of a file is run. +- `Test::UnstubbedRequestError` is a `StandardError` rather than a `ZammadAPI::Error`, so + a forgotten stub is not caught by the `rescue ZammadAPI::Error` in the code under test. +- The test kit matches array-valued query stubs, such as `query: {ids: [1, 2]}`, which + could never match before. + ## [1.4.0] - 2026-08-25 - Follow up - c3af2a9 - Fixes #29 - [JSON::ParserError on gateway timeout when proxy responds with HTML](https://github.com/zammad/zammad-api-client-ruby/issues/29) - Dependencies updated diff --git a/Gemfile b/Gemfile index 2d9e06b..d9de46c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,17 +1,25 @@ +# frozen_string_literal: true + source 'https://rubygems.org' -# runtime dependencies are defined in zammad_api.gemspec +# Runtime dependencies are defined in zammad_api.gemspec. gemspec -# development dependencies group :development, :test do - gem 'bundler', '>= 2.2.10' - gem 'overcommit' - gem 'rake' - gem 'rspec' - gem 'rubocop' - gem 'rubocop-performance' - gem 'rubocop-rake' - gem 'rubocop-rspec' - gem 'webmock' + gem 'overcommit', require: false + gem 'rake', require: false + gem 'rspec', '~> 3.13' + gem 'rubocop', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rake', require: false + gem 'rubocop-rspec', require: false + gem 'simplecov', '~> 0.22', require: false + gem 'webmock', '~> 3.26' + gem 'yard', require: false +end + +group :development do + # Static type checking against the signatures in sig/. + gem 'rbs', require: false + gem 'steep', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index d4fcd98..6282f88 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,9 @@ PATH remote: . specs: - zammad_api (1.3.1) - faraday (~> 2) + zammad_api (2.0.0) + faraday (~> 2.9) + faraday-retry (~> 2.2) GEM remote: https://rubygems.org/ @@ -13,21 +14,42 @@ GEM bigdecimal (4.1.2) childprocess (5.1.0) logger (~> 1.5) + concurrent-ruby (1.3.8) crack (1.0.1) bigdecimal rexml + csv (3.3.6) diff-lcs (1.6.2) + docile (1.4.1) faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger faraday-net_http (3.4.4) net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fileutils (1.8.0) hashdiff (1.2.1) iniparse (1.5.0) json (2.21.2) language_server-protocol (3.17.0.6) lint_roller (1.1.0) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) net-http (0.9.1) uri (>= 0.11.1) @@ -35,15 +57,22 @@ GEM childprocess (>= 0.6.3, < 6) iniparse (~> 1.4) rexml (>= 3.4.2) - parallel (1.28.0) + parallel (2.1.0) parser (3.3.12.0) ast (~> 2.4.1) racc prism (1.9.0) - public_suffix (6.0.2) + public_suffix (7.0.5) racc (1.8.1) rainbow (3.1.1) rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort regexp_parser (2.12.0) rexml (3.4.4) rspec (3.13.2) @@ -59,8 +88,8 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) - rubocop (1.89.0) - json (~> 2.3) + rubocop (1.90.0) + json (>= 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) parallel (>= 1.10) @@ -85,6 +114,33 @@ GEM regexp_parser (>= 2.0) rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) + securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + steep (2.1.0) + concurrent-ruby (>= 1.1.10) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.17.0.4, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + parser (>= 3.2) + prism (>= 0.25.0) + rainbow (>= 2.2.2, < 4.0) + rbs (~> 4.2) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 5) + uri (>= 0.12.0) + strscan (3.1.8) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + tsort (0.2.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -93,20 +149,34 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + yard (0.9.45) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES - bundler (>= 2.2.10) overcommit rake - rspec + rbs + rspec (~> 3.13) rubocop rubocop-performance rubocop-rake rubocop-rspec - webmock + simplecov (~> 0.22) + steep + webmock (~> 3.26) + yard zammad_api! BUNDLED WITH diff --git a/README.md b/README.md index 211469b..ac6f7d5 100644 --- a/README.md +++ b/README.md @@ -1,390 +1,1029 @@ -# Zammad API Client (Ruby) [![Gem Version](https://badge.fury.io/rb/zammad_api.svg)](https://badge.fury.io/rb/zammad_api) - -## API version support -This client supports Zammad API version 1.0. +# Zammad API Client (Ruby) + +[![Gem Version](https://badge.fury.io/rb/zammad_api.svg)](https://badge.fury.io/rb/zammad_api) +[![CI](https://github.com/zammad/zammad-api-client-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/zammad/zammad-api-client-ruby/actions/workflows/ci.yml) + +--- + +> [!IMPORTANT] +> ## 📣 2.0 is taking shape — tell us what you think +> +> **Version 2.0 is a breaking release, and it is not finished yet.** This is the moment when +> your feedback can still change it: names, defaults, what is missing, what reads wrong, and +> anything that makes upgrading from 1.x harder than it should be. +> +> **[→ Open an issue and tell us](https://github.com/zammad/zammad-api-client-ruby/issues/new)** +> +> Especially useful to hear: +> +> - Which 1.x calls in **your** code the [migration table](#migrating-from-1x) does not cover. +> - Endpoints you reach with [raw requests](#raw-requests) that should be modelled resources. +> - Anything the [test kit](#testing-code-that-uses-this-client) cannot stand in for. +> - Naming that made you look twice, and defaults you had to override every time. +> +> Rough notes are welcome — a half-formed "this felt off" is worth more to us now than a +> polished report after the release. + +--- + +Ruby client for the Zammad API v1.0. + +- Requires **Ruby 3.4** or later. +- Ships **RBS signatures** in `sig/`, so typed projects get completion and checking out of the box. +- Requests carry **timeouts** and **retry with backoff** for transient failures by default. +- Collections are **lazily paginated** `Enumerable`s, with a familiar + `where` / `page` / `in_batches` / `find_each` surface. +- Records support **pattern matching**, and clients are **immutable** and safe to share + between threads. +- **Raw requests** reach the endpoints this gem does not model yet, without giving up + authentication, retries or the error classes. +- A **test kit** (`zammad_api/test`) stands in for a Zammad, so your own tests need no + HTTP interception. + +> **Upgrading from 1.x?** See [Migrating from 1.x](#migrating-from-1x), which lists every +> call that changed. ## Installation -Add this line to your application's Gemfile: - ```ruby -gem 'zammad_api' +gem 'zammad_api', '~> 2.0' ``` -And then execute: +Or: - $ bundle - -Or install it yourself as: +```sh +gem install zammad_api +``` - $ gem install zammad_api +## Creating a client -## Available objects +### Access token -* user -* organization -* group -* ticket -* ticket_article -* ticket_state -* ticket_priority +```ruby +client = ZammadAPI::Client.new( + url: 'https://zammad.example.com/', + http_token: 'your-access-token' +) +``` -## Usage +### OAuth2 -### Create instance +```ruby +client = ZammadAPI::Client.new( + url: 'https://zammad.example.com/', + oauth2_token: 'your-oauth2-token' +) +``` -#### Username/email and password +### Username and password ```ruby client = ZammadAPI::Client.new( - url: 'http://localhost:3000/', - user: 'user', + url: 'https://zammad.example.com/', + user: 'user@example.com', password: 'some_pass' ) ``` -#### Access token +### From the environment + +`from_env` reads `ZAMMAD_URL` and `ZAMMAD_TOKEN` (or `ZAMMAD_USER` and +`ZAMMAD_PASSWORD`, or `ZAMMAD_OAUTH2_TOKEN`), so a script needs no configuration of its +own. Anything passed in wins over the environment: ```ruby -client = ZammadAPI::Client.new( - url: 'http://localhost:3000/', - http_token: '12345678901234567890', -) +client = ZammadAPI::Client.from_env +client = ZammadAPI::Client.from_env(timeout: 300) ``` -#### OAuth2 +### Options + +| Option | Default | Description | +| ---------------- | ------------------ | ------------------------------------------------------------------ | +| `url` | *required* | Base URL. A sub-path such as `https://example.com/zammad/` works. | +| `http_token` | `nil` | Zammad access token. | +| `oauth2_token` | `nil` | OAuth2 bearer token. | +| `user` | `nil` | Login for basic authentication. | +| `password` | `nil` | Password for basic authentication. | +| `timeout` | `60` | Seconds to wait for a response. | +| `open_timeout` | `10` | Seconds to wait for the connection. | +| `retries` | `2` | Retry attempts for idempotent requests. `0` disables retrying. | +| `retry_interval` | `0.5` | Seconds before the first retry; doubles on each attempt. | +| `ssl_verify` | `true` | Set to `false` only against a server with a self-signed certificate. | +| `proxy` | `nil` | Proxy URL. | +| `user_agent` | `zammad_api-ruby/` | Value of the `User-Agent` header. | +| `logger` | discards output | Any `Logger`; the client logs requests and responses at `debug`. | +| `adapter` | Faraday's default | Name of the Faraday adapter to use. | +| `middleware` | `nil` | Callable that receives the Faraday connection while it is built. | + +Credentials are never written to the log, and `client.config.inspect` redacts them, so a +configuration object is safe to include in an error report. + +### Checking the connection + +```ruby +client.me.email # => "agent@example.com", the account the credentials belong to +client.version # => "6.4.0", the Zammad instance's version +``` + +`client.version` is the version of the Zammad instance; `ZammadAPI::VERSION` is the +version of this gem. + +### Adapter and middleware + +The HTTP stack is Faraday's, and these two options are the seam into it — for a +persistent-connection adapter, instrumentation, or a cache: ```ruby client = ZammadAPI::Client.new( - url: 'http://localhost:3000/', - oauth2_token: '12345678901234567890', + url: 'https://zammad.example.com/', + http_token: 'token', + adapter: :net_http_persistent, + middleware: ->(connection) { connection.use(MyInstrumentation) } ) ``` -## Resource management +The callable runs last, after this gem's own middleware and before the adapter, so it sees +requests as the client finished building them and responses before anything else does. +Faraday stays an implementation detail either way: an unregistered adapter raises +`ZammadAPI::ConfigurationError`, not a Faraday error. + +Faraday's default adapter, `net_http`, opens and closes a connection around every +request, so a walk of N pages is N TCP connections and N TLS handshakes. That is the +default because it needs no extra gem; for anything that pages, downloads attachments in a +loop, or runs a worker pool, add `faraday-net_http_persistent` to your Gemfile and pass +`adapter: :net_http_persistent` as above. Nothing else changes — the option is the whole of +it. + +Middleware that decodes the response body — `connection.response :json` — is the one thing +the seam will not take, and says so with a `ZammadAPI::ConfigurationError`. This gem parses +JSON itself, and hands the undecoded bytes back as the file from +`attachment.download`, so a stack that has already consumed the body leaves nothing +faithful to return. + +## Available resources + +`group`, `organization`, `ticket`, `ticket_article`, `ticket_priority`, `ticket_state`, `user` + +`client.resource_names` returns the current list. + +Anything not in that list is reachable with [raw requests](#raw-requests). -Individual resources can be created, modified, saved, and destroyed. +## Raw requests -### Create object +`get`, `post`, `put` and `delete` reach any endpoint of the Zammad API, without giving up +authentication, timeouts, retries, credential redaction, JSON decoding or the error +classes. Use them for the endpoints this gem does not model yet. -With new and save: ```ruby -group = client.group.new( - name: 'Support', - note: 'Some note', -); +client.get('api/v1/roles').body +# => [{id: 1, name: "Admin", ...}, ...] + +client.post('api/v1/tags/add', query: {object: 'Ticket', o_id: 1, item: 'urgent'}) +client.put('api/v1/roles/2', body: {note: 'Updated'}) +client.delete('api/v1/tags/remove', query: {object: 'Ticket', o_id: 1, item: 'urgent'}) +``` + +All four take `headers:`, for an endpoint that needs one: + +```ruby +client.get('api/v1/tickets/1', headers: {'Accept-Language' => 'de-de'}) +``` + +Names are case-insensitive, and two spellings of one header are refused rather than merged. +`Authorization` and `From` are refused outright: the first is what the client's credentials +are for, the second is what `on_behalf_of` sets, and replacing either from a single request +would leave the client reporting something it no longer does. + +Each returns a `ZammadAPI::Response`, so the status and headers stay reachable: + +```ruby +response = client.get('api/v1/tickets') +response.status # => 200 +response.headers['content-type'] +response.body # decoded JSON, or the raw body for anything else +response.raw_body # the bytes as they arrived +response.json? # => whether the body was decoded +``` + +Zammad reports the size of a result in the body rather than in a header, and only +where you ask for it: `only_total_count=true` or `with_total_count=true` on a `/search` +endpoint, and `full=true` on the index endpoints that render through +`model_index_render`. `count` on a search collection uses the first of those. + +Paths are relative to the instance URL, and a leading slash is ignored, so they can be +pasted straight from the Zammad documentation. A non-2xx response raises the same error +class it would raise for a modelled resource, and `POST` is not retried. + +## Working with records + +### Create + +```ruby +group = client.group.new(name: 'Support', note: 'Some note') group.save -group.id # id of record -group.name # 'Support' +group.id # => 42 +group.name # => "Support" ``` -With create: +Or in one call: + ```ruby -group = client.group.create( - name: 'Support', - note: 'Some note', -); +group = client.group.create(name: 'Support', note: 'Some note') +``` + +### Fetch -group.id # id of record -group.name # 'Support' +```ruby +group = client.group.find(42) +group.name # => "Support" +group[:name] # same, and nil rather than an error when it is absent +group.fetch(:name) # raises KeyError if the attribute is absent +group.key?(:name) # => true +group.to_h # every attribute, as a Hash you may modify ``` -### Fetch object +Or by attribute value: ```ruby -group = client.group.find(123) -puts group.inspect +client.user.find_by(email: 'someone@example.com') # => the record, or nil +client.user.find_by!(email: 'nobody@example.com') # raises NotFoundError +client.group.exists?(42) # => true ``` -### Update object + +`find_by` searches and then checks the hits itself, because Zammad's index endpoints +cannot filter — see [Filters](#filters). A record it returns genuinely carries the +attributes you asked for, compared exactly and against the value as Zammad stores it, so +`find_by(email: 'Someone@Example.com')` does not match a login Zammad downcased. + +The search term is one string value, because Zammad matches words: a value of another type +would go out as the word it prints as, so `find_by(active: true)` would look for records +containing `"true"` and find none. Give at least one string value to search on — every +other value is matched exactly against the record: ```ruby -group = client.group.find(123) +client.user.find_by(email: 'someone@example.com', active: true) # searches the email, matches both +client.user.find_by(firstname: 'Jane', lastname: 'Doe') # searches "Jane", matches both +client.user.find_by(active: true) # raises ArgumentError +client.ticket_state.all.detect { it.name == 'open' } # short list, no search needed +``` + +The values are never joined into one term. An instance searching without Elasticsearch +matches the term literally, through a SQL `LIKE` over each string column, so `"Jane Doe"` +would ask for a single column containing the whole of it and match nobody. + +What the search can surface is Zammad's business: a value the instance has not indexed, or +cannot index, is a record `find_by` will not find. `find_by(...) || create(...)` can +therefore still create a duplicate — as writing the search out by hand would. + +`find_by` examines only the first page of hits, so it costs one request whether it matches +or not. Search hits come back by relevance, so a record carrying the value is at the top of +them or not among them at all; to look further, page through `search` yourself. + +Zammad records can carry administrator-defined custom attributes, so readers are resolved +against the attributes the record arrived with rather than declared ahead of time. A reader +for an attribute the record does not carry raises `NoMethodError`, naming what it does +carry — a typo is not worth a silent `nil` that flows on into whatever you write with it: + +```ruby +group.titel # NoMethodError: undefined attribute titel for ... +group[:titel] # => nil +group.fetch(:titel, nil) # => nil +group.key?(:titel) # => false +``` + +That matters beyond typos: Zammad serves a reduced object where the authenticated user may +not see the whole record, so an attribute that exists in Zammad can be missing here. The +message says so. Use `[]`, `fetch` with a default, or `key?` wherever an attribute may +legitimately be absent. + +`attributes` and `changes` are deeply frozen, because a record that let you write into +them would report a change it had never staged and would not send: + +```ruby +group.attributes[:name] = 'Support 2' # FrozenError +group.name = 'Support 2' # the way to stage a change +group.to_h # a deep copy, yours to modify +``` + +### Pattern matching + +Records implement `deconstruct_keys`, so they work with `case/in`: + +```ruby +case client.ticket.find(1) +in {state: 'closed'} + nil +in {state: String => state, priority: '3 high'} + escalate(state) +in {group: {name: 'Support'}} + notify_support +end +``` + +`Config` and `Response` are `Data` objects, so their members match too: + +```ruby +case client.config +in {http_token: String} + :token_auth +in {user: String, password: String} + warn 'prefer an access token over basic auth' +end +``` + +### Update + +```ruby +group = client.group.find(42) group.name = 'Support 2' -group.save + +group.changed? # => true +group.changes # => {name: ["Support", "Support 2"]} + +group.save # sends only the changed attributes +``` + +A `save` on a persisted record with nothing staged sends no request at all and returns +`true`. Zammad applies an empty update, bumping `updated_at` and `updated_by`, so a no-op +save would otherwise rewrite the record's audit trail. + +Writing back the value a record was loaded with is not a change, so it stages nothing. +Writing an attribute the record does not carry always is one, even when the value is `nil`: +Zammad reduces the object it serializes for a permission-scoped client, so a key being +absent says nothing about what is stored, and `group.note = nil` is a request to store +`nil` rather than a write to drop. + +Or in one call: + +```ruby +group.update(name: 'Support 2', note: 'Renamed') # assigns, then saves +group.assign_attributes(name: 'Support 3') # assigns without saving +``` + +### Saving and validation failures + +`save` returns whether the record was stored, and leaves a rejection in `error`: + +```ruby +group = client.group.new(name: '') + +if group.save + puts group.id +else + warn group.error.server_message # => "Name is required" +end ``` -### Destroy object +Only a rejection of the attributes (HTTP 422) is reported that way. An expired token, a +missing record or an unreachable instance still raises, because those are not something +the calling code can correct by fixing an attribute. + +`save!` and `update!` raise on every failure, including validation, which is what you want +in a script: ```ruby -group = client.group.find(123) -group.destroy +group.save! # raises ZammadAPI::ValidationError +group.update!(name: '') # the same, in one call ``` -## Collection management +`client.group.create(...)` uses `save!`, so it raises rather than handing back a record +that looks created but is not. Use `new` plus `save` when you need to branch instead. -A list of individual resources. +### Associations -### All +Zammad expands an association into a name under the plain attribute, so those reads are +already loaded and free: ```ruby -groups = client.group.all +ticket = client.ticket.find(1) + +ticket.customer # => "customer@example.com" +ticket.state # => "open" +ticket.group # => "Users" +ticket.customer_id # => 7 +``` -group1 = groups[0] -group1.note = 'Some note' -group1.save +`related` reaches the whole record behind one of those, which costs a request: -groups.each {|group| - p "group: #{group.name}" -} +```ruby +ticket.related.customer.firstname # => "Nicole" +ticket.related.group.note +ticket.related.articles # => [TicketArticle, ...] +ticket.related.created_by.email + +client.user.find(7).related.organization ``` -### Search +A `has_many` reader such as `articles` is the one list here that is read in a single +request and comes back as an Array rather than a paginating collection, because the +association endpoints Zammad routes serve the whole list. If one ever answers with a page +of several, the reader raises `ZammadAPI::PaginationError` rather than quietly handing back +a short list. + +The readers live under `related` rather than on the record so that `ticket.customer` keeps +returning the name it always did — an attribute read that silently became an HTTP request +would be a poor trade. `belongs_to` targets are memoized, and `reload` or a save drops the +memo; `has_many` lists are fetched each call, so an article added in between shows up. +`Ticket.associations` lists what a resource declares. + +### Comparing and serializing + +A record is the Zammad record it came from, not the object that happens to hold it, so two +records of the same kind carrying the same id are equal. That makes `uniq`, `Set`, `include?` +and records-as-Hash-keys behave: + ```ruby -groups = client.group.search(query: 'some name') +client.ticket.find(1) == client.ticket.find(1) # => true + +[client.ticket.find(1), client.ticket.find(1)].uniq.size # => 1 +Set[client.ticket.find(1), client.ticket.find(1)].size # => 1 +seen = { client.ticket.find(1) => :handled } +seen[client.ticket.find(1)] # => :handled +``` -group1 = groups[0] -group1.note = 'Some note' -group1.save +A record with no id is equal only to itself, because two unsaved records are two records +waiting to be created however alike their attributes are. One consequence: a record's first +save assigns its id and so changes its hash, and a record used as a Hash key before that +save has to be rehashed after it. -groups.each {|group| - p "group: #{group.name}" -} +`to_json` renders the attributes, so a record can be cached, queued or logged as it stands, +and nests inside a structure being generated: + +```ruby +client.group.find(1).to_json # => "{\"id\":1,\"name\":\"Support\"}" +JSON.generate(group: client.group.find(1)) ``` -### All with pagination (beta) +`as_json` returns the same attributes as a Hash, for ActiveSupport and any encoder that +follows its convention. + +### Reload and destroy ```ruby -groups = client.group.all +group.reload # re-reads from Zammad, discarding unsaved changes +group.destroy # => true -groups.page(1,3) {|group| - p "group: #{group.name}" +group.destroyed? # => true +group.persisted? # => false, so this is not the inverse of new_record? +group.save # raises: the record is gone, and a PUT would only 404 +group.update(...) # raises too, and stages nothing, so the record stays unchanged - group.note = 'Some new note, inclued in page 1 with 3 per page' - group.save -} +client.group.destroy(42) # delete by id, without fetching first +``` -groups.page(2,3) {|group| - p "group: #{group.name}" +## Collections - group.note = 'Some new note, inclued in page 2 with 3 per page' - group.save -} +`all`, `where` and `search` return a lazily paginated `ZammadAPI::Collection`. No request +is made until you iterate, and pages are fetched as needed. + +A resource proxy is itself `Enumerable` over `all`, so `.all` is optional: + +```ruby +client.ticket.each { |ticket| puts ticket.title } +client.ticket.first(5) +client.ticket.pluck(:title) +client.ticket.find_each(batch_size: 50) { |ticket| archive(ticket) } ``` -### Search with pagination (beta) +`find` keeps its own meaning there — `client.ticket.find(1)` is a lookup by id, not +`Enumerable#find`. Use `detect` for the block form. + ```ruby -groups = client.group.search(query: 'some name') +# Walks every page automatically. +client.ticket.all.each do |ticket| + puts ticket.title +end -groups.page(1,3) {|group| - p "group: #{group.name}" +# Stops after the first page, because Enumerable stops consuming. +first_five = client.ticket.all.first(5) - group.note = 'Some new note, inclued in page 1 with 3 per page' - group.save -} +# Lazy chains work as expected. +client.ticket.all.lazy.select { |t| t.state == 'open' }.first(10) -groups.page(2,3) {|group| - p "group: #{group.name}" +# One array of records per request, e.g. for a bulk import. +client.ticket.all.in_batches(of: 50) do |tickets| + import(tickets) +end - group.note = 'Some new note, inclued in page 2 with 3 per page' - group.save -} +# Record by record, with the page size set inline. +client.ticket.all.find_each(batch_size: 50) do |ticket| + archive(ticket) +end ``` -## Perform actions on behalf of another user +### Filters -As described in the [Zammad API documentation](https://docs.zammad.org/en/latest/api/intro.html#actions-on-behalf-of-other-users) it is possible to perfom actions on behalf other users. To use this feature you can set the attribute of the client accordingly: +**Zammad's index endpoints do not filter.** `ApplicationController#model_index_render` +sorts and pages and drops every other parameter, and `/api/v1/tickets` and +`/api/v1/users` hardcode their order, so they honour nothing beyond paging. Filtering by +an attribute value exists only on the `/search` endpoints. -> **Note:** This feature requires Zammad 5.0 or later, since the client sends the standard HTTP `From` header instead of the deprecated `X-On-Behalf-Of` header (see [zammad/zammad#3113](https://github.com/zammad/zammad/issues/3113)). +So `where` accepts only what the endpoint actually reads, and says so about anything else +rather than handing back an unfiltered list: ```ruby -client.on_behalf_of = 'some_login' +client.group.all.where(sort_by: 'name', order_by: 'DESC') # honoured +client.ticket.where(state: 'open') +# ArgumentError: api/v1/tickets ignores state, so where would hand back unfiltered +# records. That endpoint honours nothing beyond paging. Zammad filters by attribute +# value only through a search endpoint, so use find_by for one record or search for many. ``` -All following actions with the client will be performed on behalf of the user with the `login` "some_login". +Narrow by a value with [`find_by`](#fetch) for one record, or `search` for many: + +```ruby +client.user.find_by(email: 'someone@example.com') +client.ticket.search('state.name:open').first(10) +``` -To reset this back to regular requests just set `nil`: +Zammad routes a search endpoint per model, not for every model. `user`, `organization`, +`ticket` and `group` have one; `ticket_state`, `ticket_priority` and `ticket_article` do +not, and `search` and `find_by` on those raise `ZammadAPI::Error` rather than reaching a +404 that would read as a missing record. Those lists are short, so walk them: ```ruby -client.on_behalf_of = nil +client.ticket_state.all.detect { it.name == 'open' } ``` -It's possible to perform only a block of actions on behalf of another user via: +Paging is not a filter either: that is what `page`, `in_batches` and `find_each` are for, +and passing `page:` or `per_page:` to `where` raises `ArgumentError` rather than being +silently ignored. So does `query:`, which is the term `search` set. + +A `nil` value raises too. There is no query string that means "this field is null", so +`where(owner_id: nil)` could not ask for unassigned tickets — it would ask for all of them. + +### Search ```ruby -client.perform_on_behalf_of('some_login') do - # ticket is created on behalf of the user with - # the login "some_login" - client.ticket.create( - ... - ) +client.organization.search('zammad').each do |organization| + puts organization.name end +``` + +### Explicit pages + +```ruby +tickets = client.ticket.all -# further actions are performed regularly. +tickets.page(2) # page 2 at the size the endpoint serves +tickets.page(2, of: 10) # records 11 to 20 ``` -## Examples +Collections are immutable: `where` and `page` return a new collection and leave the +original untouched. -Create a ticket: -```ruby -ticket = client.ticket.create( - title: 'a new ticket #1', - state: 'new', - group: 'Users', - priority: '2 normal', - customer: 'some_customer@example.com', - article: { - content_type: 'text/plain', # or text/html, if not given test/plain is used - body: 'some body', - # attachments can be optional, data needs to be base64 encoded - attachments: [ - 'filename' => 'some_file.txt', - 'data' => 'dGVzdCAxMjM=', - 'mime-type' => 'text/plain', - ], - }, -) +### Page size + +A request fetches as many records as the endpoint will serve — 100 for `/api/v1/tickets`, +200 for a search, 1000 for the other index endpoints — so a walk spends as few round trips +as it can. Three calls take another size, each for its own kind of work: -ticket.id # id of record -ticket.number # uniq number of ticket -ticket.title # 'a new ticket #1' -ticket.group # 'Support' -ticket.created_at # '2022-01-01T12:42:01Z' -# ... +```ruby +client.ticket.all.find_each(batch_size: 50) { |ticket| archive(ticket) } # walking +client.ticket.all.in_batches(of: 50) { |tickets| import(tickets) } # batching +client.ticket.all.page(2, of: 100) # one page ``` -List all new or open tickets: +`find_each` without a block is an Enumerator, so it is also how you read at a chosen page +size: `client.ticket.all.find_each(batch_size: 50).first(7)`. + +`page` and a batch size are two ways of naming the same thing, so combining them raises: +`page(3, of: 50)` already says which records the collection holds, and re-sizing it would +quietly hand back different ones. Size the page itself, or slice with `each_slice`. + +That cap is also the ceiling. `find_each` and `in_batches` are reduced to it: a +batch size is how much to fetch at a time, so a smaller one costs more requests and still +yields every record. `page` raises instead, because a page size also says *which* records +you get — `page(3, of: 500)` reduced to 100 hands back records 201 to 300 rather than 1001 +to 1500. So `in_batches(of: 500)` over tickets yields batches of 100, while +`page(3, of: 500)` over tickets is refused. +A walk learns the size the endpoint actually serves from the first page rather than trusting +that cap, so an instance that pages smaller than expected is walked to the end rather than +truncated at the first short page. +That keeps a walk complete: a page size the server silently shrank would otherwise end the +iteration at the first page. + +`first` and `take` size their own request rather than taking records off the front of one +sized for walking, so the cheap-looking call is cheap: + ```ruby -tickets = client.ticket.search(query: 'state.name:new OR state.name:open') +client.ticket.all.first # one request, for one record +client.ticket.all.first(5) # one request, for five +client.ticket.all.take(5) # the same, and the same cost +``` + +They size the request, not the collection, so they still read on where a page comes back +shorter than it was asked for — `first(5)` answers with five records if five exist, however +the endpoint chooses to page them. A collection `page` already limited keeps its own size, +since that size says which records it holds. -ticket[0].id # id of record -ticket[0].number # uniq number of ticket -ticket[0].title # 'title of ticket' -ticket[0].group # 'Support' -ticket[0].created_at # '2022-01-01T12:42:01Z' +### Reading single attributes -tickets.each {|ticket| - p "ticket: #{ticket.number} - #{ticket.title}" -} +```ruby +client.user.all.pluck(:email) # => ["a@example.com", ...] +client.ticket.all.pluck(:id, :title) # => [[1, "Help"], ...] ``` -Get all articles of a ticket: +Zammad cannot be asked for a subset of the fields, so this shapes the result rather than +shrinking the request. + +### Counting + +`count` is one request on a search, which Zammad can answer without serving the records. +Every other endpoint has to be walked, because an index endpoint offers nothing cheaper: +`model_index_render` reads `sort_by`, `order_by` and the paging and drops every other +parameter, so it has no answer for `only_total_count` and no total to report. + ```ruby -ticket = client.ticket.find(123) -articles = ticket.articles +client.ticket.search('state.name:open').count # one request +client.ticket.all.count # one request per page +``` -articles[0].id # id of record -articles[0].from # creator of article -articles[0].to # recipients of article -articles[0].subject # article subject -articles[0].body # text of message -articles[0].content_type # text/plain or text/html of .body -articles[0].type # 'note' -articles[0].sender # 'Customer' -articles[0].created_at # '2022-01-01T12:42:01Z' +`size` and `length` are `count`, and cost the same. `empty?` and `first` ask for a single +record rather than a page: -p "ticket: #{ticket.number} - #{ticket.title}" -articles.each {|article| - p "article: #{article.from} - #{article.subject}" -} +```ruby +client.ticket.search('state.name:merged').empty? # one request, for one record +client.group.all.size # => 12, having walked ``` -Create an article for a ticket: +A block or an argument is `Enumerable#count` counting matches, and walks either way: + ```ruby -ticket = client.ticket.find(123) +client.ticket.all.count { it.state == 'open' } # walks every page +``` -article = ticket.article( - type: 'note', - subject: 'some subject 2', - body: 'some body 2', - # attachments can be optional, data needs to be base64 encoded - attachments: [ - 'filename' => 'some_file.txt', - 'data' => 'dGVzdCAxMjM=', - 'mime-type' => 'text/plain', - ], -) +Nothing is cached, so every traversal of a collection fetches again. -article.id # id of record -article.from # creator of article -article.to # recipients of article -article.subject # article subject -article.body # text of message -article.content_type # text/plain or text/html of .body -article.type # 'note' -article.sender # 'Customer' -article.created_at # '2022-01-01T12:42:01Z' -article.attachments.each { |attachment| - attachment.filename # 'some_file.txt' - attachment.size # 1234 - attachment.preferences # { :"Mime-Type"=>"image/jpeg" } - attachment.download # content of attachment / extra REST call will be executed -} - -p "article: #{article.from} - #{article.subject}" -``` - -Create an article with html and inline images for a ticket: -```ruby -ticket = client.ticket.find(123) - -article = ticket.article( - type: 'note', - subject: 'some subject 2', - body: 'some body with an image Red dot', - content_type: 'text/html', # optional, default is text/plain -) +### `find` is a lookup, not a filter -article.id # id of record -article.from # creator of article -article.to # recipients of article -article.subject # article subject -article.body # text of message -article.content_type # text/plain or text/html of .body -article.type # 'note' -article.sender # 'Customer' -article.created_at # '2022-01-01T12:42:01Z' -article.attachments.each { |attachment| - attachment.filename # '122.146472496@www.znuny.com' - attachment.size # 1167 - attachment.preferences # { :'Mime-Type'=>'image/jpeg', :'Content-ID'=>'122.146472496@www.znuny.com', :'Content-Disposition'=>'inline'} } - attachment.download # content of attachment / extra REST call will be executed -} +`find` on a resource takes an id. On a collection it is `Enumerable#find`, which takes a +block — and its argument is an ifnone callable, not an id, so passing one is refused rather +than answered with an Enumerator and no request: -p "article: #{article.from} - #{article.subject}" +```ruby +client.ticket.find(1) # the lookup by id +client.ticket.all.detect { ... } # the block form +client.ticket.all.find { ... } # the same +client.ticket.all.find(1) # ArgumentError, naming both of the above ``` -## Testing +## Deriving clients -### Setup an (empty Zammad) test env +A client is immutable. `with` returns a new one with some options changed, re-validating +them and carrying over any `on_behalf_of` scope: +```ruby +bulk = client.with(timeout: 300, retries: 5) +bulk.ticket.all.each { |ticket| archive(ticket) } ``` -git clone git@github.com:zammad/zammad.git -cd zammad -export RAILS_ENV="test" -export APP_RESTART_CMD="bundle exec rake zammad:ci:app:restart" -script/bootstrap.sh && echo '' > log/test.log -cp contrib/auto_wizard_test.json auto_wizard.json -bundle exec rake zammad:ci:test:start + +Because nothing is mutated after construction, one client — and any client derived from it — +is safe to use from several threads at once. + +## Acting on behalf of another user + +As described in the [Zammad API documentation](https://docs.zammad.org/en/latest/api/intro.html#actions-on-behalf-of-other-users), +actions can be performed on behalf of another user. `on_behalf_of` returns a **new** +client, so the original is unaffected and both are safe to use concurrently. + +```ruby +support = client.on_behalf_of('agent@example.com') +support.ticket.create(title: 'Help', group: 'Users', customer_id: 1) ``` -### Execute client tests +Or scoped to a block: -Run tests via `rake spec`. (Remember to export the vars above if you are running this in another shell.) +```ruby +client.on_behalf_of('agent@example.com') do |scoped| + scoped.ticket.find(1) +end +``` -## Publishing +The identifier can be a login, an email address or a user id. This sends the standard +HTTP `From` header and requires Zammad 5.0 or later. + +## Error handling + +Every error descends from `ZammadAPI::Error`. + +```text +ZammadAPI::Error +├── ZammadAPI::ConfigurationError invalid client options +├── ZammadAPI::UnknownResourceError no such resource, e.g. client.unicorn +├── ZammadAPI::ParseError unexpected response shape +├── ZammadAPI::PaginationError endpoint ignored the page parameter +├── ZammadAPI::TransportError +│ ├── ZammadAPI::ConnectionError unreachable host or TLS failure +│ └── ZammadAPI::TimeoutError exceeded timeout or open_timeout +└── ZammadAPI::ResponseError carries the HTTP response + ├── ZammadAPI::ClientError 4xx + │ ├── ZammadAPI::AuthenticationError 401 + │ ├── ZammadAPI::AuthorizationError 403 + │ ├── ZammadAPI::NotFoundError 404 + │ ├── ZammadAPI::ValidationError 422 + │ └── ZammadAPI::RateLimitError 429 + └── ZammadAPI::ServerError 5xx +``` -1. Update version in [version.rb](lib/zammad_api/version.rb). -2. Add release to [CHANGELOG.md](CHANGELOG.md) -3. Commit. -4. Test build. +```ruby +begin + client.ticket.find(1) +rescue ZammadAPI::NotFoundError + nil +rescue ZammadAPI::RateLimitError => e + sleep(e.retry_after || 5) + retry +rescue ZammadAPI::ResponseError => e + warn "#{e.status}: #{e.server_message}" + warn e.body.inspect +end +``` + +`ResponseError` exposes `status`, `body`, `headers`, `server_message`, `operation` and +`resource_class`. A proxy that returns an HTML error page instead of JSON produces a +`ServerError` describing the status, not a JSON parse failure. + +### Timeouts and retries + +Idempotent requests (`GET`, `PUT`, `DELETE`) are retried on connection failures, timeouts +and the transient statuses 429, 500, 502, 503 and 504, with exponential backoff. `POST` is +never retried, so a failed create cannot silently produce duplicate records. + +```ruby +client = ZammadAPI::Client.new( + url: 'https://zammad.example.com/', + http_token: 'token', + timeout: 10, + retries: 5 +) +``` + +## Logging + +```ruby +client = ZammadAPI::Client.new( + url: 'https://zammad.example.com/', + http_token: 'token', + logger: Logger.new($stdout) +) +``` + +Requests, response statuses and durations are logged at `debug` level. Payload keys that +look like credentials (`password`, `token`, `secret`, ...) are redacted. + +## Testing code that uses this client + +`zammad_api/test` ships a stand-in Zammad, so your own tests need no HTTP interception: + +```ruby +require 'zammad_api/test' + +RSpec.describe TicketCloser do + let(:zammad) { ZammadAPI::Test.new } + + it 'closes the ticket' do + zammad.stub(:get, 'api/v1/tickets/1', body: {id: 1, title: 'Help', state: 'open'}) + zammad.stub(:put, 'api/v1/tickets/1', body: {id: 1, state: 'closed'}) + + described_class.new(zammad.client).close(1) + + expect(zammad.requests.last.verb).to eq(:put) + expect(zammad.requests.last.body).to eq({state: 'closed'}) + end +end ``` -> rake build -zammad_api 1.0.7 built to pkg/zammad_api-1.0.7.gem. + +`zammad.client` is a real `ZammadAPI::Client`, so responses come back through the same +decoding, error mapping and record building as real ones — a stub with `status: 404` +raises `NotFoundError`, and one with `status: 422` makes `save` return `false`. + +| Method | What it does | +| ------ | ------------ | +| `stub(verb, path, status:, body:, headers:, query:)` | Declares a response. Stubbing the same endpoint twice with the same scope describes a sequence; the last stub answers every later request. `query:` matches a subset, so it need not repeat `expand`, `page` or `per_page`. | +| `client` | A client wired to this stand-in. | +| `requests` | Every request made, oldest first, as `verb` / `path` / `query` / `body` / `headers` / `on_behalf_of`. | +| `reset` | Forgets the stubs and the recorded requests. | + +A `Hash` or `Array` body is served as JSON and gets `content-type: application/json` +unless `headers:` says otherwise — and it is that header, not the Ruby type, that decides +whether the body comes back decoded, exactly as it does on the wire. So a `Hash` stubbed +as `text/html` comes back as the raw string and reading a record from it raises +`ParseError`, which is what Zammad would do. Header names and values are stringified and +names downcased, the way a real `Response` carries them; a value that is not text, or two +spellings of one header name, are refused where the stub is written rather than turned +into something the wire could not send. + +`query` and `headers` are recorded through the real transport's own stringification, so +they hold what a request would have carried rather than the raw Ruby values — and a header +the wire would refuse is refused here too. `headers` names only what the call asked for; +the ones the client sets from its configuration are not in it, and an `on_behalf_of` scope +has a field of its own. + +A request that was not stubbed raises `ZammadAPI::Test::UnstubbedRequestError`, listing +what is stubbed, rather than answering with something empty. It is deliberately not a +`ZammadAPI::Error`: a forgotten stub means the test is wrong, not that Zammad refused +something, so a `rescue ZammadAPI::Error` in the code under test lets it through instead of +reporting it as an API failure. + +### Stubbing a list endpoint + +One stub is enough for a collection that fits in a page. The records it holds are one page +of them, so a request for any later page is answered the way an endpoint out of records +would answer it: + +```ruby +zammad.stub(:get, 'api/v1/groups', body: [{id: 1, name: 'Users'}, {id: 2, name: 'Sales'}]) + +client.group.all.map(&:name) # => ["Users", "Sales"] ``` -5. Release + +Name a `page` to say what each one holds, and the stub is served exactly as written: + +```ruby +zammad.stub(:get, 'api/v1/groups', body: first_hundred, query: {page: 1}) +zammad.stub(:get, 'api/v1/groups', body: [{id: 101}], query: {page: 2}) +``` + +### Scopes that overlap + +A stub naming `query:` answers ahead of one that does not, so a scoped stub and a catch-all +are two separate answers rather than a sequence: + +```ruby +zammad.stub(:get, 'api/v1/tickets/search', body: {total_count: 42}, query: {only_total_count: true}) +zammad.stub(:get, 'api/v1/tickets/search', body: [{id: 1}]) + +client.ticket.search('urgent').count # => 42, however often it is asked ``` -> rake release -zammad_api 1.0.7 built to pkg/zammad_api-1.0.7.gem. -Tag v1.0.7 has already been created. -Pushing gem to https://rubygems.org... -You have enabled multi-factor authentication. Please enter OTP code. -Code: ...... -Successfully registered gem: zammad_api (1.0.7) -Pushed zammad_api 1.0.7 to https://rubygems.org +Two stubs whose scopes match a request equally well raise +`ZammadAPI::Test::AmbiguousStubError` rather than one of them being picked: `count` sends +the search term and `only_total_count` together, so a records stub scoped to +`{query: 'urgent'}` matches it just as well as the count stub does. Name what tells the +requests apart — here, the search term on the count stub too — or leave the more general +one unscoped as above. + +## Type signatures + +RBS signatures ship in `sig/` and are checked in CI with [Steep](https://github.com/soutaro/steep). +Add the gem to your own RBS collection to type-check calls into this client. + +## Examples + +Runnable scripts covering pagination, pattern matching, acting on behalf of a user, +attachments, error handling and threaded use live in [`examples/`](examples/README.md). + +## Development + +```sh +bin/setup # or: bundle install +bundle exec rake # unit specs, RuboCop and Steep ``` -## Contributing +| Task | What it does | +| ------------------------ | ----------------------------------------------------- | +| `rake spec:unit` | Unit specs; stubbed, no Zammad needed | +| `rake spec:integration` | Integration specs against a live Zammad | +| `rake check_connection` | Drives a live Zammad end to end and prints a transcript | +| `rake rubocop` | Style checks | +| `rake steep` | Type-check `lib/` against `sig/` | + +Set `COVERAGE=true` to produce a coverage report in `coverage/`. + +### Testing against a live Zammad + +The integration specs and `check_connection` need a reachable Zammad instance and **will +create and delete records**, so point them at something disposable: + +```sh +export TEST_URL=http://localhost:3000/ +export TEST_USER=admin@example.com +export TEST_PASSWORD=test + +bundle exec rake check_connection # one linear pass, readable transcript +bundle exec rake spec:integration # the full spec suite +``` -Bug reports and pull requests are welcome on [GitHub](https://github.com/zammad/zammad-api-client-ruby). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +`check_connection` walks the documented workflows in order — create, find, update, reload, +pattern match, paginate, search, ticket with articles, attachment download, acting on +behalf of a user, and each error class — printing `ok` or `FAIL` per step and cleaning up +after itself. It stops early if a precondition fails, so a broken instance produces one +clear line rather than a cascade. + +CI runs both against a Zammad booted from source: the `integration` job clones Zammad, +starts it, waits for it to answer, runs `check_connection` as a fast preflight, then runs +the integration specs. Trigger it by hand from the Actions tab (`workflow_dispatch`) to +test against a specific Zammad ref. + +## Migrating from 1.x + +Version 2.0 fixes long-standing behaviour that could not change without breaking +compatibility. Most calling code needs no edits, and almost everything that does raises +at the call site. Start with the handful of changes that do not. + +### Changes that do not announce themselves + +- **`record.attributes = {...}`** was a writer in 1.x. It is now an ordinary attribute + assignment, so it stages a change named `attributes` and `save` sends it to Zammad: + + ```ruby + group.attributes = {name: 'Support'} + group.changes # => {attributes: [nil, {name: "Support"}]} + ``` + + Use `assign_attributes(name: 'Support')`, or `update` to assign and save. + +- **`rescue Faraday::ConnectionFailed`** (and any other Faraday exception) no longer + matches. Transport failures are wrapped, so rescue `ZammadAPI::ConnectionError`, + `ZammadAPI::TimeoutError`, or `ZammadAPI::TransportError` for both. + +### The client + +| 1.x | 2.0 | Why | +| -------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------- | +| `Client.new(config_hash)` | `Client.new(**config_hash)` | Options are keyword arguments now, so a Hash has to be splatted | +| an unknown option was ignored | raises `ArgumentError: unknown keyword` | A typo'd option used to be dropped without a word | +| `logger: true` | `logger: Logger.new($stderr)` | The flag became an object, so you choose the device and level. Anything that answers `debug` is accepted; `true` raises `ConfigurationError` | +| `client.on_behalf_of = 'login'` | `client.on_behalf_of('login')` → new client | The setter mutated the client and leaked across threads | +| `client.perform_on_behalf_of('x') { }` | `client.on_behalf_of('x') { \|scoped\| ... }` | The old block form left the header set if the block raised | +| `ZammadAPI::Resources::Role < Base` reached by `client.role` | `client.get('api/v1/roles')` | Resources are a fixed list; [raw requests](#raw-requests) reach the rest | + +### Collections + +| 1.x | 2.0 | Why | +| ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------- | +| `collection.each` stopped after one page | `each` walks every page | Iterating truncated silently at the page size: 100 for `all`, 10 for `search` | +| `collection.page(1, 3) { \|r\| ... }` | `collection.page(1, of: 3).each { ... }` | `page` now returns a collection instead of mutating and yielding | +| `collection.page_next` / `page_prev` | `collection.page(n)` or `in_batches` | Removed; they mutated shared state | +| `collection.each_page { ... }` | `collection.in_batches { ... }` | Ruby already has a name for this | +| `collection[3]` | `collection.page(4, of: 1).first` | An index that costs a request, and that ignored `page`, was a trap | +| `collection.find(1)` | `client.x.find(1)`, or `collection.detect { ... }` | On a collection `find` is `Enumerable#find`, whose argument is an ifnone callable — so an id answered with an Enumerator and made no request. It raises now | +| `client.x.all(per_page: 50)` | `client.x.all.page(1, of: 50)`, `find_each(batch_size: 50)` | `all` accepted the argument and discarded it; page size belongs to the call that reads | +| `client.x.all(active: true)` | `client.x.search(...)`, or `client.x.all.detect { ... }` | The filter never reached the request in 1.x, and could not have: Zammad's index endpoints do not filter. `where` now raises instead of quietly returning everything. `find_by` needs a string value to search on, so it replaces `all(email: '...')` rather than `all(active: true)` | +| `client.x.search(query: 'zammad')` | `client.x.search('zammad')` | The search term is the argument, not a keyword | +| `client.x.search(query: 'z', page: 2, per_page: 50)` | `client.x.search('z').page(2, of: 50)` | Search did honour those two; paging is the collection's job now | + +### Records + +| 1.x | 2.0 | Why | +| ----------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------- | +| `record.save` raised on a rejection | `save` → `false` with `record.error`; `save!` raises | Branching on a rejected attribute needed a begin/rescue | +| `record.attributes[:x] = 1` | `record.x = 1`, or `record.to_h` for a copy | Writing through the reader staged no change, so `save` never sent it | +| `record.attributes = {...}` | `record.assign_attributes(...)` / `record.update(...)` | The writer is gone, and the name now stages an attribute of its own | +| `record.id = 5` | `client.x.find(5)` | The id addresses the record, so a staged one deleted or updated a different record than it reported | +| `record.new_instance` | `record.new_record?` / `record.persisted?` | Internal flag is no longer public; the old name raises `NoMethodError`, naming the attributes the record does carry | +| `resource.url` (instance) | `Resource.resource_path` (class) | Clashed with an attribute named `url` | +| `record.unknown_attribute` → `nil` | raises `NoMethodError`; use `record[:x]`, `fetch(:x, nil)` or `key?(:x)` | A typo read as `nil` and flowed on into whatever was written with it. Zammad also serves a reduced object where the user may not see the whole record, and that is worth being told about | +| `client.x.new(id: 5)` | `client.x.find(5)` | The constructor was the one door that did not refuse a staged id — and the only one whose value reached Zammad, since a new record is sent in full | +| `client.user.find(ticket.customer_id)` | `ticket.related.customer` | Following a foreign key needed the client threaded through | + +### Errors + +| 1.x | 2.0 | Why | +| ------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| `ZammadAPI::Error < RuntimeError` | `ZammadAPI::Error < StandardError` | `RuntimeError` is for `raise "string"` | +| `ZammadAPI::ResourceNotFoundError` | `ZammadAPI::UnknownResourceError` | Renamed so it is not confused with a 404, now `NotFoundError` | +| `ClientError` for every 4xx | `AuthenticationError`, `NotFoundError`, `ValidationError`, … | All still `ClientError`, so existing rescues keep working | +| a Faraday exception for a dead host | `ZammadAPI::ConnectionError` / `TimeoutError` | Every failure this gem can raise descends from `ZammadAPI::Error` | +| `error.response` was a Faraday object | `ZammadAPI::Response` with `status`/`body`/`headers` | Faraday is no longer part of the public surface | +| `error.body` was a raw JSON string | decoded Hash, or the raw body for non-JSON | Saves every caller from parsing it again | + +### Removed constants, and the Ruby version + +| 1.x | 2.0 | Why | +| ------------------------------------------------ | -------------------------- | ------------------------------------------------------------ | +| `ZammadAPI::ListBase` / `ListAll` / `ListSearch` | `ZammadAPI::Collection` | One class instead of three | +| `ZammadAPI::Dispatcher` | `ZammadAPI::ResourceProxy` | Renamed; `client.` hands you one | +| `ZammadAPI::Log`, `ZammadAPI::JsonHelper` | removed | Pass any `Logger` as `logger:`; decoding moved into the transport | +| Ruby >= 3.0 | Ruby >= 3.4 | 3.0 through 3.3 are end-of-life or nearly so | + +### Defaults 1.x did not have + +A collection fetches as many records per request as the endpoint serves — 1000 on the index +endpoints, 100 on tickets, 200 on a search — rather than a fixed 100, so a walk spends +roughly a tenth of the round trips. `page(n)` without `of:` is a page of that size, so pin +it with `page(n, of: 100)` if a persisted page number has to keep meaning what it did. + +A request now times out after 60 seconds (10 to connect) where 1.x waited as long as the +server took, so a call that used to hang raises `ZammadAPI::TimeoutError`. `GET`, `PUT` +and `DELETE` are retried twice with exponential backoff on connection failures, timeouts +and the transient statuses, which means a genuinely broken endpoint takes a little longer +to report itself; `POST` is never retried. Both are options — see +[Timeouts and retries](#timeouts-and-retries). The `User-Agent` is now +`zammad_api-ruby/` rather than `Zammad API Ruby`. + +### What did not change + +`client..find/all/create/new`, `record.destroy`, attribute readers and writers, +`ticket.articles`, `ticket.article`, and `attachment.download`. + +`record.save`, `record.changes` and `record.attributes` still exist and still mean what +they meant; the tables above only change how they behave at the edges. + +## License + +Dual licensed under the [AGPL-3.0-only](LICENSE.AGPL.txt) or [MIT](LICENSE.MIT.txt) +licenses. See [LICENSE.md](LICENSE.md). diff --git a/Rakefile b/Rakefile index 4c774a2..28f96e9 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,58 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' +require 'fileutils' require 'rspec/core/rake_task' +require 'rubocop/rake_task' + +namespace :spec do + desc 'Run the unit specs (no Zammad instance required)' + RSpec::Core::RakeTask.new(:unit) do |task| + task.pattern = 'spec/unit/**/*_spec.rb' + end + + desc 'Run the integration specs against a live Zammad (see TEST_URL)' + RSpec::Core::RakeTask.new(:integration) do |task| + task.pattern = 'spec/integration/**/*_spec.rb' + end +end + +desc 'Run all specs' +task spec: ['spec:unit', 'spec:integration'] + +desc 'Drive a live Zammad instance end to end with this gem (see TEST_URL)' +task :check_connection do + sh 'ruby script/check_connection.rb' +end + +RuboCop::RakeTask.new + +desc 'Type-check lib/ against the signatures in sig/' +task :steep do + sh 'bundle exec steep check' +end + +# sig/vendor stands in for dependencies that ship no signatures and is kept +# out of the gem, so the published set has to hold up without it. Naming a +# Faraday type in a published signature made `rbs validate` fail for every +# consumer, and nothing in this repo noticed, because sig/vendor is always +# on the load path here. +desc 'Check that the signatures the gem ships validate without sig/vendor' +task :rbs_published do + require 'tmpdir' -RSpec::Core::RakeTask.new(:spec) + Dir.mktmpdir do |dir| + published = Dir['sig/**/*.rbs'].grep_v(%r{\Asig/vendor/}) + published.each do |file| + target = File.join(dir, file) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp(file, target) + end + # `logger` is the only library the published signatures name, and it is a + # standard one RBS ships declarations for, so a consumer already has it. + sh "bundle exec rbs -r logger -I #{File.join(dir, 'sig')} validate" + end +end -task default: :spec +desc 'Run everything that does not need a Zammad instance' +task default: ['spec:unit', :rubocop, :steep, :rbs_published] diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..dfb6563 --- /dev/null +++ b/Steepfile @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +target :lib do + signature 'sig' + check 'lib' + + # socket is here for SocketError and openssl for OpenSSL::SSL::SSLError, + # both of which Transport maps to ConnectionError; forwardable is here for + # the collection shorthands ResourceProxy delegates to Collection. None of + # the three is named in the signatures the gem publishes - see SSL_ERRORS in + # sig/zammad_api/transport.rbs and sig/vendor/internal.rbs - so this does not + # add anything a consumer has to load. + library 'json', 'logger', 'timeout', 'socket', 'openssl', 'forwardable' + + configure_code_diagnostics do |hash| + # Default keyword-argument hashes such as `attributes = {}` cannot be + # annotated without hurting readability. + hash[Steep::Diagnostic::Ruby::UnannotatedEmptyCollection] = nil + end +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..dcbd7d1 --- /dev/null +++ b/bin/console @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'zammad_api' +require 'irb' + +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..bc8f0ad --- /dev/null +++ b/bin/setup @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +bundle install +bundle exec overcommit --install diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f36b9d2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,37 @@ +# Examples + +Runnable scripts showing how the 2.0 API works in a real project. Each one is +self-contained and builds its client with `ZammadAPI::Client.from_env`, which +reads the credentials from the environment: + +```sh +export ZAMMAD_URL=https://zammad.example.com/ +export ZAMMAD_TOKEN=your-access-token + +ruby examples/quickstart.rb +``` + +`ZAMMAD_USER` and `ZAMMAD_PASSWORD` work instead of a token, as does +`ZAMMAD_OAUTH2_TOKEN`. + +> These scripts **create, modify and delete records**. Point them at a +> disposable instance. + +None of them set a timeout or a retry policy: requests time out after 60s and +transient failures are retried with backoff out of the box, so the examples +show what is left for your own code to do. + +| Script | What it does | API features it shows | +| ------ | ------------ | --------------------- | +| [`quickstart.rb`](quickstart.rb) | Creates a ticket, reads it back, adds an article, updates it | The basics end to end | +| [`pagination.rb`](pagination.rb) | Reads a collection every available way | `each`, `find_each`, `in_batches`, `page(n, of: m)`, `where`, `count`, `empty?`, `lazy`, `first(n)`, collection immutability | +| [`manual_batches.rb`](manual_batches.rb) | Drives pagination by hand: pull-based, numbered, resumable | `in_batches` as an Enumerator (`next`, `with_index`), an explicit `page(n, of: m)` loop with a persisted cursor | +| [`ticket_report.rb`](ticket_report.rb) | Exports every ticket to CSV | Automatic pagination, `in_batches` batching, `fetch` for required attributes | +| [`triage_tickets.rb`](triage_tickets.rb) | Flags urgent tickets, nudges stale ones | `search`, `case/in` pattern matching on records, staged `changes` so only diffs are sent, `article` | +| [`onboard_customer.rb`](onboard_customer.rb) | Creates an organization, a user, and a welcome ticket raised as that user | `find_by`, `create`, `on_behalf_of` as a scoped client and as a block | +| [`download_attachments.rb`](download_attachments.rb) | Saves a ticket's attachments to disk | `articles`, attachment metadata, binary-safe `download` | +| [`error_handling.rb`](error_handling.rb) | Handles the failures that are yours to handle | The error hierarchy, `save` → `false` with `record.error`, rescuing by category, `client.with` for different retry settings | +| [`concurrent_sync.rb`](concurrent_sync.rb) | Syncs tickets with a worker pool sharing one client | Immutable clients are thread-safe; also sketches the Rails initializer pattern | + +The examples are linted along with the rest of the repository (`rake rubocop`), +so they cannot silently rot. diff --git a/examples/concurrent_sync.rb b/examples/concurrent_sync.rb new file mode 100755 index 0000000..9ad1d61 --- /dev/null +++ b/examples/concurrent_sync.rb @@ -0,0 +1,57 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Syncs tickets from a worker pool that shares a single client. +# +# A client is immutable once built, so one instance is safe to share between +# threads: no locking, no client per thread, and `on_behalf_of` scoping in one +# thread cannot leak into another. It is the same reason one client works as a +# Rails initializer constant used from every background worker: +# +# # config/initializers/zammad.rb +# ZAMMAD = ZammadAPI::Client.new( +# url: Rails.application.credentials.zammad_url, +# http_token: Rails.application.credentials.zammad_token, +# logger: Rails.logger +# ) +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/concurrent_sync.rb + +require 'zammad_api' + +WORKERS = 4 + +client = ZammadAPI::Client.from_env + +# Collect the work first; `first` stops paginating once it has enough. +queue = Queue.new +client.ticket.all.first(40).each { queue << it.id } +queue.close # so a worker draining an empty queue stops instead of blocking + +results = Queue.new + +workers = Array.new(WORKERS) do + Thread.new do + # Every worker shares this one client. Nothing about it is mutated by + # making a request, so there is nothing to synchronise. + while (id = queue.pop) + results << begin + client.ticket.find(id) + :ok + rescue ZammadAPI::Error => e + # Transient failures were already retried, so anything arriving here + # is worth reporting rather than trying again. + warn "ticket #{id}: #{e.class}" + :failed + end + end + end +end + +workers.each(&:join) + +tally = Hash.new(0) +tally[results.pop] += 1 until results.empty? + +puts "synced #{tally[:ok]}, failed #{tally[:failed]}" diff --git a/examples/download_attachments.rb b/examples/download_attachments.rb new file mode 100755 index 0000000..439d42c --- /dev/null +++ b/examples/download_attachments.rb @@ -0,0 +1,45 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Saves every attachment of a ticket to a directory. +# +# Shows walking articles, attachment metadata, and binary-safe downloads. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/download_attachments.rb 12345 ./downloads + +require 'zammad_api' +require 'fileutils' + +client = ZammadAPI::Client.from_env + +ticket_id = Integer(ARGV.fetch(0) { abort "usage: #{$PROGRAM_NAME} TICKET_ID [DIRECTORY]" }) +directory = ARGV.fetch(1, "ticket-#{ticket_id}") + +ticket = client.ticket.find(ticket_id) +FileUtils.mkdir_p(directory) + +# The filename comes from the server, so `File.basename` strips any directory +# part that would otherwise let `../` escape the download directory. The ids +# keep two same-named attachments from overwriting each other. +def safe_filename(article_id, attachment) + name = File.basename(attachment.filename.to_s) + name = 'attachment' if name.empty? || name.start_with?('.') + "#{article_id}-#{attachment.id}-#{name}" +end + +saved = 0 + +ticket.articles.each do |article| + article.attachments.each do |attachment| + # `download` returns the bytes as ASCII-8BIT, so images and archives + # survive intact. + contents = attachment.download + File.binwrite(File.join(directory, safe_filename(article.id, attachment)), contents) + saved += 1 + + puts format('%-40s %8d bytes', file: attachment.filename, size: contents.bytesize) + end +end + +puts saved.zero? ? "Ticket ##{ticket.number} has no attachments." : "Saved #{saved} file(s) to #{directory}/" diff --git a/examples/error_handling.rb b/examples/error_handling.rb new file mode 100755 index 0000000..c276cca --- /dev/null +++ b/examples/error_handling.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# What to rescue, and what the client has already handled for you. +# +# Timeouts, connection failures and the transient statuses (429, 500, 502, +# 503, 504) are retried with backoff on GET, PUT and DELETE before any error +# reaches your code, so what is left to handle is what only you can decide +# about: a missing record, a rejected attribute, bad credentials. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/error_handling.rb + +require 'zammad_api' + +# Options are validated up front, before any request is made. +begin + ZammadAPI::Client.new(url: 'not-a-url', http_token: 'x') +rescue ZammadAPI::ConfigurationError => e + puts "rejected early: #{e.message}" +end + +client = ZammadAPI::Client.from_env + +# A missing record is a decision to make, not a failure to retry. +ticket = begin + client.ticket.find(0) +rescue ZammadAPI::NotFoundError + nil +end + +puts "missing ticket: #{ticket.inspect}" + +# A rejected attribute (422) makes `save` return false and leaves the reason +# on the record, so branching needs no begin/rescue. +group = client.group.new(name: '') + +if group.save + puts "created group: #{group.id}" +else + puts "rejected save: #{group.error.status} #{group.error.server_message}" + puts " body #{group.error.body.inspect}" + puts " operation #{group.error.operation} on #{group.error.resource_class}" +end + +# `create` and `save!` raise instead, which is what a script wants. +begin + client.group.create(name: '') +rescue ZammadAPI::ValidationError => e + puts "raised instead: #{e.class}" +end + +# Rescue by category where the exact class does not matter. +begin + client.ticket.find(0) +rescue ZammadAPI::ClientError => e # any 4xx + puts "client error: #{e.status}" +rescue ZammadAPI::ServerError => e # any 5xx, including an HTML proxy page + puts "server error: #{e.status}" +rescue ZammadAPI::TransportError => e # never reached the server, retries spent + puts "transport error: #{e.message}" +end + +# Or catch everything this gem raises in one place. +begin + client.ticket.find(0) +rescue ZammadAPI::Error => e + puts "any gem error: #{e.class}" +end + +# Where the defaults do not suit the job, change them once on a derived +# client instead of writing a retry loop around every call. +patient = client.with(retries: 5, retry_interval: 1) + +puts "derived client: #{patient.config.retries} retries, original still #{client.config.retries}" diff --git a/examples/example_http_token.rb b/examples/example_http_token.rb deleted file mode 100755 index 5cf5803..0000000 --- a/examples/example_http_token.rb +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env ruby - -$LOAD_PATH << './lib' -require 'rubygems' -require 'zammad_api' - -client = ZammadAPI::Client.new( - url: 'https://you.zammad.com/', - http_token: 'XXXX', -) - -# create ticket -ticket = client.ticket.new( - title: 'some new title', - state: 'new', - priority: '2 normal', - owner: '-', - customer: 'nicole.braun@zammad.org', - group: 'Users', - article: { - sender: 'Customer', - type: 'note', - subject: 'some subject', - content_type: 'text/plain', - body: "some body\nnext line", - } -) -ticket.save - -p '--------------------------------------------------------' -p "Ticket has been created: #{ticket.number} - #{ticket.title} at #{ticket.created_at}" -p " Attributes: #{ticket.attributes.inspect}" - -# get ticket -p '--------------------------------------------------------' -ticket = client.ticket.find(ticket.id) -p "Ticket found on server: #{ticket.number} - #{ticket.title} at #{ticket.created_at}" -p " Attributes: #{ticket.attributes.inspect}" - -# get articles of ticket -p '--------------------------------------------------------' -articles = ticket.articles -p "Total #{articles.length} articles" - -# create article -p '--------------------------------------------------------' -article = ticket.article( - type: 'note', - subject: 'some subject 2', - body: 'some body 2', -) -p "Article has been created: #{article.subject} at #{article.created_at}" -p " Attributes: #{article.attributes.inspect}" - -# get articles of ticket -p '--------------------------------------------------------' -articles = ticket.articles -p "Total #{articles.length} articles now" -p '--------------------------------------------------------' diff --git a/examples/manual_batches.rb b/examples/manual_batches.rb new file mode 100755 index 0000000..a8193b1 --- /dev/null +++ b/examples/manual_batches.rb @@ -0,0 +1,83 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Driving pagination yourself, for when the loop is not yours to own: a job +# that has to checkpoint and resume, or a producer feeding a queue. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/manual_batches.rb + +require 'zammad_api' +require 'fileutils' +require 'tmpdir' + +PER_PAGE = 50 +CURSOR = File.join(Dir.tmpdir, 'zammad_batch_cursor') + +client = ZammadAPI::Client.from_env +tickets = client.ticket.all + +# 1. Pull a page when you are ready for it ----------------------------------- +# +# `in_batches` without a block is an Enumerator: nothing is fetched until +# `next`, and each `next` costs exactly one request. + +puts '1. pulling two pages, leaving the rest unfetched' + +pages = tickets.in_batches(of: PER_PAGE) + +2.times do + puts " pulled #{pages.next.size} tickets" +rescue StopIteration + break +end + +# 2. Numbered batches -------------------------------------------------------- +# +# `with_index` is Ruby's own, and works for the same reason: an Enumerator. + +puts '2. every batch, numbered' + +tickets.in_batches(of: PER_PAGE).with_index do |batch, index| + puts " batch #{index}: #{batch.size} tickets" +end + +# 3. A resumable page loop --------------------------------------------------- +# +# Own the page number when the job has to survive being interrupted. A page +# shorter than the page size is the last one. + +puts '3. resumable page loop' + +page = File.exist?(CURSOR) ? Integer(File.read(CURSOR)) : 1 +puts " starting at page #{page}" + +loop do + batch = tickets.page(page, of: PER_PAGE).to_a + break if batch.empty? + + puts " page #{page}: #{batch.size} tickets" + + # Checkpoint once the batch is safely handled, so an interrupted run + # repeats a batch rather than skipping one. + File.write(CURSOR, page + 1) + break if batch.size < PER_PAGE + + page += 1 +end + +FileUtils.rm_f(CURSOR) + +puts <<~NOTE + + Which to reach for: + + each / find_each the loop is yours and runs to completion + in_batches you want one whole response at a time + in_batches.next you want to pull batches as a consumer is ready + in_batches.with_index you want the batches numbered as they arrive + page(n, of: m) the page number must be persisted, retried or skipped + + Pacing is not on this list: the client already backs off and retries a 429, + so a manual sleep loop only duplicates it. +NOTE diff --git a/examples/onboard_customer.rb b/examples/onboard_customer.rb new file mode 100755 index 0000000..71fb5bf --- /dev/null +++ b/examples/onboard_customer.rb @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Onboards a new customer: organization, user, and a welcome ticket raised as +# that user. +# +# Shows `find_by` for a lookup by attribute, `create`, and `on_behalf_of` both +# as a scoped client and as a block. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/onboard_customer.rb "Acme Inc" jane@acme.test Jane Doe + +require 'zammad_api' + +client = ZammadAPI::Client.from_env + +company, email, firstname, lastname = ARGV +abort "usage: #{$PROGRAM_NAME} COMPANY EMAIL FIRSTNAME LASTNAME" if [company, email, firstname, lastname].any?(&:nil?) + +# `find_by` searches and then checks the hits itself, because Zammad's index +# endpoints cannot filter. It returns a record that genuinely carries the +# attribute, or nil - never an unrelated one. +# +# What the search surfaces is Zammad's business, so a value it has not indexed +# is a record this will not find, and the `|| create` below would then make a +# second organization. Searching by hand has the same gap. +organization = client.organization.find_by(name: company) || + client.organization.create(name: company) + +puts "organization: #{organization.name} (id=#{organization.id})" + +user = client.user.find_by(email: email) || + client.user.create( + firstname: firstname, + lastname: lastname, + email: email, + organization_id: organization.id, + roles: ['Customer'] + ) + +puts "user: #{user.firstname} #{user.lastname} <#{user.email}> (id=#{user.id})" + +# `on_behalf_of` returns a new client rather than mutating this one, so the +# admin client stays unscoped and both remain safe to use. +as_customer = client.on_behalf_of(user.email) + +ticket = as_customer.ticket.create( + title: "Welcome, #{firstname}!", + group: 'Users', + customer: user.email, + article: { + subject: 'Getting started', + body: "Hi #{firstname},\n\nyour account is ready.", + type: 'note' + } +) + +puts "ticket: ##{ticket.number} raised as #{user.email}" + +# The block form scopes a single operation. +own_tickets = client.on_behalf_of(user.email) { |scoped| scoped.ticket.all.count } + +puts "the customer can see #{own_tickets} ticket(s) of their own" diff --git a/examples/pagination.rb b/examples/pagination.rb new file mode 100755 index 0000000..89d2844 --- /dev/null +++ b/examples/pagination.rb @@ -0,0 +1,65 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Every way to read a collection, and what each one costs. +# +# Collections are lazy: nothing is fetched until you iterate, and only the +# pages you actually consume are fetched. A request carries as many records as +# the endpoint serves - 100 for tickets, 200 for a search, 1000 for the other +# index endpoints - unless a call asks for another size. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/pagination.rb + +require 'zammad_api' + +client = ZammadAPI::Client.from_env +tickets = client.ticket.all # no request yet + +# Walk every record. Pages are fetched as the iteration reaches them, so only +# one page is ever held in memory. +seen = 0 +tickets.each { seen += 1 } +puts "each #{seen} tickets, one request per page" + +# The same walk with the page size chosen for the job at hand. +ids = [] +tickets.find_each(batch_size: 50) { ids << it.id } +puts "find_each #{ids.size} tickets, 50 per request" + +# One whole page per block call, for work that batches: an import, a bulk +# insert, a push onto a queue. +sizes = [] +tickets.in_batches(of: 50) { sizes << it.size } +puts "in_batches pages of #{sizes.inspect}" + +# Stop early and the remaining pages are never fetched. +puts "first(3) #{tickets.first(3).map(&:id).inspect}, one request, for three records" +puts "lazy.select #{tickets.lazy.select { it.state == 'open' }.first(2).map(&:id).inspect}" +puts "detect ##{tickets.detect { it.state == 'open' }&.number}, stops at the match" + +# One specific page, when you are driving the paging yourself. +puts "page(2, of: 10) #{tickets.page(2, of: 10).map(&:id).inspect}" + +# Narrowing by a value is `search`, not `where`: Zammad's index endpoints sort +# and page and drop every other parameter, so `where(state: 'open')` raises +# rather than handing back every ticket. Searches compose with all of the above. +puts "search #{client.ticket.search('state.name:open').first(5).size} open tickets" + +# Counting a search is one request, because Zammad can answer `only_total_count` +# without serving the records. An index endpoint has no answer for it and no +# total to report, so counting one means walking it. +puts "search.count #{client.ticket.search('state.name:open').count}, one request" +puts "empty? #{client.ticket.search('state.name:merged').empty?}, asks for a single record" + +# `search` and `page` return a new collection, so scoping one never disturbs +# the original. +puts "immutable #{tickets.page(2).equal?(tickets)}" + +puts <<~NOTE + + Upgrading from 1.x: `each` used to fetch a single page, so iterating a + collection silently stopped at 100 records. It now walks every page. Ask + for one page explicitly with `page(1, of: 100)` where that is what you + wanted. +NOTE diff --git a/examples/quickstart.rb b/examples/quickstart.rb new file mode 100755 index 0000000..5c54124 --- /dev/null +++ b/examples/quickstart.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# The basics, end to end: create a ticket, read it back, add an article and +# update it. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/quickstart.rb + +require 'zammad_api' + +client = ZammadAPI::Client.from_env + +puts "connected to Zammad #{client.version} as #{client.me.email}" + +# Create a ticket together with its first article. +ticket = client.ticket.create( + title: 'Cannot log in', + group: 'Users', + customer: 'nicole.braun@zammad.org', + article: { + subject: 'Cannot log in', + body: "Hi,\n\nmy password stopped working.", + type: 'note' + } +) + +puts "created ##{ticket.number} - #{ticket.title}" + +# Read it back. Associations come expanded, so these are plain attribute +# reads rather than further requests. +ticket = client.ticket.find(ticket.id) +puts "state #{ticket.state}, priority #{ticket.priority}, group #{ticket.group}" + +# Add another article. +ticket.article(subject: 'Update', body: 'Reset link sent.', type: 'note') +puts "#{ticket.articles.size} article(s)" + +# Assignments are staged, and `save` sends only what changed. +ticket.priority = '3 high' +puts "sending #{ticket.changes.inspect}" +ticket.save + +# Collections paginate themselves, and `first` stops as soon as it has enough. +client.ticket.search('state.name:open').first(5).each do |open_ticket| + puts "open: ##{open_ticket.number} #{open_ticket.title}" +end diff --git a/examples/ticket_report.rb b/examples/ticket_report.rb new file mode 100755 index 0000000..9f34d24 --- /dev/null +++ b/examples/ticket_report.rb @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Exports every ticket to CSV. +# +# Shows automatic pagination with `in_batches`, and `fetch` for an attribute +# that has to be there. The defaults carry a long export on their own: a +# request times out after 60s, and transient failures are retried with +# backoff before any error reaches this script. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/ticket_report.rb tickets.csv + +require 'zammad_api' +require 'csv' + +client = ZammadAPI::Client.from_env +destination = ARGV.fetch(0, 'tickets.csv') +exported = 0 + +# A spreadsheet reads a cell starting with =, +, -, @, tab or CR as a formula, +# and a ticket title is whatever the customer typed. An apostrophe keeps every +# exported cell text. +def csv_safe(value) + text = value.to_s + text.match?(/\A[=+\-@\t\r]/) ? "'#{text}" : text +end + +CSV.open(destination, 'w') do |csv| + csv << %w[id number title state priority group customer created_at] + + # Nothing is loaded until the block runs, and each call is one page. + client.ticket.all.in_batches(of: 100) do |tickets| + tickets.each do |ticket| + row = [ + ticket.fetch(:id), # raises KeyError if it is missing + ticket.number, + ticket.title, + ticket.state, # present because associations come expanded + ticket.priority, + ticket.group, + ticket.customer, + ticket.created_at + ] + + csv << row.map { csv_safe(it) } + end + + exported += tickets.size + warn "exported #{exported} tickets..." + end +end + +puts "Wrote #{exported} tickets to #{destination}" diff --git a/examples/triage_tickets.rb b/examples/triage_tickets.rb new file mode 100755 index 0000000..b00bf54 --- /dev/null +++ b/examples/triage_tickets.rb @@ -0,0 +1,48 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Triages open tickets: flags the urgent ones, nudges the stale ones. +# +# Shows `search`, pattern matching against records, staged changes so only +# what was modified is sent, and adding an article. +# +# ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=... \ +# ruby examples/triage_tickets.rb + +require 'zammad_api' +require 'time' + +STALE_AFTER = 7 * 24 * 60 * 60 # a week, in seconds + +client = ZammadAPI::Client.from_env +flagged = 0 +nudged = 0 + +# `first` stops paginating as soon as it has what it asked for. +client.ticket.search('state.name:open').first(200).each do |ticket| + # Records implement `deconstruct_keys`, so case/in works on them. + case ticket + in { priority: '3 high', owner_id: 1 } # 1 is Zammad's "-", i.e. unassigned + puts "unassigned and high priority: ##{ticket.number} #{ticket.title}" + flagged += 1 + + in { updated_at: String => updated } if Time.now - Time.parse(updated) > STALE_AFTER + puts "stale: ##{ticket.number} #{ticket.title}" + + ticket.priority = '3 high' + ticket.save # sends the one changed attribute, nothing else + + ticket.article( + subject: 'Automated follow-up', + body: "No activity for over #{STALE_AFTER / 86_400} days; priority raised.", + type: 'note', + internal: true + ) + nudged += 1 + + else + next + end +end + +puts "\n#{flagged} ticket(s) flagged, #{nudged} nudged." diff --git a/lib/zammad_api.rb b/lib/zammad_api.rb index 608477d..4e1a126 100644 --- a/lib/zammad_api.rb +++ b/lib/zammad_api.rb @@ -1,6 +1,20 @@ -require 'zammad_api/version' -require 'zammad_api/errors' -require 'zammad_api/client' +# frozen_string_literal: true +require_relative 'zammad_api/version' +require_relative 'zammad_api/errors' +require_relative 'zammad_api/deep_copy' +require_relative 'zammad_api/config' +require_relative 'zammad_api/response' +require_relative 'zammad_api/transport' +require_relative 'zammad_api/attribute_access' +require_relative 'zammad_api/associations' +require_relative 'zammad_api/collection' +require_relative 'zammad_api/resources' +require_relative 'zammad_api/resource_proxy' +require_relative 'zammad_api/client' + +# Ruby client for the Zammad API v1.0. +# +# @see Client module ZammadAPI end diff --git a/lib/zammad_api/associations.rb b/lib/zammad_api/associations.rb new file mode 100644 index 0000000..5a20b35 --- /dev/null +++ b/lib/zammad_api/associations.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'transport' + +module ZammadAPI + # Readers for the records a record points at. + module Associations + # Fetches the records an association points at. + # + # Reached through +record.related+, never built directly. The readers live + # here rather than on the record itself because Zammad already expands an + # association into a name: +ticket.customer+ is the customer's login and + # +ticket.state+ is +"open"+, both free of charge. An association reader + # returns the whole record and costs a request, so it is worth telling the + # two apart at the call site. + # + # @example + # ticket = client.ticket.find(1) + # + # ticket.customer # => "customer@example.com", already loaded + # ticket.related.customer # => the User record, one request + # ticket.related.customer.email # => "customer@example.com" + # + # ticket.related.articles # => [TicketArticle, ...] + class Proxy + # @api private + # @param record [Resources::Base] + def initialize(record) + @record = record + @cache = {} + end + + def inspect + "#<#{Proxy.name} #{@record.class.name} id=#{@record.id.inspect} #{@record.class.associations.keys.join(', ')}>" + end + + private + + # Memoized: the record an id points at does not change under the caller, + # and an unmemoized reader would turn a loop over tickets into a request + # per ticket per mention. {Resources::Base#reload} drops the memo. + def belongs_to_target(name, class_name, foreign_key) + return @cache[name] if @cache.key?(name) + + id = @record[foreign_key] + # {Resources::Base.fetch_one} rather than a ResourceProxy built for the + # one call: {Client#setup} builds and freezes one proxy per resource so + # that callers get one proxy per resource, and a reader here that built + # a fresh one per association turned a walk over ten thousand tickets + # reading two associations each into twenty thousand objects that exist + # for a single method call. It is the read {ResourceProxy#find} makes + # too, so there is one of it rather than one here and one there. + @cache[name] = id.nil? ? nil : resolve(class_name).fetch_one(@record.transport, id) + end + + # Deliberately not memoized: a list can grow while the record is held, + # and +ticket.related.articles+ after +ticket.article(...)+ has to show + # the article that was just added. + # + # The path proc is handed the escaped id rather than the record, so that + # a declaration cannot paste a raw id into a path. One that did resolved + # `related.articles` on a record carrying `id: "1/../../users"` onto the + # users endpoint, the same way an unescaped {Resources::Base#member_path} + # used to - and a proc is exactly where that is easy to forget. + def has_many_target(name, class_name, path) + raise Error, "#{@record.class.name} has no id, so it has no #{name} to read; save it first" if @record.id.nil? + + target_class = resolve(class_name) + operation = "get #{name}" + + response = @record.transport.get( + path.call(Transport.escape_path_segment(@record.id)), + operation: operation, + resource_class: target_class, + query: { expand: true } + ) + records = response.decoded(:array, operation: operation, resource_class: target_class) + records.map { target_class.from_response(@record.transport, it) } + end + + # The target is named rather than referenced so that resources may point + # at each other without a load order between their files. The name comes + # from a declaration in this gem, never from a caller. + def resolve(class_name) = Resources.const_get(class_name, false) + end + end +end diff --git a/lib/zammad_api/attribute_access.rb b/lib/zammad_api/attribute_access.rb new file mode 100644 index 0000000..323d3c2 --- /dev/null +++ b/lib/zammad_api/attribute_access.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require 'json' + +require_relative 'deep_copy' + +module ZammadAPI + # Read access to a Zammad record's attributes. + # + # Zammad objects can carry administrator-defined custom attributes, so the + # set of readable attributes is not known ahead of time and is resolved + # through +method_missing+. A reader for an attribute the record does not + # carry raises +NoMethodError+; {#[]}, {#fetch} with a default, and {#key?} + # are the readers for an attribute that may be absent. + # + # This also carries the object protocols a record is expected to answer: + # {#==} and {#hash} identify a record by its id, {#deconstruct_keys} makes + # one matchable with +case/in+, and {#to_json} serializes its attributes. + module AttributeAccess + # Suffixes that mark a method call as a predicate or bang method rather + # than an attribute, so that typos like +save!+ still raise NoMethodError. + NON_ATTRIBUTE_SUFFIXES = %w[! ?].freeze + + # What a writer for an attribute is named: a plain identifier and an `=`. + # + # Ending in `=` is not enough, because Ruby's operators do too. `record[:x] + # = 1` reaches `method_missing` as `:[]=` and used to stage an attribute + # literally called `[]` whose value was the index - the write was lost, no + # error was raised, and the next `save` sent `{"[]": "x"}` to Zammad. + # `record <= 5` did the same for an attribute called `<`. + ATTRIBUTE_WRITER = /\A[a-zA-Z_]\w*=\z/ + private_constant :ATTRIBUTE_WRITER + + # All known attributes, deeply frozen. + # + # Writing through this hash would change what the record reports without + # staging a change, so the next +save+ would not send it. {#to_h} returns a + # copy that is safe to modify; an attribute writer is the way to stage one. + # + # @return [Hash{Symbol => Object}] + attr_reader :attributes + + # @param key [Symbol, String] + # @return [Object, nil] + def [](key) = attributes[key.to_sym] + + # Stages an attribute by name, the writer matching {#[]}. + # + # Defined rather than left to `method_missing`, which saw `:[]=` as a + # writer for an attribute called `[]` and staged the index as its value. + # A read-only record refuses this the way it refuses any other write, and + # {#respond_to?} says so before it is called. + # + # @param key [Symbol, String] + # @param value [Object] + # @return [Object] the staged value + # @raise [Error] when the attribute cannot be staged, such as +id+ + # @raise [NoMethodError] when the record is read-only + def []=(key, value) + write_attribute(key.to_sym, value) + end + + # @param key [Symbol, String] + # @param default [Object] returned instead of raising + # @yieldparam key [Symbol] called instead of raising + # @return [Object] + # @raise [ArgumentError] when more than one fallback was given + # @raise [KeyError] when the attribute is absent and no fallback was given + def fetch(key, *default) + # Hash#fetch refuses a third argument, and so does this: collecting the + # fallback with a splat and reading `default.first` accepted + # `fetch(:a, :b, :c)` - a multi-key read that this has never been - and + # answered it with `:b`. A method whose whole point is that a missing + # attribute is an error has no business swallowing a mistyped call. + raise ArgumentError, "wrong number of arguments (given #{default.size + 1}, expected 1..2)" if default.size > 1 + + # Hash#fetch warns for this and then ignores the default, so this does + # too rather than silently picking one of the two fallbacks a caller + # cannot have meant to pass together. + # + # `uplevel` so that this reads like the warning it mirrors: Hash#fetch + # names the line that made the call, and a bare Kernel#warn named + # nothing at all - neither the call site nor the library it came from, + # which in an application with several such calls is everything the + # reader needs. Kernel#warn writes the `warning: ` prefix itself when + # given `uplevel`, so the message must not carry its own. + # + # Nothing here has to consult $VERBOSE. Kernel#warn is already silent + # when warnings are off, so `ruby -W0` and `$VERBOSE = nil` quiet this + # the same way they quiet Hash#fetch's own warning. + warn('block supersedes default value argument', uplevel: 1) if block_given? && !default.empty? + + symbol = key.to_sym + # An explicit &block argument cannot be resolved against Hash#fetch's + # overloads by the type checker, so the block is forwarded with yield. + # rubocop:disable-next Style/ExplicitBlockArgument + return attributes.fetch(symbol) { |missing| yield(missing) } if block_given? + return attributes.fetch(symbol, default.first) if !default.empty? + + attributes.fetch(symbol) + end + + # @return [Boolean] + def key?(key) = attributes.key?(key.to_sym) + + # @return [Hash{Symbol => Object}] a deep copy of all attributes, safe to + # modify + def to_h = deep_dup(attributes) + + # @return [Integer, nil] + def id = attributes[:id] + + # Enables Ruby pattern matching against a record's attributes. + # + # @example + # case client.ticket.find(1) + # in {state: 'closed'} + # nil + # in {state: String => state, priority: '3 high'} + # escalate(state) + # end + # + # @param keys [Array, nil] the keys the pattern asks for + # @return [Hash{Symbol => Object}] + def deconstruct_keys(keys) = keys.nil? ? attributes : attributes.slice(*keys) + + # Whether +other+ is the same Zammad record: the same class, carrying the + # same id. + # + # A record with no id is equal only to itself, because two unsaved records + # are two records waiting to be created however alike their attributes + # are. That also means the first save of a record changes its {#hash}, so + # one used as a Hash key before being saved has to be rehashed after. + # + # @example + # client.ticket.find(1) == client.ticket.find(1) # => true + # [client.ticket.find(1), client.ticket.find(1)].uniq.size # => 1 + # + # @param other [Object] + # @return [Boolean] + def ==(other) + return true if equal?(other) + return false if !other.instance_of?(self.class) + + !id.nil? && other.id == id + end + alias eql? == + + # Consistent with {#==}, so that records can be deduplicated with +uniq+, + # collected in a +Set+ and used as Hash keys. + # + # The class is part of the digest because an id is only unique within one + # kind of record: ticket 1 and user 1 are different records. + # + # @return [Integer] + def hash + id.nil? ? super : [self.class, id].hash + end + + # The attributes, for a JSON encoder. + # + # Named the way ActiveSupport and its encoders expect, so that a record + # nested inside a structure being serialized renders as its attributes. + # + # @return [Hash{Symbol => Object}] + def as_json(*) = to_h + + # Without this, a record would serialize as its +to_s+, because that is + # what +Object#to_json+ falls back to. + # + # @example + # client.group.find(1).to_json # => "{\"id\":1,\"name\":\"Support\"}" + # + # @param state [JSON::State, nil] passed by +JSON.generate+ when a record + # is nested in a structure it is serializing + # @return [String] the attributes as a JSON object + def to_json(state = nil) = to_h.to_json(state) + + def method_missing(name, *args) + identifier = name.to_s + return super if NON_ATTRIBUTE_SUFFIXES.any? { identifier.end_with?(it) } + return write_attribute(identifier.delete_suffix('=').to_sym, args.first) if ATTRIBUTE_WRITER.match?(identifier) + return attributes[name] if attributes.key?(name) + + # An attribute the record does not carry used to read as nil, which made + # `ticket.titel` a silent nil that flowed on into whatever was written + # with it - and left this module the one place in the gem that answers a + # question it cannot answer instead of saying so. It also put + # `respond_to?` and the call itself at odds: `respond_to?(:titel)` was + # false and `method(:titel)` raised NameError while `ticket.titel` + # worked, so generic code that asks before it calls - a serializer, a + # delegator, `try` - was told the reader did not exist. + # + # Built rather than left to `super`, so that the message can name what + # to reach for instead, while `name` and `receiver` stay what a bare + # NoMethodError would have carried. + # + # The steep:ignore is for `name`: NameError.new takes a Symbol there and + # NoMethodError#name answers with one, while the core signature + # describes the parameter as a String. + raise NoMethodError.new(unknown_attribute_message(name), name, args, receiver: self) # steep:ignore ArgumentTypeMismatch + end + + # `[]=` is a defined method, so `respond_to_missing?` never sees it and a + # read-only record answered true for the one writer it has while answering + # false for every named one - then raised NoMethodError when it was called. + # That is the invariant the writer branch below is conditional for: generic + # code asks before it writes, and a record that claims a writer it would + # refuse leads it straight into the exception it was checking to avoid. + # + # Compared as a String, because `respond_to?` takes either spelling and + # Ruby does not normalise the argument before it reaches here. Compared to + # the Symbol alone, `respond_to?('[]=')` fell through to the definition + # and answered true on a record that refuses every write - the same wrong + # answer this method exists to prevent, reached by the other spelling. + # rubocop:disable-next Style/OptionalBooleanParameter -- Ruby's own signature + def respond_to?(name, include_private = false) + return writable_attributes? if name.to_s == '[]=' + + super + end + + def respond_to_missing?(name, include_private = false) + identifier = name.to_s + return false if NON_ATTRIBUTE_SUFFIXES.any? { identifier.end_with?(it) } + # Not an unconditional true: a read-only record that claimed a writer and + # then raised NoMethodError when one was called would defeat the point of + # asking, and lead generic code - serializers, form binders, + # assign_attributes loops - straight into the exception it was checking + # to avoid. + return writable_attributes? && attribute_writable?(identifier.delete_suffix('=').to_sym) if ATTRIBUTE_WRITER.match?(identifier) + + attributes.key?(name) || super + end + + private + + # Why an attribute is missing is worth saying, because the two reasons + # lead somewhere quite different: a name that is simply wrong, and a + # record Zammad served less of than it holds. The second is the same fact + # {Resources::Base#no_id_message} and {Resources::Base#write_attribute} + # are written around - Zammad reduces the object it serializes for a + # client whose user may not see the whole record - and it is not + # something the spelling of the call can show. + def unknown_attribute_message(name) + # Sorted by the name rather than by the key, because a key that could + # not become a Symbol is left as it arrived - {DeepCopy.symbolize} says + # so - and `[:name, 1].sort` raises, which would replace this message + # with an ArgumentError from inside the method that explains it. + carried = attributes.keys.sort_by(&:to_s).join(', ') + + "undefined attribute #{name} for #{self.class.name}. This record carries " \ + "#{attributes.empty? ? 'no attributes at all' : carried}. " \ + "Check the spelling, or read it with [#{name.inspect}] or fetch(#{name.inspect}, nil) where it may be absent. " \ + 'Zammad also serves a reduced object where the authenticated user may not see the whole record, so an ' \ + 'attribute the record has in Zammad can still be missing here - check what this client may read.' + end + + # Whether this record stages attribute writes, so that {#respond_to?} and + # calling a writer agree. + def writable_attributes? = false + + # Whether one particular attribute may be written. Overridden by records + # that refuse one: a record that claimed `id=` and then raised when it was + # called would defeat the point of asking, which is the whole reason the + # writer branch above is not an unconditional true. + def attribute_writable?(_key) = true + + # Overridden by writable records; read-only ones fall back to NoMethodError. + def write_attribute(key, _value) + raise NoMethodError, "#{self.class.name} attributes are read-only (tried to set #{key})" + end + + # Recursively converts string keys to symbols, including inside arrays, so + # that user supplied attributes behave the same as decoded responses, and + # freezes the result. See {DeepCopy} for why the walk lives there. + def frozen_attributes(value) = DeepCopy.frozen_copy(value, symbolize_keys: true) + + # The inverse of {#frozen_attributes}, for handing out a copy that callers + # may treat as their own. + def deep_dup(value) = DeepCopy.writable_copy(value) + end +end diff --git a/lib/zammad_api/client.rb b/lib/zammad_api/client.rb index b1d6c85..05c6aa9 100644 --- a/lib/zammad_api/client.rb +++ b/lib/zammad_api/client.rb @@ -1,65 +1,352 @@ -require 'forwardable' +# frozen_string_literal: true -require 'zammad_api/log' -require 'zammad_api/transport' -require 'zammad_api/dispatcher' -require 'zammad_api/resources' +require_relative 'config' +require_relative 'errors' +require_relative 'resource_proxy' +require_relative 'resources' +require_relative 'transport' module ZammadAPI + # The entry point of this gem. + # + # @example Token authentication + # client = ZammadAPI::Client.new( + # url: 'https://zammad.example.com/', + # http_token: 'your-access-token' + # ) + # client.ticket.find(1).title + # + # @example Basic authentication with a custom timeout and logger + # client = ZammadAPI::Client.new( + # url: 'https://zammad.example.com/', + # user: 'user@example.com', + # password: 'secret', + # timeout: 10, + # logger: Logger.new($stdout) + # ) class Client - extend Forwardable + # Maps the reader methods of this client to their resource classes. + RESOURCES = { + group: Resources::Group, + organization: Resources::Organization, + ticket: Resources::Ticket, + ticket_article: Resources::TicketArticle, + ticket_priority: Resources::TicketPriority, + ticket_state: Resources::TicketState, + user: Resources::User + }.freeze - def_delegators :@transport, :on_behalf_of, :on_behalf_of= + # Methods Ruby calls implicitly for type coercion. They must keep raising + # NoMethodError so that this object behaves normally in core operations. + CONVERSION_METHODS = %i[to_ary to_a to_hash to_str to_io to_proc coerce].freeze - def initialize(config) - @config = config - @logger = ZammadAPI::Log.new(@config) - @transport = ZammadAPI::Transport.new(@config, @logger) - check_config + # @return [Config] the validated configuration, with credentials redacted + # from its +inspect+ output + attr_reader :config + + # @!method group + # @return [ResourceProxy] proxy for {Resources::Group} + # @!method organization + # @return [ResourceProxy] proxy for {Resources::Organization} + # @!method ticket + # @return [ResourceProxy] proxy for {Resources::Ticket} + # @!method ticket_article + # @return [ResourceProxy] proxy for {Resources::TicketArticle} + # @!method ticket_priority + # @return [ResourceProxy] proxy for {Resources::TicketPriority} + # @!method ticket_state + # @return [ResourceProxy] proxy for {Resources::TicketState} + # @!method user + # @return [ResourceProxy] proxy for {Resources::User} + RESOURCES.each_key do |name| + define_method(name) { resource(name) } # steep:ignore NoMethod end - def perform_on_behalf_of(identifier) - self.on_behalf_of = identifier - yield.tap do |_| - self.on_behalf_of = nil + # Environment variables {.from_env} reads, mapped to the options they set. + ENV_OPTIONS = { + 'ZAMMAD_URL' => :url, + 'ZAMMAD_TOKEN' => :http_token, + 'ZAMMAD_HTTP_TOKEN' => :http_token, + 'ZAMMAD_OAUTH2_TOKEN' => :oauth2_token, + 'ZAMMAD_USER' => :user, + 'ZAMMAD_PASSWORD' => :password + }.freeze + + # Builds a client from the environment. + # + # Reads {ENV_OPTIONS}, so a script needs no configuration of its own. + # Anything passed in wins over the environment, and every other option + # keeps its default. An empty variable counts as unset, and + # +ZAMMAD_HTTP_TOKEN+ wins over +ZAMMAD_TOKEN+ if both are set. + # + # @example + # ZAMMAD_URL=https://zammad.example.com/ ZAMMAD_TOKEN=secret ruby report.rb + # + # client = ZammadAPI::Client.from_env + # client = ZammadAPI::Client.from_env(timeout: 300) # for one bulk job + # + # @param overrides [Hash] any option accepted by {Config} + # @return [Client] + # @raise [ConfigurationError] when neither the environment nor +overrides+ + # supply a URL and credentials + def self.from_env(**overrides) + from_environment = ENV_OPTIONS.filter_map do |name, option| + value = ENV.fetch(name, nil) + [option, value] if !value.to_s.empty? end + options = from_environment.to_h.merge(overrides) + + raise ConfigurationError, "missing url: set ZAMMAD_URL or pass url: to #{name}.from_env" if options[:url].to_s.empty? + + new(**options) end - def method_missing(method, *_args) - method = modulize(method.to_s) - class_name = "ZammadAPI::Resources::#{method}" - begin - class_object = Kernel.const_get(class_name) - rescue - raise ResourceNotFoundError, "Resource for #{method} does not exist" - end - ZammadAPI::Dispatcher.new(@transport, class_object) + # Builds a client from a {Config} that is already validated and a + # transport that is already built. + # + # The public constructor turns options into a Config and a Config into a + # Transport, and building a Transport means building a Faraday stack: + # authentication, JSON, the retry middleware, adapter resolution. Anything + # holding a transport of its own - {Test} - threw all of that away one + # line later, and paid for it again for every stand-in a suite builds. + # This is the way in for those, and the reason there is no public + # `with_transport` to swap one in after the fact. + # + # @api private + # @param config [Config] an already validated configuration + # @param transport [Transport] anything with a {Transport} interface + # @return [Client] + def self.build(config, transport) + allocate.send(:setup, config, transport) end - private + # @param options [Hash] see {Config} for every supported option + # @option options [String] :url base URL of the Zammad instance + # @option options [String] :http_token access token + # @option options [String] :oauth2_token OAuth2 token + # @option options [String] :user login for basic authentication + # @option options [String] :password password for basic authentication + # @raise [ConfigurationError] when the options are incomplete or invalid + def initialize(**options) + config = Config.new(**options) + setup(config, Transport.new(config)) + end - def check_config - raise ConfigurationError, 'missing url in config' if !@config[:url] - raise ConfigurationError, 'config url needs to start with http:// or https://' if !%r{^(http|https)://}.match?(@config[:url]) + # @param name [Symbol, String] a key of {RESOURCES} + # @return [ResourceProxy] + # @raise [UnknownResourceError] when the resource is not known + def resource(name) + resource_class = RESOURCES.fetch(name.to_sym) { raise UnknownResourceError, unknown_resource_message(name) } + @resources.fetch(resource_class) + end - # check for token auth - return if @config[:http_token] && !@config[:http_token].empty? - return if @config[:oauth2_token] && !@config[:oauth2_token].empty? + # @return [Array] every resource name this client supports + def resource_names = RESOURCES.keys - if !@config[:user] || @config[:user].empty? - raise ConfigurationError, 'missing user in config' - end + # The user these requests authenticate as, or the one {#on_behalf_of} + # scoped them to. + # + # @example Checking which account a token belongs to + # client.me.email # => "agent@example.com" + # + # @return [Resources::User] + # @raise [AuthenticationError] when the credentials are not valid + def me + response = @transport.get( + # Derived from the resource that declares the endpoint, not spelled + # out again: `member_path` exists for the same reason, and a path kept + # in two places is one that moves in one of them. Spelled out here, + # `client.user` would follow the users endpoint wherever it went and + # `client.me` would keep requesting the old one. + "#{Resources::User.resource_path}/me", + operation: 'find current user', + resource_class: Resources::User, + query: { expand: true } + ) + Resources::User.from_response( + @transport, + response.decoded(:object, operation: 'find current user', resource_class: Resources::User) + ) + end + + # The version of the Zammad instance, not of this gem — that is + # {ZammadAPI::VERSION}. + # + # @example + # client.version # => "6.4.0" + # + # @return [String, nil] nil when the instance reported no version + def version + response = @transport.get('api/v1/version', operation: 'get the Zammad version') + version = response.decoded(:object, operation: 'get the Zammad version')[:version] + version&.to_s + end + + # @!group Raw requests + + # Performs a +GET+ against any endpoint of the Zammad API. + # + # The resource classes cover a part of the API; these four methods reach + # the rest of it without giving up authentication, retries, credential + # redaction, JSON decoding or the error classes. + # + # +headers:+ is here because an escape hatch that cannot set one does not + # reach an endpoint that needs one. What it will not set is the two + # headers this client owns - see the +headers+ parameter below. + # + # @example An endpoint this gem does not model + # client.get('api/v1/roles').body + # # => [{id: 1, name: "Admin", ...}, ...] + # + # @example Reading a response header + # client.get('api/v1/tickets').headers['content-type'] + # + # @example An endpoint that needs a header of its own + # client.get('api/v1/tickets/1', headers: {'Accept-Language' => 'de-de'}) + # + # @param path [String] path relative to {Config#url}; a leading slash is + # ignored, so paths can be pasted from the Zammad documentation + # @param query [Hash, nil] query string parameters + # @param headers [Hash, nil] request headers. Names are case-insensitive, + # and +Authorization+ and +From+ are refused: the first is what the + # client's credentials are for, the second is what {#on_behalf_of} sets. + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @raise [TransportError] when the request could not be completed + # @raise [ArgumentError] for a header this client sets itself, or a value + # that is not text + def get(path, query: nil, headers: nil) = raw(:get, path, query: query, headers: headers) + + # Performs a +POST+ against any endpoint of the Zammad API. + # + # @example + # client.post('api/v1/tags/add', query: {object: 'Ticket', o_id: 1, item: 'urgent'}) + # + # @param path [String] path relative to {Config#url} + # @param query [Hash, nil] query string parameters + # @param body [Hash, Array, nil] request payload, encoded as JSON + # @param headers [Hash, nil] request headers + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @see #get + def post(path, query: nil, body: nil, headers: nil) = raw(:post, path, query: query, body: body, headers: headers) + + # Performs a +PUT+ against any endpoint of the Zammad API. + # + # @param path [String] path relative to {Config#url} + # @param query [Hash, nil] query string parameters + # @param body [Hash, Array, nil] request payload, encoded as JSON + # @param headers [Hash, nil] request headers + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @see #get + def put(path, query: nil, body: nil, headers: nil) = raw(:put, path, query: query, body: body, headers: headers) - return if @config[:password] && !@config[:password].empty? + # Performs a +DELETE+ against any endpoint of the Zammad API. + # + # @param path [String] path relative to {Config#url} + # @param query [Hash, nil] query string parameters + # @param headers [Hash, nil] request headers + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @see #get + def delete(path, query: nil, headers: nil) = raw(:delete, path, query: query, headers: headers) - raise ConfigurationError, 'missing password in config' + # @!endgroup + + # Returns a new client with some configuration options changed. + # + # The options are re-validated, and any {#on_behalf_of} scope is carried + # over. The original client keeps its own connection and settings. + # + # The transport derives from the current one rather than being built from + # scratch, so a client wired to a stand-in stays wired to it. + # + # @example A longer timeout for one bulk job + # bulk = client.with(timeout: 300, retries: 5) + # bulk.ticket.all.each { |ticket| archive(ticket) } + # + # @param options [Hash] any option accepted by {Config} + # @return [Client] + # @raise [ConfigurationError] when the resulting options are invalid + def with(**options) + derived_config = config.with(**options) + dup.send(:setup, derived_config, @transport.with_config(derived_config)) end - def modulize(string) - string.gsub(/__(.?)/) { "::#{$1.upcase}" } - .gsub(%r{/(.?)}) { "::#{$1.upcase}" } - .gsub(/(?:_+|-+)([a-z])/) { $1.upcase } - .gsub(/(\A|\s)([a-z])/) { $1 + $2.upcase } + # Performs requests on behalf of another user. + # + # Returns a new client rather than mutating this one, so the original + # client is unaffected and both can be used concurrently. + # + # @example Scoped client + # support = client.on_behalf_of('agent@example.com') + # support.ticket.create(title: 'Help', group: 'Users', customer_id: 1) + # + # @example Block form + # client.on_behalf_of('agent@example.com') do |scoped| + # scoped.ticket.find(1) + # end + # + # @param identifier [String, Integer] login, email address or user id + # @yieldparam scoped [Client] + # @return [Client] when no block is given, otherwise the block's value + def on_behalf_of(identifier) + scoped = dup.send(:setup, config, @transport.with_on_behalf_of(identifier)) + return scoped if !block_given? + + yield scoped + end + + def inspect = "#<#{self.class.name} url=#{config.redacted_url.inspect} auth=#{config.authentication_scheme}>" + + def method_missing(name, *args) + return super if CONVERSION_METHODS.include?(name) || name.to_s.end_with?('=', '!', '?') + + raise UnknownResourceError, unknown_resource_message(name) + end + + def respond_to_missing?(_name, _include_private = false) = false + + private + + def raw(method, path, query: nil, body: nil, headers: nil) + relative = Transport.relative_path(path) + + @transport.request( + method, + relative, + operation: "#{method.to_s.upcase} #{relative}", + query: query, + body: body, + headers: headers + ) + end + + def unknown_resource_message(name) = "Unknown resource #{name}, available resources are: #{RESOURCES.keys.join(', ')}" + + # Everything a client is, in the one place all four ways of making one go + # through: {.new}, {.build}, {#with} and {#on_behalf_of}. Held apart, + # `build` restated `initialize`'s list of instance variables by hand, and + # the fourth one added to `initialize` would have left every client built + # for a test unset where it mattered. + # + # The proxies are built here rather than memoized on first use, because a + # client is documented - and used, in examples/concurrent_sync.rb - as + # immutable once built and safe to share between threads without locking. + # A memo populated by the first `client.ticket` in each worker is a write + # to shared state, and while CRuby's GVL makes it harmless, a contract + # that only holds on one implementation is not the contract that was + # written down. Building all seven up front costs an allocation each and + # keeps the object finished the moment it is handed back. + # + # @return [self] + def setup(config, transport) + @config = config + @transport = transport + @resources = RESOURCES.values.to_h { [it, ResourceProxy.new(transport, it)] }.freeze + + self end end end diff --git a/lib/zammad_api/collection.rb b/lib/zammad_api/collection.rb new file mode 100644 index 0000000..2e6ca36 --- /dev/null +++ b/lib/zammad_api/collection.rb @@ -0,0 +1,548 @@ +# frozen_string_literal: true + +require_relative 'duplicate_keys' +require_relative 'errors' + +module ZammadAPI + # A lazily fetched, automatically paginated list of records. + # + # Nothing is requested until the collection is iterated. {#where} and + # {#page} return new collections, so a collection can be built up in + # steps and shared without being disturbed. + # + # {#each} walks every page until the server runs out of records, so it is + # safe to iterate a collection larger than one page. {#first} reads one page + # sized for what it was asked for, and +lazy+, +detect+ and the rest of + # +Enumerable+ stop the walk as soon as they have enough, so neither has to + # download everything. + # + # @example Iterate every ticket + # client.ticket.all.each { |ticket| puts ticket.title } + # + # @example Narrow to matches, then stop after the first five + # client.ticket.search('state.name:open').first(5) + # + # @example Work in batches, e.g. for an import + # client.ticket.all.in_batches(of: 50) { |tickets| import(tickets) } + # + # @example One explicit page + # client.ticket.all.page(2, of: 50).to_a + class Collection + include Enumerable + + # Query parameters this collection owns. Passing them to {#where} would be + # silently overridden, so they are rejected instead. + # + # +query+ is in the list because it is the search term {ResourceProxy#search} + # set: merging another one replaced it, so + # +search('login failure').where(query: 'anything')+ searched for + # "anything" and said nothing about it. + RESERVED_QUERY_KEYS = %i[page per_page expand only_total_count query].freeze + private_constant :RESERVED_QUERY_KEYS + + # @api private + # @param per_page [Integer, nil] records fetched per request, or nil for + # as many as the endpoint serves + # @param countable [Boolean] whether this endpoint answers + # +only_total_count+, which only a search endpoint does + def initialize(transport:, resource_class:, path:, operation:, max_per_page:, filterable:, filter_hint:, query: {}, per_page: nil, page: nil, countable: false) + @transport = transport + @resource_class = resource_class + @path = path + @operation = operation + @query = query + @max_per_page = max_per_page + @filterable = filterable + @filter_hint = filter_hint + # As many as the endpoint serves, unless a call asks for another size. + # + # A fixed default of 100 was the earlier answer, and it cost a request + # for every 100 records where the endpoint would have served 1000: a + # walk of the user index spent ten times the round trips it needed, and + # each of those is a TLS handshake of its own under Faraday's default + # adapter. The endpoint's own cap is the one number that is right for + # every resource without a caller looking each of them up - it is + # already what {#clamp_per_page} measures against. + # + # What that costs is a larger page held at once - a thousand expanded + # users rather than a hundred. {#first} is what made that affordable: + # the cheap-looking call that only wants a record or two now sizes its + # own page instead of taking them off the front of this one. + @per_page = clamp_per_page(per_page || max_per_page) + @page = page + @countable = countable + end + + # Yields every record, fetching further pages as needed. + # + # @yieldparam record [Resources::Base] + # @return [Enumerator] when no block is given + def each(&block) + return to_enum(:each) if !block + + in_batches { |records| records.each(&block) } + self + end + + # Yields every record, like {#each}, with the page size set inline. + # + # @param batch_size [Integer, nil] records fetched per request + # @yieldparam record [Resources::Base] + # @return [Enumerator] when no block is given + def find_each(batch_size: nil, &block) + return to_enum(:find_each, batch_size: batch_size) if !block + return with(per_page: page_size!(batch_size, 'batch_size')).find_each(&block) if batch_size + + each(&block) + end + + # Yields one array of records per page. + # + # A batch is one response: what a request returned is what the block gets, + # so +of+ is what sizes it. For groups of a size the API knows nothing + # about, slice the records instead: +find_each.each_slice(12)+. + # + # @param of [Integer, nil] records fetched per request + # @yieldparam records [Array] + # @return [Enumerator] when no block is given + def in_batches(of: nil, &block) + return to_enum(:in_batches, of: of) if !block + return with(per_page: page_size!(of, 'of')).in_batches(&block) if of + + walk(&block) + self + end + + # Returns a new collection limited to a single page. + # + # +of+ decides how big that page is, and so which records it holds: + # +page(2, of: 50)+ is records 51 to 100. A size larger than the endpoint + # serves is refused rather than reduced, because reducing it moves the + # page: +page(3, of: 500)+ against an endpoint capping at 100 was sent as + # +page=3&per_page=100+ and answered with records 201 to 300 instead of + # 1001 to 1500. A job that checkpoints a page number then re-read what it + # had already handled and never reached the rest. + # + # @example + # client.ticket.all.page(2, of: 50).to_a + # + # @param number [Integer] one-based page number + # @param of [Integer, nil] records on the page, as many as the endpoint + # serves by default + # @return [Collection] + # @raise [ArgumentError] for a page size the endpoint does not serve + def page(number, of: nil) + raise ArgumentError, 'page needs to be a positive integer' if !number.is_a?(Integer) || !number.positive? + return with(page: number) if of.nil? + + size = positive_integer!(of, 'of') + raise ArgumentError, "#{@path} serves at most #{@max_per_page} records per page, so page(#{number}, of: #{size}) would be sent as page #{number} of #{@max_per_page} and hold different records. Ask for page(#{number}, of: #{@max_per_page}) or fewer, or walk the records with find_each." if size > @max_per_page + + with(page: number, per_page: size) + end + + # Returns a new collection with additional query parameters applied. + # + # Only parameters the endpoint actually reads are accepted. Zammad drops + # the ones it does not know rather than refusing them, so + # +client.user.where(email: 'someone@example.com')+ used to come back as + # the whole user index and nothing said otherwise - the caller iterated + # every user believing they had matched one. An endpoint that cannot + # answer the question has to say so. + # + # @param params [Hash] query parameters the endpoint honours + # @return [Collection] + # @raise [ArgumentError] for a parameter this collection controls itself, + # or one the endpoint would ignore + # @see ResourceProxy#find_by for looking a record up by attribute value + def where(**params) + # Both lists below hold Symbols, while `**params` collects a String key + # just as happily. Unnormalised, `where('sort_by' => 'name')` failed the + # second check and reported that the endpoint "ignores sort_by ... That + # endpoint honours sort_by" in one breath, and `where('page' => 2)` + # missed the reserved-key check entirely and was refused with a message + # that never mentioned paging. + filters = normalized_filters(params) + + reserved = filters.keys & RESERVED_QUERY_KEYS + raise ArgumentError, "#{reserved.join(', ')} cannot be passed to where: use page, in_batches or find_each for paging, pass a search term to search, and leave expand and only_total_count to the collection" if !reserved.empty? + + ignored = filters.keys - @filterable + raise ArgumentError, ignored_message(ignored) if !ignored.empty? + + with(query: @query.merge(filters)) + end + + # Reads one or more attributes from every record. + # + # Zammad has no way to ask an index endpoint for a subset of the fields, so + # this shapes the result rather than shrinking the request. + # + # @example + # client.user.all.pluck(:email) # => ["a@example.com", ...] + # client.ticket.all.pluck(:id, :title) # => [[1, "Help"], ...] + # + # @param keys [Array] attribute names + # @return [Array] one value per record for a single key, one array of + # values per record for several + # @raise [ArgumentError] when no attribute name was given + def pluck(*keys) + raise ArgumentError, 'pluck needs at least one attribute name' if keys.empty? + return map { it[keys.first] } if keys.one? + + map { |record| keys.map { record[it] } } + end + + # The first record, or the first +count+ of them. + # + # Sized to what was asked for, which +Enumerable#first+ cannot be: it + # takes its records off the front of a page this collection sized for + # walking, so +all.first+ downloaded a page of a thousand users to hand + # back one of them. + # + # The request is sized, not the collection. Limiting it to one page of + # +count+ records would have been the shorter way to write this and + # answers wrongly where the endpoint serves a smaller page than the + # +max_per_page+ this resource declares: +first(500)+ against a server + # capping at 100 came back with 100 records and nothing to say that the + # other 400 were there to be read. Sizing the request instead leaves the + # walk able to fetch a second page, and +Enumerable#first+ stops it as + # soon as it has what it asked for - so the ordinary case is still the one + # request it looks like. + # + # A collection {#page} already limited is left as it is. Its page size + # says which records it holds, so re-sizing it would move them - the same + # reason {#page_size!} refuses to. + # + # @param count [Integer, nil] how many records to read + # @return [Resources::Base, Array, nil] one record, or an + # array of them when +count+ was given + def first(count = nil) + wanted = count || 1 + sized = own_request_for_first?(wanted) ? with(per_page: wanted) : self + # Through the enumerator rather than `super`, so that the sized + # collection does the reading and this method is not asked to be both + # the caller and the callee of Enumerable#first. + count.nil? ? sized.each.first : sized.each.first(count) + end + + # The first +count+ records, read the way {#first} reads them. + # + # +take(n)+ and +first(n)+ ask one question, and +Enumerable+ answers both + # by taking records off the front of a page this collection sized for + # walking. With {#first} sizing its own request and this one left alone, + # what the same read cost depended on which of the two words was typed. + # + # @param count [Integer] how many records to read + # @return [Array] + def take(count) + # Enumerable#take always answers with an Array, and {#first} only does + # when it is given a count - a nil is "just the one" there. Refused with + # the error Enumerable#take raises for it, rather than quietly answering + # a different question with a different type. + raise TypeError, 'no implicit conversion from nil to integer' if count.nil? + + first(count) + end + + # +Enumerable#find+, which takes a block. + # + # Defined only to refuse the other reading of it. {ResourceProxy#find} + # takes an id - +client.ticket.find(1)+ is the lookup by id - and the same + # word on a collection is +Enumerable#find+, whose argument is an ifnone + # callable rather than an id. So +client.ticket.all.find(1)+ made no + # request, raised nothing, and answered with an Enumerator: a silent + # no-op, on the one spelling a caller is most likely to reach for. + # + # A collection cannot do the lookup either, even where it would be + # unambiguous - {#where} and {#search} have already narrowed what it + # holds, so an id found through one of them would mean something different + # from an id found through another. + # + # @yieldparam record [Resources::Base] + # @return [Resources::Base, nil] + # @raise [ArgumentError] when given an id where a block belongs + def find(*args, &block) + raise ArgumentError, find_by_id_message(args.first) if block.nil? && !args.empty? + + super + end + + # Number of records in this collection. + # + # One request on a search, which Zammad can count without serving the + # records: +only_total_count+ is the first thing + # ApplicationController#model_search_render looks at, and it answers with + # the figure alone. + # + # Every other endpoint has to be walked. An index endpoint drops + # +only_total_count+ along with every other parameter it does not know - + # model_index_render reads +sort_by+, +order_by+ and the paging and + # nothing else - and there is no header to read a total from either, so + # there is nothing cheaper to ask. Probing anyway cost a wasted request + # before the walk that had to happen regardless. + # + # @return [Integer] + def count(*args, &block) + # A block or an argument is Enumerable counting matches, not this asking + # how large the result is. A collection limited to one page has to read + # that page, because the total describes the whole query. + return super if !args.empty? || block || @page || !@countable + + total_count || super + end + + # Number of records in this collection. + # + # An alias of {#count}, and so the same cost: one request on a search + # endpoint, a walk of every page on any other. + # + # @return [Integer] + # @see #count + alias size count + + # @return [Integer] + # @see #count + alias length count + + # Whether this collection has no records. + # + # Costs the one request {#first} costs: a page of a single record, except + # on a collection limited to one page, where the page size decides which + # records that page holds and so cannot be narrowed. + # + # @example + # client.ticket.search('state.name:merged').empty? + # + # @return [Boolean] + def empty? = first.nil? + + def inspect + "#<#{self.class.name} #{@resource_class.name} path=#{@path.inspect} per_page=#{@per_page}#{" page=#{@page}" if @page}>" + end + + private + + # Whether a read of this many records is worth sizing the request for. + # + # Not where this collection is already limited to a page, whose size says + # which records it holds, and not where the count is larger than the + # endpoint serves - {#clamp_per_page} would reduce it to the same size the + # collection already has. A zero or negative count is left to + # Enumerable#first, which has its own answers for both. + def own_request_for_first?(count) + return false if @page + + count.is_a?(Integer) && count.positive? && count <= @max_per_page + end + + def find_by_id_message(id) + "find on a #{self.class.name} is Enumerable#find, which takes a block: #{id.inspect} would be read as its " \ + 'ifnone argument and answered with an Enumerator, without a request. ' \ + "Look a record up by id on the resource itself - client.#{resource_name}.find(#{id.inspect}) - " \ + 'or pick one out of the records this collection holds with detect { ... }.' + end + + # The resource as a client names it: ZammadAPI::Resources::TicketArticle + # is reached as client.ticket_article. Derived rather than looked up in + # {Client::RESOURCES}, because a resource a caller subclasses themselves is + # in no list, and this is a sentence in an error message either way. + def resource_name + @resource_class.name.to_s.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase + end + + def ignored_message(ignored) + honoured = @filterable.empty? ? 'nothing beyond paging' : @filterable.join(', ') + + "#{@path} ignores #{ignored.join(', ')}, so where would hand back unfiltered records. " \ + "That endpoint honours #{honoured}. #{@filter_hint}" + end + + def positive_integer!(value, name) + raise ArgumentError, "#{name} needs a positive integer" if !value.is_a?(Integer) || !value.positive? + + value + end + + # Re-sizing the page of a collection that {#page} already limited would + # change which records it holds: `page(3, of: 50)` names records 101 to + # 150, and re-sizing to 10 behind the caller's back served records 21 to + # 30 instead - a different answer to the same question, with nothing said + # about it. The two ways of naming a page cannot both be honoured, so this + # says so rather than picking one, the way {#where} does. + def page_size!(value, name) + size = positive_integer!(value, name) + raise ArgumentError, "#{name} cannot be combined with page: page(#{@page}, of: #{@per_page}) already named which records this collection holds. Size that page with page(#{@page}, of: #{size}), or slice the records with each_slice(#{size})." if @page + + size + end + + def walk + page = @page || 1 + previous = nil + page_size = 0 + loop do + records, digest = fetch(page, @per_page) + + # Before the records are handed over, not after. Yielding first meant + # an endpoint that ignores `page` had its repeated page imported, + # queued or written by the block, and only then did the guard that + # exists to prevent that get to look at it. + # + # Whole payloads rather than ids: an endpoint that serves records + # without an id would compare two empty lists on every page and so + # report a perfectly good paginator as stuck. A digest rather than the + # payloads themselves, because holding the previous page across the + # next fetch doubled a walk's peak memory for a guard that only ever + # asks whether two pages are equal. + # + # The decoded payload, not the bytes it arrived as. Hashing raw_body + # is cheaper and looks equivalent - identical bytes do mean identical + # records - but the implication that matters here runs the other way: + # an endpoint that ignores `page` and re-serializes the same records + # with a different key order produces different bytes every time, so + # the guard never fires, and because every page is full the short-page + # break does not fire either. A missed repeat is not a missed error, + # it is a walk that never ends. `Hash#hash` + # ignores key order and whitespace, which is exactly the insensitivity + # this needs - and taking it from the decoded payload rather than from + # the built records costs nothing extra, because that payload is what + # the records were built from one line earlier. + raise PaginationError.build(operation: @operation, page: page, resource_class: @resource_class) if digest == previous + + yield records if !records.empty? + + # A collection limited to a single page never advances. + break if @page + break if records.empty? + + # Every stop condition here is derived from the records the endpoint + # actually served. There was one that was not - an index endpoint was + # believed to report the size of the whole result in a header, which + # would have ended a short walk one request earlier - and Zammad sends + # no such header from any endpoint, so it never once fired. What it + # did leave behind was a stop condition that could end a walk early on + # a figure the records did not corroborate, which is the one way a + # walk can be wrong rather than merely slow. + # + # How big a page this endpoint actually serves, learned from the first + # one rather than assumed. The requested size is clamped to a per- + # resource page limit, and where that guess was higher than the + # server's real cap - a lowered setting, a custom deployment, an + # endpoint whose cap was never this generous - every page came back + # short and the walk stopped on page one with a truncated result and + # nothing to show for it. + page_size = records.size if page_size.zero? + break if records.size < page_size + + previous = digest + page += 1 + end + end + + def with(page: @page, per_page: @per_page, query: @query) + self.class.new( + transport: @transport, + resource_class: @resource_class, + path: @path, + operation: @operation, + max_per_page: @max_per_page, + filterable: @filterable, + filter_hint: @filter_hint, + query: query, + per_page: per_page, + page: page, + countable: @countable + ) + end + + # Normalises the keys, refusing two spellings of one parameter rather than + # letting the normalisation merge them. + # + # Normalising is exactly what hid the collision: `sort_by` and `'sort_by'` + # became one key here, last one winning, and the request went out carrying + # a value the caller never saw dropped. The reserved-key and ignored-key + # checks in `where` read the collapsed hash too, so nothing else was going + # to notice either. + # + # Nested too, because `condition` - the structured parameter the search + # endpoints read, and one {ResourceProxy::SEARCH_QUERY_KEYS} lists so that + # `where` accepts it - is a Hash. {Transport.stringify_query} would refuse + # the pair eventually, but not until the collection is enumerated, and + # every other refusal `where` makes happens at the call that wrote it. + # + # @raise [ArgumentError] for two keys that name the same parameter + def normalized_filters(params, prefix = nil) + DuplicateKeys + .normalize(params, prefix: prefix) { it.to_s.to_sym } + .to_h { |name, value| [name, nested_filters(name, value, prefix)] } + end + + # A Hash filter is normalised the same way, one level down. + def nested_filters(name, value, prefix) + return value if !value.is_a?(Hash) + + normalized_filters(value, prefix ? "#{prefix}[#{name}]" : name.to_s) + end + + # Fetches one page and the digest the repeated-page guard compares. + # + # @return [Array(Array, Integer)] the records and the + # digest + def fetch(page, per_page) + response = @transport.get( + @path, + operation: @operation, + resource_class: @resource_class, + query: @query.merge(page: page, per_page: per_page) + ) + decoded = response.decoded(:array, operation: @operation, resource_class: @resource_class) + # The digest for the repeated-page guard is taken here, from the decoded + # payload, which is the one structure that has everything the guard needs + # and is already in hand. See the guard in `walk` for why it is this and + # not the records or the raw body. + [decoded.map { @resource_class.from_response(@transport, it) }, decoded.hash] + end + + # Asked only of a search endpoint, which is the only kind that answers + # +only_total_count+. Every one of them routes through + # model_search_render, which reads it before it reads anything else, so a + # shape other than the +{total_count: n}+ object means something has + # answered that is not the endpoint this was addressed to - a proxy error + # page, a login form - and the walk is the honest fallback. + # + # A negative figure is refused rather than trusted: it cannot describe a + # result, and a count is the one answer nothing downstream can sanity + # check - `Array.new(collection.count)` and `count.zero?` both take it at + # its word. + # + # @return [Integer, nil] nil when the endpoint did not report a total + def total_count + response = @transport.get( + @path, + operation: @operation, + resource_class: @resource_class, + query: @query.merge(only_total_count: true) + ) + return nil if !response.body.is_a?(Hash) + + total = response.body[:total_count] + total if total.is_a?(Integer) && !total.negative? + end + + # Reduced rather than refused, unlike the size {#page} takes. The + # asymmetry is deliberate: a batch size says how much to fetch at a time, + # so a smaller one costs more requests and still walks to the same last + # record, while {#page}'s size also decides which records the page holds, + # and reducing that answers a different question than the one asked. + # + # It is also what lets one batch size be written against resources that + # cap differently - the index endpoints allow 1000, tickets 100, a search + # 200 - without the caller looking each of them up. + def clamp_per_page(size) + raise ArgumentError, 'per_page needs to be a positive integer' if !size.is_a?(Integer) || !size.positive? + + [size, @max_per_page].min + end + end +end diff --git a/lib/zammad_api/config.rb b/lib/zammad_api/config.rb new file mode 100644 index 0000000..6218d98 --- /dev/null +++ b/lib/zammad_api/config.rb @@ -0,0 +1,354 @@ +# frozen_string_literal: true + +require 'logger' +require_relative 'errors' +require_relative 'version' + +module ZammadAPI + # Immutable, validated client configuration. + # + # Credentials are never included in {#inspect} output, so configuration + # objects are safe to log or attach to exception reports. + # + # @example + # ZammadAPI::Config.new(url: 'https://zammad.example.com/', http_token: 'secret') + # + # @!attribute [r] url + # @return [String] base URL, always with a trailing slash + # @!attribute [r] user + # @return [String, nil] login for basic authentication + # @!attribute [r] password + # @return [String, nil] password for basic authentication + # @!attribute [r] http_token + # @return [String, nil] Zammad access token + # @!attribute [r] oauth2_token + # @return [String, nil] OAuth2 bearer token + # @!attribute [r] user_agent + # @return [String] value of the +User-Agent+ request header + # @!attribute [r] timeout + # @return [Numeric] seconds to wait for a response + # @!attribute [r] open_timeout + # @return [Numeric] seconds to wait for the connection + # @!attribute [r] retries + # @return [Integer] retry attempts for idempotent requests + # @!attribute [r] retry_interval + # @return [Numeric] seconds before the first retry, doubling after that + # @!attribute [r] ssl_verify + # @return [Boolean] whether TLS certificates are verified + # @!attribute [r] proxy + # @return [String, nil] proxy URL + # @!attribute [r] adapter + # @return [Symbol, nil] name of the Faraday adapter, +nil+ for Faraday's + # default + # @!attribute [r] middleware + # @return [Proc, nil] called with the Faraday connection while it is + # being built + # @!attribute [r] logger + # @return [Logger] where debug output goes + Config = Data.define( + :url, + :user, + :password, + :http_token, + :oauth2_token, + :user_agent, + :timeout, + :open_timeout, + :retries, + :retry_interval, + :ssl_verify, + :proxy, + :adapter, + :middleware, + :logger + ) + + class Config + # Seconds to wait for a response before raising {TimeoutError}. + DEFAULT_TIMEOUT = 60 + + # Seconds to wait for the connection to be established. + DEFAULT_OPEN_TIMEOUT = 10 + + # How often an idempotent request is retried on a transient failure. + DEFAULT_RETRIES = 2 + + # Seconds to wait before the first retry; doubles on each attempt. + DEFAULT_RETRY_INTERVAL = 0.5 + + # Attributes whose values must never be rendered. + REDACTED_ATTRIBUTES = %i[password http_token oauth2_token].freeze + + # Placeholder rendered in place of a credential. + REDACTION = '[REDACTED]' + + # Identifies this gem in the +User-Agent+ header. + DEFAULT_USER_AGENT = "zammad_api-ruby/#{ZammadAPI::VERSION}".freeze + + SCHEME_PATTERN = %r{\Ahttps?://}i + + # An absolute http(s) URL, authority included. The host is part of the + # pattern because a bare scheme - +https://+ - passed a scheme-only check + # and was accepted here, then failed deep inside the adapter on the first + # request instead of at construction, which is the opposite of the + # up-front validation the rest of this class exists for. + URL_PATTERN = %r{\Ahttps?://[^/?\#]+}i + + # Where a URL stops being the part a trailing slash belongs on. + URL_SUFFIX_PATTERN = /[?\#]/ + + # The +user:password@+ part of a URL. A proxy URL carries its credentials + # inline, so {#inspect} has to blank them while keeping the host visible. + # + # Anchored on the last +@+ before the path rather than the first, because + # a password may carry an unencoded one: +pa@ss+ used to leave +@ss+ in + # the rendered URL, and a partly redacted credential still reaches every + # log and exception report the whole one was kept out of. + # + # Bounded by +?+ and +#+ as well as +/+, so that it stays inside the + # authority. Bounded only by the path, it crossed into a query string and + # read an +@+ there as a credential marker: +https://host?a=b@c+ rendered + # as +https://[REDACTED]@c+, a host that does not exist - printed in every + # ConnectionError and TimeoutError message and in {#inspect}, so the + # operator debugging an outage was shown the wrong instance. + # + # The scheme is optional, and matched rather than looked behind, because a + # proxy is configured without one as often as with: +u:p@proxy:8080+ is + # the shape an +http_proxy+ style setting is copied out of, and a + # +://+ lookbehind left that password in {#inspect} in full. + USERINFO_PATTERN = %r{\A(?[a-z][a-z0-9+.-]*://)?(?[^/?\#]+)(?=@)}i + + # Replacement that keeps the scheme and drops the credential. + USERINFO_REPLACEMENT = "\\k#{REDACTION}".freeze + + def initialize( + url:, + user: nil, + password: nil, + http_token: nil, + oauth2_token: nil, + user_agent: DEFAULT_USER_AGENT, + timeout: DEFAULT_TIMEOUT, + open_timeout: DEFAULT_OPEN_TIMEOUT, + retries: DEFAULT_RETRIES, + retry_interval: DEFAULT_RETRY_INTERVAL, + ssl_verify: true, + proxy: nil, + adapter: nil, + middleware: nil, + logger: nil + ) + # RBS cannot describe the initializer that Data.define generates, so + # these keyword arguments are invisible to the type checker. + # steep:ignore:start + super( + url: immutable(normalize_url(url)), + user: immutable(presence(user)), + password: immutable(presence(password)), + http_token: immutable(presence(http_token)), + oauth2_token: immutable(presence(oauth2_token)), + user_agent: immutable(presence(user_agent) || DEFAULT_USER_AGENT), + timeout: timeout, + open_timeout: open_timeout, + retries: retries, + retry_interval: retry_interval, + ssl_verify: ssl_verify, + proxy: immutable(normalize_proxy(proxy)), + adapter: normalize_adapter(adapter), + middleware: middleware, + logger: logger || Logger.new(IO::NULL) + ) + # steep:ignore:end + validate_credentials! + validate_numbers! + validate_user_agent! + validate_ssl_verify! + validate_middleware! + validate_logger! + end + + # The instance URL, with any inline credentials blanked. + # + # A URL may carry basic-auth credentials in its userinfo, and 1.x users + # who put them there rather than in +user:+ and +password:+ still do. The + # host has to stay readable for the URL to be worth printing, so the + # credentials are replaced rather than the whole value. + # + # @return [String] + def redacted_url = redacted(url) + + # Blanks the credentials in any URL this configuration holds. + # + # Public because {Transport} has to scrub the values it configured out of + # an error message raised from deep inside Faraday or URI, which quotes + # the proxy URL it rejected - credentials and all. + # + # @api private + # @param value [String] a URL that may carry inline credentials + # @return [String] the same URL with the credentials blanked + def redacted(value) = redact_userinfo(value) + + # @return [Symbol] +:http_token+, +:oauth2_token+ or +:basic+ + def authentication_scheme + return :http_token if http_token + return :oauth2_token if oauth2_token + + :basic + end + + # @return [String] configuration description with credentials redacted + def inspect + rendered = to_h.map { |key, value| "#{key}=#{render(key, value)}" } + "#" + end + alias to_s inspect + + private + + # @param value [String] a URL that may carry inline credentials + # @return [String] the same URL with the credentials blanked + def redact_userinfo(value) = value.sub(USERINFO_PATTERN, USERINFO_REPLACEMENT) + + def render(key, value) + return REDACTION if REDACTED_ATTRIBUTES.include?(key) && value + # Loggers and procs have verbose default inspect output that would drown + # out the rest of the configuration. + return "#<#{value.class}>" if key == :logger + return "#<#{value.class}>" if key == :middleware && value + return redacted_url.inspect if key == :url + return redact_userinfo(value).inspect if key == :proxy && value + + value.inspect + end + + # The type check comes before the pattern, because every check after it + # is a String method. A URI is the plausible mistake here - it is what + # `URI(...)` hands back and it prints as the URL - and it used to reach + # `end_with?` and die there as a NoMethodError, past the ConfigurationError + # a caller had wrapped the constructor in. + def normalize_url(value) + raise ConfigurationError, 'missing url in config' if presence(value).nil? + raise ConfigurationError, "config url needs to be a string, got #{value.class}" if !value.is_a?(String) + raise ConfigurationError, 'config url needs to start with http:// or https://' if !SCHEME_PATTERN.match?(value) + raise ConfigurationError, "config url needs a host after the scheme, got #{value.inspect}" if !URL_PATTERN.match?(value) + + # A trailing slash keeps Zammad installations served from a sub-path + # (e.g. https://example.com/zammad/) working, because request paths are + # appended relative to this prefix. + # + # Onto the path, not onto the end of the string. Appended blindly it + # landed behind a query string or fragment, so a url of + # `https://host/zammad?a=1` became `https://host/zammad?a=1/` - the base + # every request is resolved against, and the value {#redacted_url} + # prints in every ConnectionError and TimeoutError message. + path, separator, rest = value.partition(URL_SUFFIX_PATTERN) + path.end_with?('/') ? value : "#{path}/#{separator}#{rest}" + end + + def validate_credentials! + return if http_token || oauth2_token + + raise ConfigurationError, 'missing user in config' if user.nil? + raise ConfigurationError, 'missing password in config' if password.nil? + end + + # Numeric is not the whole test. Complex is one and answers neither + # `positive?` nor `>`, so `timeout: Complex(1, 2)` left a NoMethodError + # where every other rejected option raises ConfigurationError - the same + # escape normalize_adapter, normalize_proxy and validate_logger! were each + # written to close. + def positive_number?(value) = value.is_a?(Numeric) && value.respond_to?(:positive?) && value.positive? + + def validate_numbers! + { timeout: timeout, open_timeout: open_timeout, retry_interval: retry_interval }.each do |name, value| + raise ConfigurationError, "config #{name} needs to be a positive number" if !positive_number?(value) + end + + raise ConfigurationError, 'config retries needs to be a non-negative integer' if !retries.is_a?(Integer) || retries.negative? + end + + # Not just a default: `user_agent: nil` reached Faraday as a nil header, + # and Faraday filled in its own, so the gem stopped identifying itself in + # the instance log an operator greps to find its requests - silently, and + # on every request. + def validate_user_agent! + return if user_agent.is_a?(String) + + raise ConfigurationError, 'config user_agent needs to be a string' + end + + # A proxy is the second string here that may carry credentials, and the + # only other one rendered rather than replaced wholesale. Unchecked, a + # URI - what `URI(...)` hands back, and what prints as the URL - was + # accepted here and reached String#sub inside {#inspect}, so the object + # this class documents as safe to log raised NoMethodError at exactly the + # moment something tried to log it. The same mistake {#normalize_url} + # already refuses for the instance URL. + def normalize_proxy(value) + return nil if presence(value).nil? + raise ConfigurationError, "config proxy needs to be a string, got #{value.class}" if !value.is_a?(String) + + value + end + + # `adapter&.to_sym` accepted anything that answered to_sym and died with + # a bare NoMethodError on anything that did not - `adapter: 1` and + # `adapter: true` both escaped the ConfigurationError that building a + # client is documented to need, from inside the constructor, before any + # of the validators below ran. + def normalize_adapter(value) + return nil if presence(value).nil? + raise ConfigurationError, "config adapter needs to be a symbol or a string, got #{value.class}" if !value.is_a?(Symbol) && !value.is_a?(String) + + value.to_sym + end + + # The one option that fails open. Every other value here is checked up + # front, while ssl_verify was passed to Faraday as it arrived: read from + # an environment variable, `ssl_verify: 'false'` is the string "false", + # which is truthy, so the setting a caller believed they had turned off + # was silently still on. That direction is the safe one, which is why it + # went unnoticed - `ssl_verify: 'no'` disables nothing either way, but a + # caller who cannot tell which of their settings took effect has no way + # to find out. + def validate_ssl_verify! + return if [true, false].include?(ssl_verify) + + raise ConfigurationError, "config ssl_verify needs to be true or false, got #{ssl_verify.inspect}" + end + + def validate_middleware! + return if middleware.nil? || middleware.respond_to?(:call) + + raise ConfigurationError, 'config middleware needs to respond to call' + end + + # In 1.x this option was a flag, so `logger: true` is a plausible thing to + # carry over. Without this it would be accepted here and raise NoMethodError + # at the first request instead. + def validate_logger! + return if logger.respond_to?(:debug) + + raise ConfigurationError, 'config logger needs to respond to debug' + end + + def presence(value) + return nil if value.nil? + return nil if value.respond_to?(:empty?) && value.empty? + + value + end + + # Ruby leaves the members of a Data object mutable, so a caller-supplied + # String would otherwise stay writable through the config - and shared + # with the caller's own variable. Freezing a copy keeps a Config, and any + # Transport built from one, genuinely immutable. + # + # This deliberately copies rather than using String#-@: interning would + # keep a credential in the global fstring table for the life of the + # process, well past the config that held it. + def immutable(value) + value.is_a?(String) ? value.dup.freeze : value + end + end +end diff --git a/lib/zammad_api/deep_copy.rb b/lib/zammad_api/deep_copy.rb new file mode 100644 index 0000000..cc7e8db --- /dev/null +++ b/lib/zammad_api/deep_copy.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module ZammadAPI + # Recursive copies of the plain JSON structures this gem passes around. + # + # Three places need the same walk - a record freezing the attributes it was + # built with, a record handing a copy of them back, and the test kit + # recording the payload a request carried - and each used to carry its own. + # A rule written three times is one that gets fixed in one of them: the + # test kit's copy already differed by not symbolizing keys, and nothing + # said whether that was the point or an oversight. + # + # Only Hash, Array and String are walked, because that is the whole of what + # +JSON.parse+ builds and what a caller may hand in as attributes. Numbers, + # booleans and nil are immutable already and are passed through untouched. + # + # One walk, deliberately. A record built from a response copies every String + # of a body +JSON.parse+ has just built and nothing else holds, so a variant + # that froze such a body in place would save that copy on the paging hot + # path - which is a real cost, and still not a reason for a second walk: + # three of them is what this module replaced, and the one that differed did + # so without anything to say whether the difference was the point. If the + # copy is worth removing, it is an argument to this walk and it applies to + # the one place that can prove nothing else references the value. + # + # @api private + module DeepCopy + module_function + + # A frozen deep copy. + # + # Named for what it returns rather than `freeze`/`dup`, which would shadow + # the Object methods of those names inside this module and read as them at + # every call site. + # + # Freezing is what makes a record's attribute hash safe to hand out: one + # that gave away a writable hash would report changes it never staged and + # so never sent. Strings are copied before being frozen, so freezing a + # value the caller passed in does not reach back into their own variable. + # + # Keys are left alone. Ruby copies and freezes an unfrozen String key on + # insertion, so a Hash built here never holds the caller's own String, and + # symbolizing produces an immutable key anyway - which leaves only an + # Array or a custom object as a key that this walk would change. `JSON` + # cannot build one, so it could only come from a caller's own attribute + # hash, and paying a call per key on the record-building path for it is + # the cost this module's own note above weighs and declines. + # + # @param value [Object] + # @param symbolize_keys [Boolean] whether Hash keys become Symbols, so + # that caller-supplied attributes behave the same as decoded responses + # @return [Object] a frozen copy + def frozen_copy(value, symbolize_keys: false) + case value + when Hash then value.to_h { |key, nested| [symbolize_keys ? symbolize(key) : key, frozen_copy(nested, symbolize_keys: symbolize_keys)] }.freeze + when Array then value.map { frozen_copy(it, symbolize_keys: symbolize_keys) }.freeze + when String then value.dup.freeze + else value + end + end + + # A writable deep copy, for handing out something callers may treat as + # their own. The inverse of {frozen_copy}. + # + # @param value [Object] + # @return [Object] an unfrozen copy + def writable_copy(value) + case value + when Hash then value.to_h { |key, nested| [key, writable_copy(nested)] } + when Array then value.map { writable_copy(it) } + when String then value.dup + else value + end + end + + # A key that cannot become a Symbol is left as it is rather than refused: + # it came from a caller's attribute hash, and a record is not the place + # to decide that Zammad will not accept it. + # + # @api private + def symbolize(key) = key.respond_to?(:to_sym) ? key.to_sym : key + end +end diff --git a/lib/zammad_api/dispatcher.rb b/lib/zammad_api/dispatcher.rb deleted file mode 100644 index 07b311b..0000000 --- a/lib/zammad_api/dispatcher.rb +++ /dev/null @@ -1,12 +0,0 @@ -module ZammadAPI - class Dispatcher - def initialize(transport, resource) - @transport = transport - @resource = resource - end - - def method_missing(method, *args) - @resource.send(method, @transport, args[0]) - end - end -end diff --git a/lib/zammad_api/duplicate_keys.rb b/lib/zammad_api/duplicate_keys.rb new file mode 100644 index 0000000..5b46f69 --- /dev/null +++ b/lib/zammad_api/duplicate_keys.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module ZammadAPI + # One rule for two keys that name the same parameter. + # + # Both places that normalise caller-supplied parameter names had to refuse a + # collision, because normalising is what creates one: {Collection#where} maps + # every key to a Symbol and {Transport.stringify_query} maps every key to a + # String, so `sort_by` and `'sort_by'` become one key and the value that + # arrived first is dropped without a word. Written twice, the two refusals + # had already drifted into two different sentences for the same mistake. + # + # @api private + module DuplicateKeys + module_function + + # Maps every key through the block, refusing two that land on one name. + # + # The key each name was first seen as is kept, so the message can print + # both spellings: naming only the second left the reader to guess the + # first, which in a parameter hash assembled across several merges is the + # whole of the debugging. + # + # @param hash [Hash] + # @param prefix [String, nil] the parameter path this Hash sits at, for a + # nested structure + # @param noun [String] what the caller calls one of these, for the message + # @yieldparam key [Object] a key to normalise + # @return [Hash] the hash with normalised keys and untouched values + # @raise [ArgumentError] for two keys that name the same thing + def normalize(hash, prefix: nil, noun: 'parameter') + seen = {} + + hash.each_with_object({}) do |(key, value), result| + name = yield(key) + raise ArgumentError, message(noun, prefix ? "#{prefix}[#{name}]" : name, seen[name], key) if seen.key?(name) + + seen[name] = key + result[name] = value + end + end + + # @api private + def message(noun, path, first, second) + "#{noun} #{path} was given twice, as #{first.inspect} and as #{second.inspect}: " \ + 'pass it once, so that which value reaches the endpoint does not depend on Hash order' + end + end +end diff --git a/lib/zammad_api/errors.rb b/lib/zammad_api/errors.rb index 4e07e1d..c5bd351 100644 --- a/lib/zammad_api/errors.rb +++ b/lib/zammad_api/errors.rb @@ -1,62 +1,201 @@ -require 'json' +# frozen_string_literal: true module ZammadAPI - class Error < RuntimeError; end + # Base class for every error raised by this gem. + # + # Rescuing +ZammadAPI::Error+ catches all of them. + class Error < StandardError + # Formats the " ()" fragment shared by error + # messages, so every error reads the same way. + # + # @api private + # @param operation [String] + # @param resource_class [Class, nil] + # @return [String] + def self.subject_for(operation, resource_class) + resource_class ? "#{operation} (#{resource_class.name})" : operation + end + end + # Raised when {Config} cannot be built from the supplied options. class ConfigurationError < Error; end - class ResourceNotFoundError < Error; end + # Raised when a resource name is requested that this client does not know + # about, e.g. +client.unicorn+. + class UnknownResourceError < Error; end + + # Base class for failures that prevented a request from completing. + class TransportError < Error; end + + # Raised when the connection to the Zammad instance could not be + # established (DNS, refused connection, TLS failure, ...). + class ConnectionError < TransportError; end + + # Raised when a request exceeded {Config#open_timeout} or {Config#timeout}. + class TimeoutError < TransportError; end + # Raised when a response did not have the shape the caller expected. + class ParseError < Error + # @param operation [String] + # @param expected [Symbol, String] +:object+, +:array+, or a longer + # description of the shape that was wanted + # @param actual [Class, String] the class that was decoded instead, or a + # description of it + # @param resource_class [Class, nil] + # @return [ParseError] + def self.build(operation:, expected:, actual:, resource_class: nil) + new("Can't #{subject_for(operation, resource_class)}: expected a JSON #{expected}, got #{actual}") + end + end + + # Raised when the server answers a page request with the page before it, + # which means it is ignoring +page+ and a collection walk would never end. + class PaginationError < Error + # @param operation [String] + # @param page [Integer] the page that repeated its predecessor + # @param resource_class [Class, nil] + # @return [PaginationError] + def self.build(operation:, page:, resource_class: nil) + new("Can't #{subject_for(operation, resource_class)}: page #{page} repeated page #{page - 1}, so the endpoint is ignoring the page parameter") + end + end + + # Base class for errors carrying an HTTP response. + # + # Use {.build} rather than +new+ to get the most specific subclass for a + # given status code. class ResponseError < Error - attr_reader :response, :operation, :resource_class + # @return [Response, nil] the decoded HTTP response + attr_reader :response + + # @return [String] human readable description of what was attempted + attr_reader :operation + + # @return [Class, nil] the resource class involved, when applicable + attr_reader :resource_class - def initialize(operation:, response:, resource_class: nil) + # Returns the most specific error class for +response+ and instantiates it. + # + # @param response [Response, nil] + # @param operation [String] + # @param resource_class [Class, nil] + # @return [ResponseError] + def self.build(response, operation:, resource_class: nil) + error_class_for(response).new( + response: response, + operation: operation, + resource_class: resource_class + ) + end + + # @api private + def self.error_class_for(response) + return self if response.nil? + + STATUS_ERRORS.fetch(response.status) do + response.status >= 500 ? ServerError : ClientError + end + end + private_class_method :error_class_for + + # @param operation [String] what was attempted + # @param response [Response, nil] + # @param resource_class [Class, nil] + # @param detail [String, nil] replaces the part of the message that would + # otherwise describe the response, for a failure that has none + def initialize(operation:, response: nil, resource_class: nil, detail: nil) @operation = operation @response = response @resource_class = resource_class - super(default_message) + @detail = detail + super(build_message) end + # The status this error carries, or - for one this gem raised without a + # request, such as {ResourceProxy#find_by!} finding nothing - the status + # its class is the name for. A NotFoundError used to answer nil there, + # so `rescue NotFoundError => e; retry if e.status == 404` quietly stopped + # retrying for the one NotFoundError that came from this gem rather than + # from Zammad, while every other one carried 404. + # + # @return [Integer, nil] HTTP status code def status - response&.status + response&.status || STATUS_ERRORS.key(self.class) end + # @return [Hash, String, nil] parsed JSON body, or the raw body for + # non-JSON responses def body response&.body end - def self.from(response, **kwargs) - klass = if response.nil? - self - elsif response.status >= 500 - ServerError - else - ClientError - end - klass.new(response: response, **kwargs) + # @return [Hash] response headers, empty when there is no response + def headers + response&.headers || {} + end + + # @return [String, nil] the error message reported by Zammad, if any + def server_message + return nil if !body.is_a?(Hash) + + # Symbol keys only: both decode paths - Transport#decode_body and the + # test kit - parse with symbolize_names, so a string-keyed body cannot + # reach here and the fallbacks that read one were never run. + value = body[:error_human] || body[:error] + value.to_s.empty? ? nil : value.to_s end private - def default_message - subject = resource_class ? "#{operation} (#{resource_class.name})" : operation - "Can't #{subject}: #{detail}" + def build_message + "Can't #{Error.subject_for(operation, resource_class)}: #{detail}" end def detail - body_error || "HTTP #{status}" + @detail || server_message || (status ? "HTTP #{status}" : 'no response') end + end + + # Any 4xx response that has no more specific subclass. + class ClientError < ResponseError; end + + # Any 5xx response. + class ServerError < ResponseError; end + + # 401 - credentials missing, wrong, or expired. + class AuthenticationError < ClientError; end + + # 403 - authenticated, but not permitted to perform the operation. + class AuthorizationError < ClientError; end + + # 404 - the requested record does not exist. + class NotFoundError < ClientError; end - def body_error - return nil if body.to_s.strip.empty? + # 422 - Zammad rejected the submitted attributes. + class ValidationError < ClientError; end - parsed = JSON.parse(body) - parsed.is_a?(Hash) ? parsed['error'] : nil - rescue JSON::ParserError - nil + # 429 - too many requests. + class RateLimitError < ClientError + # @return [Integer, nil] value of the +Retry-After+ response header + def retry_after + # Downcased, because every {Response} is built with its header keys + # downcased - {Transport#decode} and {Test#stub} both do it - so there + # is no capitalised spelling left to fall back to. + value = headers['retry-after'] + return nil if value.nil? + + Integer(value, exception: false) end end - class ClientError < ResponseError; end - class ServerError < ResponseError; end + class ResponseError + STATUS_ERRORS = { + 401 => AuthenticationError, + 403 => AuthorizationError, + 404 => NotFoundError, + 422 => ValidationError, + 429 => RateLimitError + }.freeze + private_constant :STATUS_ERRORS + end end diff --git a/lib/zammad_api/json_helper.rb b/lib/zammad_api/json_helper.rb deleted file mode 100644 index fd8ca4c..0000000 --- a/lib/zammad_api/json_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -require 'json' - -module ZammadAPI - module JsonHelper - def safe_json_parse(string) - JSON.parse(string) - rescue JSON::ParserError - {} - end - end -end diff --git a/lib/zammad_api/list_all.rb b/lib/zammad_api/list_all.rb deleted file mode 100644 index 393c56a..0000000 --- a/lib/zammad_api/list_all.rb +++ /dev/null @@ -1,11 +0,0 @@ -require 'zammad_api/list_base' - -module ZammadAPI - class ListAll < ListBase - private - - def perform_request(parameter) - request('all', @url, parameter) - end - end -end diff --git a/lib/zammad_api/list_base.rb b/lib/zammad_api/list_base.rb deleted file mode 100644 index c8b692c..0000000 --- a/lib/zammad_api/list_base.rb +++ /dev/null @@ -1,84 +0,0 @@ -require 'zammad_api/json_helper' - -module ZammadAPI - class ListBase - include Enumerable - include ZammadAPI::JsonHelper - - def initialize(resource, transport, parameter = {}) - @resource = resource - @url = @resource.get_url - @transport = transport - @parameter = { - page: 1, - per_page: 10, - expand: 'true', - }.merge(parameter) - end - - def [](position) - local_parameter = @parameter.merge( - page: position + 1, - per_page: 1 - ) - perform_request(local_parameter)[0] - end - - def page(page, per_page, &block) - @parameter[:page] = page - @parameter[:per_page] = per_page - fetch_and_yield_each(&block) - end - - def page_next(&block) - @parameter[:page] += 1 - fetch_and_yield_each(&block) - end - - def page_prev(&block) - @parameter[:page] -= 1 - fetch_and_yield_each(&block) - end - - def each(&block) - fetch_and_yield_each(&block) - end - - private - - def fetch_and_yield_each(&block) - result = perform_request(@parameter) - result.each(&block) - end - - def request(request, url, parameter) - # convert parameters into a GET query - url += '?' + parameter.map { |key, value| - if !value.is_a? String - value = value.to_s - end - - "#{key}=#{CGI.escape value}" - }.join('&') - - response = @transport.get(url: url) - if response.status != 200 - raise ResponseError.from(response, operation: "get .#{request} of object", resource_class: @resource.class) - end - - data = safe_json_parse(response.body) - - list = [] - data.each do |local_data| - item = @resource.new(@transport, local_data) - item.new_instance = false - list.push item - end - list - end - - def perform_request(_parameter) - raise Error, "no perform_request implementation for #{self.class.name} found" - end - end -end diff --git a/lib/zammad_api/list_search.rb b/lib/zammad_api/list_search.rb deleted file mode 100644 index fac7f2b..0000000 --- a/lib/zammad_api/list_search.rb +++ /dev/null @@ -1,11 +0,0 @@ -require 'zammad_api/list_base' - -module ZammadAPI - class ListSearch < ListBase - private - - def perform_request(parameter) - request('search', "#{@url}/search", parameter) - end - end -end diff --git a/lib/zammad_api/log.rb b/lib/zammad_api/log.rb deleted file mode 100644 index b67ad26..0000000 --- a/lib/zammad_api/log.rb +++ /dev/null @@ -1,18 +0,0 @@ -module ZammadAPI - class Log - def initialize(config) - return if !config[:logger] - - require 'logger' - @logger = Logger.new($stderr) - #@logger.level = Logger::WARN - @logger.level = Logger::DEBUG - end - - def method_missing(method, *args) - return if !@logger - - @logger.send(method, args) - end - end -end diff --git a/lib/zammad_api/resource_proxy.rb b/lib/zammad_api/resource_proxy.rb new file mode 100644 index 0000000..97523ab --- /dev/null +++ b/lib/zammad_api/resource_proxy.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +require 'forwardable' + +require_relative 'collection' +require_relative 'errors' + +module ZammadAPI + # Entry point for working with one kind of Zammad record. + # + # Obtained from {Client}, e.g. +client.ticket+, and exposes the operations + # that are not tied to an individual record. + # + # @example + # client.group.find(1) + # client.group.create(name: 'Support') + # client.group.all.each { |group| puts group.name } + # client.group.all.where(sort_by: 'name').first(5) + # client.group.search('support').first + # + # A proxy is +Enumerable+ over {#all}, so the collection surface is reachable + # without naming it: + # + # @example + # client.group.each { |group| puts group.name } + # client.group.first(5) + # client.group.map(&:name) + # client.group.find_each(batch_size: 500) { |group| archive(group) } + # + # {#find} takes an id and is not +Enumerable#find+; +detect+ is still the + # block form. + class ResourceProxy + include Enumerable + extend Forwardable + + # Largest page size Zammad's search endpoints serve, from + # ApplicationController#model_search_render. + SEARCH_MAX_PER_PAGE = 200 + + # Query parameters Zammad's search endpoints honour, from the call + # ApplicationController#model_search_render makes into Model.search. Unlike + # an index endpoint a search does narrow, but through these parameters and + # the term - never through an attribute name of its own. + SEARCH_QUERY_KEYS = %i[condition ids role_ids group_ids permissions sort_by order_by].freeze + + # Said at the end of the error for a parameter an index endpoint ignores. + INDEX_FILTER_HINT = 'Zammad filters by attribute value only through a search endpoint, so use find_by for one record or search for many.' + + # The same, for a resource Zammad serves no search endpoint for, where + # find_by and search are not an answer either. + UNSEARCHABLE_FILTER_HINT = 'Zammad filters by attribute value only through a search endpoint, and routes none for this resource, so walk the records and pick with detect.' + + # The same, for a search endpoint. + SEARCH_FILTER_HINT = 'Put the value in the search term instead.' + + # @return [Class] the resource class this proxy operates on + attr_reader :resource_class + + # @api private + def initialize(transport, resource_class) + @transport = transport + @resource_class = resource_class + end + + # Builds an unsaved record. + # + # @param attributes [Hash] + # @return [Resources::Base] + def new(attributes = {}) + resource_class.new(@transport, attributes) + end + + # Builds and immediately saves a record. + # + # This raises when Zammad rejects the attributes, rather than handing back + # a record that looks created but is not. Use +new+ and + # {Resources::Base#save} to branch on a validation failure instead. + # + # @param attributes [Hash] + # @return [Resources::Base] + # @raise [ResponseError] when Zammad rejected the request + def create(attributes = {}) + record = new(attributes) + record.save! + record + end + + # Fetches a single record by id. + # + # @param id [Integer, String] + # @return [Resources::Base] + # @raise [NotFoundError] when no such record exists + def find(id) = resource_class.fetch_one(@transport, id) + + # Fetches the first record carrying all of these attribute values. + # + # This searches and then checks the hits itself, because Zammad's index + # endpoints cannot filter: ApplicationController#model_index_render sorts + # and pages and drops every other parameter, so the previous + # implementation - a query parameter on the index - asked for + # +email=someone@example.com+ and got back the whole user index, of which + # it returned the first record. A lookup that answers with an unrelated + # record is worse than one that answers with nothing. + # + # Values are compared exactly, and against the record as Zammad stores it: + # +find_by(email: 'Someone@Example.com')+ does not match a login Zammad + # downcased. + # + # The search term is one string value. Zammad searches by word, so a value + # of another type went out as the word it prints as: +find_by(active: + # true)+ searched for "true" and matched records carrying that word, which + # is essentially none of them, and then reported the miss as nil. A call + # with nothing to search for says so instead. + # + # One value, never all of them joined. An instance searching without + # Elasticsearch matches the term literally, through a SQL LIKE over the + # string columns, so a joined term asks every column to contain the whole + # of it: +find_by(firstname: 'Jane', lastname: 'Doe')+ went out as "Jane + # Doe" and no column of Jane Doe's holds that, so a user that exists came + # back as nil and the +find_by(...) || create(...)+ this documents made a + # duplicate on every run. Single-attribute lookups were unaffected, which + # is why it stood. The longest value goes out, as the most selective of + # them, and every other value is compared here - which is where the + # non-string ones are compared anyway, so +find_by(email: ..., active: + # true)+ searches the email and then checks both. + # + # Values go out as they are. Quoting one that carries search syntax, so + # that it is looked for rather than obeyed, sounds like an improvement and + # is not: an instance searching without Elasticsearch matches the term + # literally, through a SQL LIKE over the string columns, so the quotes + # become characters the value has to contain. That made + # +find_by(name: 'support-eu')+ - a hyphen is syntax - find nothing on + # every such instance, which is how this was found. + # + # So a value carrying syntax is searched as syntax, and what that surfaces + # depends on the backend. On Elasticsearch +find_by(note: 'a AND b')+ goes + # out as a boolean query, and a value Elasticsearch cannot parse at all - + # an unbalanced +(+ or +"+ - takes SearchIndexBackend#search_by_index down + # the branch that logs the error and returns no hits, so it arrives here + # as a miss rather than as a failure. + # + # What the search can surface is Zammad's business. A value the instance + # has not indexed, or cannot index, is a record this does not find, so + # +find_by(...) || create(...)+ can still create a duplicate - exactly as + # writing the search out by hand would. + # + # Only the first {SEARCH_MAX_PER_PAGE} hits are examined, so this costs one + # request whether it matches or not. Walking every page instead made a miss + # cost a request per page of hits - a term appearing in a few thousand + # records billed forty requests to answer "no", on exactly the + # +find_by(...) || create(...)+ path that runs for every new record. Search + # hits come back by relevance, so a record carrying the value searched for + # is at the top of them or not among them at all; to look further, page + # through {#search} directly. + # + # @example + # client.user.find_by(email: 'someone@example.com')&.id + # + # @param params [Hash] attribute names and the values to match exactly + # @return [Resources::Base, nil] nil when nothing matched + # @raise [ArgumentError] when no attribute was given, or none of the + # values is a string the search can be run on + def find_by(**params) + searchable! + raise ArgumentError, 'find_by needs at least one attribute to match' if params.empty? + + term = search_term_for(params) + raise ArgumentError, unsearchable_values_message(params) if term.nil? + + search(term) + .page(1, of: SEARCH_MAX_PER_PAGE) + .detect { |record| params.all? { |key, value| record[key] == value } } + end + + # Fetches the first record carrying all of these attribute values, raising + # when nothing matched. + # + # @param params [Hash] attribute names and the values to match exactly + # @return [Resources::Base] + # @raise [NotFoundError] when nothing matched + # @see #find_by + def find_by!(**params) + find_by(**params) || raise( + NotFoundError.new( + operation: "find object by #{params.keys.join(' and ')}", + resource_class: resource_class, + detail: 'no record matched' + ) + ) + end + + # Whether a record with this id exists. + # + # This costs one request, and reads the record to find out, because Zammad + # has no cheaper answer for a single id. + # + # @param id [Integer, String] + # @return [Boolean] + def exists?(id) + find(id) + true + rescue NotFoundError + false + end + + # Deletes a record by id, without fetching it first. + # + # @param id [Integer, String] + # @return [true] + # @raise [ResponseError] when Zammad rejected the request + def destroy(id) + @transport.delete(member_path(id), operation: 'destroy object', resource_class: resource_class) + true + end + + # Every record of this kind, as a lazily paginated collection. + # + # @return [Collection] + def all + collection(path, 'get .all of object') + end + + # @!group Collection shorthands + + # Everything below forwards to {#all}, the {Collection} that decides for + # itself what a missing block means and what a page size is measured + # against. + # + # Named once rather than written out one +def+ at a time. Each was a body + # that did nothing but pass its arguments on, and three carried a + # +steep:ignore+ because forwarding into {Collection}'s with-block and + # without-block overloads cannot be resolved - forwarded by name there is + # no call site left to resolve. What callers are checked against is + # sig/zammad_api/resource_proxy.rbs, where each of these is declared with + # the types {Collection} gives it. + + # @!method where(**params) + # Records narrowed by the query parameters this endpoint honours, as a + # lazily paginated collection. Shorthand for +all.where(...)+. + # + # An index endpoint does not filter by attribute value - see + # {Collection#where} - so this is for sorting, and {#find_by} or + # {#search} are how a value narrows anything. + # @param params [Hash] query parameters the endpoint honours + # @return [Collection] + # @raise [ArgumentError] for a parameter the endpoint would ignore + + # @!method each(&block) + # Yields every record of this kind, fetching pages as needed. + # + # This is what makes a proxy +Enumerable+, so +first+, +map+, +lazy+ + # and the rest work straight off +client.group+. + # @yieldparam record [Resources::Base] + # @return [Enumerator] when no block is given + # @see Collection#each + + # @!method find_each(batch_size: nil, &block) + # @param batch_size [Integer, nil] records fetched per request + # @yieldparam record [Resources::Base] + # @return [Enumerator] when no block is given + # @see Collection#find_each + + # @!method in_batches(of: nil, &block) + # @param of [Integer, nil] records fetched per request + # @yieldparam records [Array] + # @return [Enumerator] when no block is given + # @see Collection#in_batches + + # @!method page(number, of: nil) + # @param number [Integer] one-based page number + # @param of [Integer, nil] records on the page + # @return [Collection] + # @see Collection#page + + # @!method pluck(*keys) + # @param keys [Array] attribute names + # @return [Array] + # @see Collection#pluck + + # @!method count(*args, &block) + # Overrides +Enumerable#count+ so that a collection counts the way it + # knows how. + # @return [Integer] + # @see Collection#count + + # @!method size + # @return [Integer] + # @see Collection#size + + # @!method length + # @return [Integer] + # @see Collection#size + + # @!method empty? + # @return [Boolean] + # @see Collection#empty? + + # @!method first(count = nil) + # Overrides +Enumerable#first+ so that it reads one sized page rather + # than taking records off the front of one sized for walking. + # @param count [Integer, nil] how many records to read + # @return [Resources::Base, Array, nil] + # @see Collection#first + + # @!method take(count) + # @param count [Integer] how many records to read + # @return [Array] + # @see Collection#take + + def_delegators :all, :where, :each, :find_each, :in_batches, :page, :pluck, :count, :size, :length, :empty?, :first, :take + + # @!endgroup + + # Records matching a Zammad search term, as a lazily paginated collection. + # + # @param term [String] the Zammad search term + # @return [Collection] + # @raise [ArgumentError] when +term+ is not a non-empty string + # @raise [Error] when Zammad routes no search endpoint for this resource + def search(term) + searchable! + raise ArgumentError, 'search needs a non-empty query string' if !term.is_a?(String) || term.strip.empty? + + collection( + "#{path}/search", + 'get .search of object', + query: { query: term }, + max_per_page: SEARCH_MAX_PER_PAGE, + filterable: SEARCH_QUERY_KEYS, + filter_hint: SEARCH_FILTER_HINT, + countable: true + ) + end + + def inspect = "#<#{self.class.name} #{resource_class.name}>" + + private + + def collection(path, operation, query: {}, max_per_page: resource_class.page_limit, filterable: resource_class.filterable_keys, filter_hint: index_filter_hint, countable: false) + Collection.new( + transport: @transport, + resource_class: resource_class, + path: path, + operation: operation, + max_per_page: max_per_page, + filterable: filterable, + filter_hint: filter_hint, + countable: countable, + query: { expand: true }.merge(query) + ) + end + + # Zammad routes a search endpoint per model, not for every model: users, + # organizations, tickets and groups have one, ticket states, ticket + # priorities and ticket articles do not. Asking one of the latter for + # `.../search` is a 404, and a 404 out of `find_by` reads as "no such + # record" - so `find_by(...) || create(...)`, the shape + # examples/onboard_customer.rb is built on, raised instead of creating. + def searchable! + return if resource_class.searchable? + + raise Error, "Zammad routes no search endpoint for #{resource_class.name}, so it cannot be searched by term or looked up with find_by: #{path}/search answers 404, which would arrive here as a NotFoundError about a record. Walk the records and pick one instead, with all.detect { ... }." + end + + def index_filter_hint = resource_class.searchable? ? INDEX_FILTER_HINT : UNSEARCHABLE_FILTER_HINT + + # The one value {#find_by} searches on: the longest, as the most selective + # of them, which matters because only the first {SEARCH_MAX_PER_PAGE} hits + # are examined. + # + # One value rather than all of them joined, and unquoted - both spellings + # find nothing on an instance without Elasticsearch, for the same reason. + # See {#find_by}. + # + # @return [String, nil] nil when no value can be searched on + def search_term_for(params) = params.values.grep(String).reject { it.strip.empty? }.max_by(&:length) + + def unsearchable_values_message(params) + given = params.map { |key, value| "#{key}: #{value.inspect}" }.join(', ') + + "find_by has nothing to search for in #{given}. Zammad matches words, so a value of another type goes out as the word it prints as - find_by(active: true) looks for records containing \"true\". " \ + 'Pass at least one string value to search on, and the rest are still matched exactly: find_by(email: \'someone@example.com\', active: true). ' \ + 'For a list short enough to walk, all.detect { ... } needs no search index at all.' + end + + def path = resource_class.resource_path + + def member_path(id) = resource_class.member_path(id) + end +end diff --git a/lib/zammad_api/resources.rb b/lib/zammad_api/resources.rb index 8106a4d..f388c7d 100644 --- a/lib/zammad_api/resources.rb +++ b/lib/zammad_api/resources.rb @@ -1,17 +1,17 @@ -require 'zammad_api/list_base' -require 'zammad_api/list_all' -require 'zammad_api/list_search' -require 'zammad_api/resources/base' -require 'zammad_api/resources/user' -require 'zammad_api/resources/group' -require 'zammad_api/resources/organization' -require 'zammad_api/resources/ticket' -require 'zammad_api/resources/ticket_article' -require 'zammad_api/resources/ticket_article_attachment' -require 'zammad_api/resources/ticket_state' -require 'zammad_api/resources/ticket_priority' +# frozen_string_literal: true + +require_relative 'resources/base' +require_relative 'resources/group' +require_relative 'resources/organization' +require_relative 'resources/ticket' +require_relative 'resources/ticket_article' +require_relative 'resources/ticket_article_attachment' +require_relative 'resources/ticket_priority' +require_relative 'resources/ticket_state' +require_relative 'resources/user' module ZammadAPI - class Resource # rubocop:disable Lint/EmptyClass + # Namespace for the Zammad record classes. + module Resources end end diff --git a/lib/zammad_api/resources/base.rb b/lib/zammad_api/resources/base.rb index 2652c4d..cbe052e 100644 --- a/lib/zammad_api/resources/base.rb +++ b/lib/zammad_api/resources/base.rb @@ -1,140 +1,776 @@ -require 'cgi' -require 'zammad_api/json_helper' -require 'zammad_api/transport' +# frozen_string_literal: true + +require_relative '../associations' +require_relative '../attribute_access' +require_relative '../deep_copy' +require_relative '../errors' +require_relative '../transport' module ZammadAPI module Resources + # Shared behaviour for every Zammad record. + # + # Attributes are read and written through +method_missing+, because Zammad + # records can carry administrator-defined custom attributes: + # + # group = client.group.find(1) + # group.name # read + # group.name = 'Support' # stage a change + # group.changed? # => true + # group.save # persist class Base - include ZammadAPI::JsonHelper - extend ZammadAPI::JsonHelper + include AttributeAccess - attr_accessor :new_instance, :url, :attributes - attr_reader :changes + # Largest page size Zammad's generic index endpoints serve, from + # ApplicationController#model_index_render via CanPaginate. Resources + # whose endpoint caps lower declare +max_per_page+. + DEFAULT_MAX_PER_PAGE = 1000 - def initialize(transport, attributes = {}) - @new_instance = true - @transport = transport - @changes = {} - @url = self.class.get_url + # Guards the class-level memos on the singleton below. One lock for every + # resource, because it is held only while a memo is first built. + MEMO_LOCK = Mutex.new + private_constant :MEMO_LOCK + + # Query parameters an index endpoint honours, beyond the paging + # {Collection} owns. + # + # ApplicationController#model_index_render builds its query as + # `reorder(order_sql).offset(...).limit(...)` - it sorts and pages and + # drops every other parameter. There is no attribute filtering on an + # index endpoint at all; that is what /search is for. Resources whose + # controller hardcodes the order declare +index_query_keys+ with + # nothing in it. + DEFAULT_INDEX_QUERY_KEYS = %i[sort_by order_by].freeze + + # Staged changes as +attribute => [old_value, new_value]+. + # + # A copy, and frozen: writing to the change set a record hands out would + # decide what the next +save+ sends. + # + # @return [Hash{Symbol => Array(Object, Object)}] + def changes = @changes.dup.freeze + + # The validation failure from the most recent {#save}, so that a +false+ + # return value can be acted on. Cleared when the next save is attempted, + # so it never describes anything but the most recent one. + # + # @return [ValidationError, nil] + attr_reader :error - if attributes.nil? - attributes = {} + # @api private + attr_reader :transport + + class << self + # Declares the API path of this resource, relative to the instance URL. + # + # @param value [String] + # @return [void] + def path(value) + @path = value end - @attributes = attributes - symbolize_keys_deep!(@attributes) - end - def method_missing(method, *args) - return @attributes[method] if !method.to_s.end_with?('=') + # Declares that Zammad routes a +/search+ endpoint for this resource. + # + # Declared rather than assumed, and false unless a resource says + # otherwise: an unrouted `.../search` answers 404, which arrives as a + # NotFoundError from inside {ResourceProxy#find_by} - a method + # documented to return nil when nothing matched. A resource that + # forgets to declare this is refused at the call site instead, which + # is a question about the resource rather than a wrong answer about a + # record. + # + # @param value [Boolean] + # @return [void] + def searchable(value) + @searchable = value + end - method = method.to_s[0, method.length - 1].to_sym - @changes[method] = [@attributes[method], args[0]] - @attributes[method] = args[0] - nil - end + # Declares the largest page size this resource's index endpoint + # serves. + # + # @param value [Integer] + # @return [void] + def max_per_page(value) + @max_per_page = value + end + + # Declares the query parameters this resource's index endpoint + # honours, beyond the paging {Collection} owns. + # + # @param keys [Array] + # @return [void] + def index_query_keys(*keys) + @index_query_keys = keys.flatten.freeze + end + + # @return [String] the API path of this resource + def resource_path + declaration(:path) || raise(Error, "#{name} does not declare an API path") + end + + # @return [Boolean] whether Zammad routes a +/search+ endpoint here + def searchable? = declaration(:searchable) { false } + + # @return [Integer] the largest page size this endpoint serves + def page_limit = declaration(:max_per_page) { DEFAULT_MAX_PER_PAGE } + + # @return [Array] the query parameters {Collection#where} may + # pass to this endpoint + def filterable_keys = declaration(:index_query_keys) { DEFAULT_INDEX_QUERY_KEYS } + + # The API path of one record of this kind. + # + # The id is escaped rather than interpolated, so that one taken from a + # request parameter cannot walk out of its segment into another + # endpoint. Held here because {ResourceProxy} builds this path too, + # from an id a caller handed it, and an escaping rule kept in two + # places is one that gets changed in one of them. + # + # @api private + # @param id [Integer, String] + # @return [String] + # @raise [ArgumentError] when the id cannot go into a path segment + def member_path(id) = "#{resource_path}/#{Transport.escape_path_segment(id)}" + + # Reads one record by id. + # + # On the resource rather than on {ResourceProxy}, because both callers + # of it need a proxy for nothing else: {ResourceProxy#find} has one, + # and {Associations::Proxy} was building a throwaway per association + # read until it grew a second copy of this instead. The resource + # already knows its member path and how to build itself from a body, + # which is the whole of the read. + # + # @api private + # @param transport [Transport] + # @param id [Integer, String] + # @return [Base] + def fetch_one(transport, id) + operation = 'find object' + response = transport.get( + member_path(id), + operation: operation, + resource_class: self, + query: { expand: true } + ) + from_response(transport, response.decoded(:object, operation: operation, resource_class: self)) + end - def new_record? - @new_instance + # Builds a record that is already stored in Zammad. + # + # Straight to the state, past the constructor's refusal of attributes + # a caller may not stage. Zammad serves +id+ in every body it answers + # with, and +id+ is the one attribute that refusal exists for. + # + # @api private + # @param transport [Transport] + # @param attributes [Hash] + # @return [Base] + def from_response(transport, attributes) + record = allocate + record.send(:setup, transport, attributes) + record.send(:mark_persisted!) + record + end + + # Every association declared on this resource, including inherited + # ones. + # + # @return [Hash{Symbol => Hash}] + def associations + ancestors + .select { it.respond_to?(:declared_associations, true) } + .reverse + .inject({}) { |result, ancestor| result.merge(ancestor.send(:declared_associations)) } + end + + # The attributes a +belongs_to+ reader resolves through, so that + # writing one can drop the record it had already resolved to. + # + # Both of the class-level memos here are built under one lock, because + # a plain `@x ||=` is a write to shared state that two threads can + # reach at once: on JRuby or TruffleRuby each could see the ivar unset + # and build a different anonymous proxy class for the same resource, + # and whichever write lost would still be held by the records already + # built from it. + # + # Populating them eagerly was the earlier answer, first on first use, + # then in `Client#setup`, then at require time. Each move shrank the + # window without closing it: `Client.new` is itself something two + # threads can call, and a resource a caller subclasses themselves - + # `class MyTicket < Ticket; end`, which this gem supports - is never in + # any list built ahead of time. + # + # The unlocked read first, so the settled case stays a plain ivar read: + # this is asked on every attribute write, and a lock on that path would + # cost far more than the one build it guards. + # + # @api private + # @return [Array] + def belongs_to_foreign_keys + @belongs_to_foreign_keys || MEMO_LOCK.synchronize { @belongs_to_foreign_keys ||= associations.filter_map { |_, spec| spec[:foreign_key] if spec[:type] == :belongs_to } } + end + + # The class carrying this resource's association readers, reached + # through {Base#related}. + # + # @api private + # @return [Class] + def related_class + return @related_class if @related_class + + # A resource's proxy inherits its parent's readers, so Base's + # created_by and updated_by reach every resource. The parent is + # resolved before the lock is taken, because resolving it may build + # the parent's own proxy through this same method and a Mutex is not + # reentrant. + parent = superclass.respond_to?(:related_class) ? superclass.related_class : Associations::Proxy # steep:ignore NoMethod + MEMO_LOCK.synchronize { @related_class ||= Class.new(parent) } + end + + private + + # Reads a declaration from this class, or failing that from the + # resource it inherits from. + # + # A plain class-level ivar is not inherited, so `class MyTicket < + # Ticket; end` used to inherit all nine of Ticket's associations, its + # page limit and its searchability, and lose only its API path - + # `MyTicket.resource_path` raised "does not declare an API path" from + # a class that plainly did. {.associations} and {.related_class} both + # walk the ancestry deliberately; these read through the same way. + # + # `instance_variable_defined?` rather than a truth test, so that a + # resource may declare a value that is false or nil and have it + # override an inherited one. + def declaration(name, &default) + variable = :"@#{name}" + return instance_variable_get(variable) if instance_variable_defined?(variable) + return superclass.send(:declaration, name, &default) if superclass.respond_to?(:declaration, true) + + default&.call + end + + def declared_associations = @declared_associations ||= {} + + # Declares that this resource points at a single other record, through + # a foreign key on itself. + # + # The reader lands on {Base#related}, not on the record, because Zammad + # already expands the association into a name under the plain + # attribute: +ticket.customer+ is a login, +ticket.related.customer+ is + # the User record. + # + # @param name [Symbol] name of the reader on {Base#related} + # @param class_name [String] the target resource, named rather than + # referenced so that two resources may point at each other + # @param foreign_key [Symbol] attribute holding the target's id + # @return [void] + def belongs_to(name, class_name:, foreign_key: :"#{name}_id") + declared_associations[name] = { type: :belongs_to, class_name: class_name, foreign_key: foreign_key } + # The block runs against a Proxy instance, which the type checker + # cannot see through define_method. + related_class.define_method(name) { belongs_to_target(name, class_name, foreign_key) } # steep:ignore NoMethod + end + + # Declares that this resource points at a list of other records, + # served by an endpoint of its own. + # + # The path has to name an endpoint that serves the whole list in one + # response, which is what the association endpoints Zammad routes do. + # The reader spends one request and hands back an Array rather than a + # walking {Collection}, and refuses a response that turns out to be + # one page of several rather than returning a short list quietly. + # + # @param name [Symbol] name of the reader on {Base#related} + # @param class_name [String] the target resource + # @param path [Proc] called with the record id, already escaped for + # a path segment, and returns the API path + # @return [void] + def has_many(name, class_name:, path:) + declared_associations[name] = { type: :has_many, class_name: class_name, path: path } + related_class.define_method(name) { has_many_target(name, class_name, path) } # steep:ignore NoMethod + end end - def changed? - !@changes.to_h.empty? + # Zammad stamps every object with the user that created and last + # touched it. + belongs_to :created_by, class_name: 'User' + belongs_to :updated_by, class_name: 'User' + + # @param transport [Transport] + # @param attributes [Hash, nil] + # @raise [Error] when an attribute cannot be staged, such as +id+ + def initialize(transport, attributes = {}) + given = attributes || {} + # The same refusal the writers make, at the one door that did not make + # it. `group.id = 99` is refused with a sentence about what an id is + # for, and `client.group.new(id: 99)` was accepted in silence - and + # then `save` POSTed `{"id": 99, ...}`, because a new record is sent in + # full. So the one spelling that reached the wire was the one nothing + # checked, and Zammad's answer to it was the first news of the + # mistake. {.from_response} is the way in for a body Zammad served, + # which carries an id precisely because it is a record that has one. + given.each { |key, value| refuse_unwritable!(DeepCopy.symbolize(key), value) } + setup(transport, given) end - def destroy - response = @transport.delete(url: "#{@url}/#{@attributes[:id]}") - return true if response.status == 200 + # @return [Boolean] whether this record has not been stored yet + def new_record? = @new_record + + # Whether this record exists in Zammad. + # + # False both before the first save and after {#destroy}, so this is not + # the inverse of {#new_record?}. + # + # @return [Boolean] + def persisted? = !@new_record && !@destroyed + + # Whether {#destroy} removed this record from Zammad. + # + # The attributes stay readable, so a destroyed record can still be logged + # or reported on; it just no longer stands for anything on the server. + # + # @return [Boolean] + def destroyed? = @destroyed - raise ResponseError.from(response, operation: 'destroy object', resource_class: self.class) + # @return [Boolean] whether there are unsaved changes + def changed? = !@changes.empty? + + # The records this one points at, each fetched on demand. + # + # Zammad expands an association into a name under the plain attribute, + # so +ticket.customer+ is already the customer's login. These readers + # return the whole record instead, which costs a request. + # + # @example + # ticket.customer # => "customer@example.com", already loaded + # ticket.related.customer.email # => the same, from the User record + # ticket.related.articles # => [TicketArticle, ...] + # + # @return [Associations::Proxy] + # @raise [Error] when the record was destroyed + # @see .associations + def related + # Asked here rather than left to the request. `destroy` drops the memo, + # and that was taken to be the whole of it - but this reader rebuilds + # on the next call, so a destroyed record went on handing out a working + # proxy and `related.articles` fetched the articles of a ticket that is + # gone. Clearing a memo is not refusing a reader, and only the refusal + # is worth asserting: the spec that covered this compared proxy + # identity, so it passed throughout. + raise_if_destroyed!('read related records from') + @related ||= self.class.related_class.new(self) end - def save - attributes = saved_attributes - symbolize_keys_deep!(attributes) - attributes.delete(:article) - @attributes = attributes - @new_instance = false - @changes = {} - true + # Stages several attributes as changes, without saving. + # + # @example + # group.assign_attributes(name: 'Support 2', note: 'Renamed') + # group.changed? # => true + # + # @param attributes [Hash] attribute names and their new values + # @return [self] + # @raise [Error] when an attribute cannot be staged, such as +id+. Nothing + # is staged in that case, so the record is left as it was. + def assign_attributes(attributes) + # rubocop:disable Style/CombinableLoops -- combining them is the bug + # Two passes on purpose: every key is checked before any of them is + # written. Combined, `assign_attributes(name: 'X', id: 9, note: 'Y')` + # staged the name, raised on the id and never reached the note, leaving + # the record dirty with half a change set - the state `update` takes + # its own guard one line early to avoid. + # + # `write_attribute` checks again for each key, because it is also the + # direct writer's own guard and cannot assume a caller came through + # here. That is a second Symbol comparison per attribute, which is not + # worth a bypass to avoid. + attributes.each { |key, value| refuse_unwritable!(key.to_sym, value) } + attributes.each { |key, value| write_attribute(key.to_sym, value) } + # rubocop:enable Style/CombinableLoops + self end - def self.get_url - @url + # Creates or updates the record, reporting a validation failure as + # +false+ rather than by raising. + # + # Only a rejection of the submitted attributes is caught, and it is left + # in {#error}. A missing record, an expired token or an unreachable + # instance still raises, because retrying or branching on those is not + # the caller's business here. + # + # @example + # if group.save + # puts group.id + # else + # warn group.error.server_message + # end + # + # @return [Boolean] whether the record was stored + # @raise [ResponseError] for any failure other than a validation error + # @see #save! + def save + save! + rescue ValidationError => e + @error = e + false end - def self.url(value) - @url = value + # Creates or updates the record, raising on any failure. + # + # New records are sent in full; existing records send only the attributes + # that changed. + # + # @return [true] + # @raise [ResponseError] when Zammad rejected the request + # @see #save + def save! + raise_if_destroyed!('save') + + # Before the request, not after it. Only the success path and the + # rescue in `save` used to clear this, so a save that raised anything + # else left the previous attempt's ValidationError in place and a + # caller reading #error to report the failure read the wrong cause. + @error = nil + + if !new_record? + # An existing record is addressed by its id, so establish there is + # one before anything here can report success. Only a record left + # behind by a 2xx that did not decode reaches this without one, and + # for that record the short circuit below is a lie: nothing is + # staged, so `save` answered true without sending a request. + require_id! + + # An unchanged record has nothing to send. The empty PUT this used + # to issue was not just a wasted round trip: Zammad applies it, + # bumping updated_at and updated_by, so re-saving a record that + # nobody touched rewrote its audit trail and moved the timestamp + # that other callers use to tell whether it changed under them. + return true if !changed? + end + + response = new_record? ? create_record : update_record + + replace_attributes!(response, operation: 'save object', saved: true) + true end - def self.all(transport, _) - ZammadAPI::ListAll.new(self, transport, per_page: 100) + # Stages several attributes and saves in one call. + # + # @example + # ticket.update(state: 'closed', priority: '1 low') + # + # @param attributes [Hash] attribute names and their new values + # @return [Boolean] whether the record was stored + # @raise [Error] when the record was destroyed, or when an attribute + # cannot be staged, such as +id+ + # @raise [ResponseError] for any failure other than a validation error + # @see #save + def update(attributes) + # Before the attributes are staged, not after. `save!` asks the same + # question one line later, but by then `assign_attributes` has already + # written into @changes, so `update` on a destroyed record raised and + # left it dirty with a change set that can never be sent - exactly the + # state `destroy` clears the staged changes to prevent. + raise_if_destroyed!('save') + assign_attributes(attributes) + save end - def self.search(transport, parameter) - ZammadAPI::ListSearch.new(self, transport, parameter) + # Stages several attributes and saves in one call, raising on any + # failure. + # + # @param attributes [Hash] attribute names and their new values + # @return [true] + # @raise [Error] when the record was destroyed, or when an attribute + # cannot be staged, such as +id+ + # @raise [ResponseError] when Zammad rejected the request + # @see #save! + def update!(attributes) + raise_if_destroyed!('save') + assign_attributes(attributes) + save! end - def self.find(transport, id) - response = transport.get(url: "#{@url}/#{id}?expand=true") - if response.status != 200 - raise ResponseError.from(response, operation: 'find object', resource_class: self) - end + # Re-reads the record from Zammad, discarding unsaved changes. + # + # @return [self] + # @raise [Error] when the record was destroyed + # @raise [ResponseError] when Zammad rejected the request + # @raise [ParseError] when the response is not a JSON object + def reload + raise_if_destroyed!('reload') + raise_if_new!('reload') - data = safe_json_parse(response.body) - item = new(transport, data) - item.new_instance = false - item + response = transport.get( + member_path, + operation: 'reload object', + resource_class: self.class, + query: { expand: true } + ) + replace_attributes!(response, operation: 'reload object') + self end - def self.create(transport, data) - item = new(transport, data) - item.save - item - end + # Deletes the record. + # + # The record is marked {#destroyed?} rather than left looking live, so + # that a later {#save}, {#reload} or second {#destroy} fails here with + # the reason rather than one call later as a 404 from Zammad. The + # attributes stay readable, but the state that only meant something + # while the record existed does not: staged changes, the last validation + # failure, and the association readers all go. + # + # Readable as the record last was in Zammad, which is not what dropping + # the staged changes alone left behind: `reset_pending_state!` empties + # `@changes` and leaves the writes those changes described standing in + # `@attributes`, so `group.name = 'B'; group.destroy` answered + # `changed?` with false, `changes` with `{}` and `name` with "B" - a + # value Zammad never saw, with nothing left to tell it apart from one it + # served. `@baseline` is what the record arrived with, and every other + # path through this state moves the two together. + # + # @return [true] + # @raise [Error] when the record was already destroyed + # @raise [ResponseError] when Zammad rejected the request + def destroy + raise_if_destroyed!('destroy') + raise_if_new!('destroy') - def self.destroy(transport, id) - item = find(transport, id) - item.destroy + transport.delete(member_path, operation: 'destroy object', resource_class: self.class) + @destroyed = true + @attributes = @baseline + reset_pending_state! true end + def inspect = "#<#{self.class.name} id=#{id.inspect} new_record=#{new_record?}#{' destroyed=true' if destroyed?} attributes=#{attributes.inspect}>" + private - def saved_attributes - return save_new if @new_instance + # Everything a record is, in the one place both ways of building one go + # through. Held apart, {.from_response} would restate this list by hand + # and the next field added to the constructor would be left unset on + # every record built from a response - the argument {Client#setup} is + # written around, one class over. + # + # @return [void] + def setup(transport, attributes) + @transport = transport + @attributes = frozen_attributes(attributes) + # What #changes measures against: the attributes this record arrived + # with, kept apart from the ones it currently holds. The two share the + # one frozen Hash until the first write copies it. + @baseline = @attributes + @changes = {} + @new_record = true + @destroyed = false + @error = nil + @related = nil + # Whether what this record holds came back from a save rather than + # from a read. Only {#no_id_message} asks, and only for a record left + # without an id, where the two lead somewhere quite different. + @saved = false + end - save_existing + def mark_persisted! + @new_record = false end - def save_new - response = @transport.post(url: "#{@url}?expand=true", params: @attributes) - return safe_json_parse(response.body) if response.status == 201 + # Refuses an operation on a record Zammad never had. + # + # `destroyed?` was the only thing these asked, so a record built with an + # id it was simply handed - `client.group.new(id: 99)`, which the + # attribute writers refuse but the constructor still allows - reported + # `new_record?` true and `persisted?` false and then issued a real DELETE + # against group 99. The id addresses a record this one does not stand + # for, and nothing about it came from Zammad. + def raise_if_new!(operation) + raise Error, "#{self.class.name} has not been saved, so there is nothing to #{operation}" if new_record? + end - save_error(response) + # Refuses an operation on a record Zammad no longer holds. + # + # `destroyed?` is sticky, and all three state-changing paths ask here. + # Only `save!` used to: `reload` re-read a record that no longer exists + # and cleared the flag on the way back, so a destroyed record came back + # reporting itself as persisted and its next `save` issued a PUT against + # the deleted path, while a second `destroy` surfaced Zammad's 404 + # instead of the local reason. A record that is gone is gone, and every + # path that acts on the server says so here rather than one request + # later. + def raise_if_destroyed!(action) + raise Error, "#{self.class.name} #{id} was destroyed, there is nothing to #{action}" if destroyed? end - def save_existing - attributes_to_post = {} - @changes.each do |name, values| - attributes_to_post[name] = values[1] + def writable_attributes? = true + + # Asked by {#write_attribute} and by {AttributeAccess#respond_to_missing?}, + # so that a record never claims a writer it would then refuse. That is + # the invariant the writer branch of `respond_to_missing?` exists for. + def attribute_writable?(key) = key != :id + + # Everything a freshly loaded record has to forget, in the one place that + # every load path goes through. Held apart, `save!` and `reload` drifted + # the moment a sixth field was added to only one of them, and nothing + # would have caught a reloaded record still holding an association proxy + # from before the reload. + # The flag goes down before the body is decoded, because what makes a + # record persisted is that Zammad answered 2xx, not that the answer + # parsed. Decoded first, a create whose 201 carried something other than + # a JSON object - an HTML error page from an intervening proxy - raised + # ParseError with @new_record still true, so the ticket existed in + # Zammad while the record here still looked unsaved and a retried `save` + # POSTed a second one. + # + # What that leaves behind is a record which is persisted and carries no + # id, and the rest of this class has to treat it as the unusable thing + # it is: a record built by `new` has nothing staged, so without a word + # from {#require_id!} the retried `save` would have taken the "nothing + # to send" short circuit and reported true, having made no request at + # all, for a record that may or may not be in Zammad. + def replace_attributes!(response, operation:, saved: false) + @new_record = false + @saved = saved + @attributes = frozen_attributes(response.decoded(:object, operation: operation, resource_class: self.class)) + @baseline = @attributes + reset_pending_state! + end + + # The part of that which is not about arriving with new attributes, but + # about the record's state on the server having changed underneath what + # is held here. `destroy` is the third path through this, and was the + # one left out: a destroyed record went on reporting `changed?` and a + # change set that can never be sent, and went on handing out a `related` + # proxy that would happily request a record that no longer exists. + def reset_pending_state! + @changes = {} + @error = nil + @related = nil + end + + # The baseline is the value this record was loaded with, not the value + # the previous assignment happened to leave behind. Writing twice must + # still report the original, and writing a value back to the original + # is not a change at all - but only where the record was loaded + # carrying that attribute in the first place. + # + # That last part is why the baseline is held rather than read back out + # of @attributes: an attribute the record does not carry - Zammad + # reduces the object it serializes for a permission-scoped client - + # compared nil against nil on the way in, staged nothing, and was still + # merged into @attributes below. The write was dropped without a word, + # no request was ever sent for it, and the record went on reporting a + # key Zammad had never sent it, so #changes and #attributes disagreed. + def write_attribute(key, value) + refuse_unwritable!(key, value) + + staged = frozen_attributes(value) + + if @baseline.key?(key) && @baseline[key] == staged + @changes.delete(key) + else + @changes[key] = [@baseline[key], staged].freeze end - response = @transport.put(url: "#{@url}/#{@attributes[:id]}?expand=true", params: attributes_to_post) - return safe_json_parse(response.body) if response.status == 200 - save_error(response) + # Copy on write, because @attributes is frozen for the benefit of + # every reader that hands it out. + @attributes = @attributes.merge(key => staged).freeze + # A resolved association is only correct for the id it was resolved + # from. `replace_attributes!` drops the proxy on save and reload, but + # the write that actually changes a foreign key did not, so + # `ticket.customer_id = 9` left `ticket.related.customer` answering + # with user 3 and no request to show for it. The whole proxy goes: + # evicting one reader means reaching into its cache for a saving that + # is one request at most. + @related = nil if self.class.belongs_to_foreign_keys.include?(key) + staged + end + + def create_record + transport.post( + self.class.resource_path, + operation: 'save object', + resource_class: self.class, + query: { expand: true }, + body: attributes + ) end - def save_error(response) - raise ResponseError.from(response, operation: 'save object', resource_class: self.class) + def update_record + transport.put( + member_path, + operation: 'save object', + resource_class: self.class, + query: { expand: true }, + body: @changes.transform_values { it[1] } + ) end - def symbolize_keys_deep!(hash) - hash.keys.each do |key| - key_symbol = key.respond_to?(:to_sym) ? key.to_sym : key - hash[key_symbol] = hash.delete key # Preserve order even when key == key_symbol + # The id is what addresses the record, so it is not an attribute a caller + # stages. Written, it took effect immediately for every path that builds + # a URL from `@attributes` and not at all for the record those paths then + # reported on: `group.id = 99; group.destroy` sent DELETE to group 99 and + # left the record saying group 1 was the one destroyed. Zammad would not + # have applied it either - an id is not something its endpoints let you + # set - so there is no call here that a refusal takes away. + def refuse_unwritable!(key, value) + return if attribute_writable?(key) - symbolize_keys_deep! hash[key_symbol] if hash[key_symbol].is_a? Hash + message = "#{self.class.name}##{key} cannot be staged as an attribute" + # `attribute_writable?` is a hook a resource may override, so the + # reason belongs to the key that was refused rather than to the raise. + if key == :id + message += ', because it is what addresses this record; look up the record you meant ' \ + "with find(#{value.inspect})" end + raise Error, message + end + + def member_path = self.class.member_path(require_id!) + + # This record's id, for the paths and bodies that cannot be built + # without one. + # + # @return [Integer] the id + # @raise [Error] when the record has none + def require_id! + # Read once into a local, so that the guard below narrows what is + # handed on - `id` is an attribute reader, and the type checker cannot + # tell that two calls to one answer the same thing. + record_id = id + raise Error, no_id_message if record_id.nil? + + record_id + end + + # A record has no id in three quite different situations, and one message + # for more than one of them sends people looking in the wrong place. + # + # Before the first save there is simply nothing to address yet. + # + # After one there is - Zammad answered 2xx, so the record is in Zammad - + # but the response carried no id to address it by, which is what + # {#replace_attributes!} leaves behind when a 2xx body does not decode + # as the object it claims to be. + # + # And a record can arrive without one from a plain read: Zammad reduces + # the object it serializes for a permission-scoped client, which is the + # same thing {#write_attribute} is written around. Such a record is + # persisted, so it used to be told it "was saved" - pointing the caller + # at a save that never happened, when what they need to look at is which + # user the client authenticates as. + def no_id_message + return "#{self.class.name} has no id, save it first" if new_record? + + if @saved + return "#{self.class.name} was saved, but the response carried no id to address it by, " \ + 'so this record cannot act on the server. Look it up again to get one that can.' + end + + "#{self.class.name} was loaded without an id, so this record cannot act on the server. " \ + 'Zammad serves a reduced object where the authenticated user may not see the whole record, ' \ + 'so check what this client may read, then look it up again to get one that can.' end end end diff --git a/lib/zammad_api/resources/group.rb b/lib/zammad_api/resources/group.rb index 3a6a768..7e3cd64 100644 --- a/lib/zammad_api/resources/group.rb +++ b/lib/zammad_api/resources/group.rb @@ -1,3 +1,13 @@ -class ZammadAPI::Resources::Group < ZammadAPI::Resources::Base - url '/api/v1/groups' +# frozen_string_literal: true + +require_relative 'base' + +module ZammadAPI + module Resources + class Group < Base + path 'api/v1/groups' + + searchable true + end + end end diff --git a/lib/zammad_api/resources/organization.rb b/lib/zammad_api/resources/organization.rb index 671939f..2ffd0a3 100644 --- a/lib/zammad_api/resources/organization.rb +++ b/lib/zammad_api/resources/organization.rb @@ -1,3 +1,13 @@ -class ZammadAPI::Resources::Organization < ZammadAPI::Resources::Base - url '/api/v1/organizations' +# frozen_string_literal: true + +require_relative 'base' + +module ZammadAPI + module Resources + class Organization < Base + path 'api/v1/organizations' + + searchable true + end + end end diff --git a/lib/zammad_api/resources/ticket.rb b/lib/zammad_api/resources/ticket.rb index 448209b..3276233 100644 --- a/lib/zammad_api/resources/ticket.rb +++ b/lib/zammad_api/resources/ticket.rb @@ -1,25 +1,66 @@ -class ZammadAPI::Resources::Ticket < ZammadAPI::Resources::Base - url '/api/v1/tickets' +# frozen_string_literal: true - def articles - response = @transport.get(url: "/api/v1/ticket_articles/by_ticket/#{id}?expand=true") - if response.status != 200 - raise ZammadAPI::ResponseError.from(response, operation: 'get articles', resource_class: self.class) - end +require_relative 'base' +require_relative 'ticket_article' - data = safe_json_parse(response.body) +module ZammadAPI + module Resources + class Ticket < Base + path 'api/v1/tickets' - data.collect do |raw| - item = ZammadAPI::Resources::TicketArticle.new(@transport, raw) - item.new_instance = false - item - end - end + # TicketsController#index hardcodes `reorder(id: :asc)`, so it honours + # nothing but the paging - not even sort_by. + index_query_keys + + # /api/v1/tickets caps the page size at 100, unlike the generic index + # endpoints, see TicketsController#index. + max_per_page 100 + + searchable true - def article(data) - data[:ticket_id] = @attributes[:id] - item = ZammadAPI::Resources::TicketArticle.new(@transport, data) - item.save - item + belongs_to :customer, class_name: 'User' + belongs_to :owner, class_name: 'User' + belongs_to :organization, class_name: 'Organization' + belongs_to :group, class_name: 'Group' + belongs_to :state, class_name: 'TicketState' + belongs_to :priority, class_name: 'TicketPriority' + + has_many :articles, class_name: 'TicketArticle', path: ->(id) { "api/v1/ticket_articles/by_ticket/#{id}" } + + # Every article of this ticket, refetched on each call. + # + # The same list as +ticket.related.articles+; this is the older name and + # stays because it reads better than reaching through +related+ for the + # one association a ticket is usually asked for. + # + # @return [Array] + # @raise [ResponseError] when Zammad rejected the request + def articles = related.articles + + # Adds an article to this ticket. + # + # @param attributes [Hash] article attributes, e.g. +body:+, +type:+ + # @return [TicketArticle] the created article + # @raise [Error] when the ticket has no id yet, or was destroyed + # @raise [ResponseError] when Zammad rejected the request + def article(attributes = {}) + # A destroyed ticket takes no articles, and says so here rather than + # one request later. `require_id!` passes on its own, because destroy + # leaves the id readable, so without this the POST went out naming a + # ticket that is gone and Zammad's 422 about ticket_id was the first + # news of it - the fourth state-changing path, after the three that + # already ask. + raise_if_destroyed!('add an article to') + + # An article belongs to a ticket by id, so ask for one here rather + # than merging nil. Unchecked, this POSTed `ticket_id: null` and left + # the caller reading Zammad's 422 to work out that the ticket they + # were adding to had never been saved - the one path in the gem that + # needed a stored id and went to the server to find out it had none. + record = TicketArticle.new(transport, attributes.merge(ticket_id: require_id!)) + record.save! + record + end + end end end diff --git a/lib/zammad_api/resources/ticket_article.rb b/lib/zammad_api/resources/ticket_article.rb index 890918b..88a0434 100644 --- a/lib/zammad_api/resources/ticket_article.rb +++ b/lib/zammad_api/resources/ticket_article.rb @@ -1,11 +1,48 @@ -class ZammadAPI::Resources::TicketArticle < ZammadAPI::Resources::Base - url '/api/v1/ticket_articles' - - def attachments - @attributes[:attachments].collect do |raw| - raw[:ticket_id] = @attributes[:ticket_id] - raw[:article_id] = @attributes[:id] - ZammadAPI::Resources::TicketArticleAttachment.new(@transport, raw) +# frozen_string_literal: true + +require_relative '../errors' +require_relative 'base' +require_relative 'ticket_article_attachment' + +module ZammadAPI + module Resources + class TicketArticle < Base + path 'api/v1/ticket_articles' + + # Named the way a has_many reader names its own read - `get articles` - + # so the refusal below reads like every other one this gem raises. + ATTACHMENTS_OPERATION = 'get attachments' + private_constant :ATTACHMENTS_OPERATION + + belongs_to :ticket, class_name: 'Ticket' + + # @return [Array] the article's attachments + # @raise [ParseError] when the article does not carry attachment + # metadata in the shape Zammad serves + def attachments + list = attributes[:attachments] || [] + # The same check {Response#decoded} makes on a list of records, for + # the same reason: this metadata comes off a response body, and an + # element that is not an object reached `raw.merge` and died there as + # `undefined method 'merge' for an instance of String` - a bare + # NoMethodError from inside the gem, past the `rescue ZammadAPI::Error` + # every caller is told to write, for a body that was simply not what + # it claimed to be. + raise ParseError.build(operation: ATTACHMENTS_OPERATION, expected: 'array of objects', actual: list.class, resource_class: self.class) if !list.is_a?(Array) + + list.map { attachment(it) } + end + + private + + def attachment(raw) + raise ParseError.build(operation: ATTACHMENTS_OPERATION, expected: 'array of objects', actual: "an array holding #{raw.class}", resource_class: self.class) if !raw.is_a?(Hash) + + TicketArticleAttachment.new( + transport, + raw.merge(ticket_id: attributes[:ticket_id], article_id: id) + ) + end end end end diff --git a/lib/zammad_api/resources/ticket_article_attachment.rb b/lib/zammad_api/resources/ticket_article_attachment.rb index c3e06d9..330bbb6 100644 --- a/lib/zammad_api/resources/ticket_article_attachment.rb +++ b/lib/zammad_api/resources/ticket_article_attachment.rb @@ -1,18 +1,45 @@ -class ZammadAPI::Resources::TicketArticleAttachment < ZammadAPI::Resources::Base - def initialize(transport, attributes = {}) # rubocop:disable Lint/MissingSuper - @transport = transport - @attributes = attributes - symbolize_keys_deep!(@attributes) - end +# frozen_string_literal: true - def method_missing(method, *_args) - @attributes[method.to_sym] - end +require_relative '../attribute_access' +require_relative '../errors' +require_relative '../transport' + +module ZammadAPI + module Resources + # An attachment of a ticket article. + # + # Attachments are read-only metadata until {#download} is called, which + # returns the file contents. + class TicketArticleAttachment + include AttributeAccess + + # @api private + # @param transport [Transport] + # @param attributes [Hash] + def initialize(transport, attributes = {}) + @transport = transport + @attributes = frozen_attributes(attributes || {}) + end - def download - response = @transport.get(url: "/api/v1/ticket_attachment/#{ticket_id}/#{article_id}/#{id}") - return response.body if response.status == 200 + # Downloads the attachment. + # + # @return [String] the file contents, in +ASCII-8BIT+ encoding + # @raise [ResponseError] when Zammad rejected the request + def download + segments = %i[ticket_id article_id id].map { Transport.escape_path_segment(fetch(it)) } + response = @transport.get( + "api/v1/ticket_attachment/#{segments.join('/')}", + operation: 'download attachment', + resource_class: self.class + ) + # Attachments are arbitrary binary data; the transport's charset + # guess must not corrupt them. + response.raw_body.dup.force_encoding(Encoding::BINARY) + end - raise ZammadAPI::ResponseError.from(response, operation: 'get articles', resource_class: self.class) + def inspect + "#<#{self.class.name} id=#{id.inspect} filename=#{self[:filename].inspect} size=#{self[:size].inspect}>" + end + end end end diff --git a/lib/zammad_api/resources/ticket_priority.rb b/lib/zammad_api/resources/ticket_priority.rb index c46534a..a02df3c 100644 --- a/lib/zammad_api/resources/ticket_priority.rb +++ b/lib/zammad_api/resources/ticket_priority.rb @@ -1,3 +1,11 @@ -class ZammadAPI::Resources::TicketPriority < ZammadAPI::Resources::Base - url '/api/v1/ticket_priorities' +# frozen_string_literal: true + +require_relative 'base' + +module ZammadAPI + module Resources + class TicketPriority < Base + path 'api/v1/ticket_priorities' + end + end end diff --git a/lib/zammad_api/resources/ticket_state.rb b/lib/zammad_api/resources/ticket_state.rb index d05b75f..091aa4d 100644 --- a/lib/zammad_api/resources/ticket_state.rb +++ b/lib/zammad_api/resources/ticket_state.rb @@ -1,3 +1,11 @@ -class ZammadAPI::Resources::TicketState < ZammadAPI::Resources::Base - url '/api/v1/ticket_states' +# frozen_string_literal: true + +require_relative 'base' + +module ZammadAPI + module Resources + class TicketState < Base + path 'api/v1/ticket_states' + end + end end diff --git a/lib/zammad_api/resources/user.rb b/lib/zammad_api/resources/user.rb index c5c667a..878ea7c 100644 --- a/lib/zammad_api/resources/user.rb +++ b/lib/zammad_api/resources/user.rb @@ -1,3 +1,19 @@ -class ZammadAPI::Resources::User < ZammadAPI::Resources::Base - url '/api/v1/users' +# frozen_string_literal: true + +require_relative 'base' + +module ZammadAPI + module Resources + class User < Base + path 'api/v1/users' + + # UsersController#index hardcodes `reorder(id: :asc)`, so it honours + # nothing but the paging - not even sort_by. + index_query_keys + + searchable true + + belongs_to :organization, class_name: 'Organization' + end + end end diff --git a/lib/zammad_api/response.rb b/lib/zammad_api/response.rb new file mode 100644 index 0000000..8b60b38 --- /dev/null +++ b/lib/zammad_api/response.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'json' + +require_relative 'errors' + +module ZammadAPI + # A decoded HTTP response. + # + # This deliberately does not expose Faraday objects, so that the HTTP client + # stays an implementation detail of {Transport}. + # + # @!attribute [r] status + # @return [Integer] HTTP status code + # @!attribute [r] headers + # @return [Hash{String => String}] response headers, keys downcased + # @!attribute [r] body + # @return [Hash, Array, String] JSON responses are decoded with symbol + # keys; every other content type is left as the raw body + # @!attribute [r] raw_body + # @return [String] the undecoded response body + # @!attribute [r] json + # @return [Boolean] whether {#body} was decoded from JSON + Response = Data.define(:status, :headers, :body, :raw_body, :json) + + class Response + SUCCESS_STATUSES = (200..299) + + # There is no reader here for the size of the whole result, and the + # absence is deliberate. This class carried a `reported_total` that read + # an `x-total-count` response header, and two stop conditions were built + # on it: {Collection#each} ended a walk one request early on it, and a + # has_many reader refused a list it judged truncated by it. + # + # Zammad has never sent that header, from any endpoint. Its only custom + # response header is `X-Failure`, and the totals it does report are + # fields in a JSON body: `only_total_count` and `with_total_count` on a + # search, `full` on the endpoints that render through + # model_index_render. So the reader answered nil every time, both guards + # were dead, and the one of them that could have acted - ending a walk on + # a figure the records did not corroborate - was the one that could have + # been wrong. {Collection#total_count} asks a search endpoint for its + # figure the way Zammad actually offers it. + + # @return [Boolean] whether the status code is in the 2xx range + def success? = SUCCESS_STATUSES.cover?(status) + + # Recorded at decode time, where the answer is known, rather than derived + # from `body` and `raw_body` being the same object. That identity held only + # while every producer was careful to hand the same String to both, and any + # edit that duped, re-encoded or normalised the raw body would have flipped + # this to true with nothing asserting otherwise. + # + # @return [Boolean] whether {#body} was decoded from JSON + def json? = json + + # Decodes a body the way this gem decodes every body, and says whether it + # did. + # + # Only JSON responses are decoded. Anything else - a proxy error page, a + # file download - is handed back untouched so that callers and error + # messages can still work with it. + # + # Here rather than on {Transport}, because {Test} has to answer the same + # way and was deciding from the Ruby type of the stub's body instead: a + # stub could declare `content-type: text/html` and still hand back a + # decoded Hash with `json?` true, where Zammad would have given the raw + # string and a ParseError. A stand-in whose decoding disagrees with the + # wire is the thing that kit exists to rule out. + # + # Public for that reason and no other, the way {Transport.stringify_query} + # is: both transports call it from outside this class. It is not part of + # what this gem promises a caller. + # + # @api private + # @param content_type [String, nil] + # @param raw_body [String] + # @return [Array(Hash | Array | String, bool)] the body and whether it was + # decoded from JSON + def self.decode_body(content_type, raw_body) + return [raw_body, false] if !content_type.to_s.include?('json') + return [raw_body, false] if raw_body.empty? + + [JSON.parse(raw_body, symbolize_names: true), true] + rescue JSON::ParserError + [raw_body, false] + end + + # Returns the decoded body once it matches the expected shape. + # + # Zammad answers with an object for a single record and an array for a + # list; anything else (a proxy error page, an unexpanded search result) + # is a {ParseError} rather than a confusing failure further downstream. + # + # @param shape [Symbol] +:object+ or +:array+ + # @param operation [String] description used in the error message + # @param resource_class [Class, nil] used in the error message + # @return [Hash, Array] + # @raise [ParseError] when the body has a different shape + def decoded(shape, operation:, resource_class: nil) + case [shape, body] + in [:object, Hash => object] then object + in [:array, Array => array] then array_of_objects!(array, operation: operation, resource_class: resource_class) + else + raise ParseError.build( + operation: operation, + expected: shape, + actual: body.class, + resource_class: resource_class + ) + end + end + + private + + # Only the top-level shape used to be checked, which is not what the + # promise above says. A search answering `[1, 2, 3]` - what an unexpanded + # one looks like - passed as an array, and every element went to + # `from_response`, which stored the Integer as a record's attributes. The + # first `record.id` then died with `TypeError: no implicit conversion of + # Symbol into Integer` from deep inside the gem: precisely the confusing + # failure further downstream. + def array_of_objects!(array, operation:, resource_class:) + offender = array.find { !it.is_a?(Hash) } + return array if offender.nil? + + raise ParseError.build( + operation: operation, + expected: 'array of objects', + actual: "an array holding #{offender.class}", + resource_class: resource_class + ) + end + end +end diff --git a/lib/zammad_api/test.rb b/lib/zammad_api/test.rb new file mode 100644 index 0000000..589dd8c --- /dev/null +++ b/lib/zammad_api/test.rb @@ -0,0 +1,467 @@ +# frozen_string_literal: true + +require 'json' + +require_relative '../zammad_api' + +module ZammadAPI + # A stand-in Zammad, for testing code that calls this client. + # + # Stub the endpoints the code under test will reach, hand it {#client}, then + # assert against {#requests}. No HTTP stack is involved, so nothing has to be + # intercepted at the socket level and the responses come back through the + # same decoding, error mapping and record building as real ones. + # + # @example + # require 'zammad_api/test' + # + # zammad = ZammadAPI::Test.new + # zammad.stub(:get, 'api/v1/tickets/1', body: {id: 1, title: 'Help', state: 'open'}) + # zammad.stub(:put, 'api/v1/tickets/1', body: {id: 1, state: 'closed'}) + # + # TicketCloser.new(zammad.client).close(1) + # + # zammad.requests.last.verb # => :put + # zammad.requests.last.body # => {state: "closed"} + # + # @example An error path + # zammad.stub(:get, 'api/v1/tickets/9', status: 404, body: {error: 'not found'}) + # # the code under test sees ZammadAPI::NotFoundError + class Test + # Raised when the code under test reaches an endpoint that was not stubbed. + # + # The message lists what is stubbed, because the usual cause is a path that + # differs from the expected one. + # + # Deliberately outside {ZammadAPI::Error}: this says the test is wrong, not + # that Zammad refused something, and code under test is written to handle + # the latter. Inside the hierarchy, the `rescue ZammadAPI::Error` that this + # gem's own examples recommend swallowed a forgotten stub and reported it + # as an API failure - so a test asserting the error path passed green over + # a request it had never declared. + class UnstubbedRequestError < StandardError; end + + # Raised when two stubs describe one request equally well. + # + # Two stubs that name different query parameters can both match a request + # carrying all of them, and there is no honest way to rank them: a search + # stubbed once for its records and once for its count is matched by both + # when `count` sends the search term and +only_total_count+ together. The + # stand-in used to pick one, hand back the wrong body, consume the stub on + # the way past, and then report the endpoint as unstubbed - three + # confusing symptoms for one fixable declaration. + # + # Outside {ZammadAPI::Error} for the same reason as + # {UnstubbedRequestError}: it says the test is wrong, not that Zammad + # refused anything. + class AmbiguousStubError < StandardError; end + + # One request the code under test made. + # + # The verb is +verb+ rather than +method+, because a member named +method+ + # would shadow +Object#method+ on every recorded request. + # + # @!attribute [r] verb + # @return [Symbol] +:get+, +:post+, +:put+ or +:delete+ + # @!attribute [r] path + # @return [String] path relative to the instance URL + # @!attribute [r] query + # @return [Hash{String => String, Array}] query parameters as the + # client sent them, stringified the way the real transport sends them + # @!attribute [r] body + # @return [Hash, nil] the request payload + # @!attribute [r] headers + # @return [Hash{String => String}] the headers the call asked for, + # downcased and stringified the way the real transport sends them. + # Empty for a request that named none; the headers the client sets + # from its own configuration are not in here, and the +From+ scope has + # a member of its own below. + # @!attribute [r] on_behalf_of + # @return [String, nil] the +From+ scope in effect, stringified the way + # the +From+ header carries it + Request = Data.define(:verb, :path, :query, :body, :headers, :on_behalf_of) + + # @return [Config] the configuration the stand-in client reports + attr_reader :config + + # @param options [Hash] any option accepted by {Config}; the defaults are + # enough for a client that never opens a connection + def initialize(**options) + @config = Config.new(url: 'https://zammad.test/', http_token: 'test-token', **options) + @stubs = {} + @requests = [] + @monitor = Mutex.new + @transport = Transport.new(self) + # Built once, and built straight onto the stand-in. Going through + # `Client.new` re-validated the config assembled one line above and then + # assembled a whole Faraday stack - auth, JSON, retries, adapter - only + # to swap it straight back out, which is the cost the comment here used + # to claim it was avoiding. A suite writing + # `let(:zammad) { ZammadAPI::Test.new }` paid it once per example. + @client = Client.build(@config, @transport) + end + + # A client that talks to this stand-in instead of to a Zammad. + # + # The same client every time; it holds no per-request state, and + # {Client#on_behalf_of} and {Client#with} return copies of their own. + # + # @return [Client] + attr_reader :client + + # Declares the response for one endpoint. + # + # Stubbing the same method and path again queues a second response: the + # first request gets the first, and the last stub answers every request + # after it. A +query+ matches when every parameter it names is present in + # the request with that value, so a stub does not have to repeat the + # +expand+, +page+ and +per_page+ parameters the client adds itself. + # + # A stub that names a +query+ is more specific than one that does not, and + # answers ahead of it however they were declared. Queueing applies within + # a scope: two stubs carrying the same +query+ describe a sequence, while + # a scoped stub and a catch-all are two separate answers, each of which + # keeps answering. + # + # @param method [Symbol] +:get+, +:post+, +:put+ or +:delete+ + # @param path [String] path relative to the instance URL, leading slash + # optional + # @param status [Integer] HTTP status to answer with + # @param body [Hash, Array, String, nil] a Hash or Array is serialized to + # JSON bytes, anything else is served as it is. Whether those bytes are + # then decoded is decided by the content-type, the way a real response + # decides it - so a Hash served as +text/html+ comes back undecoded, and + # a JSON String served as +application/json+ comes back decoded. + # @param headers [Hash] response headers. Names and values are stringified + # and names downcased, the way a {Response} carries them. A Hash or Array + # body is given +content-type: application/json+ unless this says + # otherwise. + # @param query [Hash, nil] only answer requests carrying these parameters + # @return [self] + def stub(method, path, status: 200, body: nil, headers: {}, query: nil) + # Stringified here rather than on each request, so that a query the + # transport would refuse - a nil value - is reported against the line + # that wrote the stub instead of against whichever request reached it. + scope = query && ::ZammadAPI::Transport.stringify_query(query) + # A scope that names nothing matches every request, which is what a stub + # with no query at all is. Kept apart, the two were equally specific and + # `query: {}` collided with a catch-all as an ambiguous pair rather than + # joining it. + scope = nil if scope.nil? || scope.empty? + + # Refused here rather than stringified into something the wire could not + # carry, and for the same reason the query above is: the error names the + # line that wrote the stub. A nil `Content-Type` became `''`, which then + # beat the JSON default this method supplies and served a Hash body + # undecoded, so the test failed inside the code under test with nothing + # to say the stub was at fault. + headers.each do |name, value| + next if value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) + + raise ArgumentError, "header #{name} was stubbed as #{value.inspect}, and a response header is always text: pass a String" + end + + @monitor.synchronize do + (@stubs[key(method, path)] ||= []) << { + status: status, + body: body, + headers: response_headers(headers, body), + query: scope + } + end + self + end + + # Every request the code under test made, oldest first. + # + # @return [Array] + def requests = @monitor.synchronize { @requests.dup.freeze } + + # Forgets the stubs and the recorded requests. + # + # @return [self] + def reset + @monitor.synchronize do + @stubs.clear + @requests.clear + end + self + end + + # @return [Array] one +"GET api/v1/groups"+ per stubbed endpoint + def stubbed = @monitor.synchronize { @stubs.keys.map { |method, path| "#{method.to_s.upcase} #{path}" } } + + # Reads both collections under the monitor, and reaches for @stubs rather + # than {#stubbed} because the monitor is a plain Mutex and would deadlock + # on the way back in. The counts used to be read outside it, so printing a + # stand-in from a failure message or a debugger raced the thread under + # test appending to @requests - which is the one thing the monitor is here + # to prevent. + def inspect = @monitor.synchronize { "#<#{self.class.name} stubbed=#{@stubs.size} requests=#{@requests.size}>" } + + # Answers a request from the stubs, recording it first. + # + # This is the {Transport} interface, called by the client rather than by a + # test. + # + # @api private + # @return [Response] + # @raise [ResponseError] for a stubbed non-2xx status + # @raise [UnstubbedRequestError] when no stub matches + def answer(method, path, operation:, query: nil, body: nil, headers: nil, resource_class: nil, on_behalf_of: nil) + relative = ::ZammadAPI::Transport.relative_path(path) + # Through the real transport's own stringification, so that {Request#query} + # and {Request#headers} hold what a request would have carried rather + # than the raw Ruby values. A stand-in that records a different shape + # than the wire makes an assertion pass here and fail in production, or + # the other way round - and the header rules are where that matters + # most, because a reserved name or a non-text value is refused on the + # wire and would otherwise sail through here. + params = ::ZammadAPI::Transport.stringify_query(query || {}) + fields = ::ZammadAPI::Transport.stringify_headers(headers || {}) + + stub = @monitor.synchronize do + @requests << Request.new(verb: method, path: relative, query: params, body: snapshot(body), headers: fields, on_behalf_of: on_behalf_of) + take(method, relative, params) + end + + raise UnstubbedRequestError, unstubbed_message(method, relative) if stub.nil? + + response = response_for(stub, params) + return response if response.success? + + raise ResponseError.build(response, operation: operation, resource_class: resource_class) + end + + private + + def key(method, path) = [method.to_sym, ::ZammadAPI::Transport.relative_path(path)] + + # The headers a stub answers with, downcased the way a {Response} carries + # them. + # + # A Hash or Array body is served as JSON, so it gets the content-type a + # real one would. {Transport#decode} always hands over a response whose + # headers name the type - it is what the decode branches on - while this + # set `json: true` directly and left the headers as written, so every + # stubbed response differed from the wire in a header a test can read. + # Code that branches on `response.headers['content-type']` passed against + # Zammad and failed against the stand-in, or the reverse, which is the + # divergence this kit exists to keep out. + # + # A caller's own content-type wins, so a test can still say the endpoint + # answered with something else. + def response_headers(headers, body) + # Values stringified as well as names. Only the name was normalised, so + # `headers: { 'X-Total-Count' => 7 }` reached the code under test as an + # Integer where the wire always carries "7": an assertion written the + # natural way passed against Zammad and failed here, or the reverse, and + # the one reader inside this gem had to tolerate both types to cope. + # Downcasing is what creates the collision, so it is refused here rather + # than merged: `{'Content-Type' => 'text/html', 'content-type' => + # 'application/json'}` kept whichever Hash order put last and dropped the + # other without a word - the same silent drop DuplicateKeys refuses for a + # query parameter, and here it decided whether the body was decoded. + normalized = DuplicateKeys + .normalize(headers, noun: 'header') { it.to_s.downcase } + .transform_values(&:to_s) + return normalized if !body.is_a?(Hash) && !body.is_a?(Array) + + { 'content-type' => 'application/json' }.merge(normalized) + end + + # A copy of the payload, frozen, for the record of what was sent. + # + # The caller's Hash used to be recorded by reference, so a test that built + # one payload, sent it, then changed it for a second call rewrote the + # first recorded request and asserted against a body that never went + # anywhere. `query` is already a fresh structure by the time it gets here, + # because the transport's stringification builds one; `body` is handed + # over untouched, and was the one shape left sharing state with the test. + def snapshot(value) = DeepCopy.frozen_copy(value) + + # Picks the stub that answers this request, and keeps the last one of its + # kind in place so that one stub can answer any number of requests while + # two describe a sequence. + # + # Sequencing runs within a query scope, not across the endpoint. Keying it + # on the last stub queued meant a query-scoped stub was consumed on its + # first use as soon as any other stub for the same verb and path existed + # behind it - the pair a `search(...).count` test needs - so the second + # count silently fell through to the records stub and walked the pages. + # + # A scope here is the exact set of parameters a stub names, not merely the + # fact that it names some. Grouped by whether a stub was scoped at all, + # two stubs carrying *different* scopes were read as a sequence and the + # first was consumed: stubbing a search once for its records and once for + # its count made `count` eat the records stub, hand back an Array where a + # count belonged, and then raise UnstubbedRequestError for a page that was + # stubbed all along. + # + # The most specific scope answers, counting the parameters it pins, so a + # stub written for one request still wins over a catch-all for the + # endpoint however they were declared. Scopes that tie are genuinely + # ambiguous and say so. + def take(method, path, params) + queued = @stubs[key(method, path)] + return nil if queued.nil? + + matching = queued.each_index.select { matches?(queued[it][:query], params) } + return nil if matching.empty? + + group = most_specific(queued, matching, method, path) + + group.one? ? queued[group.first] : queued.delete_at(group.first) + end + + # The matching stubs that share the most specific scope, in the order they + # were declared. + # + # @raise [AmbiguousStubError] when two scopes are equally specific + def most_specific(queued, matching, method, path) + scopes = matching.group_by { queued[it][:query] } + depth = scopes.keys.to_h { [it, it.nil? ? 0 : it.size] } + best = depth.values.max + + winners = scopes.select { |scope, _| depth[scope] == best } + raise AmbiguousStubError, ambiguous_message(method, path, winners.keys) if winners.size > 1 + + winners.values.first + end + + def ambiguous_message(method, path, scopes) + rendered = scopes.map { "query: #{it.inspect}" }.join(' and ') + "#{method.to_s.upcase} #{path} is matched equally well by #{scopes.size} stubs (#{rendered}), " \ + 'and this stand-in will not guess which one you meant. Name the parameters that tell the requests apart ' \ + '- a count stub that also carries the search term is more specific than one that does not - or leave the ' \ + 'more general stub unscoped, which makes it a catch-all that answers only what the scoped ones do not.' + end + + # Both sides have been through the transport's own stringification, the + # stub's when it was declared. Comparing a raw value against a stringified + # one worked for a scalar - `1.to_s` is `"1"` either way - and could never + # match for an Array: `[1, 2].to_s` is `"[1, 2]"` while the recorded + # `["1", "2"].to_s` is `"[\"1\", \"2\"]"`. `ids`, `role_ids`, `group_ids` + # and `permissions` are all array-valued search parameters, so a stub + # scoped to any of them silently never answered and the request came back + # as unstubbed. + def matches?(expected, params) + return true if expected.nil? + + expected.all? { |name, value| params[name] == value } + end + + # The records a stub holds are one page of them, not the answer to every + # page. + # + # A collection walks until a page repeats, comes back short, or comes back + # empty. A stub that keeps serving the same records to every page trips + # the first of those, so the obvious way to stand in for a list endpoint - + # one `stub(:get, 'api/v1/groups', body: [...])` - made every full read of + # that collection raise PaginationError, and the only way to find that out + # was to hit it. Against a real Zammad the same code works, because page 2 + # comes back empty; the stand-in is what differed. + # + # So a stub that does not name a page answers one, and a request for any + # page after it gets an empty one. A stub that does name a page is left + # exactly as written - that is how a test says what the second page holds. + def paged_body(stub, params, body) + return body if !body.is_a?(Array) || stub[:query]&.key?('page') + return body if [nil, '1'].include?(params['page']) + + [] + end + + # Decoded by {Response.decode_body}, the rule {Transport#decode} reads, so + # that a stub answers the way the wire does. + # + # `json:` used to be set from the Ruby type of the stub's body, which made + # the content-type beside it decorative: a stub could say `text/html` and + # still hand back a decoded Hash with `json?` true, where Zammad gives the + # raw string and `decoded(:object)` raises ParseError. A test asserting + # that path passed here and failed in production, which is the divergence + # this kit exists to rule out. + # Paged after decoding, not before. Paging read the stub's body as written, + # which was the same thing only while a list could arrive as an Array - and + # once the content-type decided decoding, a list stubbed as a JSON string + # decoded to one and was never paged, so it answered every page with the + # same records and every full read of that collection raised + # PaginationError. Against Zammad the same code works, because page two + # comes back empty. + def response_for(stub, params) + raw = raw_body_for(stub[:body]) + body, json = Response.decode_body(stub[:headers]['content-type'], raw) + paged = paged_body(stub, params, body) + # Re-serialized only where paging replaced the body, so `raw_body` stays + # the bytes the stub was written with for every other response. + return build_response(stub, body, raw, json: json) if paged.equal?(body) + + build_response(stub, paged, JSON.generate(paged), json: json) + end + + # The bytes a stub's body would have arrived as. A Hash or Array is what a + # test writes when it means JSON, so that is what it is serialized to; + # anything else is already the body. + def raw_body_for(body) + case body + when Hash, Array then JSON.generate(body) + when nil then '' + else body.to_s + end + end + + # A copy, because the stub keeps serving after this response is built and + # Response is a value. Handing out the stub's own Hash made every response + # from one stub share it, so a test that wrote to `response.headers` - + # editing a content-type to check a decode path, say - rewrote the + # stand-in for every later request in the example. The real transport builds a fresh + # hash per response, and this exists to behave like it. + def build_response(stub, body, raw_body, json:) + Response.new(status: stub[:status], headers: stub[:headers].dup.freeze, body: body, raw_body: raw_body, json: json) + end + + def unstubbed_message(method, path) + stubbed_endpoints = stubbed.empty? ? 'nothing is stubbed' : "stubbed: #{stubbed.join(', ')}" + "#{method.to_s.upcase} #{path} was not stubbed on this #{self.class.name} (#{stubbed_endpoints})" + end + + # Routes the client's requests to {Test#answer}, and carries the + # +on_behalf_of+ scope the way the real transport does. + # + # @api private + class Transport + include ::ZammadAPI::Transport::Verbs + + attr_reader :test, :on_behalf_of + + def initialize(test, on_behalf_of: nil) + @test = test + @on_behalf_of = on_behalf_of + end + + def config = test.config + + # Stringified like the real transport's, so that a recorded scope is what + # a request would have carried. + def with_on_behalf_of(identifier) = self.class.new(test, on_behalf_of: identifier&.to_s) + + # {Client#with} re-validates the options and hands them here. There is + # no connection to rebuild, and the derived client reports the derived + # config itself, so the stand-in keeps answering. + def with_config(_config) = self + + def request(method, path, operation:, query: nil, body: nil, headers: nil, resource_class: nil) + test.answer( + method, + path, + operation: operation, + query: query, + body: body, + headers: headers, + resource_class: resource_class, + on_behalf_of: on_behalf_of + ) + end + end + end +end diff --git a/lib/zammad_api/transport.rb b/lib/zammad_api/transport.rb index 5ade333..2dfb701 100644 --- a/lib/zammad_api/transport.rb +++ b/lib/zammad_api/transport.rb @@ -1,63 +1,612 @@ +# frozen_string_literal: true + require 'faraday' +require 'faraday/retry' +require 'json' require 'openssl' +require 'socket' +require 'timeout' + +require_relative 'duplicate_keys' +require_relative 'errors' +require_relative 'response' module ZammadAPI + # Performs the HTTP requests against a Zammad instance. + # + # Instances are immutable once built: {#with_on_behalf_of} returns a copy + # rather than mutating shared state, which makes a single transport safe to + # use from several threads. + # + # @api private class Transport - attr_accessor :url, :user, :password, :on_behalf_of - - def initialize(config, logger) - @logger = logger - @logger.debug "Transport to #{config[:url]} with #{config[:user]}:#{config[:password]}" - @conn = Faraday.new(url: config[:url]) do |faraday| - #faraday.request :url_encoded # form-encode POST params - #faraday.response :logger # log requests to STDOUT - faraday.adapter Faraday.default_adapter # make requests with Net::HTTP + # The verb shorthands, defined once for the two transports that answer + # them. + # + # {Test::Transport} stands in for this class precisely so that the code + # under test cannot tell them apart, and it carried its own copy of this + # loop - so a fifth verb added here would have left the stand-in unable to + # answer one the real transport had. The same argument as + # {Transport.relative_path} and {Resources::Base.member_path}: a rule kept + # in two places is one that gets changed in one of them. + # + # @api private + module Verbs + # @!method get(path, operation:, query: nil, headers: nil, resource_class: nil) + # @!method post(path, operation:, query: nil, body: nil, headers: nil, resource_class: nil) + # @!method put(path, operation:, query: nil, body: nil, headers: nil, resource_class: nil) + # @!method delete(path, operation:, query: nil, headers: nil, resource_class: nil) + # @return [Response] + %i[get post put delete].each do |verb| + define_method(verb) do |path, **options| + request(verb, path, **options) # steep:ignore NoMethod + end end - @conn.headers[:user_agent] = 'Zammad API Ruby' - if config[:http_token] && !config[:http_token].empty? - @conn.request :authorization, 'Token', config[:http_token] - elsif config[:oauth2_token] && !config[:oauth2_token].empty? - @conn.request :authorization, 'Bearer', config[:oauth2_token] - else - @conn.request :authorization, :basic, config[:user], config[:password] + end + + include Verbs + + # HTTP methods that Zammad handles idempotently and that are therefore + # safe to retry. POST is excluded on purpose - retrying it could create + # duplicate tickets or users. + RETRIABLE_METHODS = %i[get put delete head options].freeze + + # Transient statuses worth retrying. + RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze + + # Socket failures that mean the request ran out of time. Most adapters + # wrap these into a Faraday error, but not all do, and this gem lets a + # caller choose the adapter. + # + # `timeout` and `socket` are required above for this list and the next + # one. Both constants used to resolve only because `require 'faraday'` + # reaches net/http, which loads them - so the day Faraday stops eagerly + # loading its default adapter, or a caller picks a slimmer one, this class + # body raises NameError and `require 'zammad_api'` fails before a single + # request. The Steepfile has declared both libraries all along. + TIMEOUT_ERRORS = [Errno::ETIMEDOUT, Timeout::Error].freeze + + # Socket failures that mean the instance could not be reached. Listed + # rather than caught as SystemCallError, so that an unrelated Errno - a + # logger writing to a full disk, say - is not relabelled as a network + # problem. + CONNECTION_ERRORS = [ + Errno::ECONNREFUSED, + Errno::ECONNRESET, + Errno::EHOSTUNREACH, + Errno::ENETUNREACH, + Errno::EPIPE, + SocketError + ].freeze + + # TLS failures, for the same reason the socket errors above are listed: + # most adapters wrap one into a Faraday::SSLError, but this gem lets a + # caller choose the adapter and not every adapter does. Unlisted, a + # certificate mismatch through such an adapter left `request` raw, past + # the `rescue ZammadAPI::TransportError` a caller had written - the one + # thing the rescue clauses there exist to prevent. + # + # Not in {RETRIABLE_EXCEPTIONS}: a rejected certificate is a fact about + # the instance, not a transient failure, and retrying it only delays the + # error by the backoff. + SSL_ERRORS = [OpenSSL::SSL::SSLError].freeze + + # Failures worth retrying. Faraday::RetriableResponse is how the retry + # middleware signals a retriable status internally and must stay in this + # list, otherwise it escapes as an unhandled Faraday error. + # + # Composed from the two lists above rather than repeating them. Listed + # by hand, CONNECTION_ERRORS was left out: a transient ECONNRESET through + # an adapter that wraps it was retried as a Faraday::ConnectionFailed, + # while the same failure through an adapter that does not raised on the + # first attempt - so how often a request was retried depended on which + # adapter a caller picked, and `request` already documents these as + # failures this class expects to see. The retry middleware still only + # retries RETRIABLE_METHODS, so a POST is not repeated. + RETRIABLE_EXCEPTIONS = [ + Faraday::RetriableResponse, + Faraday::ConnectionFailed, + Faraday::TimeoutError, + *TIMEOUT_ERRORS, + *CONNECTION_ERRORS + ].freeze + + # Substrings that mark a request payload key as carrying a credential. + # Matching on a substring rather than the whole key covers the variants + # Zammad and OAuth actually send - password_confirm, access_token, + # refresh_token, client_secret - which an exact-match list silently let + # through to the log. + # + # The same argument reaches further than the first version of this list + # took it. `password` alone wrote `passwd` and `pwd` out in full, and + # `private_key` covered exactly one of the key spellings, so `api_key` + # and a bare `key` went to the log intact. `key` is matched as a word + # rather than as a substring, so `ssh_key` and `key` are covered while + # `keyboard` and `monkey_id` are not; `apikey` is spelled out because + # nothing separates the word there. + SENSITIVE_KEY_PATTERN = /password|passwd|pwd|token|secret|credential|api_?key|(? 2}` used to collapse + # into one parameter and send whichever Hash insertion order put last, + # dropping the other value without a word - the same silent-wrong-result + # shape as the dropped nil above, and the reason {Collection#where} + # normalises its keys before they ever reach here. + # + # @api private + # @param query [Hash] + # @return [Hash{String => String, Array, Hash}] + # @raise [ArgumentError] for a nil value, or for two keys that name the + # same parameter, either of them at any depth + def self.stringify_query(query) = stringify_query_hash(query, nil) + + # Stringifies one level of a query, at whatever depth it sits. + # + # The duplicate check lives here rather than at the top level alone, + # because `condition` - which the search endpoints read, and which + # {ResourceProxy::SEARCH_QUERY_KEYS} lists so that {Collection#where} + # accepts it - is a Hash, and two spellings inside it collapsed exactly + # the way two spellings at the top level did. The nil check below has been + # at every depth all along; this is the same kind of rule. + # + # The key each name was first seen as is kept, so the message can print + # both spellings. Naming only the second left the reader to guess the + # first, which in a query assembled across several merges is the whole of + # the debugging. + # + # @api private + # @param hash [Hash] + # @param prefix [String, nil] the parameter path this Hash sits at + # @return [Hash{String => String, Array, Hash}] + def self.stringify_query_hash(hash, prefix) + DuplicateKeys + .normalize(hash, prefix: prefix, &:to_s) + .to_h { |name, value| [name, stringify_query_value(prefix ? "#{prefix}[#{name}]" : name, value)] } + end + + # Stringifies the scalars and leaves the structure to Faraday. + # + # A nested value used to be rendered with to_s, so the `condition` that + # Zammad's search endpoints read - and that {ResourceProxy::SEARCH_QUERY_KEYS} + # lists, so {Collection#where} accepts it - went on the wire as a Ruby + # inspect string. Zammad could not parse that, dropped the parameter, and + # answered with an unnarrowed search that nothing marked as unnarrowed. + # Faraday's default encoder renders a Hash as + # `condition[ticket.state_id][operator]=is`, which is the shape Rails reads + # back, so the structure is handed over intact. + # + # @api private + # @param key [String] the parameter path, for the error message + # @param value [Object] + # @return [String, Array, Hash] + # @raise [ArgumentError] for a nil value + def self.stringify_query_value(key, value) + case value + when nil then raise ArgumentError, "query parameter #{key} is nil, and Zammad has no way to read that: pass a value, or leave the parameter out" + when Hash then stringify_query_hash(value, key) + when Array then value.each_with_index.map { |inner, index| stringify_query_value("#{key}[#{index}]", inner) } + else value.to_s end end - %w[get post put delete].each do |method| - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method}(params) # def get(params) - run_request(:#{method}, params) # run_request(:get, params) - end # end - RUBY + # Header names this class sets from the configuration, and will not take + # from a caller. + # + # +Authorization+ is built from the credentials {Config} validated, and + # +From+ is what {Client#on_behalf_of} means. Faraday's authorization + # middleware leaves a header that is already set alone, so a raw request + # carrying one of these would have replaced the client's own - quietly, + # and while {Client#inspect} went on reporting the authentication scheme + # it was built with. Naming the option that does mean it is the useful + # answer; overwriting in silence is not. + RESERVED_HEADERS = { + 'authorization' => 'authentication is configured on the client: pass http_token:, oauth2_token:, or user: and password:, or build a second client with Client#with', + 'from' => 'the From header is what on_behalf_of sets: use client.on_behalf_of(...) to perform requests for another user' + }.freeze + + # The headers a request carries beyond the ones this class sets. + # + # Names are downcased, which is what makes two spellings of one header + # comparable at all: HTTP treats them as the same header, so + # +{'Accept' => 'a', 'accept' => 'b'}+ would otherwise send whichever Hash + # order put last and drop the other without a word - the silent drop + # {DuplicateKeys} exists to refuse. + # + # Values are stringified for the reason {#with_on_behalf_of} stringifies + # the +From+ scope: a header is text on the wire, and a value of another + # type reaches Net::HTTP intact and dies there with + # `undefined method 'strip' for an instance of Integer`. + # + # Public for the same reason as {.stringify_query}: {Test} records what a + # request would have carried, and a stand-in whose recorded values + # disagree with the wire makes green tests mean less than they appear to. + # + # @api private + # @param headers [Hash] + # @return [Hash{String => String}] + # @raise [ArgumentError] for a nil value, a value that is not text, two + # spellings of one header, or a header this class sets itself + def self.stringify_headers(headers) + DuplicateKeys + .normalize(headers, noun: 'header') { it.to_s.downcase } + .to_h { |name, value| [name, stringify_header_value(name, value)] } + end + + # @api private + # @return [String] + def self.stringify_header_value(name, value) + reserved = RESERVED_HEADERS[name] + raise ArgumentError, "header #{name} is set by this client, not by a request: #{reserved}" if reserved + raise ArgumentError, "header #{name} is nil, and a header is always text: pass a value, or leave the header out" if value.nil? + raise ArgumentError, "header #{name} was given as #{value.inspect}, and a header is always text: pass a String" if !value.is_a?(String) && !value.is_a?(Symbol) && !value.is_a?(Numeric) + + value.to_s + end + + # Percent-encodes one segment of a request path. + # + # Record ids are pasted into the path, and an id taken straight from a + # request parameter used to be pasted in whole: `find("1/../../api/v1/ + # users/1")` resolved to the users endpoint, so the parameter, not the + # call, chose which records the verb applied to. Encoding everything + # outside the unreserved set keeps a separator inside the segment it was + # written in, and a caller that really means a sub-path can spell it out + # with the raw request methods. + # + # A dot segment is refused rather than encoded. `.` and `..` are unreserved + # all the way through, so the encoding below returns them exactly as they + # arrived and `find('..')` still resolved one path level up - onto the + # index endpoint, and through a has_many path onto every article on the + # instance offered as one ticket's. Percent-encoding the dots would hold + # them inside the segment here, but a proxy that normalises a path before + # routing it would undo that, and no Zammad record is named for one. + # + # Public for the same reason as {.stringify_query}: {Test} builds the + # paths it records with it. + # + # @api private + # @param value [Object] + # @return [String] + # @raise [ArgumentError] when there is nothing to send, or when the id + # navigates instead of naming a record + def self.escape_path_segment(value) + segment = value.to_s + raise ArgumentError, 'a record id is required, and this one is empty' if segment.empty? + raise ArgumentError, "#{segment.inspect} points at another endpoint rather than naming a record" if DOT_SEGMENTS.include?(segment) + + segment.gsub(UNRESERVED_IN_PATH) { |character| character.bytes.map { format('%%%02X', it) }.join } + end + + # Strips the leading slashes from a path so that it resolves against the + # instance URL rather than against the host. + # + # {Config#url} always ends in a slash and every request path is appended + # relative to it, so a leading slash would drop the sub-path of a Zammad + # served from one - `https://host/zammad/` + `/api/v1/tickets` resolves to + # `https://host/api/v1/tickets`. + # + # Public for the same reason as {.stringify_query} and + # {.escape_path_segment}: {Client} and {Test} both apply this rule, the + # latter to decide which stub a request matches, and a path rule kept in + # three places is one that gets changed in one of them - at which point + # the stand-in quietly stops matching what the client sends. + # + # @api private + # @param path [Object] + # @return [String] + def self.relative_path(path) = path.to_s.sub(%r{\A/+}, '') + + # @param config [Config] + def initialize(config) + @config = config + @on_behalf_of = nil + @connection = build_connection + end + + # Returns a copy of this transport that sends the +From+ header. + # + # A user id is a documented way to name the user, and arrives here as an + # Integer. Header values are stringified here rather than at the point the + # header is set, so that {Test} records the value the wire would carry - + # an Integer used to reach Net::HTTP intact and die there with + # `undefined method 'strip' for an instance of Integer`, while the test + # kit accepted it happily. + # + # @param identifier [String, Integer, nil] login, email or user id + # @return [Transport] + def with_on_behalf_of(identifier) + copy = dup + copy.instance_variable_set(:@on_behalf_of, identifier&.to_s) + copy + end + + # Returns a transport of this kind configured with +config+, keeping any + # {#with_on_behalf_of} scope. + # + # {Client#with} goes through here rather than building a Transport itself, + # so that a client whose transport was replaced - the test kit's stand-in, + # say - derives another of the same kind instead of silently reverting to + # a real HTTP one. + # + # @param config [Config] + # @return [Transport] + def with_config(config) = self.class.new(config).with_on_behalf_of(on_behalf_of) + + # Performs a request and raises on anything but a 2xx response. + # + # @param method [Symbol] +:get+, +:post+, +:put+ or +:delete+ + # @param path [String] path relative to {Config#url} + # @param operation [String] description used in error messages + # @param query [Hash, nil] query string parameters + # @param body [Hash, nil] request payload, encoded as JSON + # @param headers [Hash, nil] request headers, beyond the ones this class + # sets from the configuration + # @param resource_class [Class, nil] used in error messages + # @return [Response] + # @raise [ResponseError] for non-2xx responses + # @raise [TimeoutError] when the request timed out + # @raise [ConnectionError] when the instance was unreachable + # + # Every failure leaves here as a {ZammadAPI::Error}. The bare socket + # errors are caught alongside the Faraday ones because they are already + # in {RETRIABLE_EXCEPTIONS}, which is this class saying it expects to see + # them: an adapter that does not wrap them used to let them out raw once + # the retries were spent, past every rescue a caller had written. + def request(method, path, operation:, query: nil, body: nil, headers: nil, resource_class: nil) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = decode(perform(method, path, query, body, headers)) + log_response(method, path, response, started) + + return response if response.success? + + raise ResponseError.build(response, operation: operation, resource_class: resource_class) + rescue Faraday::TimeoutError, *TIMEOUT_ERRORS => e + raise TimeoutError, "Can't #{operation}: request to #{path} timed out (#{e.message})" + rescue Faraday::SSLError, *SSL_ERRORS => e + raise ConnectionError, "Can't #{operation}: TLS handshake with #{config.redacted_url} failed (#{e.message})" + rescue Faraday::ConnectionFailed, *CONNECTION_ERRORS => e + raise ConnectionError, "Can't #{operation}: #{config.redacted_url} is unreachable (#{e.message})" end private - def run_request(verb, param) - @logger.debug "#{verb.to_s.upcase}: #{@url}#{param[:url]}" + def perform(method, path, query, body, headers) + # Built before the request is logged, so a rejected query or header does + # not leave a line claiming a request that was never made. + params = query && Transport.stringify_query(query) + fields = headers && Transport.stringify_headers(headers) + log_request(method, path, query, body, fields) - with_params = !param[:params].nil? - if with_params - @logger.debug "Params: #{param[:params].inspect}" + @connection.public_send(method, path) do |request| + request.params.update(params) if params + # One at a time rather than in bulk, so that every name goes through + # Faraday's own case-insensitive writer and replaces the header it + # names rather than sitting beside it under another spelling. + fields&.each { |name, value| request.headers[name] = value } + request.body = body if body + request.headers['From'] = on_behalf_of if on_behalf_of end + end - response = @conn.public_send(verb) do |req| - req.url param[:url] + def build_connection + connection = Faraday.new( + url: config.url, + proxy: config.proxy, + ssl: { verify: config.ssl_verify }, + request: { timeout: config.timeout, open_timeout: config.open_timeout }, + headers: { 'User-Agent' => config.user_agent, 'Accept' => 'application/json' } + ) do |faraday| + apply_authentication(faraday) + faraday.request :json + faraday.request :retry, retry_options + # Last in the stack, so a caller's middleware sees the request as this + # gem finished building it and the response before anything else does. + config.middleware&.call(faraday) + faraday.adapter(config.adapter || Faraday.default_adapter) + end - if with_params - req.headers['Content-Type'] = 'application/json' - req.body = param[:params].to_json - end + refuse_decoding_middleware!(connection) + connection + rescue ZammadAPI::Error + raise + rescue => e + # An unregistered adapter, a proxy that is not a URL, or a middleware + # that rejects its options is a configuration mistake, and neither + # Faraday nor URI is part of this gem's surface. + # + # Caught as StandardError rather than as Faraday::Error, because the + # failures that do not come from Faraday are the ones a caller is least + # equipped to place: `proxy: 'http://user:pa ss@host'` escaped as + # URI::InvalidURIError and a `middleware` callable that raises escaped + # as whatever it raised, both straight past the + # `rescue ZammadAPI::ConfigurationError` that building a client is + # documented to need. The class is named in the message because + # "bad URI (is not URI?)" on its own says nothing about where to look. + raise ConfigurationError, "config could not be used to build a connection: #{e.class}: #{redact_config_values(e.message)}" + end - if !on_behalf_of.nil? - req.headers['From'] = on_behalf_of - end + # The underlying error quotes the value it rejected, and for a proxy that + # value carries its credentials: `proxy: 'http://user:pa ss@host:3128'` + # came back as URI::InvalidURIError with the whole URL, password included, + # in its message - and that message goes into the ConfigurationError + # above, which lands in every log and exception report. The one case + # {Config#inspect} and USERINFO_PATTERN exist to prevent, reached by + # another route. + # + # The configured values are swapped for their redacted forms rather than + # the message being dropped, because "bad URI (is not URI?)" without the + # URI says nothing about where to look. Substring replacement, because + # the message embeds the value verbatim, and the url as well as the proxy + # because 1.x callers still put credentials in the instance URL. + def redact_config_values(message) + [config.proxy, config.url].compact.inject(message.to_s) do |text, value| + redacted = config.redacted(value) + + # The block form. A String replacement expands the backslash + # sequences it contains, and the replacement here is the configured + # value with its userinfo blanked - so a value carrying `\0` or `\&` + # put the whole matched text back, credentials included, into the + # message it had just been taken out of, and one carrying `\1` cut + # the replacement short instead. + # + # Both spellings of the value, because the error being quoted may + # have inspected it rather than interpolated it: URI::InvalidURIError + # does, and inspect escapes exactly the backslashes, quotes and + # control characters that make a URL invalid in the first place. The + # escaped text no longer equals the value as configured, so the swap + # matched nothing and left the password standing in full - the case + # this method exists for, reached through the quoting rather than + # the value. + inspected = value.inspect + quoted = inspected[1...-1] || inspected + + text.gsub(value) { redacted }.gsub(quoted) { redacted } + end + end - yield(req) if block_given? + def apply_authentication(faraday) + case config.authentication_scheme + when :http_token then faraday.request :authorization, 'Token', config.http_token + when :oauth2_token then faraday.request :authorization, 'Bearer', config.oauth2_token + else faraday.request :authorization, :basic, config.user, config.password end + end - @logger.debug "Response: #{response.body}" - response + def retry_options + { + max: config.retries, + interval: config.retry_interval, + interval_randomness: 0.5, + backoff_factor: 2, + retry_statuses: RETRIABLE_STATUSES, + methods: RETRIABLE_METHODS, + exceptions: RETRIABLE_EXCEPTIONS + } end + + def decode(faraday_response) + headers = faraday_response.headers.to_h.transform_keys { it.to_s.downcase }.freeze + raw_body = raw_body!(faraday_response.body) + body, json = Response.decode_body(headers['content-type'], raw_body) + + Response.new( + status: faraday_response.status, + headers: headers, + body: body, + raw_body: raw_body, + json: json + ) + end + + # Middleware that reads the body before this gem can. Matched by name so + # that naming one does not require it to be loaded. + DECODING_MIDDLEWARE = %w[Faraday::Response::Json].freeze + private_constant :DECODING_MIDDLEWARE + + # Refuses a stack that would decode the response body, while the stack is + # still the only thing that has happened. + # + # `raw_body!` below catches the same mistake, but only once a response is + # in hand - which is one request too late: `client.group.create(...)` sent + # the POST, Zammad created the group, and only then did a + # ConfigurationError come back, so a caller retrying what looked like a + # configuration failure created a second one. The middleware is fully + # visible here, where the unregistered adapter and the unusable proxy are + # already refused before anything is sent. + def refuse_decoding_middleware!(connection) + offender = connection.builder.handlers.find { DECODING_MIDDLEWARE.include?(it.klass.name) } + return if offender.nil? + + raise ConfigurationError, + "the configured middleware includes #{offender.klass.name}, which decodes the response body before " \ + 'this gem can. This gem parses JSON itself, and hands the undecoded bytes to attachment downloads, ' \ + 'so it needs the body as it arrives: drop `c.response :json` from the `middleware:` callable.' + end + + # The body as it came off the wire. + # + # `middleware:` is a documented seam and `c.response :json` is a + # reasonable thing to put through it, at which point Faraday hands over a + # Hash rather than the bytes. `to_s` turned that into a Ruby inspect + # string, which JSON.parse then refused, so every record built from the + # response died in {Response#decoded} with a ParseError naming Zammad for + # what the caller's own stack had done. + # + # A backstop rather than the first line of defence: the stack is checked + # when it is built, which catches `c.response :json` before a request goes + # out. This is what is left for a middleware that decodes without being one + # of the names that check knows. + # + # Said plainly instead. Re-encoding the parsed structure was the other + # way out, and it is worse than it looks: {Response#raw_body} is + # documented as the undecoded body and + # {Resources::TicketArticleAttachment#download} hands exactly those bytes + # back as the file, so a re-encoding silently returns something that is + # not what Zammad stored - and JSON.generate has its own failures, which + # would escape from here past the `rescue ZammadAPI::Error` every caller + # is told to write. Once the bytes are gone they cannot be recovered, so + # the honest answer is to name the cause while it is still visible. + def raw_body!(body) + return body.to_s if body.nil? || body.is_a?(String) + + raise ConfigurationError, + 'the configured middleware decoded the response body before this gem could ' \ + "(Faraday handed over #{body.class} rather than the raw body). This gem parses JSON itself, " \ + 'and hands the undecoded bytes to attachment downloads, so it needs the body as it arrived: ' \ + 'drop the parsing middleware - `c.response :json` is the usual one - from the `middleware:` callable.' + end + + def log_request(method, path, query, body, headers) + logger.debug { "Zammad API request: #{method.to_s.upcase} #{path}#{" query=#{redact(query).inspect}" if query}#{" headers=#{redact(headers).inspect}" if headers}" } + logger.debug { "Zammad API payload: #{redact(body).inspect}" } if body + end + + def log_response(method, path, response, started) + duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round + logger.debug { "Zammad API response: #{method.to_s.upcase} #{path} -> #{response.status} in #{duration}ms" } + end + + def redact(value) + case value + when Hash then value.to_h { |key, nested| [key, SENSITIVE_KEY_PATTERN.match?(key.to_s) ? REDACTED : redact(nested)] } + when Array then value.map { redact(it) } + else value + end + end + + def logger = config.logger end end diff --git a/lib/zammad_api/version.rb b/lib/zammad_api/version.rb index 4915c54..a2ab9db 100644 --- a/lib/zammad_api/version.rb +++ b/lib/zammad_api/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ZammadAPI - VERSION = '1.4.0'.freeze + VERSION = '2.0.0' end diff --git a/script/check_connection.rb b/script/check_connection.rb new file mode 100755 index 0000000..3d52a86 --- /dev/null +++ b/script/check_connection.rb @@ -0,0 +1,323 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# End-to-end check that this gem can actually drive a live Zammad instance. +# +# Deliberately standalone: it does not load the spec suite, so it still +# reports usefully when the specs themselves are what is broken. CI runs it +# after booting Zammad and before the integration specs, so a broken +# gem-to-Zammad link fails fast with a readable transcript. +# +# TEST_URL=http://localhost:3000/ \ +# TEST_USER=admin@example.com \ +# TEST_PASSWORD=test \ +# bundle exec ruby script/check_connection.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'zammad_api' +require 'faraday' +require 'json' +require 'securerandom' + +URL = ENV['TEST_URL'] || 'http://localhost:3000/' +LOGIN = ENV['TEST_USER'] || 'admin@example.com' +PASSWORD = ENV['TEST_PASSWORD'] || 'test' +SUFFIX = SecureRandom.hex(4) + +@failures = [] +@group = nil +@ticket = nil + +# Runs one named check, printing its result and recording any failure. +def check(name) + detail = yield + puts format(' ok %-42s %s', name: name, detail: detail) + true +rescue => e + @failures << name + puts format(' FAIL %-42s %s: %s', name: name, error: e.class, message: e.message) + false +end + +# A check whose failure makes every later check meaningless, so the run stops +# rather than burying the cause under cascading NoMethodErrors. +def check!(name, &block) + return if check(name, &block) + + puts "\nAborting: '#{name}' is a precondition for the remaining checks." + finish +end + +def section(title) + puts "\n#{title}" +end + +def parse_json(body) + JSON.parse(body) +rescue JSON::ParserError + {} +end + +def cleanup + return if @group.nil? && @ticket.nil? + + section 'Cleanup' + check('destroy the ticket') { CLIENT.ticket.destroy(@ticket.id) } if @ticket + check('destroy the group') { CLIENT.group.destroy(@group.id) } if @group +end + +def finish + cleanup + puts + if @failures.empty? + puts 'All checks passed.' + exit 0 + end + + puts "#{@failures.size} check(s) failed:" + @failures.each { puts " - #{it}" } + exit 1 +end + +puts "Checking #{URL} with zammad_api #{ZammadAPI::VERSION} on Ruby #{RUBY_VERSION}" + +section 'Instance setup' + +# The auto wizard creates the admin account. An instance that is already +# configured reports failure here, which is fine as long as it is set up. +check!('auto wizard or already configured') do + wizard = parse_json(Faraday.new(url: URL).get('api/v1/getting_started/auto_wizard').body) + next 'auto wizard ran' if wizard['auto_wizard_success'] + + # A configured Zammad requires authentication even for + # /api/v1/getting_started, so the setup state cannot be read from there. + # An authenticated request answers the only question that matters. + ZammadAPI::Client.new(url: URL, user: LOGIN, password: PASSWORD, retries: 0) + .group.all.page(1, of: 1).to_a + 'already set up' +end + +CLIENT = ZammadAPI::Client.new(url: URL, user: LOGIN, password: PASSWORD, timeout: 30) + +section 'Client' + +check('credentials are redacted') do + raise 'password leaked into inspect output' if CLIENT.config.inspect.include?(PASSWORD) + + 'password absent from inspect output' +end + +check('derived client re-validates options') do + CLIENT.with(timeout: 45) + CLIENT.with(timeout: -1) + raise 'expected ConfigurationError' +rescue ZammadAPI::ConfigurationError + 'invalid option rejected' +end + +section 'Records' + +check!('create a group') do + @group = CLIENT.group.create(name: "smoke-#{SUFFIX}", note: 'created by check_connection.rb') + raise 'no id assigned' if @group.id.nil? + + "id=#{@group.id}" +end + +check('find it back') do + found = CLIENT.group.find(@group.id) + raise "name mismatch: #{found.name}" if found.name != "smoke-#{SUFFIX}" + + found.name +end + +check('update only the changed attribute') do + @group.note = 'updated' + raise 'change not staged' if !@group.changed? + + @group.save + CLIENT.group.find(@group.id).note +end + +check('reload discards local changes') do + @group.note = 'not saved' + @group.reload.note +end + +check('pattern match a record') do + case CLIENT.group.find(@group.id) + in { name: String => name, active: true } then name + else raise 'record did not match the expected pattern' + end +end + +section 'Collections' + +check('iterate every group across pages') do + names = CLIENT.group.all.map(&:name) + raise 'created group missing from .all' if !names.include?("smoke-#{SUFFIX}") + + "#{names.size} groups" +end + +check('fetch a single page') { "#{CLIENT.group.all.page(1, of: 1).to_a.size} record" } + +check('lazy enumeration stops early') { CLIENT.group.all.lazy.map(&:id).first(1).inspect } + +check('page by page') do + pages = 0 + CLIENT.group.all.in_batches(of: 1) { pages += 1 } + "#{pages} page(s)" +end + +check('search') { "#{CLIENT.user.search(LOGIN).to_a.size} hits" } + +# These were never driven against a live Zammad, which is why nobody noticed +# that an attribute passed to `where` never reached the query at all. +check('find_by returns a record that matches') do + found = CLIENT.group.find_by(name: "smoke-#{SUFFIX}") + raise 'no record found' if found.nil? + raise "found the wrong record: #{found.name}" if found.name != "smoke-#{SUFFIX}" + + "id=#{found.id}" +end + +# Two string attributes, because one is the shape that works whatever the +# search term looks like. Joined into a single term - which is what this used +# to send - the query asks every string column to contain the whole of it, and +# on an instance searching through SQL LIKE rather than Elasticsearch no +# column does, so a record that exists comes back as nil. A stubbed unit spec +# cannot see that; it answers whatever term it is handed. +check('find_by matches on two string attributes') do + group = CLIENT.group.find(@group.id) + found = CLIENT.group.find_by(name: group.name, note: group.note) + raise 'no record found' if found.nil? + raise "found the wrong record: #{found.name}" if found.id != group.id + + "id=#{found.id}" +end + +check('find_by returns nil for no match') do + found = CLIENT.group.find_by(name: "no-such-group-#{SUFFIX}") + raise "expected nil, got #{found.inspect}" if !found.nil? + + 'nil' +end + +check('find_by! raises for no match') do + CLIENT.group.find_by!(name: "no-such-group-#{SUFFIX}") + raise 'expected NotFoundError' +rescue ZammadAPI::NotFoundError + 'raised' +end + +check('exists?') do + raise 'the created group is missing' if !CLIENT.group.exists?(@group.id) + raise 'a made-up id exists' if CLIENT.group.exists?(0) + + 'true and false' +end + +check('pluck') do + names = CLIENT.group.all.pluck(:name) + raise 'created group missing' if !names.include?("smoke-#{SUFFIX}") + + "#{names.size} names" +end + +check('empty? on a search with no hits') do + raise 'expected empty' if !CLIENT.group.search("no-such-group-#{SUFFIX}").empty? + + 'true' +end + +check('where refuses a filter the endpoint would drop') do + CLIENT.group.where(name: "smoke-#{SUFFIX}").to_a + raise 'expected ArgumentError' +rescue ArgumentError => e + e.message.split('.').first +end + +check('where accepts a parameter the endpoint honours') do + "#{CLIENT.group.all.where(sort_by: 'name').page(1, of: 2).to_a.size} record(s)" +end + +section 'Tickets' + +check!('create a ticket with its first article') do + @ticket = CLIENT.ticket.create( + title: "smoke ticket #{SUFFIX}", + group: 'Users', + customer: LOGIN, + article: { subject: 'smoke', body: 'created by check_connection.rb', type: 'note' } + ) + "number=#{@ticket.number}" +end + +check('read its articles') { "#{@ticket.articles.size} article(s)" } + +check('add another article') do + CLIENT.ticket.find(@ticket.id).article(subject: 'second', body: 'another one', type: 'note').id +end + +check('article count grew') do + count = @ticket.articles.size + raise "expected 2 articles, got #{count}" if count != 2 + + count +end + +check('attachment metadata and download') do + CLIENT.ticket.find(@ticket.id).article( + subject: 'with attachment', + body: 'see attachment', + type: 'note', + attachments: [{ filename: 'smoke.txt', data: ['smoke test 123'].pack('m0'), 'mime-type': 'text/plain' }] + ) + attachment = @ticket.articles.last.attachments.first + raise 'no attachment returned' if attachment.nil? + + # Once: each call is a full attachment fetch, and this transcript is meant to + # be a fast read of a freshly booted Zammad. + contents = attachment.download + raise "unexpected content: #{contents.inspect}" if contents != 'smoke test 123' + + "#{attachment.filename} (#{contents.bytesize} bytes)" +end + +section 'On behalf of another user' + +check('sends the From header') do + CLIENT.on_behalf_of(LOGIN) { |scoped| scoped.ticket.find(@ticket.id).number } +end + +check('block form leaves the outer client unscoped') do + CLIENT.on_behalf_of(LOGIN) { |scoped| scoped.group.find(@group.id) } + CLIENT.group.find(@group.id).name +end + +section 'Errors' + +check('missing record raises NotFoundError') do + CLIENT.group.find(0) + raise 'expected NotFoundError' +rescue ZammadAPI::NotFoundError => e + "status=#{e.status}" +end + +check('invalid attributes raise a ClientError') do + CLIENT.group.create({}) + raise 'expected a ClientError' +rescue ZammadAPI::ClientError => e + "status=#{e.status}" +end + +check('bad credentials raise AuthenticationError') do + ZammadAPI::Client.new(url: URL, user: 'nobody', password: 'wrong', retries: 0).group.find(1) + raise 'expected AuthenticationError' +rescue ZammadAPI::AuthenticationError => e + "status=#{e.status}" +end + +finish diff --git a/sig/vendor/faraday.rbs b/sig/vendor/faraday.rbs new file mode 100644 index 0000000..37a69db --- /dev/null +++ b/sig/vendor/faraday.rbs @@ -0,0 +1,50 @@ +# Minimal declarations for the parts of Faraday this gem uses. +# Faraday does not ship RBS signatures of its own. + +module Faraday + class Error < StandardError + end + + class ConnectionFailed < Error + end + + class TimeoutError < Error + end + + class SSLError < Error + end + + class RetriableResponse < Error + end + + class Response + def status: () -> Integer + def body: () -> untyped + def headers: () -> untyped + end + + class Request + def url: (String) -> void + def params: () -> Hash[String, untyped] + def body=: (untyped) -> void + def headers: () -> Hash[String, untyped] + end + + # Faraday.new yields the connection itself, which delegates the stack + # building methods below to its RackBuilder. + class Connection + def get: (String) ?{ (Request) -> void } -> Response + def post: (String) ?{ (Request) -> void } -> Response + def put: (String) ?{ (Request) -> void } -> Response + def delete: (String) ?{ (Request) -> void } -> Response + def public_send: (Symbol, *untyped) ?{ (Request) -> void } -> Response + + def request: (Symbol, *untyped) -> void + def response: (Symbol, *untyped) -> void + def adapter: (untyped, *untyped) -> void + def use: (untyped, *untyped) -> void + end + + def self.new: (**untyped) ?{ (Connection) -> void } -> Connection + def self.default_adapter: () -> Symbol +end diff --git a/sig/vendor/internal.rbs b/sig/vendor/internal.rbs new file mode 100644 index 0000000..aea3d0b --- /dev/null +++ b/sig/vendor/internal.rbs @@ -0,0 +1,14 @@ +# Declarations that hold only while this repository is type-checked, and are +# deliberately not part of what the gem publishes. +# +# ResourceProxy forwards the collection shorthands with Forwardable, so the +# type checker has to know the class extends it before it can see +# `def_delegators` on the singleton. Consumers never need that: the published +# signatures declare each forwarded method concretely, which is what a caller +# is checked against, and naming Forwardable there would make `rbs validate` +# fail for anyone who has not loaded the stdlib declarations for it. +module ZammadAPI + class ResourceProxy + extend Forwardable + end +end diff --git a/sig/zammad_api.rbs b/sig/zammad_api.rbs new file mode 100644 index 0000000..3a9109a --- /dev/null +++ b/sig/zammad_api.rbs @@ -0,0 +1,3 @@ +module ZammadAPI + VERSION: String +end diff --git a/sig/zammad_api/associations.rbs b/sig/zammad_api/associations.rbs new file mode 100644 index 0000000..d5663d0 --- /dev/null +++ b/sig/zammad_api/associations.rbs @@ -0,0 +1,35 @@ +module ZammadAPI + module Associations + # The readers are defined by the belongs_to and has_many declarations in + # lib/zammad_api/resources, onto a per-resource subclass of this one that + # has no name to declare. They are listed here so that callers get types + # and completion; every name is declared once and means the same target + # wherever it is declared, so the type of a given reader is accurate. The + # class is wider than any one resource, so a reader the record does not + # declare type-checks here and raises NoMethodError at runtime. + class Proxy + @record: Resources::Base + @cache: Hash[Symbol, untyped] + + def initialize: (Resources::Base record) -> void + def inspect: () -> String + + def created_by: () -> Resources::User? + def updated_by: () -> Resources::User? + def customer: () -> Resources::User? + def owner: () -> Resources::User? + def organization: () -> Resources::Organization? + def group: () -> Resources::Group? + def state: () -> Resources::TicketState? + def priority: () -> Resources::TicketPriority? + def ticket: () -> Resources::Ticket? + def articles: () -> Array[Resources::TicketArticle] + + private + + def belongs_to_target: (Symbol name, String class_name, Symbol foreign_key) -> untyped + def has_many_target: (Symbol name, String class_name, ^(String) -> String path) -> Array[untyped] + def resolve: (String class_name) -> untyped + end + end +end diff --git a/sig/zammad_api/attribute_access.rbs b/sig/zammad_api/attribute_access.rbs new file mode 100644 index 0000000..6f2c8f8 --- /dev/null +++ b/sig/zammad_api/attribute_access.rbs @@ -0,0 +1,36 @@ +module ZammadAPI + module AttributeAccess + NON_ATTRIBUTE_SUFFIXES: Array[String] + # private_constant in the source; RBS has no way to say so. + ATTRIBUTE_WRITER: Regexp + + @attributes: Hash[Symbol, untyped] + + attr_reader attributes: Hash[Symbol, untyped] + + def []: (Symbol | String key) -> untyped + def []=: (Symbol | String key, untyped value) -> untyped + def fetch: (Symbol | String key, *untyped default) ?{ (Symbol) -> untyped } -> untyped + def key?: (Symbol | String key) -> bool + def to_h: () -> Hash[Symbol, untyped] + def id: () -> Integer? + def deconstruct_keys: (Array[Symbol]?) -> Hash[Symbol, untyped] + def ==: (untyped other) -> bool + def eql?: (untyped other) -> bool + def hash: () -> Integer + def as_json: (*untyped) -> Hash[Symbol, untyped] + def to_json: (?untyped state) -> String + def method_missing: (Symbol, *untyped) -> untyped + def respond_to?: (untyped name, ?bool include_private) -> bool + def respond_to_missing?: (Symbol, ?bool) -> bool + + private + + def unknown_attribute_message: (Symbol name) -> String + def writable_attributes?: () -> bool + def attribute_writable?: (Symbol key) -> bool + def write_attribute: (Symbol, untyped) -> untyped + def frozen_attributes: (untyped) -> untyped + def deep_dup: (untyped) -> untyped + end +end diff --git a/sig/zammad_api/client.rbs b/sig/zammad_api/client.rbs new file mode 100644 index 0000000..ae30108 --- /dev/null +++ b/sig/zammad_api/client.rbs @@ -0,0 +1,48 @@ +module ZammadAPI + class Client + RESOURCES: Hash[Symbol, untyped] + CONVERSION_METHODS: Array[Symbol] + ENV_OPTIONS: Hash[String, Symbol] + + attr_reader config: Config + + @transport: Transport + @resources: Hash[untyped, ResourceProxy] + + def self.from_env: (**untyped overrides) -> Client + def self.build: (Config config, untyped transport) -> Client + + def initialize: (**untyped options) -> void + + def group: () -> ResourceProxy + def organization: () -> ResourceProxy + def ticket: () -> ResourceProxy + def ticket_article: () -> ResourceProxy + def ticket_priority: () -> ResourceProxy + def ticket_state: () -> ResourceProxy + def user: () -> ResourceProxy + + def resource: (Symbol | String name) -> ResourceProxy + def resource_names: () -> Array[Symbol] + def me: () -> Resources::User + def version: () -> String? + + def get: (String path, ?query: Hash[untyped, untyped]?, ?headers: Hash[untyped, untyped]?) -> Response + def post: (String path, ?query: Hash[untyped, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?) -> Response + def put: (String path, ?query: Hash[untyped, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?) -> Response + def delete: (String path, ?query: Hash[untyped, untyped]?, ?headers: Hash[untyped, untyped]?) -> Response + + def with: (**untyped options) -> Client + def on_behalf_of: (String | Integer identifier) -> Client + | [T] (String | Integer identifier) { (Client) -> T } -> T + def inspect: () -> String + def method_missing: (Symbol, *untyped) -> untyped + def respond_to_missing?: (Symbol, ?bool) -> bool + + private + + def raw: (Symbol method, String path, ?query: Hash[untyped, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?) -> Response + def unknown_resource_message: (Symbol | String) -> String + def setup: (Config config, untyped transport) -> self + end +end diff --git a/sig/zammad_api/collection.rbs b/sig/zammad_api/collection.rbs new file mode 100644 index 0000000..54cfb70 --- /dev/null +++ b/sig/zammad_api/collection.rbs @@ -0,0 +1,69 @@ +module ZammadAPI + class Collection + include Enumerable[untyped] + + RESERVED_QUERY_KEYS: Array[Symbol] + + @transport: Transport + @resource_class: untyped + @path: String + @operation: String + @query: Hash[Symbol, untyped] + @max_per_page: Integer + @filterable: Array[Symbol] + @filter_hint: String + @per_page: Integer + @page: Integer? + @countable: bool + + def initialize: ( + transport: Transport, + resource_class: untyped, + path: String, + operation: String, + max_per_page: Integer, + filterable: Array[Symbol], + filter_hint: String, + ?query: Hash[Symbol, untyped], + ?per_page: Integer?, + ?page: Integer?, + ?countable: bool + ) -> void + + def each: () { (untyped) -> void } -> Collection + | () -> Enumerator[untyped, Collection] + def find_each: (?batch_size: Integer?) { (untyped) -> void } -> Collection + | (?batch_size: Integer?) -> Enumerator[untyped, Collection] + def in_batches: (?of: Integer?) { (Array[untyped]) -> void } -> Collection + | (?of: Integer?) -> Enumerator[Array[untyped], Collection] + def page: (Integer number, ?of: Integer?) -> Collection + def first: () -> untyped + | (Integer count) -> Array[untyped] + def take: (Integer count) -> Array[untyped] + def find: (?untyped ifnone) { (untyped) -> boolish } -> untyped + | (*untyped) -> untyped + def where: (**untyped) -> Collection + def pluck: (*(Symbol | String) keys) -> Array[untyped] + def count: (*untyped) ?{ (untyped) -> boolish } -> Integer + def size: (*untyped) ?{ (untyped) -> boolish } -> Integer + def length: (*untyped) ?{ (untyped) -> boolish } -> Integer + def empty?: () -> bool + def inspect: () -> String + + private + + def own_request_for_first?: (untyped count) -> bool + def find_by_id_message: (untyped) -> String + def resource_name: () -> String + def ignored_message: (Array[Symbol]) -> String + def positive_integer!: (untyped, String) -> Integer + def page_size!: (untyped, String) -> Integer + def walk: () { (Array[untyped]) -> void } -> void + def with: (?page: Integer?, ?per_page: Integer, ?query: Hash[Symbol, untyped]) -> Collection + def normalized_filters: (Hash[untyped, untyped] params, ?String? prefix) -> Hash[Symbol, untyped] + def nested_filters: (Symbol name, untyped value, String? prefix) -> untyped + def fetch: (Integer, Integer) -> [ Array[untyped], Integer ] + def total_count: () -> Integer? + def clamp_per_page: (untyped) -> Integer + end +end diff --git a/sig/zammad_api/config.rbs b/sig/zammad_api/config.rbs new file mode 100644 index 0000000..2831980 --- /dev/null +++ b/sig/zammad_api/config.rbs @@ -0,0 +1,84 @@ +module ZammadAPI + # Built with Data.define; the generated readers are declared explicitly. + # + # `middleware` is a block handed a Faraday::Connection, but it is declared + # untyped here: these signatures ship with the gem, while the Faraday + # declarations they would need live in sig/vendor, which does not. Naming + # Faraday here made `rbs validate` fail for every consumer with + # `Could not find Faraday::Connection`. + class Config < ::Data + DEFAULT_TIMEOUT: Integer + DEFAULT_OPEN_TIMEOUT: Integer + DEFAULT_RETRIES: Integer + DEFAULT_RETRY_INTERVAL: Float + REDACTED_ATTRIBUTES: Array[Symbol] + REDACTION: String + DEFAULT_USER_AGENT: String + SCHEME_PATTERN: Regexp + URL_PATTERN: Regexp + URL_SUFFIX_PATTERN: Regexp + USERINFO_PATTERN: Regexp + USERINFO_REPLACEMENT: String + + def self.new: (**untyped) -> instance + + attr_reader url: String + attr_reader user: String? + attr_reader password: String? + attr_reader http_token: String? + attr_reader oauth2_token: String? + attr_reader user_agent: String + attr_reader timeout: Numeric + attr_reader open_timeout: Numeric + attr_reader retries: Integer + attr_reader retry_interval: Numeric + attr_reader ssl_verify: bool + attr_reader proxy: String? + attr_reader adapter: Symbol? + attr_reader middleware: untyped + attr_reader logger: Logger + + def initialize: ( + url: untyped, + ?user: String?, + ?password: String?, + ?http_token: String?, + ?oauth2_token: String?, + ?user_agent: String, + ?timeout: untyped, + ?open_timeout: untyped, + ?retries: untyped, + ?retry_interval: untyped, + ?ssl_verify: bool, + ?proxy: String?, + ?adapter: (Symbol | String)?, + ?middleware: untyped, + ?logger: Logger? + ) -> void + + def redacted_url: () -> String + def redacted: (String value) -> String + def authentication_scheme: () -> Symbol + def with: (**untyped) -> Config + def inspect: () -> String + def to_s: () -> String + def to_h: () -> Hash[Symbol, untyped] + + private + + def redact_userinfo: (String) -> String + def normalize_proxy: (untyped) -> String? + def normalize_adapter: (untyped) -> Symbol? + def render: (Symbol, untyped) -> String + def normalize_url: (untyped) -> String + def validate_credentials!: () -> void + def positive_number?: (untyped) -> bool + def validate_numbers!: () -> void + def validate_user_agent!: () -> void + def validate_ssl_verify!: () -> void + def validate_middleware!: () -> void + def validate_logger!: () -> void + def presence: (untyped) -> untyped + def immutable: (untyped) -> untyped + end +end diff --git a/sig/zammad_api/deep_copy.rbs b/sig/zammad_api/deep_copy.rbs new file mode 100644 index 0000000..09526b8 --- /dev/null +++ b/sig/zammad_api/deep_copy.rbs @@ -0,0 +1,7 @@ +module ZammadAPI + module DeepCopy + def self?.frozen_copy: (untyped value, ?symbolize_keys: bool) -> untyped + def self?.writable_copy: (untyped value) -> untyped + def self?.symbolize: (untyped key) -> untyped + end +end diff --git a/sig/zammad_api/duplicate_keys.rbs b/sig/zammad_api/duplicate_keys.rbs new file mode 100644 index 0000000..3f26306 --- /dev/null +++ b/sig/zammad_api/duplicate_keys.rbs @@ -0,0 +1,9 @@ +module ZammadAPI + module DuplicateKeys + def self.normalize: (Hash[untyped, untyped] hash, ?prefix: String?, ?noun: String) { (untyped key) -> untyped } -> Hash[untyped, untyped] + def self.message: (String noun, untyped path, untyped first, untyped second) -> String + + def normalize: (Hash[untyped, untyped] hash, ?prefix: String?, ?noun: String) { (untyped key) -> untyped } -> Hash[untyped, untyped] + def message: (String noun, untyped path, untyped first, untyped second) -> String + end +end diff --git a/sig/zammad_api/errors.rbs b/sig/zammad_api/errors.rbs new file mode 100644 index 0000000..2b255eb --- /dev/null +++ b/sig/zammad_api/errors.rbs @@ -0,0 +1,74 @@ +module ZammadAPI + class Error < StandardError + def self.subject_for: (String operation, Class? resource_class) -> String + end + + class ConfigurationError < Error + end + + class UnknownResourceError < Error + end + + class TransportError < Error + end + + class ConnectionError < TransportError + end + + class TimeoutError < TransportError + end + + class ParseError < Error + def self.build: (operation: String, expected: (Symbol | String), actual: (Class | String), ?resource_class: Class?) -> ParseError + end + + class PaginationError < Error + def self.build: (operation: String, page: Integer, ?resource_class: Class?) -> PaginationError + end + + class ResponseError < Error + STATUS_ERRORS: Hash[Integer, singleton(ResponseError)] + + attr_reader response: Response? + attr_reader operation: String + attr_reader resource_class: Class? + + @detail: String? + + def self.build: (Response? response, operation: String, ?resource_class: Class?) -> ResponseError + def self.error_class_for: (Response?) -> singleton(ResponseError) + + def initialize: (operation: String, ?response: Response?, ?resource_class: Class?, ?detail: String?) -> void + def status: () -> Integer? + def body: () -> untyped + def headers: () -> Hash[String, String] + def server_message: () -> String? + + private + + def build_message: () -> String + def detail: () -> String + end + + class ClientError < ResponseError + end + + class ServerError < ResponseError + end + + class AuthenticationError < ClientError + end + + class AuthorizationError < ClientError + end + + class NotFoundError < ClientError + end + + class ValidationError < ClientError + end + + class RateLimitError < ClientError + def retry_after: () -> Integer? + end +end diff --git a/sig/zammad_api/resource_proxy.rbs b/sig/zammad_api/resource_proxy.rbs new file mode 100644 index 0000000..ef6f7a2 --- /dev/null +++ b/sig/zammad_api/resource_proxy.rbs @@ -0,0 +1,54 @@ +module ZammadAPI + class ResourceProxy + include Enumerable[untyped] + + SEARCH_MAX_PER_PAGE: Integer + SEARCH_QUERY_KEYS: Array[Symbol] + INDEX_FILTER_HINT: String + UNSEARCHABLE_FILTER_HINT: String + SEARCH_FILTER_HINT: String + + attr_reader resource_class: untyped + + @transport: Transport + + def initialize: (Transport, untyped resource_class) -> void + def new: (?Hash[untyped, untyped] attributes) -> untyped + def create: (?Hash[untyped, untyped] attributes) -> untyped + def find: (Integer | String id) -> untyped + def find_by: (**untyped) -> untyped + def find_by!: (**untyped) -> untyped + def exists?: (Integer | String id) -> bool + def destroy: (Integer | String id) -> true + def all: () -> Collection + def where: (**untyped) -> Collection + + def each: () { (untyped) -> void } -> Collection + | () -> Enumerator[untyped, Collection] + def find_each: (?batch_size: Integer?) { (untyped) -> void } -> Collection + | (?batch_size: Integer?) -> Enumerator[untyped, Collection] + def in_batches: (?of: Integer?) { (Array[untyped]) -> void } -> Collection + | (?of: Integer?) -> Enumerator[Array[untyped], Collection] + def page: (Integer number, ?of: Integer?) -> Collection + def pluck: (*(Symbol | String) keys) -> Array[untyped] + def count: (*untyped) ?{ (untyped) -> boolish } -> Integer + def size: () -> Integer + def length: () -> Integer + def empty?: () -> bool + def first: () -> untyped + | (Integer count) -> Array[untyped] + def take: (Integer count) -> Array[untyped] + def search: (String term) -> Collection + def inspect: () -> String + + private + + def collection: (String, String, ?query: Hash[Symbol, untyped], ?max_per_page: Integer, ?filterable: Array[Symbol], ?filter_hint: String, ?countable: bool) -> Collection + def searchable!: () -> void + def index_filter_hint: () -> String + def search_term_for: (Hash[Symbol, untyped]) -> String? + def unsearchable_values_message: (Hash[Symbol, untyped]) -> String + def path: () -> String + def member_path: (Integer | String id) -> String + end +end diff --git a/sig/zammad_api/resources/base.rbs b/sig/zammad_api/resources/base.rbs new file mode 100644 index 0000000..5fd8a9f --- /dev/null +++ b/sig/zammad_api/resources/base.rbs @@ -0,0 +1,83 @@ +module ZammadAPI + module Resources + class Base + include AttributeAccess + + DEFAULT_MAX_PER_PAGE: Integer + MEMO_LOCK: Thread::Mutex + DEFAULT_INDEX_QUERY_KEYS: Array[Symbol] + + attr_reader error: ValidationError? + attr_reader transport: Transport + + @new_record: bool + @destroyed: bool + @saved: bool + @error: ValidationError? + @changes: Hash[Symbol, Array[untyped]] + @related: Associations::Proxy? + @baseline: Hash[Symbol, untyped] + self.@related_class: untyped + self.@belongs_to_foreign_keys: Array[Symbol]? + self.@declared_associations: Hash[Symbol, Hash[Symbol, untyped]]? + self.@path: String? + self.@searchable: bool? + self.@max_per_page: Integer? + self.@index_query_keys: Array[Symbol]? + + def self.path: (String) -> void + def self.searchable: (bool) -> void + def self.max_per_page: (Integer) -> void + def self.index_query_keys: (*Symbol) -> void + def self.resource_path: () -> String + def self.searchable?: () -> bool + def self.page_limit: () -> Integer + def self.filterable_keys: () -> Array[Symbol] + def self.member_path: ((Integer | String) id) -> String + def self.fetch_one: (Transport transport, (Integer | String) id) -> Base + def self.from_response: (Transport, Hash[untyped, untyped] attributes) -> Base + def self.associations: () -> Hash[Symbol, Hash[Symbol, untyped]] + def self.belongs_to_foreign_keys: () -> Array[Symbol] + def self.related_class: () -> untyped + def self.declaration: [T] (Symbol name) { () -> T } -> T + | (Symbol name) -> untyped + def self.declared_associations: () -> Hash[Symbol, Hash[Symbol, untyped]] + def self.belongs_to: (Symbol name, class_name: String, ?foreign_key: Symbol) -> void + def self.has_many: (Symbol name, class_name: String, path: ^(String) -> String) -> void + + def initialize: (Transport, ?Hash[untyped, untyped]? attributes) -> void + def new_record?: () -> bool + def persisted?: () -> bool + def destroyed?: () -> bool + def changes: () -> Hash[Symbol, Array[untyped]] + def changed?: () -> bool + def related: () -> Associations::Proxy + def assign_attributes: (Hash[untyped, untyped] attributes) -> self + def save: () -> bool + def save!: () -> true + def update: (Hash[untyped, untyped] attributes) -> bool + def update!: (Hash[untyped, untyped] attributes) -> true + def reload: () -> self + def destroy: () -> true + def inspect: () -> String + + private + + def setup: (Transport, Hash[untyped, untyped] attributes) -> void + def mark_persisted!: () -> void + def raise_if_destroyed!: (String action) -> void + def raise_if_new!: (String operation) -> void + def writable_attributes?: () -> bool + def attribute_writable?: (Symbol key) -> bool + def refuse_unwritable!: (Symbol key, untyped value) -> void + def replace_attributes!: (Response, operation: String, ?saved: bool) -> void + def reset_pending_state!: () -> void + def write_attribute: (Symbol, untyped) -> untyped + def create_record: () -> Response + def update_record: () -> Response + def member_path: () -> String + def require_id!: () -> Integer + def no_id_message: () -> String + end + end +end diff --git a/sig/zammad_api/resources/resources.rbs b/sig/zammad_api/resources/resources.rbs new file mode 100644 index 0000000..2a2c237 --- /dev/null +++ b/sig/zammad_api/resources/resources.rbs @@ -0,0 +1,49 @@ +module ZammadAPI + module Resources + class Group < Base + end + + class Organization < Base + end + + class TicketPriority < Base + end + + class TicketState < Base + end + + class User < Base + # Narrowed from Base so Client#me returns the concrete user type. + def self.from_response: (Transport, Hash[untyped, untyped] attributes) -> User + end + + class Ticket < Base + def articles: () -> Array[TicketArticle] + def article: (?Hash[untyped, untyped] attributes) -> TicketArticle + end + + class TicketArticle < Base + # Narrowed from Base so callers get the concrete article type. + def self.from_response: (Transport, Hash[untyped, untyped] attributes) -> TicketArticle + + # private_constant in the source; RBS has no way to say so. + ATTACHMENTS_OPERATION: String + + def attachments: () -> Array[TicketArticleAttachment] + + private + + def attachment: (untyped raw) -> TicketArticleAttachment + end + + class TicketArticleAttachment + include AttributeAccess + + @transport: Transport + + def initialize: (Transport, ?Hash[untyped, untyped]? attributes) -> void + def download: () -> String + def inspect: () -> String + end + end +end diff --git a/sig/zammad_api/response.rbs b/sig/zammad_api/response.rbs new file mode 100644 index 0000000..bcf3e3e --- /dev/null +++ b/sig/zammad_api/response.rbs @@ -0,0 +1,25 @@ +module ZammadAPI + # Built with Data.define; the generated readers are declared explicitly. + class Response < ::Data + attr_reader status: Integer + attr_reader headers: Hash[String, String] + attr_reader body: untyped + attr_reader raw_body: String + attr_reader json: bool + + def self.new: (status: Integer, headers: Hash[String, String], body: untyped, raw_body: String, json: bool) -> instance + SUCCESS_STATUSES: Range[Integer] + # private_constant in the source; RBS has no way to say so. + TOTAL_COUNT_HEADER: String + + def self.decode_body: (String?, String) -> [untyped, bool] + + def success?: () -> bool + def json?: () -> bool + def decoded: (Symbol shape, operation: String, ?resource_class: Class?) -> untyped + + private + + def array_of_objects!: (Array[untyped], operation: String, resource_class: Class?) -> Array[untyped] + end +end diff --git a/sig/zammad_api/test.rbs b/sig/zammad_api/test.rbs new file mode 100644 index 0000000..a1d5e69 --- /dev/null +++ b/sig/zammad_api/test.rbs @@ -0,0 +1,81 @@ +module ZammadAPI + class Test + # Both are deliberately outside ZammadAPI::Error: they say the test is + # wrong, not that Zammad refused anything, and the signature said + # otherwise - so a `rescue ZammadAPI::Error` type-checked as catching a + # forgotten stub that it does not in fact catch. + class UnstubbedRequestError < ::StandardError + end + + class AmbiguousStubError < ::StandardError + end + + class Request < ::Data + attr_reader verb: Symbol + attr_reader path: String + attr_reader query: Hash[String, untyped] + attr_reader body: untyped + attr_reader headers: Hash[String, String] + attr_reader on_behalf_of: String? + + def self.new: (**untyped) -> instance + end + + attr_reader config: Config + attr_reader client: Client + + @stubs: Hash[[Symbol, String], Array[Hash[Symbol, untyped]]] + @requests: Array[Request] + @monitor: Thread::Mutex + @transport: Test::Transport + @client: Client + + def initialize: (**untyped options) -> void + def stub: (Symbol method, String path, ?status: Integer, ?body: untyped, ?headers: Hash[untyped, untyped], ?query: Hash[untyped, untyped]?) -> self + def requests: () -> Array[Request] + def reset: () -> self + def stubbed: () -> Array[String] + def inspect: () -> String + + def answer: ( + Symbol method, + String path, + operation: String, + ?query: Hash[untyped, untyped]?, + ?body: untyped, + ?headers: Hash[untyped, untyped]?, + ?resource_class: Class?, + ?on_behalf_of: String? + ) -> Response + + private + + def key: (Symbol method, String path) -> [Symbol, String] + def raw_body_for: (untyped body) -> String + def response_headers: (Hash[untyped, untyped] headers, untyped body) -> Hash[String, String] + def snapshot: (untyped) -> untyped + def take: (Symbol method, String path, Hash[String, untyped] params) -> Hash[Symbol, untyped]? + def most_specific: (Array[Hash[Symbol, untyped]] queued, Array[Integer] matching, Symbol method, String path) -> Array[Integer] + def ambiguous_message: (Symbol method, String path, Array[Hash[String, untyped]?] scopes) -> String + def matches?: (Hash[untyped, untyped]? expected, Hash[String, untyped] params) -> bool + def paged_body: (Hash[Symbol, untyped] stub, Hash[String, untyped] params, untyped body) -> untyped + def response_for: (Hash[Symbol, untyped] stub, Hash[String, untyped] params) -> Response + def build_response: (Hash[Symbol, untyped] stub, untyped body, String raw_body, json: bool) -> Response + def unstubbed_message: (Symbol method, String path) -> String + + class Transport + include ::ZammadAPI::Transport::Verbs + + attr_reader test: Test + attr_reader on_behalf_of: String? + + def initialize: (Test test, ?on_behalf_of: String?) -> void + def config: () -> Config + def with_on_behalf_of: ((String | Integer)? identifier) -> Test::Transport + def with_config: (Config) -> Test::Transport + + + def request: (Symbol method, String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?, ?resource_class: Class?) -> Response + end + end +end diff --git a/sig/zammad_api/transport.rbs b/sig/zammad_api/transport.rbs new file mode 100644 index 0000000..dea7a8b --- /dev/null +++ b/sig/zammad_api/transport.rbs @@ -0,0 +1,67 @@ +module ZammadAPI + # The Faraday connection and responses are untyped here rather than named: + # these signatures ship with the gem and the Faraday declarations they would + # need live in sig/vendor, which does not. See sig/zammad_api/config.rbs. + class Transport + # Included by Transport and by Test::Transport, which stands in for it. + module Verbs + def get: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?, ?resource_class: Class?) -> Response + def post: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?, ?resource_class: Class?) -> Response + def put: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?, ?resource_class: Class?) -> Response + def delete: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?, ?resource_class: Class?) -> Response + end + + include Verbs + + RETRIABLE_METHODS: Array[Symbol] + RETRIABLE_STATUSES: Array[Integer] + RETRIABLE_EXCEPTIONS: Array[untyped] + TIMEOUT_ERRORS: Array[untyped] + CONNECTION_ERRORS: Array[untyped] + # Untyped for the same reason as the Faraday ones above: naming + # OpenSSL::SSL::SSLError here would make the published signatures need + # openssl declarations that a consumer has not loaded. + SSL_ERRORS: Array[untyped] + # private_constant in the source; RBS has no way to say so. + DECODING_MIDDLEWARE: Array[String] + SENSITIVE_KEY_PATTERN: Regexp + REDACTED: String + UNRESERVED_IN_PATH: Regexp + DOT_SEGMENTS: Array[String] + RESERVED_HEADERS: Hash[String, String] + + attr_reader config: Config + attr_reader on_behalf_of: String? + + @connection: untyped + + def self.stringify_query: (Hash[untyped, untyped]) -> Hash[String, untyped] + def self.stringify_query_hash: (Hash[untyped, untyped], String?) -> Hash[String, untyped] + def self.stringify_query_value: (String, untyped) -> untyped + def self.stringify_headers: (Hash[untyped, untyped]) -> Hash[String, String] + def self.stringify_header_value: (String, untyped) -> String + def self.escape_path_segment: (untyped) -> String + def self.relative_path: (untyped) -> String + + def initialize: (Config) -> void + def with_on_behalf_of: ((String | Integer)? identifier) -> Transport + def with_config: (Config) -> Transport + + def request: (Symbol method, String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[untyped, untyped]?, ?resource_class: Class?) -> Response + + private + + def perform: (Symbol, String, Hash[Symbol, untyped]?, untyped, Hash[untyped, untyped]?) -> untyped + def build_connection: () -> untyped + def refuse_decoding_middleware!: (untyped connection) -> void + def apply_authentication: (untyped) -> void + def retry_options: () -> Hash[Symbol, untyped] + def decode: (untyped) -> Response + def raw_body!: (untyped) -> String + def log_request: (Symbol, String, Hash[Symbol, untyped]?, untyped, Hash[String, String]?) -> void + def log_response: (Symbol, String, Response, Float) -> void + def redact: (untyped) -> untyped + def redact_config_values: (untyped message) -> String + def logger: () -> Logger + end +end diff --git a/spec/integration/authentication_spec.rb b/spec/integration/authentication_spec.rb new file mode 100644 index 0000000..ee27daf --- /dev/null +++ b/spec/integration/authentication_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI, 'authentication' do + it 'has a version number' do + expect(ZammadAPI::VERSION).not_to be_nil + end + + context 'with invalid credentials' do + let(:client) { Helper.client(user: 'not_existing', password: 'not_existing') } + + it 'raises AuthenticationError with the failing operation' do + expect { client.user.find(1) }.to raise_error(ZammadAPI::AuthenticationError) do |error| + expect(error.status).to eq(401) + expect(error.operation).to eq('find object') + expect(error.resource_class).to eq(ZammadAPI::Resources::User) + end + end + + it 'raises a ClientError, so both can be rescued together' do + expect { client.user.find(1) }.to raise_error(ZammadAPI::ClientError) + end + + %i[organization group ticket_priority ticket_state].each do |resource| + it "raises for #{resource}" do + expect { client.public_send(resource).find(1) }.to raise_error(ZammadAPI::AuthenticationError) + end + end + end +end diff --git a/spec/zammad_api/resources/group_spec.rb b/spec/integration/group_spec.rb similarity index 88% rename from spec/zammad_api/resources/group_spec.rb rename to spec/integration/group_spec.rb index 6caf0d3..5f962d1 100644 --- a/spec/zammad_api/resources/group_spec.rb +++ b/spec/integration/group_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +# frozen_string_literal: true -describe ZammadAPI, 'group object basics' do +RSpec.describe ZammadAPI, 'group object basics' do client = Helper.client name = "some_group#{Helper.random}" @@ -12,7 +12,7 @@ expect(group_invalid.class).to eq(ZammadAPI::Resources::Group) expect(group_invalid.new_record?).to be(true) - expect { group_invalid.save }.to raise_error(ZammadAPI::ClientError) + expect { group_invalid.save! }.to raise_error(ZammadAPI::ClientError) end it 'new with valid attributes' do @@ -37,6 +37,7 @@ end it 'save' do + group = established!(group, 'new with valid attributes') result = group.save expect(result).to be(true) @@ -76,6 +77,7 @@ end it 'find' do + group = established!(group, 'new with valid attributes') group_lookup = client.group.find(group.id) expect(group_lookup.class).to eq(ZammadAPI::Resources::Group) @@ -89,6 +91,7 @@ end it 'all' do + group = established!(group, 'new with valid attributes') groups = client.group.all group_exists = nil @@ -123,7 +126,7 @@ it 'pagination with all' do groups = client.group.all - expect(groups[0].class).to eq(ZammadAPI::Resources::Group) + expect(groups.first.class).to eq(ZammadAPI::Resources::Group) count = 0 groups.each do |local_group| @@ -134,12 +137,12 @@ count = 0 groups = client.group.all - groups.page(1, 3) do |local_group| + groups.page(1, of: 3).each do |local_group| expect(local_group.class).to eq(ZammadAPI::Resources::Group) count += 1 end expect(count).to eq(3) - groups.page(2, 3) do |local_group| + groups.page(2, of: 3).each do |local_group| expect(local_group.class).to eq(ZammadAPI::Resources::Group) count += 1 end @@ -147,6 +150,7 @@ end it 'destroy' do + group = established!(group, 'new with valid attributes') result = group.destroy expect(result).to be(true) diff --git a/spec/zammad_api/resources/organization_spec.rb b/spec/integration/organization_spec.rb similarity index 85% rename from spec/zammad_api/resources/organization_spec.rb rename to spec/integration/organization_spec.rb index 7851181..23241a2 100644 --- a/spec/zammad_api/resources/organization_spec.rb +++ b/spec/integration/organization_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +# frozen_string_literal: true -describe ZammadAPI, 'organization object basics' do +RSpec.describe ZammadAPI, 'organization object basics' do client = Helper.client name = "some_organization#{Helper.random}" @@ -12,7 +12,7 @@ expect(organization_invalid.class).to eq(ZammadAPI::Resources::Organization) expect(organization_invalid.new_record?).to be(true) - expect { organization_invalid.save }.to raise_error(ZammadAPI::ClientError) + expect { organization_invalid.save! }.to raise_error(ZammadAPI::ClientError) end it 'new with valid attributes' do @@ -33,6 +33,7 @@ end it 'save' do + organization = established!(organization, 'new with valid attributes') result = organization.save expect(result).to be(true) @@ -72,6 +73,7 @@ end it 'find' do + organization = established!(organization, 'new with valid attributes') organization_lookup = client.organization.find(organization.id) expect(organization_lookup.class).to eq(ZammadAPI::Resources::Organization) @@ -85,6 +87,7 @@ end it 'all' do + organization = established!(organization, 'new with valid attributes') organizations = client.organization.all organization_exists = nil @@ -117,7 +120,7 @@ it 'pagination with all' do organizations = client.organization.all - expect(organizations[0].class).to eq(ZammadAPI::Resources::Organization) + expect(organizations.first.class).to eq(ZammadAPI::Resources::Organization) count = 0 organizations.each do |local_organization| @@ -128,12 +131,12 @@ count = 0 organizations = client.organization.all - organizations.page(1, 3) do |local_organization| + organizations.page(1, of: 3).each do |local_organization| expect(local_organization.class).to eq(ZammadAPI::Resources::Organization) count += 1 end expect(count).to eq(2) - organizations.page(2, 3) do |local_organization| + organizations.page(2, of: 3).each do |local_organization| expect(local_organization.class).to eq(ZammadAPI::Resources::Organization) count += 1 end @@ -141,7 +144,8 @@ end it 'search' do - organizations = client.organization.search(query: name) + organization = established!(organization, 'new with valid attributes') + organizations = client.organization.search(name) organization_exists = nil organizations.each do |local_organization| @@ -171,9 +175,10 @@ end it 'pagination with search' do - organizations = client.organization.search(query: "#{name}-2") + organization = established!(organization, 'new with valid attributes') + organizations = client.organization.search("#{name}-2") - expect(organizations[0].class).to eq(ZammadAPI::Resources::Organization) + expect(organizations.first.class).to eq(ZammadAPI::Resources::Organization) count = 0 organization_exists = nil @@ -194,13 +199,13 @@ expect(organization_exists.updated_by).to eq('admin@example.com') count = 0 - organizations = client.organization.search(query: 'zammad') - organizations.page(1, 3) do |local_organization| + organizations = client.organization.search('zammad') + organizations.page(1, of: 3).each do |local_organization| expect(local_organization.class).to eq(ZammadAPI::Resources::Organization) count += 1 end expect(count).to eq(1) - organizations.page(2, 3) do |local_organization| + organizations.page(2, of: 3).each do |local_organization| expect(local_organization.class).to eq(ZammadAPI::Resources::Organization) count += 1 end @@ -208,6 +213,7 @@ end it 'destroy' do + organization = established!(organization, 'new with valid attributes') result = organization.destroy expect(result).to be(true) diff --git a/spec/zammad_api/resources/ticket_priority_spec.rb b/spec/integration/ticket_priority_spec.rb similarity index 84% rename from spec/zammad_api/resources/ticket_priority_spec.rb rename to spec/integration/ticket_priority_spec.rb index 45f41a8..76a7dec 100644 --- a/spec/zammad_api/resources/ticket_priority_spec.rb +++ b/spec/integration/ticket_priority_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +# frozen_string_literal: true -describe ZammadAPI, 'ticket priority object basics' do +RSpec.describe ZammadAPI, 'ticket priority object basics' do client = Helper.client name = "some_ticket_priority#{Helper.random}" @@ -12,7 +12,7 @@ expect(ticket_priority_invalid.class).to eq(ZammadAPI::Resources::TicketPriority) expect(ticket_priority_invalid.new_record?).to be(true) - expect { ticket_priority_invalid.save }.to raise_error(ZammadAPI::ClientError) + expect { ticket_priority_invalid.save! }.to raise_error(ZammadAPI::ClientError) end it 'new with valid attributes' do @@ -31,6 +31,7 @@ end it 'save' do + ticket_priority = established!(ticket_priority, 'new with valid attributes') result = ticket_priority.save expect(result).to be(true) @@ -61,6 +62,7 @@ end it 'find' do + ticket_priority = established!(ticket_priority, 'new with valid attributes') ticket_priority_lookup = client.ticket_priority.find(ticket_priority.id) expect(ticket_priority_lookup.class).to eq(ZammadAPI::Resources::TicketPriority) @@ -71,6 +73,7 @@ end it 'all' do + ticket_priority = established!(ticket_priority, 'new with valid attributes') ticket_priorities = client.ticket_priority.all ticket_priority_exists = nil @@ -99,7 +102,7 @@ it 'pagination with all' do ticket_priorities = client.ticket_priority.all - expect(ticket_priorities[0].class).to eq(ZammadAPI::Resources::TicketPriority) + expect(ticket_priorities.first.class).to eq(ZammadAPI::Resources::TicketPriority) count = 0 ticket_priorities.each do |local_ticket_priority| @@ -110,17 +113,17 @@ count = 0 ticket_priorities = client.ticket_priority.all - ticket_priorities.page(1, 2) do |local_ticket_priority| + ticket_priorities.page(1, of: 2).each do |local_ticket_priority| expect(local_ticket_priority.class).to eq(ZammadAPI::Resources::TicketPriority) count += 1 end expect(count).to eq(2) - ticket_priorities.page(2, 2) do |local_ticket_priority| + ticket_priorities.page(2, of: 2).each do |local_ticket_priority| expect(local_ticket_priority.class).to eq(ZammadAPI::Resources::TicketPriority) count += 1 end expect(count).to eq(4) - ticket_priorities.page(3, 2) do |local_ticket_priority| + ticket_priorities.page(3, of: 2).each do |local_ticket_priority| expect(local_ticket_priority.class).to eq(ZammadAPI::Resources::TicketPriority) count += 1 end @@ -128,6 +131,7 @@ end it 'destroy' do + ticket_priority = established!(ticket_priority, 'new with valid attributes') result = ticket_priority.destroy expect(result).to be(true) diff --git a/spec/zammad_api/resources/ticket_spec.rb b/spec/integration/ticket_spec.rb similarity index 89% rename from spec/zammad_api/resources/ticket_spec.rb rename to spec/integration/ticket_spec.rb index 1c95705..be5fd7a 100644 --- a/spec/zammad_api/resources/ticket_spec.rb +++ b/spec/integration/ticket_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +# frozen_string_literal: true -describe ZammadAPI, 'ticket object basics' do +RSpec.describe ZammadAPI, 'ticket object basics' do client = Helper.client title = "some ticket title ##{Helper.random}" @@ -12,7 +12,7 @@ expect(ticket_invalid.class).to eq(ZammadAPI::Resources::Ticket) expect(ticket_invalid.new_record?).to be(true) - expect { ticket_invalid.save }.to raise_error(ZammadAPI::ClientError) + expect { ticket_invalid.save! }.to raise_error(ZammadAPI::ClientError) end it 'new with valid attributes' do @@ -42,6 +42,7 @@ end it 'save' do + ticket = established!(ticket, 'new with valid attributes') result = ticket.save expect(result).to be(true) @@ -80,7 +81,7 @@ articles = ticket.articles expect(articles.length).to eq(1) - expect(articles[0].class).to eq(ZammadAPI::Resources::TicketArticle) + expect(articles.first.class).to eq(ZammadAPI::Resources::TicketArticle) expect(articles[0].subject).to eq('some subject') expect(articles[0].body).to eq('some body') @@ -121,6 +122,7 @@ end it 'find' do + ticket = established!(ticket, 'new with valid attributes') ticket_lookup = client.ticket.find(ticket.id) expect(ticket_lookup.class).to eq(ZammadAPI::Resources::Ticket) @@ -135,6 +137,7 @@ end it 'all' do + ticket = established!(ticket, 'new with valid attributes') tickets = client.ticket.all ticket_exists = nil @@ -169,6 +172,7 @@ end it 'pagination with all' do + ticket = established!(ticket, 'new with valid attributes') (1..10).each do |local_count| client.ticket.create( title: "test count ticket #{local_count}", @@ -188,7 +192,7 @@ end tickets = client.ticket.all - expect(tickets[0].class).to eq(ZammadAPI::Resources::Ticket) + expect(tickets.first.class).to eq(ZammadAPI::Resources::Ticket) count = 0 tickets.each do |local_ticket| expect(local_ticket.class).to eq(ZammadAPI::Resources::Ticket) @@ -198,17 +202,17 @@ count = 0 tickets = client.ticket.all - tickets.page(1, 5) do |local_ticket| + tickets.page(1, of: 5).each do |local_ticket| expect(local_ticket.class).to eq(ZammadAPI::Resources::Ticket) count += 1 end expect(count).to eq(5) - tickets.page(2, 5) do |local_ticket| + tickets.page(2, of: 5).each do |local_ticket| expect(local_ticket.class).to eq(ZammadAPI::Resources::Ticket) count += 1 end expect(count).to eq(10) - tickets.page(3, 5) do |local_ticket| + tickets.page(3, of: 5).each do |local_ticket| expect(local_ticket.class).to eq(ZammadAPI::Resources::Ticket) count += 1 end @@ -216,6 +220,7 @@ end it 'destroy' do + ticket = established!(ticket, 'new with valid attributes') result = ticket.destroy expect(result).to be(true) diff --git a/spec/zammad_api/resources/ticket_state_spec.rb b/spec/integration/ticket_state_spec.rb similarity index 90% rename from spec/zammad_api/resources/ticket_state_spec.rb rename to spec/integration/ticket_state_spec.rb index d672398..55cbac2 100644 --- a/spec/zammad_api/resources/ticket_state_spec.rb +++ b/spec/integration/ticket_state_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +# frozen_string_literal: true -describe ZammadAPI, 'ticket state object basics' do +RSpec.describe ZammadAPI, 'ticket state object basics' do client = Helper.client name = "some_ticket_state#{Helper.random}" @@ -12,7 +12,7 @@ expect(ticket_state_invalid.class).to eq(ZammadAPI::Resources::TicketState) expect(ticket_state_invalid.new_record?).to be(true) - expect { ticket_state_invalid.save }.to raise_error(ZammadAPI::ClientError) + expect { ticket_state_invalid.save! }.to raise_error(ZammadAPI::ClientError) end it 'new with valid attributes' do @@ -38,6 +38,7 @@ end it 'save' do + ticket_state = established!(ticket_state, 'new with valid attributes') result = ticket_state.save expect(result).to be(true) @@ -91,6 +92,7 @@ end it 'find' do + ticket_state = established!(ticket_state, 'new with valid attributes') ticket_state_lookup = client.ticket_state.find(ticket_state.id) expect(ticket_state_lookup.class).to eq(ZammadAPI::Resources::TicketState) @@ -108,6 +110,7 @@ end it 'all' do + ticket_state = established!(ticket_state, 'new with valid attributes') ticket_states = client.ticket_state.all ticket_state_exists = nil @@ -149,7 +152,7 @@ it 'pagination with all' do ticket_states = client.ticket_state.all - expect(ticket_states[0].class).to eq(ZammadAPI::Resources::TicketState) + expect(ticket_states.first.class).to eq(ZammadAPI::Resources::TicketState) count = 0 ticket_states.each do |local_ticket_state| @@ -160,17 +163,17 @@ count = 0 ticket_states = client.ticket_state.all - ticket_states.page(1, 3) do |local_ticket_state| + ticket_states.page(1, of: 3).each do |local_ticket_state| expect(local_ticket_state.class).to eq(ZammadAPI::Resources::TicketState) count += 1 end expect(count).to eq(3) - ticket_states.page(2, 3) do |local_ticket_state| + ticket_states.page(2, of: 3).each do |local_ticket_state| expect(local_ticket_state.class).to eq(ZammadAPI::Resources::TicketState) count += 1 end expect(count).to eq(6) - ticket_states.page(3, 3) do |local_ticket_state| + ticket_states.page(3, of: 3).each do |local_ticket_state| expect(local_ticket_state.class).to eq(ZammadAPI::Resources::TicketState) count += 1 end @@ -178,6 +181,7 @@ end it 'destroy' do + ticket_state = established!(ticket_state, 'new with valid attributes') result = ticket_state.destroy expect(result).to be(true) diff --git a/spec/zammad_api/resources/user_spec.rb b/spec/integration/user_spec.rb similarity index 89% rename from spec/zammad_api/resources/user_spec.rb rename to spec/integration/user_spec.rb index a82b94b..1a113da 100644 --- a/spec/zammad_api/resources/user_spec.rb +++ b/spec/integration/user_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +# frozen_string_literal: true -describe ZammadAPI, 'user object basics' do +RSpec.describe ZammadAPI, 'user object basics' do client = Helper.client random = Helper.random @@ -15,7 +15,7 @@ expect(user_invalid.class).to eq(ZammadAPI::Resources::User) expect(user_invalid.new_record?).to be(true) - expect { user_invalid.save }.to raise_error(ZammadAPI::ClientError) + expect { user_invalid.save! }.to raise_error(ZammadAPI::ClientError) end it 'new with valid attributes' do @@ -44,6 +44,7 @@ end it 'save' do + user = established!(user, 'new with valid attributes') result = user.save expect(result).to be(true) @@ -88,6 +89,7 @@ end it 'find' do + user = established!(user, 'new with valid attributes') user_lookup = client.user.find(user.id) expect(user_lookup.class).to eq(ZammadAPI::Resources::User) @@ -105,6 +107,7 @@ end it 'all' do + user = established!(user, 'new with valid attributes') users = client.user.all user_exists = nil @@ -159,7 +162,7 @@ users = client.user.all - expect(users[0].class).to eq(ZammadAPI::Resources::User) + expect(users.first.class).to eq(ZammadAPI::Resources::User) count = 0 users.each do |local_user| expect(local_user.class).to eq(ZammadAPI::Resources::User) @@ -169,18 +172,18 @@ count = 0 users = client.user.all - users.page(1, 4) do |local_user| + users.page(1, of: 4).each do |local_user| expect(local_user.class).to eq(ZammadAPI::Resources::User) count += 1 end expect(count).to eq(4) - users.page(2, 5) do |local_user| + users.page(2, of: 5).each do |local_user| expect(local_user.class).to eq(ZammadAPI::Resources::User) count += 1 end expect(count).to eq(9) count = 0 - users.page(1, 200) do |local_user| + users.page(1, of: 200).each do |local_user| expect(local_user.class).to eq(ZammadAPI::Resources::User) count += 1 end @@ -188,7 +191,8 @@ end it 'search' do - users = client.user.search(query: firstname) + user = established!(user, 'new with valid attributes') + users = client.user.search(firstname) user_exists = nil users.each do |local_user| @@ -224,9 +228,10 @@ end it 'pagination with search' do - users = client.user.search(query: firstname) + user = established!(user, 'new with valid attributes') + users = client.user.search(firstname) - expect(users[0].class).to eq(ZammadAPI::Resources::User) + expect(users.first.class).to eq(ZammadAPI::Resources::User) count = 0 user_exists = nil @@ -250,13 +255,13 @@ expect(user_exists.updated_by).to eq('admin@example.com') count = 0 - users = client.user.search(query: firstname) - users.page(1, 3) do |local_user| + users = client.user.search(firstname) + users.page(1, of: 3).each do |local_user| expect(local_user.class).to eq(ZammadAPI::Resources::User) count += 1 end expect(count).to eq(1) - users.page(2, 3) do |local_user| + users.page(2, of: 3).each do |local_user| expect(local_user.class).to eq(ZammadAPI::Resources::User) count += 1 end @@ -264,6 +269,7 @@ end it 'destroy' do + user = established!(user, 'new with valid attributes') # wait until zammad scheduler wrote some entries to activity stream # to have some references and not allow users to delete sleep 12 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6c318c4..12408f8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,50 +1,62 @@ -$LOAD_PATH.unshift File.expand_path('../lib', __dir__) - -# we don't require 'webmock/rspec' over here -# since we want to mock only certain requests -# but the API should be available in general -require 'webmock' - -RSpec.configure do |config| - config.include WebMock::API - config.include WebMock::Matchers +# frozen_string_literal: true + +if ENV['COVERAGE'] + require 'simplecov' + SimpleCov.start do + enable_coverage :branch + add_filter '/spec/' + minimum_coverage line: 90, branch: 75 + end end +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +# Unit specs run entirely against stubs; integration specs opt back out below. +require 'webmock/rspec' require 'zammad_api' -class Helper - def self.config - { - url: ENV['TEST_URL'] || 'http://localhost:3000/', - user: ENV['TEST_USER'] || 'admin@example.com', - password: ENV['TEST_PASSWORD'] || 'test' - } - end +Dir[File.expand_path('support/**/*.rb', __dir__)].each { require it } - def self.client(params = {}) - ZammadAPI::Client.new( - url: params[:url] || config[:url], - user: params[:user] || config[:user], - password: params[:password] || config[:password], - ) +RSpec.configure do |config| + config.include ClientHelper + config.include LifecycleState, :integration + + config.expect_with(:rspec) { it.syntax = :expect } + config.mock_with(:rspec) { it.verify_partial_doubles = true } + + config.disable_monkey_patching! + config.warnings = false + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = 'tmp/rspec_status.txt' + config.shared_context_metadata_behavior = :apply_to_host_groups + + # Specs are grouped by what they need: unit specs run against WebMock stubs + # and never touch the network, integration specs need a live Zammad. + # + # The integration specs also walk a record through its lifecycle across + # ordered examples, so they are pinned to definition order. Without that a + # `--seed` or `--order random` run scrambled the lifecycle and failed on a + # record that had not been built yet. Unit specs stay order-independent. + config.define_derived_metadata(file_path: %r{/spec/unit/}) { it[:unit] = true } + config.define_derived_metadata(file_path: %r{/spec/integration/}) do |metadata| + metadata[:integration] = true + metadata[:order] = :defined end - # start auto wizard - def self.auto_wizard - conn = Faraday.new(url: config[:url]) do |faraday| - faraday.adapter Faraday.default_adapter # make requests with Net::HTTP - end - - url_auto_wizard = '/api/v1/getting_started/auto_wizard' - response = conn.get url_auto_wizard - data = JSON.parse(response.body) - - return true if data['auto_wizard_success'] - - raise "Unable to start auto wizard: #{response.body}" + # The instance has to have an admin account before anything can + # authenticate. Doing this from a hook rather than from one spec file keeps + # the suite independent of file order. + config.before(:each, :integration) do + Helper.ensure_configured! end - def self.random - rand(99_999_999).to_s + # Integration specs need the real network, so WebMock steps aside for them. + config.around(:each, :integration) do |example| + WebMock.allow_net_connect! + WebMock.disable! + example.run + ensure + WebMock.enable! + WebMock.disable_net_connect! end end diff --git a/spec/support/bare_socket_adapter.rb b/spec/support/bare_socket_adapter.rb new file mode 100644 index 0000000..6e6f870 --- /dev/null +++ b/spec/support/bare_socket_adapter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'faraday' +require 'openssl' + +# A Faraday adapter that lets a socket error out raw. +# +# Most adapters wrap one into Faraday::ConnectionFailed, but this gem lets a +# caller choose the adapter, and an adapter that does not wrap is the only way +# to tell apart the two halves of the retry configuration: which failures are +# mapped to a ZammadAPI error, and which are retried before being mapped. +class BareSocketAdapter < Faraday::Adapter + class << self + # @return [Array] the verb of every request that reached here + attr_accessor :attempts + end + self.attempts = [] + + def call(env) + self.class.attempts << env.method + raise Errno::ECONNRESET + end +end + +Faraday::Adapter.register_middleware(bare_socket: BareSocketAdapter) + +# A Faraday adapter that lets a TLS failure out raw. +# +# The counterpart of BareSocketAdapter for OpenSSL::SSL::SSLError, which most +# adapters wrap into Faraday::SSLError. Unwrapped and unlisted, it escaped +# `request` entirely, past every `rescue ZammadAPI::Error` a caller had +# written. +class BareTlsAdapter < Faraday::Adapter + class << self + # @return [Array] the verb of every request that reached here + attr_accessor :attempts + end + self.attempts = [] + + def call(env) + self.class.attempts << env.method + raise OpenSSL::SSL::SSLError, 'certificate verify failed' + end +end + +Faraday::Adapter.register_middleware(bare_tls: BareTlsAdapter) diff --git a/spec/support/client_helper.rb b/spec/support/client_helper.rb new file mode 100644 index 0000000..51110dd --- /dev/null +++ b/spec/support/client_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Helpers for unit specs, which talk to WebMock stubs instead of a Zammad. +module ClientHelper + BASE_URL = 'http://zammad.test/' + + # Retries are disabled by default so that specs exercising error paths do + # not wait for the backoff intervals. + def unit_config(**overrides) + { url: BASE_URL, http_token: 'test-token', retries: 0 }.merge(overrides) + end + + def unit_client(**overrides) + ZammadAPI::Client.new(**unit_config(**overrides)) + end + + def unit_transport(**overrides) + ZammadAPI::Transport.new(ZammadAPI::Config.new(**unit_config(**overrides))) + end + + # @return [Hash] arguments for WebMock's +to_return+ with a JSON body + def json_response(body, status: 200, headers: {}) + { + status: status, + body: JSON.generate(body), + headers: { 'Content-Type' => 'application/json' }.merge(headers) + } + end +end diff --git a/spec/support/integration_helper.rb b/spec/support/integration_helper.rb new file mode 100644 index 0000000..ff9732b --- /dev/null +++ b/spec/support/integration_helper.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'securerandom' + +# Helpers for integration specs, which need a reachable Zammad instance. +class Helper + class SetupError < StandardError; end + + def self.config + { + url: ENV['TEST_URL'] || 'http://localhost:3000/', + user: ENV['TEST_USER'] || 'admin@example.com', + password: ENV['TEST_PASSWORD'] || 'test' + } + end + + def self.client(**overrides) + settings = config + ZammadAPI::Client.new( + url: overrides.fetch(:url, settings[:url]), + user: overrides.fetch(:user, settings[:user]), + password: overrides.fetch(:password, settings[:password]), + **overrides.except(:url, :user, :password) + ) + end + + # Makes sure the instance has an admin account, running Zammad's auto wizard + # once per suite. + # + # Memoized and idempotent, so it does not matter which spec file happens to + # run first, and re-running the suite against an already configured instance + # is not an error. + # The failure is memoized alongside the success, because `||=` memoizes + # neither. A TEST_URL with nothing behind it re-ran the whole probe - two + # requests, each with a ten second open timeout - once per example, so a + # Zammad that never came up took the suite a very long time to say so. + def self.ensure_configured! + return true if @ensure_configured + raise @setup_failure if @setup_failure + + begin + auto_wizard? || verify_setup_done! + @ensure_configured = true + rescue SetupError => e + @setup_failure = e + raise + end + end + + # @return [Boolean] whether the auto wizard ran now + # @raise [SetupError] when the instance cannot be reached at all + def self.auto_wizard? + response = connection.get('api/v1/getting_started/auto_wizard') + parse(response.body)['auto_wizard_success'] == true + rescue Faraday::Error => e + # {.verify_setup_done!} wraps its failure into a SetupError naming the URL + # and the user; this probe runs one line earlier and did not, so the + # commonest failure of all - CI booting against a Zammad that never came + # up - reached every example as a bare Faraday exception from a helper + # that exists to explain exactly that. + raise SetupError, + "Zammad at #{config[:url]} could not be reached: the setup check failed to connect " \ + "(#{e.class}: #{e.message}). Set TEST_URL to a running instance." + end + + # A configured Zammad requires authentication even for + # /api/v1/getting_started, so the setup state cannot be read from there. + # Proving that the configured credentials work answers the only question + # that matters here. + def self.verify_setup_done! + ZammadAPI::Client.new(**config).group.all.page(1, of: 1).to_a + true + rescue ZammadAPI::Error => e + raise SetupError, + "Zammad at #{config[:url]} is not usable: the auto wizard did not run and " \ + "authenticating as #{config[:user]} failed (#{e.class}: #{e.message})" + end + + # Finite timeouts matter here: a TEST_URL that accepts the connection but + # never answers would otherwise hang the integration job until the CI + # timeout rather than failing the setup check. + def self.connection + Faraday.new(url: config[:url], request: { open_timeout: 10, timeout: 30 }) + end + + def self.parse(body) + JSON.parse(body) + rescue JSON::ParserError + {} + end + + def self.random + SecureRandom.random_number(99_999_999).to_s + end + + private_class_method :verify_setup_done!, :connection, :parse +end + +# Each integration spec file walks one record through its lifecycle - new, +# save, find, destroy - and every step asserts on what the previous one left +# behind, so the record is shared across ordered examples. +# +# That coupling is fine as long as the whole file runs in definition order, +# and silent nonsense as soon as it does not: `--only-failures`, `-e 'save'` +# or a `--seed` reordering used to fail as `NoMethodError: undefined method +# 'save' for nil`, which says nothing about the actual cause. This says it. +module LifecycleState + def established!(record, example) + return record if record + + raise "this example continues the record built by '#{example}', which did not run in this process. " \ + 'These examples share one record and have to run as a whole file, in definition order.' + end +end diff --git a/spec/unit/zammad_api/associations/proxy_spec.rb b/spec/unit/zammad_api/associations/proxy_spec.rb new file mode 100644 index 0000000..2b33705 --- /dev/null +++ b/spec/unit/zammad_api/associations/proxy_spec.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Associations::Proxy do + subject(:ticket) { client.ticket.find(42) } + + let(:client) { unit_client } + let(:ticket_url) { "#{ClientHelper::BASE_URL}api/v1/tickets/42" } + let(:users_url) { "#{ClientHelper::BASE_URL}api/v1/users" } + + let(:ticket_attributes) do + { + id: 42, + title: 'Help', + customer: 'customer@example.com', + customer_id: 7, + state: 'open', + state_id: 2, + group: 'Users', + group_id: 1 + } + end + + before do + stub_request(:get, ticket_url).with(query: hash_including({})).to_return(json_response(ticket_attributes)) + end + + describe 'belongs_to' do + it 'fetches the whole record behind a foreign key' do + stub_request(:get, "#{users_url}/7").with(query: { 'expand' => 'true' }) + .to_return(json_response({ id: 7, email: 'customer@example.com', firstname: 'Nicole' })) + + expect(ticket.related.customer.firstname).to eq('Nicole') + end + + it 'returns the resource class of the target' do + stub_request(:get, "#{users_url}/7").with(query: hash_including({})).to_return(json_response({ id: 7 })) + + expect(ticket.related.customer).to be_a(ZammadAPI::Resources::User) + end + + it 'returns a persisted record' do + stub_request(:get, "#{users_url}/7").with(query: hash_including({})).to_return(json_response({ id: 7 })) + + expect(ticket.related.customer).to be_persisted + end + + it 'leaves the expanded attribute alone, so reading a name stays free' do + expect(ticket.customer).to eq('customer@example.com') + expect(a_request(:get, "#{users_url}/7").with(query: hash_including({}))).not_to have_been_made + end + + it 'does not shadow an expanded attribute that names a state' do + expect(ticket.state).to eq('open') + end + + it 'is nil when the foreign key is not set' do + stub_request(:get, ticket_url).with(query: hash_including({})).to_return(json_response({ id: 42 })) + + expect(ticket.related.customer).to be_nil + end + + it 'makes no request when the foreign key is not set' do + stub_request(:get, ticket_url).with(query: hash_including({})).to_return(json_response({ id: 42 })) + + ticket.related.customer + expect(a_request(:get, %r{api/v1/users}).with(query: hash_including({}))).not_to have_been_made + end + + it 'memoizes, so a loop does not refetch the same record' do + stub_request(:get, "#{users_url}/7").with(query: hash_including({})).to_return(json_response({ id: 7 })) + + held = ticket + 3.times { held.related.customer } + + expect(a_request(:get, "#{users_url}/7").with(query: hash_including({}))).to have_been_made.once + end + + it 'uses the declared foreign key rather than the association name' do + stub = stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/ticket_states/2") + .with(query: hash_including({})) + .to_return(json_response({ id: 2, name: 'open' })) + + ticket.related.state + expect(stub).to have_been_requested + end + + it 'reaches the group resource' do + stub = stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/groups/1") + .with(query: hash_including({})) + .to_return(json_response({ id: 1, name: 'Users' })) + + expect(ticket.related.group.name).to eq('Users') + expect(stub).to have_been_requested + end + + it 'propagates a failure to load the target' do + stub_request(:get, "#{users_url}/7").with(query: hash_including({})) + .to_return(json_response({ error: 'not found' }, status: 404)) + + expect { ticket.related.customer }.to raise_error(ZammadAPI::NotFoundError) + end + end + + describe 'has_many' do + let(:articles_url) { "#{ClientHelper::BASE_URL}api/v1/ticket_articles/by_ticket/42" } + + it 'fetches the list from the association endpoint' do + stub_request(:get, articles_url).with(query: { 'expand' => 'true' }) + .to_return(json_response([{ id: 1, body: 'first' }])) + + expect(ticket.related.articles.map(&:body)).to eq(['first']) + end + + it 'returns records of the target class' do + stub_request(:get, articles_url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + expect(ticket.related.articles.first).to be_a(ZammadAPI::Resources::TicketArticle) + end + + it 'is not memoized, so an article added afterwards shows up' do + stub_request(:get, articles_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1 }]), json_response([{ id: 1 }, { id: 2 }])) + + held = ticket + expect(held.related.articles.size).to eq(1) + expect(held.related.articles.size).to eq(2) + end + + it 'names the association in a parse failure' do + stub_request(:get, articles_url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + + expect { ticket.related.articles } + .to raise_error(ZammadAPI::ParseError, /Can't get articles \(ZammadAPI::Resources::TicketArticle\)/) + end + + # The one list in the gem read in a single request, because the endpoint + # it is declared against serves the whole thing: index_by_ticket iterates + # `ticket.articles` and renders them all, with no pagination to opt into. + # A guard used to sit here for a target that paged anyway, reading a total + # from a response header - a header Zammad sends from no endpoint, so it + # never once looked at a figure. + it 'reads the whole list in one request' do + stub_request(:get, articles_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1 }, { id: 2 }])) + + expect(ticket.related.articles.size).to eq(2) + end + + it 'says to save an unsaved record rather than requesting a path without an id' do + unsaved = ZammadAPI::Resources::Ticket.new(unit_transport, title: 'Help') + + expect { unsaved.related.articles } + .to raise_error(ZammadAPI::Error, /has no id, so it has no articles to read; save it first/) + end + + it 'escapes a traversal in the id instead of reaching another endpoint' do + escaped = "#{ClientHelper::BASE_URL}api/v1/ticket_articles/by_ticket/1%2F..%2F..%2Fusers" + stub = stub_request(:get, escaped).with(query: hash_including({})).to_return(json_response([])) + escaping = ZammadAPI::Resources::Ticket.from_response(unit_transport, id: '1/../../users') + + escaping.related.articles + + expect(stub).to have_been_requested + expect(a_request(:get, "#{ClientHelper::BASE_URL}api/v1/users").with(query: hash_including({}))) + .not_to have_been_made + end + end + + describe 'inheritance' do + it 'gives every resource the stamps Zammad puts on every object' do + expect(ZammadAPI::Resources::Group.associations.keys).to eq(%i[created_by updated_by]) + end + + it 'adds a resource its own associations on top' do + expect(ZammadAPI::Resources::User.associations.keys).to include(:created_by, :organization) + end + + it 'does not leak one resource\'s associations into another' do + expect(ZammadAPI::Resources::Group.associations).not_to have_key(:customer) + end + + it 'does not define another resource\'s reader on the proxy' do + group = ZammadAPI::Resources::Group.from_response(unit_transport, id: 1) + expect(group.related).not_to respond_to(:customer) + end + + it 'raises NoMethodError for an association that was never declared' do + expect { ticket.related.unicorn }.to raise_error(NoMethodError) + end + end + + describe 'the memo' do + before do + stub_request(:get, "#{users_url}/7").with(query: hash_including({})).to_return(json_response({ id: 7 })) + end + + it 'is dropped by reload, because the foreign key may have moved' do + held = ticket + held.related.customer + held.reload + held.related.customer + + expect(a_request(:get, "#{users_url}/7").with(query: hash_including({}))).to have_been_made.twice + end + + it 'is dropped by a save' do + stub_request(:put, ticket_url).with(query: hash_including({})).to_return(json_response(ticket_attributes)) + + held = ticket + held.related.customer + held.update!(title: 'Renamed') + held.related.customer + + expect(a_request(:get, "#{users_url}/7").with(query: hash_including({}))).to have_been_made.twice + end + + it 'is dropped when the foreign key it resolved from is written' do + stub_request(:get, "#{users_url}/9").with(query: hash_including({})) + .to_return(json_response({ id: 9, email: 'nine@example.com' })) + + held = ticket + expect(held.related.customer.id).to eq(7) + + held.customer_id = 9 + + expect(held.related.customer.id).to eq(9) + end + + it 'survives a write to an attribute no association resolves through' do + held = ticket + held.related.customer + held.title = 'Renamed' + held.related.customer + + expect(a_request(:get, "#{users_url}/7").with(query: hash_including({}))).to have_been_made.once + end + + it 'is dropped by assign_attributes touching a foreign key' do + stub_request(:get, "#{users_url}/9").with(query: hash_including({})) + .to_return(json_response({ id: 9 })) + + held = ticket + held.related.customer + held.assign_attributes(title: 'Renamed', customer_id: 9) + + expect(held.related.customer.id).to eq(9) + end + end + + describe '.belongs_to_foreign_keys' do + it 'lists the keys a resource resolves associations through' do + expect(ZammadAPI::Resources::Ticket.belongs_to_foreign_keys) + .to include(:customer_id, :owner_id, :group_id, :state_id, :priority_id, :organization_id) + end + + it 'includes the ones inherited from Base' do + expect(ZammadAPI::Resources::Group.belongs_to_foreign_keys).to eq(%i[created_by_id updated_by_id]) + end + end + + describe '#inspect' do + it 'names the record and what can be reached from it' do + expect(ticket.related.inspect) + .to eq( + '#' + ) + end + end +end diff --git a/spec/unit/zammad_api/attribute_access_spec.rb b/spec/unit/zammad_api/attribute_access_spec.rb new file mode 100644 index 0000000..a0397ed --- /dev/null +++ b/spec/unit/zammad_api/attribute_access_spec.rb @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::AttributeAccess do + subject(:record) { record_class.new(attributes) } + + let(:record_class) do + Class.new do + include ZammadAPI::AttributeAccess + + def initialize(attributes) + @attributes = frozen_attributes(attributes) + end + end + end + + let(:attributes) do + { + 'id' => 1, + 'name' => 'Support', + 'preferences' => { 'notes' => [{ 'body' => 'hello' }] } + } + end + + describe 'key normalization' do + it 'symbolizes top level keys' do + expect(record.attributes.keys).to eq(%i[id name preferences]) + end + + it 'symbolizes nested hash keys' do + expect(record.attributes[:preferences].keys).to eq([:notes]) + end + + it 'symbolizes hash keys inside arrays' do + expect(record.attributes[:preferences][:notes].first).to eq({ body: 'hello' }) + end + + it 'leaves keys alone that cannot become symbols' do + expect(record_class.new(1 => 'one').attributes).to eq(1 => 'one') + end + end + + describe 'reading' do + it 'reads through a reader method' do + expect(record.name).to eq('Support') + end + + it 'reads through #[]' do + expect(record[:name]).to eq('Support') + end + + it 'accepts a string key in #[]' do + expect(record['name']).to eq('Support') + end + + it 'exposes the id' do + expect(record.id).to eq(1) + end + + # A reader used to answer nil here, which made a typo a silent nil that + # flowed on into whatever was written with it - and put respond_to? and + # the call itself at odds, since respond_to?(:custom_field) was false + # throughout. + it 'raises for an attribute the record does not carry' do + expect { record.custom_field }.to raise_error(NoMethodError, /undefined attribute custom_field/) + end + + it 'names the readers that tolerate an absent attribute' do + expect { record.custom_field }.to raise_error(NoMethodError, /fetch\(:custom_field, nil\)/) + end + + it 'says the record may be one Zammad served less of' do + expect { record.custom_field }.to raise_error(NoMethodError, /reduced object/) + end + + it 'lists what the record does carry, so the spelling can be compared' do + expect { record.custom_field }.to raise_error(NoMethodError, /carries id, name, preferences/) + end + + # A key that could not become a Symbol is left as it arrived, so sorting + # the keys themselves would raise from inside the message. + it 'lists mixed keys without raising from the message itself' do + expect { record_class.new(1 => 'one', 'name' => 'X').custom_field } + .to raise_error(NoMethodError, /carries 1, name/) + end + + it 'says so plainly for a record that carries nothing' do + expect { record_class.new({}).custom_field } + .to raise_error(NoMethodError, /carries no attributes at all/) + end + + it 'carries the name and the receiver a bare NoMethodError would' do + expect { record.custom_field }.to raise_error(NoMethodError) do |error| + expect(error.name).to eq(:custom_field) + expect(error.receiver).to be(record) + end + end + + it 'still reads an attribute the record carries but Zammad left nil' do + expect(record_class.new('note' => nil).note).to be_nil + end + + it 'leaves [] and fetch answering for an absent attribute' do + expect(record[:custom_field]).to be_nil + expect(record.fetch(:custom_field, 'fallback')).to eq('fallback') + end + + it 'reports known attributes via #key?' do + expect(record.key?(:name)).to be(true) + end + + it 'returns a copy from #to_h' do + record.to_h[:name] = 'changed' + expect(record.name).to eq('Support') + end + end + + describe 'immutability' do + it 'freezes the attribute hash' do + expect { record.attributes[:name] = 'changed' }.to raise_error(FrozenError) + end + + it 'freezes a nested hash' do + expect { record.attributes[:preferences][:notes] = [] }.to raise_error(FrozenError) + end + + it 'freezes a nested array' do + expect { record.attributes[:preferences][:notes] << {} }.to raise_error(FrozenError) + end + + it 'freezes a hash inside an array' do + expect { record.attributes[:preferences][:notes].first[:body] = 'changed' }.to raise_error(FrozenError) + end + + it 'freezes a string value' do + expect { record.name << '!' }.to raise_error(FrozenError) + end + + it 'hands out a deep copy from #to_h' do + copy = record.to_h + copy[:preferences][:notes].first[:body] = 'changed' + + expect(record.attributes[:preferences][:notes].first[:body]).to eq('hello') + end + + it 'hands out mutable strings from #to_h' do + copy = record.to_h + copy[:name] << '!' + + expect(record.name).to eq('Support') + end + end + + describe '#fetch' do + it 'returns the value for a known attribute' do + expect(record.fetch(:name)).to eq('Support') + end + + it 'raises for an unknown attribute' do + expect { record.fetch(:nope) }.to raise_error(KeyError) + end + + it 'supports a default' do + expect(record.fetch(:nope, 'fallback')).to eq('fallback') + end + + # Hash#fetch refuses a third argument, and so does this: collected with a + # splat and read as `default.first`, `fetch(:a, :b, :c)` - a multi-key read + # this has never been - was answered with `:b`. + it 'refuses more than one fallback, the way Hash#fetch does' do + expect { record.fetch(:nope, 'one', 'two') } + .to raise_error(ArgumentError, 'wrong number of arguments (given 3, expected 1..2)') + end + + # Hash#fetch warns and then ignores the default. Silently picking one of + # two fallbacks a caller cannot have meant to pass together is the same + # swallowed mistyped call the arity check above refuses. + # rubocop:disable Lint/UselessDefaultValueArgument -- the call under test + it 'says so when a block supersedes the default, the way Hash#fetch does' do + expect { record.fetch(:nope, 'fallback') { 'block' } } + .to output(/block supersedes default value argument/).to_stderr + end + + it 'still answers from the block when both were given' do + expect { expect(record.fetch(:nope, 'fallback') { 'block' }).to eq('block') }.to output.to_stderr + end + + # Hash#fetch names the line that made the call. A bare Kernel#warn named + # nothing at all - neither the call site nor the library it came from, + # which in an application with several such calls is everything the reader + # needs. `uplevel` supplies both that location and the `warning: ` prefix. + it 'names the line that made the call, the way Hash#fetch does' do + expect { record.fetch(:nope, 'fallback') { 'block' } } + .to output(/attribute_access_spec\.rb:\d+: warning: block supersedes/).to_stderr + end + # rubocop:enable Lint/UselessDefaultValueArgument + end + + # `record[:x] = v` reached method_missing as `:[]=`, which the writer branch + # took for an attribute called `[]` - it staged the index as the value, lost + # the write, and sent `{"[]": "x"}` on the next save. Ruby's operators end in + # `=` too, so `record <= 5` did the same for an attribute called `<`. + describe '#[]=' do + it 'goes through the same refusal a named writer does on a read-only record' do + expect { record[:note] = 'x' }.to raise_error(NoMethodError, /attributes are read-only/) + end + + it 'names the attribute that was tried, not the operator' do + expect { record[:note] = 'x' }.to raise_error(NoMethodError, /tried to set note/) + end + + # `[]=` is a defined method, so respond_to_missing? never sees it: a + # read-only record claimed the one writer it has while denying every named + # one, then raised when it was called - the invariant the writer branch of + # respond_to_missing? is conditional for. + it 'is not claimed by a record that would refuse it' do + expect(record).not_to respond_to(:[]=) + end + + it 'agrees with the named writers on the same record' do + expect(record.respond_to?(:[]=)).to eq(record.respond_to?(:name=)) + end + + # `respond_to?` takes either spelling and Ruby does not normalise the + # argument, so a Symbol-only comparison let the String fall through to the + # definition and answer true on a record that refuses every write. + it 'answers the same for the String spelling' do + # rubocop:disable-next Performance/StringIdentifierArgument -- the String spelling is the point + expect(record.respond_to?('[]=')).to eq(record.respond_to?(:[]=)) + end + end + + describe 'a name that only looks like a writer' do + it 'does not invent an attribute from a comparison operator' do + # Sent rather than written as `record <= 5`, which RuboCop reads as a + # void literal and rewrites away, taking the spec with it. + expect { record.public_send(:<=, 5) }.to raise_error(NoMethodError) + + expect(record.attributes.keys).not_to include(:<) + end + + it 'is not claimed as a writer' do + expect(record).not_to respond_to(:<=) + end + end + + describe '#respond_to?' do + it 'is true for a known attribute' do + expect(record).to respond_to(:name) + end + + it 'is false for an unknown attribute' do + expect(record).not_to respond_to(:nope) + end + + it 'is false for a writer on a read-only record, which would raise' do + expect(record).not_to respond_to(:anything=) + end + + it 'is true for any writer on a record that stages changes' do + expect(ZammadAPI::Resources::Group.new(unit_transport)).to respond_to(:anything=) + end + + it 'is false for a predicate' do + expect(record).not_to respond_to(:name?) + end + end + + describe 'method names that are not attributes' do + it 'raises NoMethodError for a bang method, so typos surface' do + expect { record.save! }.to raise_error(NoMethodError) + end + + it 'raises NoMethodError for a predicate' do + expect { record.active? }.to raise_error(NoMethodError) + end + end + + describe 'pattern matching' do + it 'matches on attribute values' do + result = case record + in { name: 'Support' } then :matched + else :not_matched + end + expect(result).to eq(:matched) + end + + it 'binds matched values' do + case record + in { name: String => name } + expect(name).to eq('Support') + end + end + + it 'matches nested structures' do + case record + in { preferences: { notes: [{ body: String => body }, *] } } + expect(body).to eq('hello') + end + end + + it 'does not match an absent attribute' do + result = case record + in { nope: _ } then :matched + else :not_matched + end + expect(result).to eq(:not_matched) + end + + it 'returns every attribute for a nil key list' do + expect(record.deconstruct_keys(nil)).to eq(record.attributes) + end + + it 'returns only the requested keys' do + expect(record.deconstruct_keys([:name])).to eq(name: 'Support') + end + end + + describe 'equality' do + it 'is the same record as another of its class carrying the same id' do + expect(record).to eq(record_class.new('id' => 1, 'name' => 'Renamed since')) + end + + it 'is not the same record as one with a different id' do + expect(record).not_to eq(record_class.new('id' => 2, 'name' => 'Support')) + end + + it 'is not the same record as one of another class with the same id' do + expect(record).not_to eq(Class.new(record_class).new('id' => 1)) + end + + it 'is not equal to something that is not a record' do + expect(record).not_to eq('id' => 1) + end + + it 'answers #eql? too, so a record can be a Hash key' do + expect({ record => :found }[record_class.new('id' => 1)]).to eq(:found) + end + + it 'hashes equal records alike' do + expect(record.hash).to eq(record_class.new('id' => 1).hash) + end + + it 'deduplicates equal records' do + expect([record, record_class.new('id' => 1)].uniq.size).to eq(1) + end + + it 'collects equal records into one Set member' do + expect(Set[record, record_class.new('id' => 1)].size).to eq(1) + end + + context 'without an id' do + subject(:unsaved) { record_class.new('name' => 'Support') } + + it 'is still itself, so it can be found again as a Hash key' do + expect({ unsaved => :found }[unsaved]).to eq(:found) + end + + it 'is not equal to an identical record, which is still a second record' do + expect(unsaved).not_to eq(record_class.new('name' => 'Support')) + end + + it 'is kept apart from an identical record' do + expect([unsaved, record_class.new('name' => 'Support')].uniq.size).to eq(2) + end + end + end + + describe 'serialization' do + it 'renders the attributes as a JSON object' do + expect(JSON.parse(record.to_json)) + .to eq('id' => 1, 'name' => 'Support', 'preferences' => { 'notes' => [{ 'body' => 'hello' }] }) + end + + it 'renders as its attributes when nested in a structure being generated' do + expect(JSON.parse(JSON.generate(group: record))['group']).to include('name' => 'Support') + end + + it 'carries the generator state, so pretty printing reaches the attributes' do + expect(JSON.pretty_generate(record)).to include("\n") + end + + it 'exposes the attributes to an encoder through #as_json' do + expect(record.as_json).to eq(record.to_h) + end + + it 'hands #as_json a copy rather than the frozen attributes' do + expect(record.as_json).not_to be_frozen + end + end + + it 'rejects writes by default' do + expect { record.name = 'other' }.to raise_error(NoMethodError, /read-only/) + end +end diff --git a/spec/unit/zammad_api/client_spec.rb b/spec/unit/zammad_api/client_spec.rb new file mode 100644 index 0000000..0043490 --- /dev/null +++ b/spec/unit/zammad_api/client_spec.rb @@ -0,0 +1,673 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Client do + let(:url) { "#{ClientHelper::BASE_URL}api/v1/users/1" } + + describe '.new' do + it 'accepts keyword arguments' do + expect(described_class.new(url: 'http://zammad.test/', http_token: 'token')).to be_a(described_class) + end + + it 'surfaces configuration errors' do + expect { described_class.new(http_token: 'token') }.to raise_error(ArgumentError) + end + + it 'rejects a missing url' do + expect { described_class.new(url: nil, http_token: 'token') } + .to raise_error(ZammadAPI::ConfigurationError, 'missing url in config') + end + + it 'rejects an unsupported scheme' do + expect { described_class.new(url: 'ftp://example.com', http_token: 'token') } + .to raise_error(ZammadAPI::ConfigurationError, /needs to start with http/) + end + + it 'rejects missing credentials' do + expect { described_class.new(url: 'http://zammad.test/') } + .to raise_error(ZammadAPI::ConfigurationError, 'missing user in config') + end + + it 'rejects an unknown option' do + expect { described_class.new(url: 'http://zammad.test/', http_token: 't', nonsense: 1) } + .to raise_error(ArgumentError) + end + + it 'exposes the configuration' do + expect(unit_client.config).to be_a(ZammadAPI::Config) + end + end + + describe '.from_env' do + it 'reads the url and access token' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_TOKEN' => 'from-env' }) + + config = described_class.from_env.config + expect(config.url).to eq('http://zammad.test/') + expect(config.http_token).to eq('from-env') + end + + it 'reads basic auth credentials' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_USER' => 'u', 'ZAMMAD_PASSWORD' => 'p' }) + + expect(described_class.from_env.config.authentication_scheme).to eq(:basic) + end + + it 'reads an OAuth2 token' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_OAUTH2_TOKEN' => 'oauth' }) + + expect(described_class.from_env.config.oauth2_token).to eq('oauth') + end + + it 'prefers ZAMMAD_HTTP_TOKEN over ZAMMAD_TOKEN' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_TOKEN' => 'short', 'ZAMMAD_HTTP_TOKEN' => 'explicit' }) + + expect(described_class.from_env.config.http_token).to eq('explicit') + end + + it 'lets an argument win over the environment' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_TOKEN' => 'from-env' }) + + expect(described_class.from_env(http_token: 'passed in').config.http_token).to eq('passed in') + end + + it 'accepts options that have no environment variable' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_TOKEN' => 't' }) + + expect(described_class.from_env(timeout: 300).config.timeout).to eq(300) + end + + it 'treats an empty variable as unset' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/', 'ZAMMAD_TOKEN' => '', 'ZAMMAD_USER' => 'u', 'ZAMMAD_PASSWORD' => 'p' }) + + expect(described_class.from_env.config.authentication_scheme).to eq(:basic) + end + + it 'names the variable to set when the url is missing' do + stub_const('ENV', { 'ZAMMAD_TOKEN' => 't' }) + + expect { described_class.from_env } + .to raise_error(ZammadAPI::ConfigurationError, /set ZAMMAD_URL or pass url:/) + end + + it 'still validates the credentials' do + stub_const('ENV', { 'ZAMMAD_URL' => 'http://zammad.test/' }) + + expect { described_class.from_env }.to raise_error(ZammadAPI::ConfigurationError, 'missing user in config') + end + end + + describe '#me' do + it 'reads the current user' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/users/me") + .with(query: { 'expand' => 'true' }) + .to_return(json_response({ id: 3, email: 'agent@example.com' })) + + expect(unit_client.me.email).to eq('agent@example.com') + end + + it 'returns a persisted user record' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/users/me").with(query: hash_including({})) + .to_return(json_response({ id: 3 })) + + me = unit_client.me + expect(me).to be_a(ZammadAPI::Resources::User) + expect(me).to be_persisted + end + + it 'follows an on_behalf_of scope' do + stub = stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/users/me") + .with(query: hash_including({}), headers: { 'From' => 'agent@example.com' }) + .to_return(json_response({ id: 3 })) + + unit_client.on_behalf_of('agent@example.com').me + expect(stub).to have_been_requested + end + + it 'raises AuthenticationError for invalid credentials' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/users/me").with(query: hash_including({})) + .to_return(json_response({ error: 'authentication failed' }, status: 401)) + + expect { unit_client.me }.to raise_error(ZammadAPI::AuthenticationError) + end + end + + describe '#version' do + it 'reports the version of the Zammad instance' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/version").to_return(json_response({ version: '6.4.0' })) + + expect(unit_client.version).to eq('6.4.0') + end + + it 'is nil when the instance reports no version' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/version").to_return(json_response({})) + + expect(unit_client.version).to be_nil + end + + it 'is not the version of this gem' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/version").to_return(json_response({ version: '6.4.0' })) + + expect(unit_client.version).not_to eq(ZammadAPI::VERSION) + end + end + + describe 'resource readers' do + ZammadAPI::Client::RESOURCES.each do |name, resource_class| + it "exposes ##{name}" do + expect(unit_client.public_send(name).resource_class).to eq(resource_class) + end + + it "responds to ##{name}" do + expect(unit_client).to respond_to(name) + end + end + + it 'returns a proxy' do + expect(unit_client.group).to be_a(ZammadAPI::ResourceProxy) + end + + it 'lists the supported resource names' do + expect(unit_client.resource_names).to eq(ZammadAPI::Client::RESOURCES.keys) + end + end + + describe '#resource' do + it 'accepts a symbol' do + expect(unit_client.resource(:group).resource_class).to eq(ZammadAPI::Resources::Group) + end + + it 'accepts a string' do + expect(unit_client.resource('group').resource_class).to eq(ZammadAPI::Resources::Group) + end + + it 'raises for an unknown resource' do + expect { unit_client.resource(:unicorn) } + .to raise_error(ZammadAPI::UnknownResourceError, /Unknown resource unicorn/) + end + + it 'lists the available resources in the error' do + expect { unit_client.resource(:unicorn) }.to raise_error(/available resources are: group, organization/) + end + end + + describe 'unknown methods' do + it 'raises UnknownResourceError for an unknown resource name' do + expect { unit_client.unicorn }.to raise_error(ZammadAPI::UnknownResourceError, /Unknown resource unicorn/) + end + + it 'does not claim to respond to it' do + expect(unit_client).not_to respond_to(:unicorn) + end + + it 'still raises NoMethodError for a bang method' do + expect { unit_client.save! }.to raise_error(NoMethodError) + end + + it 'still raises NoMethodError for a predicate' do + expect { unit_client.valid? }.to raise_error(NoMethodError) + end + + it 'stays usable in array operations that rely on to_ary' do + client = unit_client + expect([client].flatten).to eq([client]) + end + + it 'leaves Ruby core methods alone, so only declared resources are dispatched' do + expect(unit_client.hash).to be_an(Integer) + end + + it 'defines resource readers on the client itself, so they win over inherited methods' do + expect(described_class.instance_method(:user).owner).to eq(described_class) + end + end + + describe 'raw requests' do + subject(:client) { unit_client } + + let(:roles_url) { "#{ClientHelper::BASE_URL}api/v1/roles" } + + describe '#get' do + it 'reaches an endpoint the gem does not model' do + stub_request(:get, roles_url).to_return(json_response([{ id: 1, name: 'Admin' }])) + + expect(client.get('api/v1/roles').body).to eq([{ id: 1, name: 'Admin' }]) + end + + it 'returns a Response, so the status and headers stay reachable' do + stub_request(:get, roles_url).to_return(json_response([], headers: { 'X-Total-Count' => '7' })) + + response = client.get('api/v1/roles') + expect(response).to be_a(ZammadAPI::Response) + expect(response.status).to eq(200) + expect(response.headers['x-total-count']).to eq('7') + end + + it 'ignores a leading slash, so paths can be pasted from the Zammad docs' do + stub = stub_request(:get, roles_url).to_return(json_response([])) + + client.get('/api/v1/roles') + expect(stub).to have_been_requested + end + + it 'keeps the sub-path of a Zammad served from one' do + stub = stub_request(:get, 'http://zammad.test/helpdesk/api/v1/roles').to_return(json_response([])) + + unit_client(url: 'http://zammad.test/helpdesk/').get('/api/v1/roles') + expect(stub).to have_been_requested + end + + it 'passes query parameters' do + stub = stub_request(:get, roles_url).with(query: { 'active' => 'true' }).to_return(json_response([])) + + client.get('api/v1/roles', query: { active: true }) + expect(stub).to have_been_requested + end + + it 'sends the configured authentication' do + stub = stub_request(:get, roles_url) + .with(headers: { 'Authorization' => 'Token test-token' }) + .to_return(json_response([])) + + client.get('api/v1/roles') + expect(stub).to have_been_requested + end + + it 'carries an on_behalf_of scope' do + stub = stub_request(:get, roles_url) + .with(headers: { 'From' => 'agent@example.com' }) + .to_return(json_response([])) + + client.on_behalf_of('agent@example.com').get('api/v1/roles') + expect(stub).to have_been_requested + end + + # An escape hatch that cannot set a header does not reach an endpoint + # that needs one. + it 'sends the headers it was given' do + stub = stub_request(:get, roles_url) + .with(headers: { 'Accept-Language' => 'de-de' }) + .to_return(json_response([])) + + client.get('api/v1/roles', headers: { 'Accept-Language' => 'de-de' }) + expect(stub).to have_been_requested + end + + it 'stringifies a header value the wire could not carry' do + stub = stub_request(:get, roles_url) + .with(headers: { 'X-Retry' => '3' }) + .to_return(json_response([])) + + client.get('api/v1/roles', headers: { 'X-Retry' => 3 }) + expect(stub).to have_been_requested + end + + # Faraday's authorization middleware leaves a header that is already set + # alone, so this would have replaced the client's own credentials - + # quietly, while #inspect went on reporting the scheme it was built with. + it 'refuses to replace the configured authentication' do + expect { client.get('api/v1/roles', headers: { 'Authorization' => 'Token other' }) } + .to raise_error(ArgumentError, /header authorization is set by this client/) + end + + it 'names the option that does mean it' do + expect { client.get('api/v1/roles', headers: { 'Authorization' => 'Token other' }) } + .to raise_error(ArgumentError, /pass http_token:, oauth2_token:, or user: and password:/) + end + + it 'refuses the From header that on_behalf_of owns' do + expect { client.get('api/v1/roles', headers: { from: 'agent@example.com' }) } + .to raise_error(ArgumentError, /use client\.on_behalf_of/) + end + + it 'refuses a nil header value rather than sending an empty one' do + expect { client.get('api/v1/roles', headers: { 'X-Trace' => nil }) } + .to raise_error(ArgumentError, /header x-trace is nil/) + end + + it 'refuses a header value that is not text' do + expect { client.get('api/v1/roles', headers: { 'X-Trace' => ['a'] }) } + .to raise_error(ArgumentError, /a header is always text/) + end + + # HTTP reads the two names as one header, so a merge would send whichever + # Hash order put last and drop the other without a word. + it 'refuses two spellings of one header' do + expect { client.get('api/v1/roles', headers: { 'Accept-Language' => 'de', 'accept-language' => 'en' }) } + .to raise_error(ArgumentError, /header accept-language was given twice/) + end + + it 'makes no request for a header it refuses' do + expect { client.get('api/v1/roles', headers: { 'X-Trace' => nil }) }.to raise_error(ArgumentError) + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + + it 'raises the mapped error class' do + stub_request(:get, roles_url).to_return(json_response({ error: 'nope' }, status: 403)) + + expect { client.get('api/v1/roles') }.to raise_error(ZammadAPI::AuthorizationError, /nope/) + end + + it 'names the request in the error message' do + stub_request(:get, roles_url).to_return(json_response({}, status: 500)) + + expect { client.get('api/v1/roles') } + .to raise_error(ZammadAPI::ServerError, "Can't GET api/v1/roles: HTTP 500") + end + + it 'hands back a non-JSON body untouched' do + stub_request(:get, roles_url).to_return(status: 200, body: 'plain', headers: { 'Content-Type' => 'text/plain' }) + + expect(client.get('api/v1/roles').body).to eq('plain') + end + end + + describe '#post' do + it 'sends a JSON body' do + stub = stub_request(:post, roles_url) + .with(body: JSON.generate({ name: 'Agent' }), headers: { 'Content-Type' => 'application/json' }) + .to_return(json_response({ id: 2 })) + + expect(client.post('api/v1/roles', body: { name: 'Agent' }).body).to eq({ id: 2 }) + expect(stub).to have_been_requested + end + + it 'is not retried, so a failed create cannot be duplicated' do + stub_request(:post, roles_url).to_return(json_response({}, status: 500)) + + expect { unit_client(retries: 3).post('api/v1/roles', body: {}) }.to raise_error(ZammadAPI::ServerError) + expect(a_request(:post, roles_url)).to have_been_made.once + end + + it 'redacts credentials from the log' do + stub_request(:post, roles_url).to_return(json_response({})) + + log = StringIO.new + logger = Logger.new(log, level: Logger::DEBUG) + unit_client(logger: logger).post('api/v1/roles', body: { password: 'hunter2' }) + + expect(log.string).to include('[REDACTED]') + expect(log.string).not_to include('hunter2') + end + + it 'redacts a credential-bearing header from the log too' do + stub_request(:post, roles_url).to_return(json_response({})) + + log = StringIO.new + logger = Logger.new(log, level: Logger::DEBUG) + unit_client(logger: logger).post('api/v1/roles', headers: { 'X-Api-Key' => 'hunter2' }) + + expect(log.string).to include('[REDACTED]') + expect(log.string).not_to include('hunter2') + end + + it 'sends the headers it was given' do + stub = stub_request(:post, roles_url).with(headers: { 'X-Trace' => 'abc' }).to_return(json_response({})) + + client.post('api/v1/roles', body: {}, headers: { 'X-Trace' => 'abc' }) + expect(stub).to have_been_requested + end + end + + describe '#put' do + it 'sends a JSON body' do + stub = stub_request(:put, "#{roles_url}/1").with(body: JSON.generate({ name: 'Agent' })).to_return(json_response({ id: 1 })) + + client.put('api/v1/roles/1', body: { name: 'Agent' }) + expect(stub).to have_been_requested + end + + it 'sends the headers it was given' do + stub = stub_request(:put, "#{roles_url}/1").with(headers: { 'X-Trace' => 'abc' }).to_return(json_response({})) + + client.put('api/v1/roles/1', body: {}, headers: { 'X-Trace' => 'abc' }) + expect(stub).to have_been_requested + end + end + + describe '#delete' do + it 'passes query parameters, which is how Zammad takes tag removals' do + stub = stub_request(:delete, "#{ClientHelper::BASE_URL}api/v1/tags/remove") + .with(query: { 'object' => 'Ticket', 'o_id' => '1', 'item' => 'urgent' }) + .to_return(json_response({ success: true })) + + client.delete('api/v1/tags/remove', query: { object: 'Ticket', o_id: 1, item: 'urgent' }) + expect(stub).to have_been_requested + end + + it 'sends the headers it was given' do + stub = stub_request(:delete, roles_url).with(headers: { 'X-Trace' => 'abc' }).to_return(json_response({})) + + client.delete('api/v1/roles', headers: { 'X-Trace' => 'abc' }) + expect(stub).to have_been_requested + end + end + + # The scope is set after the caller's headers, so a request cannot take the + # header out from under the client that was scoped. + it 'keeps the on_behalf_of scope alongside a caller header' do + stub = stub_request(:get, roles_url) + .with(headers: { 'From' => 'agent@example.com', 'X-Trace' => 'abc' }) + .to_return(json_response([])) + + client.on_behalf_of('agent@example.com').get('api/v1/roles', headers: { 'X-Trace' => 'abc' }) + expect(stub).to have_been_requested + end + + it 'does not shadow a resource reader' do + expect(client.resource_names).not_to include(:get, :post, :put, :delete) + end + end + + describe '#on_behalf_of' do + before do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + end + + it 'sends the From header' do + stub = stub_request(:get, url) + .with(query: hash_including({}), headers: { 'From' => 'agent@example.com' }) + .to_return(json_response({ id: 1 })) + + unit_client.on_behalf_of('agent@example.com').user.find(1) + expect(stub).to have_been_requested + end + + it 'returns a new client' do + client = unit_client + expect(client.on_behalf_of('someone')).not_to be(client) + end + + it 'leaves the original client unscoped' do + client = unit_client + client.on_behalf_of('someone') + client.user.find(1) + + expect(a_request(:get, url).with { |request| request.headers.key?('From') }).not_to have_been_made + end + + it 'keeps the scoped client usable for several requests' do + scoped = unit_client.on_behalf_of('agent@example.com') + scoped.user.find(1) + scoped.user.find(1) + + expect(a_request(:get, url).with(query: hash_including({}), headers: { 'From' => 'agent@example.com' })) + .to have_been_made.twice + end + + describe 'block form' do + it 'yields a scoped client' do + unit_client.on_behalf_of('agent@example.com') { |scoped| scoped.user.find(1) } + + expect(a_request(:get, url).with(query: hash_including({}), headers: { 'From' => 'agent@example.com' })) + .to have_been_made + end + + it 'returns the block value' do + expect(unit_client.on_behalf_of('agent@example.com') { :done }).to eq(:done) + end + + it 'does not affect the outer client when the block raises' do + client = unit_client + + expect { client.on_behalf_of('agent@example.com') { raise 'boom' } }.to raise_error('boom') + + client.user.find(1) + expect(a_request(:get, url).with { |request| request.headers.key?('From') }).not_to have_been_made + end + end + end + + describe '#with' do + it 'returns a new client' do + client = unit_client + expect(client.with(timeout: 5)).not_to be(client) + end + + it 'applies the changed option' do + expect(unit_client.with(timeout: 5).config.timeout).to eq(5) + end + + it 'leaves the original client untouched' do + client = unit_client + client.with(timeout: 5) + expect(client.config.timeout).to eq(ZammadAPI::Config::DEFAULT_TIMEOUT) + end + + it 'keeps the options that were not changed' do + expect(unit_client.with(timeout: 5).config.http_token).to eq('test-token') + end + + it 're-validates the resulting configuration' do + expect { unit_client.with(timeout: -1) } + .to raise_error(ZammadAPI::ConfigurationError, /positive number/) + end + + it 'carries an on_behalf_of scope over to the derived client' do + stub = stub_request(:get, url) + .with(query: hash_including({}), headers: { 'From' => 'agent@example.com' }) + .to_return(json_response({ id: 1 })) + + unit_client.on_behalf_of('agent@example.com').with(timeout: 5).user.find(1) + expect(stub).to have_been_requested + end + + it 'still works for requests' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + expect(unit_client.with(timeout: 5).user.find(1).id).to eq(1) + end + end + + describe 'concurrent use' do + it 'does not leak an on_behalf_of scope between threads' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + + client = unit_client + logins = %w[a@example.com b@example.com c@example.com] + + threads = logins.map do |login| + Thread.new { 5.times { client.on_behalf_of(login).user.find(1) } } + end + threads << Thread.new { 5.times { client.user.find(1) } } + threads.each(&:join) + + logins.each do |login| + expect(a_request(:get, url).with(query: hash_including({}), headers: { 'From' => login })) + .to have_been_made.times(5) + end + end + + it 'leaves the shared client unscoped throughout' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + + client = unit_client + threads = %w[a@example.com b@example.com].map do |login| + Thread.new { 5.times { client.on_behalf_of(login).user.find(1) } } + end + threads.each(&:join) + + client.user.find(1) + + expect( + a_request(:get, url).with(query: hash_including({})) { |request| !request.headers.key?('From') } + ).to have_been_made + end + end + + describe '#inspect' do + it 'shows the url and auth scheme' do + expect(unit_client.inspect) + .to eq('#') + end + + it 'does not leak the token' do + expect(unit_client(http_token: 'super-secret').inspect).not_to include('super-secret') + end + + it 'does not leak credentials carried in the url' do + client = unit_client(url: 'https://admin:url-s3cret@zammad.example.com/') + + expect(client.inspect).not_to include('url-s3cret') + expect(client.inspect).to include('https://[REDACTED]@zammad.example.com/') + end + end + + # A proxy holds nothing but the client's transport and the resource class, + # and `client.ticket` is the idiom every call starts with - each one used to + # allocate a fresh one. + describe 'resource proxies' do + subject(:client) { unit_client } + + it 'hands out the same proxy for the same resource' do + first = client.ticket + + expect(client.ticket).to be(first) + end + + # Built up front rather than memoized on first use. A client is documented + # as immutable once built and safe to share between threads without + # locking, and a memo filled in by the first `client.ticket` in each worker + # is a write to shared state - harmless under CRuby's GVL, but not the + # contract that was written down. + it 'is not mutated by reading a proxy off it' do + before_read = client.instance_variable_get(:@resources) + client.ticket + + expect(client.instance_variable_get(:@resources)).to be(before_read) + end + + it 'holds a proxy for every resource before any is asked for' do + expect(client.instance_variable_get(:@resources).size).to eq(client.resource_names.size) + end + + it 'refuses to be written to after it is built' do + expect(client.instance_variable_get(:@resources)).to be_frozen + end + + it 'hands out a different proxy per resource' do + expect(client.ticket).not_to be(client.user) + end + + # The cache belongs to one transport, so a derived client must not be + # handed proxies still wired to the transport it was derived from. + it 'does not carry a proxy over to an on_behalf_of client' do + expect(client.on_behalf_of('agent@example.com').ticket).not_to be(client.ticket) + end + + it 'does not carry a proxy over to a client built with #with' do + expect(client.with(timeout: 5).ticket).not_to be(client.ticket) + end + + # The point of not sharing the cache: a proxy from the derived client has + # to send that client's From header, not the original's. + it 'gives a derived client a proxy that carries its own scope' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/tickets/1") + .with(query: hash_including({})).to_return(json_response({ id: 1 })) + + client.ticket + client.on_behalf_of('agent@example.com').ticket.find(1) + + expect(a_request(:get, "#{ClientHelper::BASE_URL}api/v1/tickets/1") + .with(query: hash_including({}), headers: { 'From' => 'agent@example.com' })).to have_been_made + end + end +end diff --git a/spec/unit/zammad_api/collection_spec.rb b/spec/unit/zammad_api/collection_spec.rb new file mode 100644 index 0000000..22e6e17 --- /dev/null +++ b/spec/unit/zammad_api/collection_spec.rb @@ -0,0 +1,815 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Collection do + subject(:collection) { client.group.all } + + let(:client) { unit_client } + let(:url) { "#{ClientHelper::BASE_URL}api/v1/groups" } + # `condition` is only honoured by a search endpoint, so the nested-filter + # examples need one. + let(:search_collection) { client.ticket.search('x') } + + # What a collection over this endpoint fetches per request when nothing asks + # for another size: as many as the endpoint serves. Read off the resource + # rather than written out, so that these stubs follow the declaration. + def default_per_page = ZammadAPI::Resources::Group.page_limit + + def stub_page(page, records, per_page: default_per_page) + stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => page.to_s, 'per_page' => per_page.to_s }) + .to_return(json_response(records)) + end + + # The walk asks for another page only after a full one, so anything about + # walking needs a page of the size that was requested. + def full_page(first_id = 1) + Array.new(default_per_page) { { id: first_id + it } } + end + + # A short page cannot be told apart from one the server shrank, so the walk + # confirms the end with one more request. Stubbing that empty page is what a + # collection that fits in a single page looks like from out here. + def stub_last_page(page, records, per_page: default_per_page) + stub_page(page, records, per_page: per_page) + stub_page(page + 1, [], per_page: per_page) + end + + # Mirrors Zammad's CanPaginate::Pagination: the endpoint reduces per_page to + # its own maximum and pages by that reduced size. + def stub_capped_endpoint(url, total:, max:) + stub_request(:get, url).with(query: hash_including({})).to_return do |request| + params = URI.decode_www_form(URI(request.uri).query).to_h + limit = [Integer(params['per_page']), max].min + offset = (Integer(params['page']) - 1) * limit + json_response(Array(offset...[offset + limit, total].min).map { { id: it + 1 } }) + end + end + + it 'is an Enumerable' do + expect(described_class.ancestors).to include(Enumerable) + end + + describe '#each' do + it 'walks every page until the server runs out of records' do + stub_page(1, full_page) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(collection.map(&:id)).to eq((1..(default_per_page + 1)).to_a) + end + + it 'confirms the end of a short first page rather than assuming it' do + stub_last_page(1, [{ id: 1 }]) + + expect(collection.map(&:id)).to eq([1]) + expect(a_request(:get, url).with(query: hash_including('page' => '2'))).to have_been_made + end + + it 'stops on a page shorter than the one the endpoint has been serving' do + stub_page(1, full_page) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(collection.map(&:id)).to eq((1..(default_per_page + 1)).to_a) + expect(a_request(:get, url).with(query: hash_including('page' => '3'))).not_to have_been_made + end + + it 'walks an endpoint that serves a smaller page than it was asked for' do + stub_capped_endpoint(url, total: 120, max: 50) + + expect(collection.map(&:id)).to eq((1..120).to_a) + end + + it 'stops on an empty page' do + stub_page(1, full_page) + stub_page(2, []) + + expect(collection.map(&:id)).to eq((1..default_per_page).to_a) + end + + it 'yields persisted records' do + stub_page(1, [{ id: 1 }], per_page: 1) + + expect(collection.first).to be_persisted + end + + it 'yields records of the right class' do + stub_page(1, [{ id: 1 }], per_page: 1) + + expect(collection.first).to be_a(ZammadAPI::Resources::Group) + end + + it 'returns an Enumerator without a block' do + expect(collection.each).to be_a(Enumerator) + end + + it 'does not make a request until it is iterated' do + collection + expect(a_request(:get, url).with(query: hash_including({}))).not_to have_been_made + end + + it 'stops fetching early when the caller stops consuming' do + stub_page(1, [{ id: 1 }], per_page: 1) + + expect(collection.first).to be_a(ZammadAPI::Resources::Group) + expect(a_request(:get, url).with(query: hash_including('page' => '2'))).not_to have_been_made + end + + it 'works with lazy enumeration' do + stub_page(1, [{ id: 1 }, { id: 2 }]) + + expect(collection.lazy.map(&:id).first(2)).to eq([1, 2]) + end + + it 'raises ParseError when the endpoint does not return a list' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + + expect { collection.to_a } + .to raise_error(ZammadAPI::ParseError, /expected a JSON array, got Hash/) + end + + it 'raises PaginationError when the endpoint ignores the page parameter' do + stub_request(:get, url).with(query: hash_including({})) + .to_return(json_response(full_page)) + + expect { collection.to_a } + .to raise_error(ZammadAPI::PaginationError, /ignoring the page parameter/) + end + + it 'keeps walking records that carry no id' do + stub_page(1, Array.new(default_per_page) { { name: "a#{it}" } }) + stub_page(2, [{ name: 'b0' }]) + + expect(collection.map(&:name)).to eq(Array.new(default_per_page) { "a#{it}" } + ['b0']) + end + + it 'does not hand the repeated page to the block before raising' do + stub_request(:get, url).with(query: hash_including({})) + .to_return(json_response(full_page)) + + batches = [] + + expect { collection.in_batches { batches << it } }.to raise_error(ZammadAPI::PaginationError) + expect(batches.size).to eq(1) + end + + it 'does not yield the repeated records to each either' do + stub_request(:get, url).with(query: hash_including({})) + .to_return(json_response(full_page)) + + seen = 0 + + expect { collection.each { seen += 1 } }.to raise_error(ZammadAPI::PaginationError) + expect(seen).to eq(default_per_page) + end + + it 'refuses two spellings of one filter rather than dropping a value' do + expect { collection.where('sort_by' => 'name', :sort_by => 'id') } + .to raise_error(ArgumentError, /sort_by was given twice, as "sort_by" and as :sort_by/) + end + + # `condition` is a Hash the search endpoints read. Transport refuses the + # pair too, but not until the collection is enumerated, and every other + # refusal `where` makes happens at the call that wrote it. + it 'refuses a collision nested inside a structured filter' do + expect { search_collection.where(condition: { 'state_id' => 1, :state_id => 2 }) } + .to raise_error(ArgumentError, /parameter condition\[state_id\] was given twice/) + end + + it 'still accepts a structured filter whose keys only look alike' do + expect(search_collection.where(condition: { 'ticket.state_id' => { operator: 'is' } })) + .to be_a(described_class) + end + + it 'still accepts a filter given once by either spelling' do + expect(collection.where('sort_by' => 'name')).to be_a(described_class) + expect(collection.where(sort_by: 'name')).to be_a(described_class) + end + + # The guard compares the decoded payload, not the bytes it arrived as. + # Hashing raw_body is cheaper and looks equivalent - identical bytes do + # mean identical records - but the implication that matters runs the other + # way: an endpoint that ignores `page` and re-serializes the same records + # with a different key order produces different bytes every time, so the + # guard never fires. Every page is full, so neither the short-page break + # nor the total break fires either, and the walk never ends. + it 'raises PaginationError when a repeated page is re-serialized differently' do + forwards = Array.new(default_per_page) { { id: it, name: "a#{it}" } } + backwards = forwards.map { { name: it[:name], id: it[:id] } } + # Every page differs from the one before it in bytes and from none of + # them in records, which a fixed sequence cannot express: WebMock repeats + # its last response, so two pages running would come back byte-identical + # and a byte digest would catch them one page later. + order = [forwards, backwards].cycle + stub_request(:get, url).with(query: hash_including({})).to_return { json_response(order.next) } + + # Bounded, because what this guards against is a walk that never ends + # rather than one that ends wrongly - unbounded, a regression here hangs + # the suite instead of failing it. + expect { Timeout.timeout(5) { collection.to_a } } + .to raise_error(ZammadAPI::PaginationError, /ignoring the page parameter/) + end + + it 'raises PaginationError when records without an id repeat' do + page = Array.new(default_per_page) { { name: "a#{it}" } } + stub_request(:get, url).with(query: hash_including({})).to_return(json_response(page)) + + expect { collection.to_a } + .to raise_error(ZammadAPI::PaginationError, /ignoring the page parameter/) + end + end + + describe '#find_each' do + it 'yields every record' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + stub_page(2, [{ id: 3 }], per_page: 2) + + ids = [] + collection.find_each(batch_size: 2) { ids << it.id } + expect(ids).to eq([1, 2, 3]) + end + + it 'walks at the default page size without one' do + stub_last_page(1, [{ id: 1 }]) + + ids = [] + collection.find_each { ids << it.id } + expect(ids).to eq([1]) + end + + it 'takes the page size inline' do + stub_last_page(1, [{ id: 1 }], per_page: 50) + + expect(collection.find_each(batch_size: 50).map(&:id)).to eq([1]) + end + + it 'returns an Enumerator without a block' do + expect(collection.find_each).to be_a(Enumerator) + end + + it 'rejects a non-positive page size' do + expect { collection.find_each(batch_size: 0) { nil } } + .to raise_error(ArgumentError, 'batch_size needs a positive integer') + end + + # Re-sizing the page silently changed which records the collection held: + # page(3, of: 50) names records 101 to 150, and a batch_size of 10 turned + # that into records 21 to 30 with nothing said about it. + it 'refuses to re-size a collection already limited to a page' do + expect { collection.page(3, of: 50).find_each(batch_size: 10) { nil } } + .to raise_error(ArgumentError, /batch_size cannot be combined with page/) + end + + it 'says how to name the page it would have served' do + expect { collection.page(3, of: 50).find_each(batch_size: 10) { nil } } + .to raise_error(ArgumentError, /page\(3, of: 10\)/) + end + end + + describe '#in_batches' do + it 'yields one array per page' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + stub_page(2, [{ id: 3 }], per_page: 2) + + batches = [] + collection.in_batches(of: 2) { batches << it.map(&:id) } + expect(batches).to eq([[1, 2], [3]]) + end + + it 'yields a whole page at the default size without one' do + stub_last_page(1, [{ id: 1 }, { id: 2 }]) + + batches = [] + collection.in_batches { batches << it.map(&:id) } + expect(batches).to eq([[1, 2]]) + end + + it 'returns an Enumerator without a block' do + expect(collection.in_batches).to be_a(Enumerator) + end + + it 'refuses to re-size a collection already limited to a page' do + expect { collection.page(3, of: 50).in_batches(of: 10) { nil } } + .to raise_error(ArgumentError, /of cannot be combined with page/) + end + + it 'pulls one page per Enumerator#next' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + + expect(collection.in_batches(of: 2).next.map(&:id)).to eq([1, 2]) + expect(a_request(:get, url).with(query: hash_including('page' => '2'))).not_to have_been_made + end + + it 'rejects a non-positive page size' do + expect { collection.in_batches(of: 0) { nil } } + .to raise_error(ArgumentError, 'of needs a positive integer') + end + end + + describe '#page' do + it 'fetches only the requested page' do + stub_page(2, [{ id: 3 }, { id: 4 }]) + + expect(collection.page(2).map(&:id)).to eq([3, 4]) + expect(a_request(:get, url).with(query: hash_including('page' => '3'))).not_to have_been_made + end + + it 'sizes the page, and so decides which records it holds' do + stub_page(2, [{ id: 3 }], per_page: 3) + + expect(collection.page(2, of: 3).map(&:id)).to eq([3]) + end + + it 'keeps the size when the page moves' do + stub_page(3, [{ id: 5 }], per_page: 3) + + expect(collection.page(2, of: 3).page(3).map(&:id)).to eq([5]) + end + + it 'returns a new collection and leaves the original unpaged' do + stub_last_page(1, [{ id: 1 }]) + + expect(collection.page(2)).not_to be(collection) + expect(collection.map(&:id)).to eq([1]) + end + + it 'rejects page zero' do + expect { collection.page(0) }.to raise_error(ArgumentError, /positive integer/) + end + + it 'rejects a non-integer page' do + expect { collection.page('2') }.to raise_error(ArgumentError, /positive integer/) + end + + it 'rejects a non-positive page size' do + expect { collection.page(1, of: 0) }.to raise_error(ArgumentError, 'of needs a positive integer') + end + + it 'rejects a non-integer page size' do + expect { collection.page(1, of: '7') }.to raise_error(ArgumentError, 'of needs a positive integer') + end + end + + describe 'page size caps' do + it 'clamps a walk to what a generic index endpoint serves' do + stub_page(1, [], per_page: 1000) + + client.group.all.find_each(batch_size: 5000).to_a + expect(a_request(:get, url).with(query: hash_including('per_page' => '1000'))).to have_been_made + end + + it 'refuses a page larger than a generic index endpoint serves' do + expect { client.group.all.page(1, of: 5000) } + .to raise_error(ArgumentError, /serves at most 1000 records per page/) + end + + it 'refuses a page larger than the ticket index endpoint serves' do + expect { client.ticket.all.page(1, of: 5000) } + .to raise_error(ArgumentError, /serves at most 100 records per page/) + end + + it 'refuses a page larger than a search endpoint serves' do + expect { client.user.search('smith').page(1, of: 5000) } + .to raise_error(ArgumentError, /serves at most 200 records per page/) + end + + it 'names the page that would have been served instead' do + expect { client.ticket.all.page(3, of: 500) } + .to raise_error(ArgumentError, /page\(3, of: 500\) would be sent as page 3 of 100/) + end + + it 'names a page size the endpoint does serve' do + expect { client.ticket.all.page(3, of: 500) } + .to raise_error(ArgumentError, /Ask for page\(3, of: 100\) or fewer/) + end + + it 'accepts a page exactly the size the endpoint serves' do + expect(client.ticket.all.page(2, of: 100).inspect).to include('per_page=100') + end + + it 'walks the whole list when asked for more per page than the endpoint serves' do + stub_capped_endpoint("#{ClientHelper::BASE_URL}api/v1/tickets", total: 250, max: 100) + + expect(client.ticket.all.find_each(batch_size: 250).map(&:id)).to eq((1..250).to_a) + end + end + + it 'raises rather than building records out of a list of ids' do + stub_page(1, [1, 2, 3]) + + expect { collection.to_a } + .to raise_error(ZammadAPI::ParseError, /expected a JSON array of objects, got an array holding Integer/) + end + + describe '#where' do + it 'adds query parameters' do + stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => default_per_page.to_s, 'sort_by' => 'name' }) + .to_return(json_response([{ id: 1 }])) + stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '2', 'per_page' => default_per_page.to_s, 'sort_by' => 'name' }) + .to_return(json_response([])) + + expect(collection.where(sort_by: 'name').map(&:id)).to eq([1]) + end + + it 'returns a new collection' do + expect(collection.where(sort_by: 'name')).not_to be(collection) + end + + it 'rejects an attribute filter the endpoint would drop' do + expect { collection.where(name: 'Users') } + .to raise_error(ArgumentError, /ignores name, so where would hand back unfiltered records/) + end + + it 'names what the endpoint does honour' do + expect { collection.where(name: 'Users') }.to raise_error(ArgumentError, /honours sort_by, order_by/) + end + + it 'points at find_by and search for a resource Zammad searches' do + expect { collection.where(name: 'Users') } + .to raise_error(ArgumentError, /use find_by for one record or search for many/) + end + + # Pointing at find_by would send the caller in a circle: find_by needs the + # search endpoint this resource has none of. + it 'points at detect for a resource Zammad does not search' do + expect { client.ticket_state.all.where(name: 'open') } + .to raise_error(ArgumentError, /routes none for this resource, so walk the records and pick with detect/) + end + + # Both guards compare against Symbol lists, while `**params` collects a + # String key just as happily. + it 'accepts a string key for a parameter the endpoint honours' do + stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => default_per_page.to_s, 'sort_by' => 'name' }) + .to_return(json_response([])) + + collection.where('sort_by' => 'name').to_a + expect(a_request(:get, url).with(query: hash_including('sort_by' => 'name'))).to have_been_made + end + + it 'rejects a string key the endpoint would drop' do + expect { collection.where('name' => 'Users') } + .to raise_error(ArgumentError, /ignores name, so where would hand back unfiltered records/) + end + + it 'does not name a string key as both ignored and honoured' do + expect { collection.where('sort_by' => 'name') }.not_to raise_error + end + + it 'rejects a string key for a parameter the collection owns' do + expect { collection.where('page' => 2) } + .to raise_error(ArgumentError, /page cannot be passed to where/) + end + + %i[page per_page expand only_total_count].each do |reserved| + it "rejects #{reserved}, which the collection controls itself" do + expect { collection.where(reserved => 1) }.to raise_error(ArgumentError, /cannot be passed to where/) + end + end + + it 'rejects a search term rather than replacing the one search set' do + expect { collection.where(query: 'anything') }.to raise_error(ArgumentError, /cannot be passed to where/) + end + end + + describe '#pluck' do + it 'returns one value per record for a single attribute' do + stub_last_page(1, [{ id: 1, name: 'Users' }, { id: 2, name: 'Support' }]) + + expect(collection.pluck(:name)).to eq(%w[Users Support]) + end + + it 'returns one array per record for several attributes' do + stub_last_page(1, [{ id: 1, name: 'Users' }, { id: 2, name: 'Support' }]) + + expect(collection.pluck(:id, :name)).to eq([[1, 'Users'], [2, 'Support']]) + end + + it 'accepts string keys' do + stub_last_page(1, [{ id: 1, name: 'Users' }]) + + expect(collection.pluck('name')).to eq(['Users']) + end + + it 'yields nil for an attribute a record does not carry' do + stub_last_page(1, [{ id: 1 }]) + + expect(collection.pluck(:name)).to eq([nil]) + end + + it 'walks every page, like each' do + stub_page(1, full_page) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(collection.pluck(:id)).to eq((1..(default_per_page + 1)).to_a) + end + + it 'needs at least one attribute name' do + expect { collection.pluck }.to raise_error(ArgumentError, 'pluck needs at least one attribute name') + end + end + + describe '#count' do + let(:search_url) { "#{ClientHelper::BASE_URL}api/v1/users/search" } + + it 'asks a search endpoint for the total in one request' do + stub_request(:get, search_url) + .with(query: { 'expand' => 'true', 'query' => 'smith', 'only_total_count' => 'true' }) + .to_return(json_response({ total_count: 4711 })) + + expect(client.user.search('smith').count).to eq(4711) + end + + # model_index_render reads sort_by, order_by and the paging and drops + # every other parameter, so only_total_count means nothing to it, and + # there is no header to read a total from either. Probing anyway spent a + # request before the walk that had to happen regardless. + it 'walks an index endpoint rather than probing it for a total it cannot give' do + stub_page(1, full_page) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(collection.count).to eq(default_per_page + 1) + expect(a_request(:get, url).with(query: hash_including('only_total_count' => 'true'))).not_to have_been_made + end + + # A count is the one answer nothing downstream can sanity check: + # `Array.new(collection.count)` and `count.zero?` both take it at its word. + it 'walks rather than reporting a total that cannot describe a result' do + stub_request(:get, search_url).with(query: hash_including('page' => '1')) + .to_return(json_response([{ id: 1 }])) + stub_request(:get, search_url).with(query: hash_including('page' => '2')) + .to_return(json_response([])) + stub_request(:get, search_url).with(query: hash_including('only_total_count' => 'true')) + .to_return(json_response({ total_count: -3 })) + + expect(client.user.search('smith').count).to eq(1) + end + + it 'walks the pages when a search endpoint answers with no total at all' do + stub_request(:get, search_url).with(query: hash_including('page' => '1')) + .to_return(json_response([{ id: 1 }])) + stub_request(:get, search_url).with(query: hash_including('page' => '2')) + .to_return(json_response([])) + stub_request(:get, search_url).with(query: hash_including('only_total_count' => 'true')) + .to_return(json_response({})) + + expect(client.user.search('smith').count).to eq(1) + end + + it 'walks the pages when the total it answers with is not a count' do + stub_request(:get, search_url).with(query: hash_including('page' => '1')) + .to_return(json_response([{ id: 1 }])) + stub_request(:get, search_url).with(query: hash_including('page' => '2')) + .to_return(json_response([])) + stub_request(:get, search_url).with(query: hash_including('only_total_count' => 'true')) + .to_return(json_response({ total_count: 'lots' })) + + expect(client.user.search('smith').count).to eq(1) + end + + # Not an endpoint Zammad has - all four search actions route through + # model_search_render, which reads only_total_count before it reads + # anything else - but something other than the endpoint can answer: a + # proxy error page, a login form, an HTML body with a 200 on it. + it 'walks the pages when something answers with records instead of a total' do + stub_request(:get, search_url).with(query: hash_including('page' => '1')) + .to_return(json_response([{ id: 1 }, { id: 2 }])) + stub_request(:get, search_url).with(query: hash_including('page' => '2')) + .to_return(json_response([])) + stub_request(:get, search_url).with(query: hash_including('only_total_count' => 'true')) + .to_return(json_response([{ id: 1 }, { id: 2 }])) + + expect(client.user.search('smith').count).to eq(2) + end + + it 'counts one page only when limited to a page' do + stub_page(3, [{ id: 5 }, { id: 6 }]) + + expect(collection.page(3).count).to eq(2) + end + + it 'counts matches when given a block' do + stub_last_page(1, [{ id: 1 }, { id: 2 }, { id: 3 }]) + + expect(collection.count { it.id > 1 }).to eq(2) + end + end + + describe '#size' do + it 'walks the pages, like #count' do + stub_last_page(1, [{ id: 1 }, { id: 2 }]) + + expect(collection.size).to eq(2) + end + + it 'asks a search endpoint for the total in one request' do + stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/users/search") + .with(query: { 'expand' => 'true', 'query' => 'smith', 'only_total_count' => 'true' }) + .to_return(json_response({ total_count: 4711 })) + + expect(client.user.search('smith').size).to eq(4711) + end + + it 'is also spelled #length' do + stub_last_page(1, [{ id: 1 }]) + + expect(collection.length).to eq(1) + end + end + + describe '#empty?' do + it 'is false when the endpoint has a record' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + expect(collection).not_to be_empty + end + + it 'is true when the endpoint has none' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([])) + + expect(collection).to be_empty + end + + it 'asks for a single record rather than a whole page' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + collection.empty? + expect(a_request(:get, url).with(query: hash_including('per_page' => '1'))).to have_been_made + end + + it 'leaves the page size alone on a collection limited to one page' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([{ id: 3 }])) + + collection.page(2, of: 5).empty? + expect(a_request(:get, url).with(query: hash_including('page' => '2', 'per_page' => '5'))).to have_been_made + end + end + + # `take(n)` and `first(n)` ask one question. Enumerable answers both by + # taking records off the front of a page sized for walking, so with #first + # sizing its own page and this one left alone, what the same read cost + # depended on which word was typed. + describe '#take' do + it 'reads a page sized for what was asked for' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + + expect(collection.take(2).map(&:id)).to eq([1, 2]) + end + + it 'costs what the same read through #first costs' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + + collection.take(2) + expect(a_request(:get, url).with(query: hash_including('per_page' => '2'))).to have_been_made.once + end + + it 'walks when more records are asked for than the endpoint serves' do + stub_page(1, full_page) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(collection.take(default_per_page + 1).size).to eq(default_per_page + 1) + end + + it 'leaves a zero count to Enumerable' do + expect(collection.take(0)).to eq([]) + end + + # Enumerable#take always answers with an Array, where `first` reads a nil + # as "just the one" and answers with a record. + it 'refuses a nil the way Enumerable#take does' do + expect { collection.take(nil) }.to raise_error(TypeError, /no implicit conversion/) + end + + it 'reads on where the endpoint serves a smaller page than it was asked for' do + stub_capped_endpoint(url, total: 5, max: 2) + + expect(collection.take(5).map(&:id)).to eq([1, 2, 3, 4, 5]) + end + end + + # `find` on the proxy is the lookup by id, and Enumerable#find reads its + # argument as an ifnone callable - so `all.find(1)` answered with an + # Enumerator, made no request and raised nothing. + describe '#find' do + it 'is Enumerable#find with a block' do + stub_last_page(1, [{ id: 1, name: 'Users' }, { id: 2, name: 'Support' }]) + + expect(collection.find { it.name == 'Support' }.id).to eq(2) + end + + it 'refuses an id where a block belongs' do + expect { collection.find(1) } + .to raise_error(ArgumentError, /find on a ZammadAPI::Collection is Enumerable#find, which takes a block/) + end + + it 'names the lookup that does take an id' do + expect { collection.find(1) }.to raise_error(ArgumentError, /client\.group\.find\(1\)/) + end + + it 'names the resource the way a client does, for a multi-word one' do + expect { client.ticket_article.all.find(7) } + .to raise_error(ArgumentError, /client\.ticket_article\.find\(7\)/) + end + + it 'points at detect for the block form' do + expect { collection.find(1) }.to raise_error(ArgumentError, /detect \{ \.\.\. \}/) + end + + it 'makes no request for the id it refuses' do + expect { collection.find(1) }.to raise_error(ArgumentError) + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + + it 'still returns an Enumerator without a block or an argument' do + expect(collection.find).to be_a(Enumerator) + end + end + + # Enumerable#first takes its records off the front of a page this collection + # sized for walking, so `all.first` downloaded a whole page to hand back one + # record. + describe '#first' do + it 'reads a page of one for a single record' do + stub_page(1, [{ id: 1 }], per_page: 1) + + expect(collection.first.id).to eq(1) + end + + it 'is nil when the endpoint has nothing' do + stub_page(1, [], per_page: 1) + + expect(collection.first).to be_nil + end + + it 'sizes the page to the number of records asked for' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + + expect(collection.first(2).map(&:id)).to eq([1, 2]) + end + + it 'walks instead when more records are asked for than the endpoint serves' do + stub_page(1, full_page) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(collection.first(default_per_page + 1).size).to eq(default_per_page + 1) + end + + # The page size of a collection limited to one says which records it + # holds, so re-sizing it would move them - the same reason batch_size + # cannot be combined with page. + it 'leaves a collection already limited to a page at its own size' do + stub_page(3, [{ id: 5 }, { id: 6 }], per_page: 2) + + expect(collection.page(3, of: 2).first.id).to eq(5) + end + + it 'leaves a zero count to Enumerable' do + expect(collection.first(0)).to eq([]) + end + + it 'makes no request for a zero count' do + expect(collection.first(0)).to eq([]) + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + + # Sizing the request rather than limiting the collection to one page: + # limited, this came back with whatever the first page held and nothing to + # say the rest were there to be read. + it 'reads on where the endpoint serves a smaller page than it was asked for' do + stub_capped_endpoint(url, total: 5, max: 2) + + expect(collection.first(5).map(&:id)).to eq([1, 2, 3, 4, 5]) + end + + it 'stops as soon as it has what it asked for' do + stub_capped_endpoint(url, total: 500, max: 2) + + expect(collection.first(3).map(&:id)).to eq([1, 2, 3]) + expect(a_request(:get, url).with(query: hash_including('page' => '3'))).not_to have_been_made + end + end + + describe '#inspect' do + it 'describes the collection without fetching it' do + expect(collection.inspect) + .to eq("#") + end + + it 'mentions the page when limited to one' do + expect(collection.page(4).inspect).to include('page=4') + end + end + + # A walk stops on what the endpoint served, and the size it serves is + # learned from the first page rather than taken from `max_per_page`. Where + # the server's cap is lower than the request - a cap the gem's declaration + # has gone stale on - every page is short of what was asked for, and reading + # the requested size would make each one look like the last. + describe 'an endpoint that pages smaller than it was asked for' do + it 'is walked to the end rather than truncated at the first short page' do + stub_capped_endpoint(url, total: 5, max: 2) + + expect(collection.map(&:id)).to eq([1, 2, 3, 4, 5]) + end + end +end diff --git a/spec/unit/zammad_api/config_spec.rb b/spec/unit/zammad_api/config_spec.rb new file mode 100644 index 0000000..6c5c788 --- /dev/null +++ b/spec/unit/zammad_api/config_spec.rb @@ -0,0 +1,453 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Config do + def build(url: 'https://zammad.example.com', http_token: 'token', **overrides) + described_class.new(url: url, http_token: http_token, **overrides) + end + + describe 'url handling' do + it 'appends a trailing slash so sub-path installations keep working' do + expect(build(url: 'https://example.com/zammad').url).to eq('https://example.com/zammad/') + end + + it 'leaves an existing trailing slash alone' do + expect(build(url: 'https://example.com/').url).to eq('https://example.com/') + end + + it 'rejects a missing url' do + expect { build(url: nil) } + .to raise_error(ZammadAPI::ConfigurationError, 'missing url in config') + end + + it 'rejects an empty url' do + expect { build(url: '') } + .to raise_error(ZammadAPI::ConfigurationError, 'missing url in config') + end + + it 'rejects a non-http scheme' do + expect { build(url: 'ftp://example.com') } + .to raise_error(ZammadAPI::ConfigurationError, 'config url needs to start with http:// or https://') + end + + # Appended blindly, the slash landed behind the query string and every + # request was resolved against a base ending in `?tenant=acme/`. + it 'puts the trailing slash on the path, not behind a query string' do + expect(build(url: 'https://example.com/zammad?tenant=acme').url) + .to eq('https://example.com/zammad/?tenant=acme') + end + + it 'puts the trailing slash on the path, not behind a fragment' do + expect(build(url: 'https://example.com/zammad#top').url) + .to eq('https://example.com/zammad/#top') + end + + # Accepted, this failed deep inside the adapter on the first request + # instead of here. + it 'rejects a scheme with no host after it' do + expect { build(url: 'https://') } + .to raise_error(ZammadAPI::ConfigurationError, 'config url needs a host after the scheme, got "https://"') + end + + # URI(...) is what a caller reaches for, and it prints as the URL, so it + # reached String#end_with? and died there as a NoMethodError - past the + # ConfigurationError the constructor is documented to raise. + it 'rejects a url that is not a string' do + expect { build(url: URI('https://example.com/')) } + .to raise_error(ZammadAPI::ConfigurationError, 'config url needs to be a string, got URI::HTTPS') + end + end + + describe 'credentials' do + it 'accepts an access token' do + expect(build(http_token: 'token').authentication_scheme).to eq(:http_token) + end + + it 'accepts an OAuth2 token' do + expect(build(http_token: nil, oauth2_token: 'token').authentication_scheme).to eq(:oauth2_token) + end + + it 'accepts user and password' do + expect(build(http_token: nil, user: 'u', password: 'p').authentication_scheme).to eq(:basic) + end + + it 'prefers the access token over other credentials' do + config = build(http_token: 'token', oauth2_token: 'other', user: 'u', password: 'p') + expect(config.authentication_scheme).to eq(:http_token) + end + + it 'rejects a missing user' do + expect { build(http_token: nil, password: 'p') } + .to raise_error(ZammadAPI::ConfigurationError, 'missing user in config') + end + + it 'rejects a missing password' do + expect { build(http_token: nil, user: 'u') } + .to raise_error(ZammadAPI::ConfigurationError, 'missing password in config') + end + + it 'treats blank credentials as absent' do + expect { build(http_token: '', oauth2_token: '', user: '', password: '') } + .to raise_error(ZammadAPI::ConfigurationError, 'missing user in config') + end + end + + describe 'defaults' do + it 'sets a request timeout' do + expect(build.timeout).to eq(described_class::DEFAULT_TIMEOUT) + end + + it 'sets a connection timeout' do + expect(build.open_timeout).to eq(described_class::DEFAULT_OPEN_TIMEOUT) + end + + it 'retries transient failures' do + expect(build.retries).to eq(described_class::DEFAULT_RETRIES) + end + + it 'identifies itself with the gem version' do + expect(build.user_agent).to eq("zammad_api-ruby/#{ZammadAPI::VERSION}") + end + + it 'verifies TLS certificates' do + expect(build.ssl_verify).to be(true) + end + + it 'discards log output when no logger is supplied' do + expect(build.logger).to be_a(Logger) + end + + it 'leaves the Faraday adapter to Faraday' do + expect(build.adapter).to be_nil + end + + it 'installs no extra middleware' do + expect(build.middleware).to be_nil + end + end + + describe 'the user agent' do + # Handed to Faraday as a nil header, Faraday filled in its own, so the gem + # stopped identifying itself in the instance log an operator greps to find + # its requests - on every request, with nothing said about it. + it 'falls back to the default when it is nil' do + expect(build(user_agent: nil).user_agent).to eq("zammad_api-ruby/#{ZammadAPI::VERSION}") + end + + it 'falls back to the default when it is empty' do + expect(build(user_agent: '').user_agent).to eq("zammad_api-ruby/#{ZammadAPI::VERSION}") + end + + it 'rejects one that is not a string' do + expect { build(user_agent: 42) } + .to raise_error(ZammadAPI::ConfigurationError, 'config user_agent needs to be a string') + end + end + + describe 'the Faraday seam' do + it 'symbolizes an adapter given as a string' do + expect(build(adapter: 'test').adapter).to eq(:test) + end + + it 'keeps an adapter given as a symbol' do + expect(build(adapter: :test).adapter).to eq(:test) + end + + it 'keeps the middleware callable' do + hook = ->(builder) { builder } + expect(build(middleware: hook).middleware).to be(hook) + end + + it 'rejects middleware that cannot be called' do + expect { build(middleware: 'not callable') } + .to raise_error(ZammadAPI::ConfigurationError, 'config middleware needs to respond to call') + end + + it 'accepts any callable, not only a proc' do + callable = Class.new { def call(builder) = builder }.new + expect(build(middleware: callable).middleware).to be(callable) + end + end + + describe 'the logger' do + it 'keeps the logger it is given' do + logger = Logger.new(IO::NULL) + expect(build(logger: logger).logger).to be(logger) + end + + it 'accepts anything that logs at debug, not only a Logger' do + logger = Class.new { def debug(...) = nil }.new + expect(build(logger: logger).logger).to be(logger) + end + + # 1.x took `logger: true` as "log to $stderr". + it 'rejects a boolean, as 1.x accepted' do + expect { build(logger: true) } + .to raise_error(ZammadAPI::ConfigurationError, 'config logger needs to respond to debug') + end + end + + describe 'numeric validation' do + # Complex is a Numeric and answers neither `positive?` nor `>`, so this + # left a NoMethodError where every other rejected option raises the error + # building a client is documented to need. + it 'rejects a Numeric that cannot be compared, as a configuration error' do + expect { build(timeout: Complex(1, 2)) } + .to raise_error(ZammadAPI::ConfigurationError, 'config timeout needs to be a positive number') + end + + it 'rejects a zero timeout' do + expect { build(timeout: 0) } + .to raise_error(ZammadAPI::ConfigurationError, 'config timeout needs to be a positive number') + end + + it 'rejects a negative open_timeout' do + expect { build(open_timeout: -1) } + .to raise_error(ZammadAPI::ConfigurationError, 'config open_timeout needs to be a positive number') + end + + it 'rejects a non-numeric timeout' do + expect { build(timeout: 'soon') } + .to raise_error(ZammadAPI::ConfigurationError, 'config timeout needs to be a positive number') + end + + it 'rejects negative retries' do + expect { build(retries: -1) } + .to raise_error(ZammadAPI::ConfigurationError, 'config retries needs to be a non-negative integer') + end + + it 'allows disabling retries' do + expect(build(retries: 0).retries).to eq(0) + end + end + + describe '#inspect' do + subject(:rendered) do + build(user: 'u', password: 'pw-s3cret', http_token: 'tok-s3cret', oauth2_token: 'oauth-s3cret').inspect + end + + it 'redacts the password' do + expect(rendered).not_to include('pw-s3cret') + end + + it 'redacts the access token' do + expect(rendered).not_to include('tok-s3cret') + end + + it 'redacts the OAuth2 token' do + expect(rendered).not_to include('oauth-s3cret') + end + + it 'marks redacted values' do + expect(rendered).to include('password=[REDACTED]') + end + + it 'keeps non-sensitive values readable' do + expect(rendered).to include('url="https://zammad.example.com/"') + end + + it 'does not dump the logger internals' do + expect(rendered).to include('logger=#') + end + + it 'does not dump the middleware internals' do + expect(build(middleware: ->(builder) { builder }).inspect).to include('middleware=#') + end + + it 'still shows that no middleware is configured' do + expect(rendered).to include('middleware=nil') + end + + it 'is used for to_s as well' do + config = build(password: 'hunter2') + expect(config.to_s).to eq(config.inspect) + end + + context 'with an authenticated proxy' do + subject(:rendered) { build(proxy: 'http://puser:pproxy-s3cret@proxy.test:8080').inspect } + + it 'redacts the proxy credentials' do + expect(rendered).not_to include('pproxy-s3cret') + end + + it 'keeps the proxy host visible' do + expect(rendered).to include('proxy.test:8080') + end + + it 'marks the redacted userinfo' do + expect(rendered).to include('proxy="http://[REDACTED]@proxy.test:8080"') + end + end + + it 'redacts a proxy password carrying an unencoded @' do + rendered = build(proxy: 'http://puser:pa@ss-s3cret@proxy.test:8080').inspect + + expect(rendered).to include('proxy="http://[REDACTED]@proxy.test:8080"') + expect(rendered).not_to include('ss-s3cret') + end + + it 'leaves a proxy without credentials alone' do + expect(build(proxy: 'http://proxy.test:8080').inspect).to include('proxy="http://proxy.test:8080"') + end + + # The shape an http_proxy style setting is copied out of. Anchored on a + # `://` lookbehind, the redaction never fired and the password went into + # every log line and exception report this class promises to be safe in. + it 'redacts the credentials of a proxy configured without a scheme' do + rendered = build(proxy: 'puser:pproxy-s3cret@proxy.test:8080').inspect + + expect(rendered).to include('proxy="[REDACTED]@proxy.test:8080"') + expect(rendered).not_to include('pproxy-s3cret') + end + + it 'leaves a scheme-less proxy without credentials alone' do + expect(build(proxy: 'proxy.test:8080').inspect).to include('proxy="proxy.test:8080"') + end + + context 'with credentials in the instance url' do + subject(:rendered) { build(url: 'https://admin:url-s3cret@zammad.example.com/').inspect } + + it 'redacts them' do + expect(rendered).not_to include('url-s3cret') + end + + it 'keeps the host visible' do + expect(rendered).to include('zammad.example.com') + end + + it 'marks the redacted userinfo' do + expect(rendered).to include('url="https://[REDACTED]@zammad.example.com/"') + end + end + + it 'leaves a url without credentials alone' do + expect(build(url: 'https://zammad.example.com/').inspect).to include('url="https://zammad.example.com/"') + end + end + + describe '#redacted_url' do + it 'blanks inline credentials' do + expect(build(url: 'https://admin:s3cret@zammad.example.com/').redacted_url) + .to eq('https://[REDACTED]@zammad.example.com/') + end + + # Anchoring on the first @ left the tail of the password in the rendered + # URL, which is interpolated into every ConnectionError message. + it 'blanks a password carrying an unencoded @' do + expect(build(url: 'https://admin:pa@ss-s3cret@zammad.example.com/').redacted_url) + .to eq('https://[REDACTED]@zammad.example.com/') + end + + it 'leaves an @ in the path alone' do + expect(build(url: 'https://zammad.example.com/tenant@acme/').redacted_url) + .to eq('https://zammad.example.com/tenant@acme/') + end + + # Bounded only by the path, the match crossed into the query string and + # rendered a host that does not exist - into every ConnectionError message. + it 'leaves an @ in a query string alone' do + expect(build(url: 'https://zammad.example.com?tenant=a@acme').redacted_url) + .to eq('https://zammad.example.com/?tenant=a@acme') + end + + it 'leaves an @ in a fragment alone' do + expect(build(url: 'https://zammad.example.com#a@b').redacted_url) + .to eq('https://zammad.example.com/#a@b') + end + + it 'still blanks credentials on a url that also carries an @ later on' do + expect(build(url: 'https://admin:s3cret@zammad.example.com/tenant@acme/').redacted_url) + .to eq('https://[REDACTED]@zammad.example.com/tenant@acme/') + end + + it 'leaves a url without credentials alone' do + expect(build(url: 'https://zammad.example.com/').redacted_url).to eq('https://zammad.example.com/') + end + + it 'does not change what requests are sent to' do + config = build(url: 'https://admin:s3cret@zammad.example.com/') + expect(config.url).to eq('https://admin:s3cret@zammad.example.com/') + end + end + + describe 'immutability of string members' do + it 'freezes the url' do + expect(build.url).to be_frozen + end + + it 'does not share the url with the caller' do + supplied = +'https://zammad.example.com/' + config = described_class.new(url: supplied, http_token: 'tok') + supplied << 'mutated' + expect(config.url).to eq('https://zammad.example.com/') + end + + it 'freezes the credentials' do + config = build(user: 'u', password: +'pw', http_token: nil) + expect(config.password).to be_frozen + end + + it 'freezes the proxy' do + expect(build(proxy: +'http://proxy.test:8080').proxy).to be_frozen + end + + it 'freezes the user agent' do + expect(build(user_agent: +'custom/1.0').user_agent).to be_frozen + end + end + + it 'is immutable' do + expect(build).to be_frozen + end + + # Every other option is checked up front; these three were not, so a bad + # value escaped the ConfigurationError that building a client is documented + # to need - two as a bare NoMethodError, one by silently staying on. + describe 'option validation' do + it 'rejects a proxy that is not a string' do + expect { build(proxy: URI('http://user:secret@proxy:3128')) } + .to raise_error(ZammadAPI::ConfigurationError, 'config proxy needs to be a string, got URI::HTTP') + end + + # The whole point of refusing it: a URI reached String#sub inside #inspect + # and died there, so the object this class documents as safe to log raised + # at the moment something logged it. + it 'keeps a proxy-bearing config renderable' do + expect { build(proxy: 'http://user:secret@proxy:3128').inspect }.not_to raise_error + end + + it 'redacts the credentials in a proxy it renders' do + rendered = build(proxy: 'http://user:secret@proxy:3128').inspect + + expect(rendered).to include('proxy="http://[REDACTED]@proxy:3128"') + expect(rendered).not_to include('secret') + end + + it 'rejects an adapter that cannot be a symbol' do + expect { build(adapter: 1) } + .to raise_error(ZammadAPI::ConfigurationError, 'config adapter needs to be a symbol or a string, got Integer') + end + + # `adapter: true` is the 1.x-flavoured mistake: it answered to_sym on + # nothing and raised NoMethodError from inside the constructor. + it 'rejects a boolean adapter' do + expect { build(adapter: true) } + .to raise_error(ZammadAPI::ConfigurationError, /config adapter needs to be a symbol or a string/) + end + + it 'accepts an adapter named as a string' do + expect(build(adapter: 'net_http').adapter).to eq(:net_http) + end + + # Read from an environment variable this is the string "false", which is + # truthy, so verification stayed on while the caller believed they had + # turned it off. + it 'rejects an ssl_verify that is not a boolean' do + expect { build(ssl_verify: 'false') } + .to raise_error(ZammadAPI::ConfigurationError, 'config ssl_verify needs to be true or false, got "false"') + end + + it 'accepts ssl_verify: false' do + expect(build(ssl_verify: false).ssl_verify).to be(false) + end + end +end diff --git a/spec/unit/zammad_api/resource_proxy_spec.rb b/spec/unit/zammad_api/resource_proxy_spec.rb new file mode 100644 index 0000000..5cda3a3 --- /dev/null +++ b/spec/unit/zammad_api/resource_proxy_spec.rb @@ -0,0 +1,659 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::ResourceProxy do + subject(:proxy) { client.group } + + let(:client) { unit_client } + let(:url) { "#{ClientHelper::BASE_URL}api/v1/groups" } + + it 'exposes the resource class' do + expect(proxy.resource_class).to eq(ZammadAPI::Resources::Group) + end + + describe '#new' do + it 'builds an unsaved record' do + expect(proxy.new(name: 'Support')).to be_new_record + end + + it 'does not talk to the server' do + proxy.new(name: 'Support') + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + + it 'accepts no attributes at all' do + expect(proxy.new.attributes).to eq({}) + end + end + + describe '#find' do + it 'requests the record with expanded attributes' do + stub = stub_request(:get, "#{url}/1").with(query: { 'expand' => 'true' }).to_return(json_response({ id: 1, name: 'Users' })) + + proxy.find(1) + expect(stub).to have_been_requested + end + + it 'returns a persisted record' do + stub_request(:get, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1, name: 'Users' })) + + expect(proxy.find(1)).to be_persisted + end + + it 'maps the attributes' do + stub_request(:get, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1, name: 'Users' })) + + expect(proxy.find(1).name).to eq('Users') + end + + it 'raises NotFoundError for an unknown id' do + stub_request(:get, "#{url}/404").with(query: hash_including({})).to_return(json_response({ error: 'not found' }, status: 404)) + + expect { proxy.find(404) }.to raise_error(ZammadAPI::NotFoundError) + end + + it 'raises ParseError when the response is not an object' do + stub_request(:get, "#{url}/1").with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + expect { proxy.find(1) }.to raise_error(ZammadAPI::ParseError, /expected a JSON object, got Array/) + end + end + + describe 'ids in the request path' do + it 'escapes a traversal instead of reaching another endpoint' do + stub = stub_request(:get, "#{url}/1%2F..%2F..%2Fapi%2Fv1%2Fusers%2F1") + .with(query: { 'expand' => 'true' }) + .to_return(json_response({ error: 'not found' }, status: 404)) + + expect { proxy.find('1/../../api/v1/users/1') }.to raise_error(ZammadAPI::NotFoundError) + expect(stub).to have_been_requested + expect(a_request(:get, %r{/api/v1/users/1\z})).not_to have_been_made + end + + it 'escapes the id on destroy too' do + stub = stub_request(:delete, "#{url}/1%2F..%2F..%2Fapi%2Fv1%2Fusers%2F1").to_return(status: 200, body: '') + + proxy.destroy('1/../../api/v1/users/1') + expect(stub).to have_been_requested + end + + it 'escapes the id a record uses for its own writes' do + record = ZammadAPI::Resources::Group.from_response(client.instance_variable_get(:@transport), { id: '1/../../api/v1/users/1' }) + stub = stub_request(:delete, "#{url}/1%2F..%2F..%2Fapi%2Fv1%2Fusers%2F1").to_return(status: 200, body: '') + + record.destroy + expect(stub).to have_been_requested + end + + it 'rejects an empty id rather than requesting the index' do + expect { proxy.find('') }.to raise_error(ArgumentError, /record id is required/) + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + end + + describe '#create' do + it 'posts the attributes' do + stub = stub_request(:post, url) + .with(query: { 'expand' => 'true' }, body: '{"name":"Support"}') + .to_return(json_response({ id: 5, name: 'Support' }, status: 201)) + + proxy.create(name: 'Support') + expect(stub).to have_been_requested + end + + it 'returns the persisted record' do + stub_request(:post, url).with(query: hash_including({})).to_return(json_response({ id: 5, name: 'Support' }, status: 201)) + + expect(proxy.create(name: 'Support')).to be_persisted + end + + it 'raises ValidationError when Zammad rejects the attributes' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ error: 'Name is required' }, status: 422)) + + expect { proxy.create({}) }.to raise_error(ZammadAPI::ValidationError, /Name is required/) + end + end + + describe '#find_by' do + let(:search_url) { "#{url}/search" } + + it 'searches instead of filtering an index that cannot filter' do + stub = stub_request(:get, search_url) + .with(query: hash_including('query' => 'Users')) + .to_return(json_response([{ id: 1, name: 'Users' }])) + + proxy.find_by(name: 'Users') + + expect(stub).to have_been_requested + expect(a_request(:get, url).with(query: hash_including({}))).not_to have_been_made + end + + it 'returns the matching record' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([{ id: 1, name: 'Users' }])) + + expect(proxy.find_by(name: 'Users').id).to eq(1) + end + + # Walking every page billed a request per page of hits to answer "no", + # on the find_by(...) || create(...) path that runs for every new record. + it 'costs one request when nothing matches, however many hits there are' do + hits = Array.new(ZammadAPI::ResourceProxy::SEARCH_MAX_PER_PAGE) { { id: it + 1, name: 'Other' } } + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response(hits)) + + expect(proxy.find_by(name: 'Users')).to be_nil + expect(a_request(:get, search_url).with(query: hash_including({}))).to have_been_made.once + end + + it 'asks for a single page of hits at the size the search endpoint serves' do + stub = stub_request(:get, search_url) + .with(query: hash_including('page' => '1', 'per_page' => ZammadAPI::ResourceProxy::SEARCH_MAX_PER_PAGE.to_s)) + .to_return(json_response([{ id: 1, name: 'Users' }])) + + proxy.find_by(name: 'Users') + + expect(stub).to have_been_requested + end + + it 'returns a persisted record' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([{ id: 1, name: 'Users' }])) + + expect(proxy.find_by(name: 'Users')).to be_persisted + end + + it 'skips a hit the search returned that does not actually match' do + stub_request(:get, search_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, name: 'Users Archive' }, { id: 2, name: 'Users' }])) + + expect(proxy.find_by(name: 'Users').id).to eq(2) + end + + it 'returns nil when the search matched nothing this record carries' do + stub_request(:get, search_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, name: 'Users Archive' }])) + + expect(proxy.find_by(name: 'Users')).to be_nil + end + + # Quoting a value that carries search syntax, to have it looked for rather + # than obeyed, cost more than it bought: an instance searching without + # Elasticsearch matches the term literally through a SQL LIKE, so the + # quotes became characters the value had to contain, and a hyphen is + # syntax - so `find_by(name: 'support-eu')` found nothing at all there. + it 'sends a value carrying search syntax as it is' do + stub = stub_request(:get, search_url) + .with(query: hash_including('query' => 'support-eu')) + .to_return(json_response([])) + + proxy.find_by(name: 'support-eu') + + expect(stub).to have_been_requested + end + + it 'sends a value that reads as a boolean query as it is' do + stub = stub_request(:get, search_url) + .with(query: hash_including('query' => 'a AND b')) + .to_return(json_response([])) + + proxy.find_by(name: 'a AND b') + + expect(stub).to have_been_requested + end + + # Whatever the term meant to the backend, the answer is decided here. + it 'still matches the record exactly' do + stub_request(:get, search_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, name: 'a AND b' }])) + + expect(proxy.find_by(name: 'a AND b').id).to eq(1) + end + + it 'returns nil when nothing matched' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([])) + + expect(proxy.find_by(name: 'Nope')).to be_nil + end + + it 'matches on every attribute given, not just one' do + stub_request(:get, search_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, name: 'Users', active: false }, { id: 2, name: 'Users', active: true }])) + + expect(proxy.find_by(name: 'Users', active: true).id).to eq(2) + end + + # What a caller is promised: a record carrying both values comes back. + # The spec here asserted the query string instead - that "Users Support" + # went out - which a stub answers whatever it is handed, so nothing could + # see that an instance without Elasticsearch matches that term through a + # SQL LIKE per column and so finds neither of them. + it 'finds a record by two string attributes' do + stub_request(:get, search_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, name: 'Users', note: 'Other' }, { id: 2, name: 'Users', note: 'Support' }])) + + expect(proxy.find_by(name: 'Users', note: 'Support').id).to eq(2) + end + + # The fence for the above: one value goes out, never the values joined. + # Joined, the term is one no single column holds. + it 'searches a single value rather than joining them' do + stub = stub_request(:get, search_url) + .with(query: hash_including('query' => 'Support')) + .to_return(json_response([])) + + proxy.find_by(name: 'Users', note: 'Support') + expect(stub).to have_been_requested + end + + it 'searches the longest value, as the most selective within the capped scan' do + stub = stub_request(:get, search_url) + .with(query: hash_including('query' => 'a-very-specific-note')) + .to_return(json_response([])) + + proxy.find_by(name: 'Users', note: 'a-very-specific-note') + expect(stub).to have_been_requested + end + + # A non-string went to the search engine as the word it prints as, so + # `find_by(name: 'Users', active: true)` searched for "Users true" and + # matched nothing. + it 'keeps a non-string value out of the search term, and still matches on it' do + stub = stub_request(:get, search_url) + .with(query: hash_including('query' => 'Users')) + .to_return(json_response([{ id: 1, name: 'Users', active: false }, { id: 2, name: 'Users', active: true }])) + + expect(proxy.find_by(name: 'Users', active: true).id).to eq(2) + expect(stub).to have_been_requested + end + + it 'stops at the first match rather than walking the rest of the search' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([{ id: 1, name: 'Users' }])) + + proxy.find_by(name: 'Users') + expect(a_request(:get, search_url).with(query: hash_including({}))).to have_been_made.once + end + + it 'rejects a lookup with no attribute at all' do + expect { proxy.find_by }.to raise_error(ArgumentError, /at least one attribute/) + end + + # /api/v1/ticket_states/search is not routed, so the 404 arrived here as a + # NotFoundError from a method documented to answer nil. + it 'refuses a resource Zammad routes no search endpoint for' do + expect { client.ticket_state.find_by(name: 'open') } + .to raise_error(ZammadAPI::Error, /routes no search endpoint for ZammadAPI::Resources::TicketState/) + end + + it 'points a refused lookup at walking the records' do + expect { client.ticket_priority.find_by(name: '2 normal') } + .to raise_error(ZammadAPI::Error, /all\.detect/) + end + + it 'makes no request for a resource that cannot be searched' do + expect { client.ticket_state.find_by(name: 'open') }.to raise_error(ZammadAPI::Error) + expect(a_request(:get, "#{ClientHelper::BASE_URL}api/v1/ticket_states/search").with(query: hash_including({}))) + .not_to have_been_made + end + + it 'rejects a lookup with nothing to search for' do + expect { proxy.find_by(name: '') }.to raise_error(ArgumentError, /nothing to search for/) + end + + it 'rejects a lookup whose only value is not a string' do + expect { proxy.find_by(active: true) }.to raise_error(ArgumentError, /nothing to search for in active: true/) + end + + it 'says what a rejected lookup should pass instead' do + expect { proxy.find_by(active: true) }.to raise_error(ArgumentError, /at least one string value to search on/) + end + + it 'makes no request for a lookup with no string value' do + expect { proxy.find_by(organization_id: 5) }.to raise_error(ArgumentError) + expect(a_request(:get, search_url).with(query: hash_including({}))).not_to have_been_made + end + + it 'rejects a lookup whose only string value is blank' do + expect { proxy.find_by(name: ' ', active: true) }.to raise_error(ArgumentError, /nothing to search for/) + end + + it 'makes no request for a lookup it rejects' do + expect { proxy.find_by(name: nil) }.to raise_error(ArgumentError) + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + end + + describe '#find_by!' do + let(:search_url) { "#{url}/search" } + + it 'returns the matching record' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([{ id: 1, name: 'Users' }])) + + expect(proxy.find_by!(name: 'Users').id).to eq(1) + end + + it 'raises NotFoundError when nothing matched' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([])) + + expect { proxy.find_by!(name: 'Nope') }.to raise_error(ZammadAPI::NotFoundError) + end + + it 'raises when the search answered with a record that does not match' do + stub_request(:get, search_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, name: 'Users Archive' }])) + + expect { proxy.find_by!(name: 'Users') }.to raise_error(ZammadAPI::NotFoundError) + end + + it 'names the query and the resource in the message' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([])) + + expect { proxy.find_by!(name: 'Nope', active: true) } + .to raise_error("Can't find object by name and active (ZammadAPI::Resources::Group): no record matched") + end + + it 'does not put the values it searched for in the message' do + stub_request(:get, search_url).with(query: hash_including({})).to_return(json_response([])) + + expect { proxy.find_by!(name: 'secret-group') } + .to raise_error(ZammadAPI::NotFoundError) { |error| expect(error.message).not_to include('secret-group') } + end + end + + describe '#exists?' do + it 'is true when the record is there' do + stub_request(:get, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1 })) + + expect(proxy.exists?(1)).to be(true) + end + + it 'is false for a 404' do + stub_request(:get, "#{url}/404").with(query: hash_including({})) + .to_return(json_response({ error: 'not found' }, status: 404)) + + expect(proxy.exists?(404)).to be(false) + end + + it 'does not swallow an authorization failure' do + stub_request(:get, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ error: 'no' }, status: 403)) + + expect { proxy.exists?(1) }.to raise_error(ZammadAPI::AuthorizationError) + end + end + + describe '#destroy' do + it 'deletes the record without fetching it first' do + stub = stub_request(:delete, "#{url}/1").to_return(status: 200, body: '') + + expect(proxy.destroy(1)).to be(true) + expect(stub).to have_been_requested + expect(a_request(:get, "#{url}/1")).not_to have_been_made + end + + it 'raises NotFoundError for an unknown id' do + stub_request(:delete, "#{url}/404").to_return(json_response({ error: 'not found' }, status: 404)) + + expect { proxy.destroy(404) }.to raise_error(ZammadAPI::NotFoundError) + end + end + + # What a collection over this endpoint fetches per request when nothing asks + # for another size: as many as the endpoint serves. Read off the resource + # rather than written out, so that these stubs follow the declaration. + def default_per_page = ZammadAPI::Resources::Group.page_limit + + describe '#all' do + it 'returns a collection' do + expect(proxy.all).to be_a(ZammadAPI::Collection) + end + + it 'defaults to the collection page size' do + stub = stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => default_per_page.to_s }) + .to_return(json_response([])) + + proxy.all.to_a + expect(stub).to have_been_requested + end + + it 'takes no arguments' do + expect { proxy.all(active: true) }.to raise_error(ArgumentError) + end + end + + describe '#where' do + it 'returns a collection carrying the query parameters' do + stub = stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => default_per_page.to_s, 'sort_by' => 'name' }) + .to_return(json_response([])) + + proxy.where(sort_by: 'name').to_a + expect(stub).to have_been_requested + end + + it 'refuses an attribute filter the index endpoint would ignore' do + expect { proxy.where(active: true) } + .to raise_error(ArgumentError, %r{api/v1/groups ignores active}) + end + + it 'points at the call that can narrow by a value' do + expect { proxy.where(active: true) }.to raise_error(ArgumentError, /use find_by for one record or search for many/) + end + + it 'makes no request for a filter it rejects' do + expect { proxy.where(active: true) }.to raise_error(ArgumentError) + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + + context 'with an endpoint that does not even sort' do + it 'says so, naming what it does honour' do + expect { unit_client.ticket.where(sort_by: 'created_at') } + .to raise_error(ArgumentError, /honours nothing beyond paging/) + end + end + end + + describe 'enumerating a proxy directly' do + def stub_page(page, records, per_page: default_per_page) + stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => page.to_s, 'per_page' => per_page.to_s }) + .to_return(json_response(records)) + end + + # The walk confirms a short page with one more request, so a collection + # that fits in a single page needs the empty page after it. + def stub_last_page(page, records, per_page: default_per_page) + stub_page(page, records, per_page: per_page) + stub_page(page + 1, [], per_page: per_page) + end + + it 'is Enumerable' do + expect(proxy).to be_a(Enumerable) + end + + it 'yields every record from #each' do + stub_last_page(1, [{ id: 1, name: 'Users' }, { id: 2, name: 'Support' }]) + + expect(proxy.map(&:name)).to eq(%w[Users Support]) + end + + it 'walks pages, like the collection does' do + stub_page(1, Array.new(default_per_page) { { id: it + 1 } }) + stub_page(2, [{ id: default_per_page + 1 }]) + + expect(proxy.map(&:id).size).to eq(default_per_page + 1) + end + + # Forwarded to the collection rather than left to Enumerable#first, which + # would take one record off the front of a page sized for walking. + it 'reads one sized page for #first, without walking everything' do + stub_page(1, [{ id: 1 }], per_page: 1) + + expect(proxy.first.id).to eq(1) + expect(a_request(:get, url).with(query: hash_including({ 'page' => '2' }))).not_to have_been_made + end + + it 'returns an Enumerator from #each without a block' do + expect(proxy.each).to be_a(Enumerator) + end + + it 'supports a lazy chain' do + stub_page(1, [{ id: 1, active: true }, { id: 2, active: false }]) + + expect(proxy.lazy.select(&:active).first(1).map(&:id)).to eq([1]) + end + + it 'forwards #page to the collection' do + stub = stub_request(:get, url) + .with(query: hash_including({ 'page' => '3' })) + .to_return(json_response([])) + + proxy.page(3).to_a + expect(stub).to have_been_requested + end + + it 'forwards the page size #page was given' do + stub = stub_page(1, [], per_page: 5) + + proxy.page(1, of: 5).to_a + expect(stub).to have_been_requested + end + + it 'forwards #find_each' do + stub_last_page(1, [{ id: 1 }], per_page: 5) + + ids = [] + proxy.find_each(batch_size: 5) { ids << it.id } + expect(ids).to eq([1]) + end + + it 'forwards #in_batches' do + stub_last_page(1, [{ id: 1 }], per_page: 5) + + sizes = [] + proxy.in_batches(of: 5) { sizes << it.size } + expect(sizes).to eq([1]) + end + + it 'forwards #pluck' do + stub_last_page(1, [{ id: 1, name: 'Users' }]) + + expect(proxy.pluck(:name)).to eq(['Users']) + end + + it 'forwards #size' do + stub_last_page(1, [{ id: 1 }]) + + expect(proxy.size).to eq(1) + end + + it 'forwards #length' do + stub_last_page(1, [{ id: 1 }]) + + expect(proxy.length).to eq(1) + end + + it 'forwards #take, so it costs what #first costs' do + stub_page(1, [{ id: 1 }, { id: 2 }], per_page: 2) + + expect(proxy.take(2).map(&:id)).to eq([1, 2]) + end + + it 'forwards #first' do + stub_page(1, [{ id: 1, name: 'Users' }], per_page: 1) + + expect(proxy.first.name).to eq('Users') + end + + it 'forwards #empty?' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([])) + + expect(proxy).to be_empty + end + + it 'keeps #find as a lookup by id rather than Enumerable#find' do + stub_request(:get, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1, name: 'Users' })) + + expect(proxy.find(1).name).to eq('Users') + end + + it 'leaves the block form to #detect' do + stub_page(1, [{ id: 1, name: 'Users' }, { id: 2, name: 'Support' }]) + + expect(proxy.detect { it.name == 'Support' }.id).to eq(2) + end + + it 'does not pretend to be an array, so it is not flattened away' do + expect([proxy].flatten).to eq([proxy]) + end + end + + describe 'narrowing a search' do + it 'refuses to replace the search term' do + expect { proxy.search('login failure').where(query: 'anything') } + .to raise_error(ArgumentError, /cannot be passed to where/) + end + + it 'still narrows a search by a parameter it does not own' do + stub_request(:get, "#{url}/search") + .with(query: hash_including({ 'query' => 'login failure', 'sort_by' => 'created_at', 'page' => '1' })) + .to_return(json_response([{ id: 1 }])) + stub_request(:get, "#{url}/search") + .with(query: hash_including({ 'query' => 'login failure', 'sort_by' => 'created_at', 'page' => '2' })) + .to_return(json_response([])) + + expect(proxy.search('login failure').where(sort_by: 'created_at').map(&:id)).to eq([1]) + end + end + + describe '#search' do + it 'refuses a resource Zammad routes no search endpoint for' do + expect { client.ticket_state.search('open') } + .to raise_error(ZammadAPI::Error, /routes no search endpoint for ZammadAPI::Resources::TicketState/) + end + + it 'refuses a search of ticket articles, which Zammad indexes but does not route' do + expect { client.ticket_article.search('hello') } + .to raise_error(ZammadAPI::Error, /routes no search endpoint/) + end + + it 'requests the search endpoint' do + stub = stub_request(:get, "#{url}/search") + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => ZammadAPI::ResourceProxy::SEARCH_MAX_PER_PAGE.to_s, 'query' => 'support' }) + .to_return(json_response([])) + + proxy.search('support').to_a + expect(stub).to have_been_requested + end + + it 'takes extra query parameters through where' do + stub = stub_request(:get, "#{url}/search") + .with(query: hash_including('query' => 'support', 'sort_by' => 'name')) + .to_return(json_response([])) + + proxy.search('support').where(sort_by: 'name').to_a + expect(stub).to have_been_requested + end + + it 'refuses a parameter the search endpoint would ignore' do + expect { proxy.search('support').where(name: 'Users') } + .to raise_error(ArgumentError, /Put the value in the search term instead/) + end + + it 'requires a term' do + expect { proxy.search }.to raise_error(ArgumentError) + end + + it 'rejects an empty term' do + expect { proxy.search(' ') }.to raise_error(ArgumentError, /non-empty query string/) + end + + it 'rejects a term that is not a string' do + expect { proxy.search(42) }.to raise_error(ArgumentError, /non-empty query string/) + end + end + + describe '#inspect' do + it 'names the resource' do + expect(proxy.inspect).to eq('#') + end + end +end diff --git a/spec/unit/zammad_api/resources/base_spec.rb b/spec/unit/zammad_api/resources/base_spec.rb new file mode 100644 index 0000000..c2389b7 --- /dev/null +++ b/spec/unit/zammad_api/resources/base_spec.rb @@ -0,0 +1,1094 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Resources::Base do + let(:client) { unit_client } + let(:url) { "#{ClientHelper::BASE_URL}api/v1/groups" } + + describe 'the path DSL' do + it 'exposes the declared path' do + expect(ZammadAPI::Resources::Group.resource_path).to eq('api/v1/groups') + end + + it 'raises for a resource that declares none' do + anonymous = Class.new(described_class) do + def self.name + 'Anonymous' + end + end + expect { anonymous.resource_path }.to raise_error(ZammadAPI::Error, /does not declare an API path/) + end + + it 'does not leak a path between sibling resources' do + expect(ZammadAPI::Resources::User.resource_path).to eq('api/v1/users') + end + end + + describe 'attributes' do + subject(:group) { client.group.new(name: 'Support', 'note' => 'from a string key') } + + it 'reads an attribute' do + expect(group.name).to eq('Support') + end + + it 'symbolizes string keys supplied by the caller' do + expect(group.note).to eq('from a string key') + end + + it 'starts without changes' do + expect(group).not_to be_changed + end + + it 'records a change as old and new value' do + group.name = 'Other' + expect(group.changes).to eq(name: %w[Support Other]) + end + + it 'reports being changed' do + group.name = 'Other' + expect(group).to be_changed + end + + it 'reflects the change when read back' do + group.name = 'Other' + expect(group.name).to eq('Other') + end + + it 'records a change for a previously unset attribute' do + group.active = true + expect(group.changes).to eq(active: [nil, true]) + end + + it 'keeps the original value when an attribute is written twice' do + group.name = 'First' + group.name = 'Second' + expect(group.changes).to eq(name: %w[Support Second]) + end + + it 'drops the change when the value returns to the original' do + group.name = 'Other' + group.name = 'Support' + expect(group.changes).to be_empty + end + + it 'is not changed once the value returns to the original' do + group.name = 'Other' + group.name = 'Support' + expect(group).not_to be_changed + end + + it 'still reads the reassigned value after the change is dropped' do + group.name = 'Other' + group.name = 'Support' + expect(group.name).to eq('Support') + end + + it 'drops the change when an attribute the record carries is set back to its nil original' do + carrying_nil = client.group.new(name: 'Support', note: nil) + carrying_nil.note = 'Renamed' + carrying_nil.note = nil + expect(carrying_nil.changes).to be_empty + end + + # An attribute the record does not carry is not an attribute whose value + # is nil: Zammad reduces the object it serializes for a permission-scoped + # client, so the key being absent says nothing about what is stored. Read + # as a nil original, writing nil to one compared equal, staged nothing and + # was still merged into the attributes - the write was dropped without a + # word and the record went on reporting a key Zammad never sent it. + it 'stages a write of an attribute the record does not carry' do + group.active = nil + expect(group.changes).to eq(active: [nil, nil]) + end + + it 'keeps that write staged when it is written twice' do + group.active = true + group.active = nil + expect(group.changes).to eq(active: [nil, nil]) + end + + it 'keeps the attributes it reports in step with the changes it staged' do + group.active = nil + expect(group.key?(:active)).to be(group.changes.key?(:active)) + end + end + + describe 'attribute state a record hands out' do + subject(:group) { client.group.new(name: 'Support', preferences: { 'note' => 'keep' }) } + + it 'cannot be written through #attributes, which would not stage a change' do + expect { group.attributes[:name] = 'Sneaky' }.to raise_error(FrozenError) + end + + it 'cannot be written through a nested value' do + expect { group.attributes[:preferences][:note] = 'Sneaky' }.to raise_error(FrozenError) + end + + it 'cannot be written through #changes, which would decide what save sends' do + group.name = 'Renamed' + expect { group.changes[:name] = %w[a b] }.to raise_error(FrozenError) + end + + it 'still records a change through the writer' do + group.name = 'Renamed' + expect(group.changes).to eq(name: %w[Support Renamed]) + end + + it 'does not report a change that was never staged' do + group.to_h[:name] = 'Sneaky' + expect(group).not_to be_changed + end + + it 'keeps a value the caller mutates after assigning it' do + note = +'Mutable' + group.note = note + note << ' changed' + + expect(group.note).to eq('Mutable') + end + + it 'freezes attributes adopted from a response' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ id: 7, preferences: { note: 'from the server' } }, status: 201)) + + group.save! + expect { group.attributes[:preferences][:note] << '!' }.to raise_error(FrozenError) + end + end + + describe '#save' do + context 'with a new record' do + subject(:group) { client.group.new(name: 'Support') } + + before do + stub_request(:post, url) + .with(query: { 'expand' => 'true' }, body: '{"name":"Support"}') + .to_return(json_response({ id: 7, name: 'Support', note: nil }, status: 201)) + end + + it 'returns true' do + expect(group.save).to be(true) + end + + it 'posts to the collection path' do + group.save + expect(a_request(:post, url).with(query: hash_including({}))).to have_been_made + end + + it 'adopts the attributes from the response' do + group.save + expect(group.id).to eq(7) + end + + it 'is no longer a new record' do + group.save + expect(group).to be_persisted + end + + it 'clears the staged changes' do + group.name = 'Support' + group.save + expect(group.changes).to be_empty + end + end + + context 'with an existing record' do + subject(:group) { client.group.find(1) } + + before do + stub_request(:get, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ id: 1, name: 'Users', note: 'old', active: true })) + end + + it 'sends only the changed attributes' do + stub = stub_request(:put, "#{url}/1") + .with(query: { 'expand' => 'true' }, body: '{"note":"new"}') + .to_return(json_response({ id: 1, name: 'Users', note: 'new', active: true })) + + group.note = 'new' + group.save + expect(stub).to have_been_requested + end + + it 'returns true' do + stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ id: 1, note: 'new' })) + + group.note = 'new' + expect(group.save).to be(true) + end + + it 'sends nothing at all when nothing changed' do + stub = stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ id: 1 })) + + expect(group.save).to be(true) + expect(stub).not_to have_been_requested + end + + it 'saves again once something changes' do + stub_request(:put, "#{url}/1").with(query: hash_including({}), body: '{"note":"new"}') + .to_return(json_response({ id: 1, note: 'new' })) + + group.save + group.note = 'new' + + expect(group.save).to be(true) + expect(group.note).to eq('new') + end + end + + it 'raises ParseError when the response is not an object' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response([], status: 201)) + + expect { client.group.new(name: 'x').save } + .to raise_error(ZammadAPI::ParseError, /expected a JSON object, got Array/) + end + + context 'when Zammad rejects the attributes' do + subject(:group) { client.group.new } + + before do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ error: 'Name is required' }, status: 422)) + end + + it 'returns false instead of raising' do + expect(group.save).to be(false) + end + + it 'leaves the validation error in #error' do + group.save + expect(group.error).to be_a(ZammadAPI::ValidationError) + end + + it 'carries the message Zammad reported' do + group.save + expect(group.error.server_message).to eq('Name is required') + end + + it 'leaves the record unsaved' do + group.save + expect(group).to be_new_record + end + + it 'keeps the staged changes, so the attributes can be corrected and resent' do + group.name = 'Support' + group.save + expect(group.changes).to eq({ name: [nil, 'Support'] }) + end + end + + context 'when the failure is not a validation error' do + it 'still raises for a 403' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ error: 'no' }, status: 403)) + + expect { client.group.new(name: 'x').save }.to raise_error(ZammadAPI::AuthorizationError) + end + + it 'still raises for a 500' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({}, status: 500)) + + expect { client.group.new(name: 'x').save }.to raise_error(ZammadAPI::ServerError) + end + end + + it 'clears a previous error once the record saves' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ error: 'Name is required' }, status: 422), json_response({ id: 7, name: 'Support' })) + + group = client.group.new + group.save + group.name = 'Support' + + expect(group.save).to be(true) + expect(group.error).to be_nil + end + + context 'when the save after a rejected one fails some other way' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users') } + + before do + stub_request(:put, "#{url}/1").with(query: hash_including({})).to_return( + json_response({ error: 'Name is required' }, status: 422), + json_response({ error: 'not yours' }, status: 403) + ) + group.name = '' + group.save + group.name = 'Support' + end + + it 'records the first rejection' do + expect(group.error).to be_a(ZammadAPI::ValidationError) + end + + it 'clears it rather than reporting it as the second failure' do + expect { group.save }.to raise_error(ZammadAPI::AuthorizationError) + expect(group.error).to be_nil + end + end + end + + describe '#save!' do + subject(:group) { client.group.new(name: 'Support') } + + it 'returns true' do + stub_request(:post, url).with(query: hash_including({})).to_return(json_response({ id: 7 }, status: 201)) + + expect(group.save!).to be(true) + end + + it 'raises ValidationError when Zammad rejects the record' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ error: 'Name is required' }, status: 422)) + + expect { group.save! }.to raise_error(ZammadAPI::ValidationError, /Name is required/) + end + + it 'does not record the error, because it was raised' do + stub_request(:post, url).with(query: hash_including({})) + .to_return(json_response({ error: 'Name is required' }, status: 422)) + + expect { group.save! }.to raise_error(ZammadAPI::ValidationError) + expect(group.error).to be_nil + end + end + + describe '#assign_attributes' do + subject(:group) { client.group.new(name: 'Support') } + + it 'stages every attribute as a change' do + group.assign_attributes(name: 'Renamed', note: 'Why') + expect(group.changes).to eq(name: %w[Support Renamed], note: [nil, 'Why']) + end + + it 'accepts string keys' do + group.assign_attributes('name' => 'Renamed') + expect(group.name).to eq('Renamed') + end + + it 'does not save' do + group.assign_attributes(name: 'Renamed') + expect(a_request(:any, /zammad\.test/)).not_to have_been_made + end + + it 'returns the record, so it can be chained' do + expect(group.assign_attributes(name: 'Renamed')).to be(group) + end + + it 'drops a change that restores the original value' do + group.assign_attributes(name: 'Renamed') + group.assign_attributes(name: 'Support') + expect(group).not_to be_changed + end + end + + describe '#update' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users', note: 'old') } + + it 'sends only the assigned attributes' do + stub = stub_request(:put, "#{url}/1") + .with(query: { 'expand' => 'true' }, body: '{"note":"new"}') + .to_return(json_response({ id: 1, name: 'Users', note: 'new' })) + + group.update(note: 'new') + expect(stub).to have_been_requested + end + + it 'returns true when the record was stored' do + stub_request(:put, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1, note: 'new' })) + + expect(group.update(note: 'new')).to be(true) + end + + it 'adopts the attributes from the response' do + stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ id: 1, name: 'Renamed by Zammad' })) + + group.update(note: 'new') + expect(group.name).to eq('Renamed by Zammad') + end + + it 'returns false and records the error when Zammad rejects the attributes' do + stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ error: 'Note is too long' }, status: 422)) + + expect(group.update(note: 'new')).to be(false) + expect(group.error.server_message).to eq('Note is too long') + end + + it 'keeps the staged changes after a rejection, so they can be corrected' do + stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ error: 'Note is too long' }, status: 422)) + + group.update(note: 'new') + expect(group.changes).to eq(note: %w[old new]) + end + + it 'creates a record that has not been saved yet' do + stub = stub_request(:post, url).with(query: hash_including({}), body: '{"name":"Support"}') + .to_return(json_response({ id: 7, name: 'Support' }, status: 201)) + + expect(client.group.new.update(name: 'Support')).to be(true) + expect(stub).to have_been_requested + end + end + + describe '#update!' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, note: 'old') } + + it 'returns true' do + stub_request(:put, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1 })) + + expect(group.update!(note: 'new')).to be(true) + end + + it 'raises when Zammad rejects the attributes' do + stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ error: 'Note is too long' }, status: 422)) + + expect { group.update!(note: 'new') }.to raise_error(ZammadAPI::ValidationError, /Note is too long/) + end + end + + describe '#reload' do + subject(:group) { ZammadAPI::Resources::Group.from_response(transport, id: 1, name: 'Users') } + + let(:transport) { unit_transport } + + before do + stub_request(:get, "#{url}/1").with(query: { 'expand' => 'true' }) + .to_return(json_response({ id: 1, name: 'Renamed' })) + end + + it 'refetches the attributes' do + expect(group.reload.name).to eq('Renamed') + end + + it 'discards unsaved changes' do + group.name = 'Local' + expect(group.reload.changes).to be_empty + end + + it 'returns itself' do + expect(group.reload).to be(group) + end + + it 'raises for a record that was never saved' do + expect { ZammadAPI::Resources::Group.new(transport).reload } + .to raise_error(ZammadAPI::Error, /has not been saved, so there is nothing to reload/) + end + + it 'raises ParseError when the response is not an object' do + stub_request(:get, "#{url}/1").with(query: { 'expand' => 'true' }) + .to_return(json_response([])) + + expect { group.reload } + .to raise_error(ZammadAPI::ParseError, /expected a JSON object, got Array/) + end + + it 'raises ParseError when the response is not JSON' do + stub_request(:get, "#{url}/1").with(query: { 'expand' => 'true' }) + .to_return(status: 200, body: 'Gateway Timeout', headers: { 'Content-Type' => 'text/html' }) + + expect { group.reload } + .to raise_error(ZammadAPI::ParseError, /expected a JSON object, got String/) + end + + it 'keeps the previous attributes when the response cannot be decoded' do + stub_request(:get, "#{url}/1").with(query: { 'expand' => 'true' }) + .to_return(json_response([])) + + expect { group.reload }.to raise_error(ZammadAPI::ParseError) + expect(group.name).to eq('Users') + end + end + + describe '.member_path' do + it 'builds the path of one record' do + expect(ZammadAPI::Resources::Group.member_path(1)).to eq('api/v1/groups/1') + end + + # ResourceProxy#find, #destroy and every instance method that reaches an + # endpoint go through here, so the escaping rule is applied once. + it 'escapes an id that would otherwise leave its segment' do + expect(ZammadAPI::Resources::Group.member_path('1/../users')).to eq('api/v1/groups/1%2F..%2Fusers') + end + + it 'refuses an id that navigates' do + expect { ZammadAPI::Resources::Group.member_path('..') }.to raise_error(ArgumentError) + end + end + + describe '#destroy' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1) } + + it 'deletes the record' do + stub = stub_request(:delete, "#{url}/1").to_return(status: 200, body: '') + expect(group.destroy).to be(true) + expect(stub).to have_been_requested + end + + it 'raises for a record that was never saved' do + expect { client.group.new.destroy }.to raise_error(ZammadAPI::Error, /has not been saved, so there is nothing to destroy/) + end + + # `destroy` asked only whether the record was already destroyed, so one + # built with an id it was simply handed issued a real DELETE for a record + # it does not stand for. The constructor refuses that id now, so there is + # no such record left to destroy. + it 'sends nothing for a record built with an id it was never saved with' do + stub = stub_request(:delete, "#{url}/99") + + expect { client.group.new(id: 99, name: 'X').destroy } + .to raise_error(ZammadAPI::Error, /is what addresses this record/) + expect(stub).not_to have_been_requested + end + + context 'when the record is gone' do + before do + stub_request(:delete, "#{url}/1").to_return(status: 200, body: '') + group.destroy + end + + it 'reports the record as destroyed' do + expect(group).to be_destroyed + end + + it 'no longer reports it as persisted' do + expect(group).not_to be_persisted + end + + it 'says so in inspect' do + expect(group.inspect).to include('destroyed=true') + end + + it 'refuses a save rather than letting it 404' do + group.name = 'Support' + + expect { group.save }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + # `reload` re-read a record that no longer exists and cleared the flag on + # the way back, so the record came back reporting itself as persisted and + # its next save issued a PUT against the deleted path. + it 'refuses a reload rather than resurrecting the record' do + expect { group.reload } + .to raise_error(ZammadAPI::Error, /was destroyed, there is nothing to reload/) + end + + it 'stays destroyed after a refused reload' do + expect { group.reload }.to raise_error(ZammadAPI::Error) + + expect(group).to be_destroyed + expect(group).not_to be_persisted + end + + it 'refuses a second destroy rather than surfacing Zammad\'s 404' do + expect { group.destroy } + .to raise_error(ZammadAPI::Error, /was destroyed, there is nothing to destroy/) + end + end + + context 'with state staged before it was destroyed' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users') } + + before do + stub_request(:delete, "#{url}/1").to_return(status: 200, body: '') + group.name = 'Support' + end + + it 'drops a change that can never be sent' do + group.destroy + + expect(group).not_to be_changed + expect(group.changes).to be_empty + end + + # Readable as Zammad last served them. The staged write above was never + # sent, and dropping the change set without it left the record reporting + # "Support" while `changed?` was false and `changes` was empty - so + # nothing was left to tell a local edit apart from a value the server + # gave, in the one state where it can never be saved. + it 'keeps the attributes readable, so a destroyed record can still be reported on' do + group.destroy + + expect(group.id).to eq(1) + expect(group.name).to eq('Users') + end + + it 'does not report a write Zammad never saw' do + group.destroy + + expect(group.attributes).to eq({ id: 1, name: 'Users' }) + expect(group.inspect).not_to include('Support') + end + + it 'refuses the association readers, which would request a record that is gone' do + group.destroy + + # The guarantee, not the mechanism that was expected to deliver it. + # This compared the proxy against the one from before the destroy, + # which clearing the memo satisfied - while `related` went on + # rebuilding a working proxy on the very next call. It passed for as + # long as the behaviour it is named for was broken. + expect { group.related }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + it 'drops the failure of a save that is over' do + stub_request(:put, "#{url}/1").with(query: hash_including({})) + .to_return(json_response({ error: 'Name is required' }, status: 422)) + group.save + + expect(group.error).to be_a(ZammadAPI::ValidationError) + group.destroy + expect(group.error).to be_nil + end + end + + # `update` stages before it saves, and `save!` is where the destroyed + # check used to live. That order left a destroyed record holding a change + # set that can never be sent - the state destroy clears the staged changes + # to prevent - so the refusal happens before anything is written. + context 'when an update is attempted after it was destroyed' do + before do + stub_request(:delete, "#{url}/1").to_return(status: 200, body: '') + group.destroy + end + + it 'refuses the update' do + expect { group.update(name: 'Renamed') }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + it 'refuses the raising update' do + expect { group.update!(name: 'Renamed') }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + it 'leaves nothing staged behind the refusal' do + begin + group.update(name: 'Renamed') + rescue ZammadAPI::Error + nil + end + + expect(group).not_to be_changed + expect(group.changes).to be_empty + end + + it 'leaves the attributes as they were' do + begin + group.update(name: 'Renamed') + rescue ZammadAPI::Error + nil + end + + expect(group.key?(:name)).to be(false) + end + end + end + + describe 'equality' do + it 'treats two separately fetched records as the same record' do + stub_request(:get, "#{url}/1").with(query: hash_including({})).to_return(json_response({ id: 1, name: 'Users' })) + + first_fetch = client.group.find(1) + second_fetch = client.group.find(1) + + expect(first_fetch).not_to equal(second_fetch) + expect(first_fetch).to eq(second_fetch) + end + + it 'tells records of different resources with the same id apart' do + group = ZammadAPI::Resources::Group.from_response(unit_transport, id: 1) + user = ZammadAPI::Resources::User.from_response(unit_transport, id: 1) + + expect(group).not_to eq(user) + end + + it 'identifies a record by its id once its first save assigns one' do + stub_request(:post, url).with(query: hash_including({})).to_return(json_response({ id: 7, name: 'Support' })) + group = client.group.new(name: 'Support') + + expect(group).not_to eq(ZammadAPI::Resources::Group.from_response(unit_transport, id: 7)) + group.save! + expect(group).to eq(ZammadAPI::Resources::Group.from_response(unit_transport, id: 7)) + end + end + + describe '#to_json' do + it 'renders the attributes rather than the object' do + group = ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users') + + expect(group.to_json).to eq('{"id":1,"name":"Users"}') + end + end + + describe '#inspect' do + it 'shows the id, state and attributes' do + group = ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users') + expect(group.inspect) + .to eq('#') + end + end + + describe '.from_response' do + it 'builds a persisted record' do + expect(ZammadAPI::Resources::Group.from_response(unit_transport, id: 1)).to be_persisted + end + end + + # A plain class-level ivar is not inherited, so a subclass used to inherit + # every association, the page limit and the searchability, and lose only the + # API path - raising "does not declare an API path" from a class that + # plainly did. + describe 'a resource subclassed by a caller' do + subject(:subclass) { Class.new(ZammadAPI::Resources::Ticket) { def self.name = 'MyTicket' } } + + it 'inherits the API path' do + expect(subclass.resource_path).to eq('api/v1/tickets') + end + + it 'inherits the member path built from it' do + expect(subclass.member_path(7)).to eq('api/v1/tickets/7') + end + + it 'inherits searchability' do + expect(subclass.searchable?).to be(true) + end + + it 'inherits the page limit' do + expect(subclass.page_limit).to eq(100) + end + + it 'inherits the filterable keys' do + expect(subclass.filterable_keys).to eq([]) + end + + it 'inherits the associations, as it always did' do + expect(subclass.associations.keys).to eq(ZammadAPI::Resources::Ticket.associations.keys) + end + + it 'lets a subclass declare a path of its own' do + own = Class.new(ZammadAPI::Resources::Ticket) { path 'api/v1/my_tickets' } + + expect(own.resource_path).to eq('api/v1/my_tickets') + end + + # An override has to win even when it is the falsey value, which is why + # the lookup asks whether the ivar is defined rather than whether it is + # truthy. + it 'lets a subclass declare itself unsearchable' do + own = Class.new(ZammadAPI::Resources::Ticket) { searchable false } + + expect(own.searchable?).to be(false) + end + end + + # Declared as bare constants, a misspelled override was silently ignored and + # the resource kept Base's default: SEARCHEABLE = true left the resource + # unsearchable, and every find_by on it raised "Zammad routes no search + # endpoint" with no hint that the declaration was the problem. + describe 'the endpoint declarations' do + it 'refuses a misspelled declaration at load' do + expect { Class.new(described_class) { searcheable true } } + .to raise_error(NoMethodError, /searcheable/) + end + + it 'defaults to unsearchable, so an unrouted /search is refused at the call site' do + expect(Class.new(described_class).searchable?).to be(false) + end + + it 'defaults to the generic index page limit' do + expect(Class.new(described_class).page_limit).to eq(described_class::DEFAULT_MAX_PER_PAGE) + end + + it 'defaults to the generic index query keys' do + expect(Class.new(described_class).filterable_keys).to eq(described_class::DEFAULT_INDEX_QUERY_KEYS) + end + + it 'records a declared page limit' do + expect(Class.new(described_class) { max_per_page 25 }.page_limit).to eq(25) + end + + it 'records declared query keys' do + expect(Class.new(described_class) { index_query_keys :sort_by }.filterable_keys).to eq([:sort_by]) + end + + it 'reads a declaration of no query keys' do + expect(Class.new(described_class) { index_query_keys }.filterable_keys).to eq([]) + end + end + + # The id is what addresses the record. Staged, it took effect for every path + # that builds a path from the attributes and not at all for the record those + # paths then reported on: `group.id = 99; group.destroy` sent DELETE to group + # 99 and left the record saying group 1 was the one destroyed. + # The parent is resolved before MEMO_LOCK is taken, because resolving it may + # build the parent's own proxy through this same method and a Mutex is not + # reentrant. Folded back inside the lock this raises + # `ThreadError: deadlock; recursive locking`, and a caller subclassing a + # resource is the case that lock exists to cover in the first place. + describe 'the association proxy of a subclass whose parent has none yet' do + it 'builds without deadlocking on the memo lock' do + leaf = Class.new(Class.new(ZammadAPI::Resources::Group)) + + expect(leaf.related_class).to be_a(Class) + end + end + + # `record[:x] = v` reached method_missing as `:[]=`, which the writer branch + # took for an attribute called `[]`: it staged the index as the value, lost + # the write, and sent `{"[]": "x"}` on the next save. + describe '#[]=' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users') } + + it 'stages the attribute it names' do + group[:note] = 'x' + + expect(group.changes).to eq({ note: [nil, 'x'] }) + end + + it 'reads back through the matching reader' do + group[:note] = 'x' + + expect(group[:note]).to eq('x') + end + + it 'takes a string key the way the reader does' do + group['note'] = 'x' + + expect(group[:note]).to eq('x') + end + + it 'refuses what the named writer refuses' do + expect { group[:id] = 9 }.to raise_error(ZammadAPI::Error, /cannot be staged as an attribute/) + end + + it 'invents no attribute from the operator itself' do + group[:note] = 'x' + + expect(group.attributes.keys).not_to include(:[]) + end + + it 'is claimed by a record that stages writes' do + expect(group).to respond_to(:[]=) + end + + it 'is claimed under either spelling' do + # rubocop:disable-next Performance/StringIdentifierArgument -- the String spelling is the point + expect(group.respond_to?('[]=')).to be(true) + end + + # The production case for recognising a writer by name rather than by a + # trailing `=`: on a record that stages writes, an operator reached the + # writer branch and invented an attribute from its stem. + it 'invents no attribute from a comparison operator' do + expect { group.public_send(:<=, 5) }.to raise_error(NoMethodError) + + expect(group.attributes.keys).not_to include(:<) + end + + it 'stages nothing for a comparison operator' do + expect { group.public_send(:<=, 5) }.to raise_error(NoMethodError) + + expect(group).not_to be_changed + end + end + + describe 'writing the id' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, id: 1, name: 'Users') } + + it 'is refused' do + expect { group.id = 99 }.to raise_error(ZammadAPI::Error, /is what addresses this record/) + end + + it 'points at the lookup that was meant' do + expect { group.id = 99 }.to raise_error(ZammadAPI::Error, /look up the record you meant with find\(99\)/) + end + + it 'is refused through assign_attributes too' do + expect { group.assign_attributes(id: 99) }.to raise_error(ZammadAPI::Error, /is what addresses this record/) + end + + # The message exists to name the id the caller passed, and the pre-check + # that runs for these two paths dropped it - so a serializer or a form + # binder, which reach the writer this way, were told to call find(nil). + it 'names the id that was passed through assign_attributes' do + expect { group.assign_attributes(id: 99) }.to raise_error(ZammadAPI::Error, /find\(99\)/) + end + + it 'names the id that was passed through update' do + expect { group.update(id: 99) }.to raise_error(ZammadAPI::Error, /find\(99\)/) + end + + it 'names the attribute it refused' do + expect { group.id = 99 }.to raise_error(ZammadAPI::Error, /Group#id cannot be staged/) + end + + # Checked before anything is written, not on the way past: refusing + # mid-loop staged the keys that came first and left the record dirty with + # half a change set, which is the state `update` takes its own guard one + # line early to avoid. + it 'stages nothing at all when one key in the hash is refused' do + expect { group.assign_attributes(name: 'X', id: 99, note: 'Y') }.to raise_error(ZammadAPI::Error) + + expect(group).not_to be_changed + expect(group.changes).to be_empty + end + + it 'leaves the attributes it had already reached alone' do + expect { group.assign_attributes(name: 'X', id: 99, note: 'Y') }.to raise_error(ZammadAPI::Error) + + expect(group.name).to eq('Users') + end + + it 'stages nothing through update either' do + expect { group.update(name: 'X', id: 99) }.to raise_error(ZammadAPI::Error) + + expect(group).not_to be_changed + end + + # A record that claimed a writer and then raised when it was called would + # defeat the point of asking, and lead a serializer or form binder straight + # into the exception it was checking to avoid. + it 'does not claim a writer it would refuse' do + expect(group).not_to respond_to(:id=) + end + + it 'still claims the writers it honours' do + expect(group).to respond_to(:name=) + end + + it 'leaves the record addressing what it did before' do + expect { group.id = 99 }.to raise_error(ZammadAPI::Error) + + expect(group.id).to eq(1) + expect(group).not_to be_changed + end + + # The constructor was the one door that did not make this refusal, and + # the only one whose value reached the wire: a new record is sent in full, + # so `new(id: 5).save` POSTed the id nothing had checked. + it 'is refused by the constructor too' do + expect { client.group.new(id: 5) }.to raise_error(ZammadAPI::Error, /is what addresses this record/) + end + + it 'still lets a record Zammad served carry one' do + expect(ZammadAPI::Resources::Group.from_response(unit_transport, id: 5).id).to eq(5) + end + end + + # The third way a record ends up persisted without an id, and the one that + # has nothing to do with a save: Zammad serves a reduced object where the + # authenticated user may not see the whole record, which is the same thing + # #write_attribute is written around. Such a record used to be told it "was + # saved, but the response carried no id" - sending the caller to look at a + # save that never happened, when what they need is which user the client + # authenticates as. + describe 'a record Zammad served without an id' do + subject(:group) { ZammadAPI::Resources::Group.from_response(unit_transport, name: 'Users') } + + it 'refuses a reload it cannot address' do + expect { group.reload }.to raise_error(ZammadAPI::Error, /was loaded without an id/) + end + + it 'points at what this client may read' do + expect { group.destroy }.to raise_error(ZammadAPI::Error, /check what this client may read/) + end + + it 'does not claim a save that never happened' do + expect { group.reload }.to raise_error(ZammadAPI::Error) { |error| expect(error.message).not_to include('was saved') } + end + + it 'does not tell the caller to save a record Zammad already holds' do + expect { group.destroy }.to raise_error(ZammadAPI::Error) { |error| expect(error.message).not_to include('save it first') } + end + + it 'refuses a save it cannot address' do + group.name = 'Support' + + expect { group.save! }.to raise_error(ZammadAPI::Error, /was loaded without an id/) + end + + it 'still says the record was saved where a save is what left it this way' do + stub_request(:post, url).with(query: hash_including({})).to_return(status: 201, body: 'proxy') + new_group = client.group.new(name: 'Support') + begin + new_group.save + rescue ZammadAPI::ParseError + nil + end + + expect { new_group.reload }.to raise_error(ZammadAPI::Error, /was saved, but the response carried no id/) + end + end + + # What makes a record persisted is that Zammad answered 2xx, not that the + # answer parsed. Decoded first, a create whose 201 carried an HTML error + # page from an intervening proxy raised ParseError with the record still + # looking new - so the ticket existed in Zammad and a retried save POSTed a + # second one. Marked persisted, the record has no id to be addressed by + # either, and nothing staged to send, so the retry has to say so rather than + # take the "nothing to send" short circuit and report success. + describe 'a create whose success body cannot be parsed' do + subject(:group) { client.group.new(name: 'Support') } + + before { stub_request(:post, url).with(query: hash_including({})).to_return(status: 201, body: 'proxy') } + + it 'still reports the parse failure' do + expect { group.save }.to raise_error(ZammadAPI::ParseError) + end + + it 'does not leave the record looking unsaved' do + begin + group.save + rescue ZammadAPI::ParseError + nil + end + + expect(group.new_record?).to be(false) + end + + it 'does not create a second record when the save is retried' do + 2.times do + group.save + rescue ZammadAPI::Error + nil + end + + expect(a_request(:post, url).with(query: hash_including({}))).to have_been_made.once + end + + it 'refuses the retried save rather than reporting it stored' do + begin + group.save + rescue ZammadAPI::ParseError + nil + end + + expect { group.save }.to raise_error(ZammadAPI::Error, /carried no id/) + end + + it 'refuses to reload a record it cannot address' do + begin + group.save + rescue ZammadAPI::ParseError + nil + end + + expect { group.reload }.to raise_error(ZammadAPI::Error, /carried no id/) + end + + it 'does not tell the caller to save a record that was already saved' do + begin + group.save + rescue ZammadAPI::ParseError + nil + end + + expect { group.destroy }.to raise_error(ZammadAPI::Error, /was saved/) + end + end +end diff --git a/spec/unit/zammad_api/resources/ticket_article_attachment_spec.rb b/spec/unit/zammad_api/resources/ticket_article_attachment_spec.rb new file mode 100644 index 0000000..95bb122 --- /dev/null +++ b/spec/unit/zammad_api/resources/ticket_article_attachment_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Resources::TicketArticleAttachment do + let(:transport) { unit_transport } + let(:download_url) { "#{ClientHelper::BASE_URL}api/v1/ticket_attachment/42/9/3" } + + describe 'built from an article' do + subject(:attachment) { article.attachments.first } + + let(:article) do + ZammadAPI::Resources::TicketArticle.from_response( + transport, + id: 9, + ticket_id: 42, + attachments: [{ id: 3, filename: 'note.txt', size: '12' }] + ) + end + + it 'carries the attachment id' do + expect(attachment.id).to eq(3) + end + + it 'carries the filename' do + expect(attachment.filename).to eq('note.txt') + end + + it 'is given the ticket id, which the attachment endpoint needs' do + expect(attachment.ticket_id).to eq(42) + end + + it 'is given the article id' do + expect(attachment.article_id).to eq(9) + end + + it 'returns an empty list when the article has no attachments' do + bare = ZammadAPI::Resources::TicketArticle.from_response(transport, id: 9) + expect(bare.attachments).to eq([]) + end + + # This metadata comes off a response body like any other, and an element + # that is not an object reached `raw.merge` and died there as a bare + # NoMethodError from inside the gem - past the `rescue ZammadAPI::Error` + # every caller is told to write. + it 'refuses metadata that is not a list' do + article = ZammadAPI::Resources::TicketArticle.from_response(transport, id: 9, attachments: 'none') + + expect { article.attachments } + .to raise_error(ZammadAPI::ParseError, /expected a JSON array of objects, got String/) + end + + it 'refuses a list holding something that is not an object' do + article = ZammadAPI::Resources::TicketArticle.from_response(transport, id: 9, attachments: [3]) + + expect { article.attachments } + .to raise_error(ZammadAPI::ParseError, /got an array holding Integer/) + end + + it 'names the article in the refusal' do + article = ZammadAPI::Resources::TicketArticle.from_response(transport, id: 9, attachments: 'none') + + expect { article.attachments } + .to raise_error(ZammadAPI::ParseError, /ZammadAPI::Resources::TicketArticle/) + end + end + + describe '#download' do + subject(:attachment) { described_class.new(transport, id: 3, ticket_id: 42, article_id: 9) } + + it 'requests the attachment endpoint' do + stub = stub_request(:get, download_url).to_return(status: 200, body: 'contents') + attachment.download + expect(stub).to have_been_requested + end + + it 'returns the file contents' do + stub_request(:get, download_url).to_return(status: 200, body: 'contents') + expect(attachment.download).to eq('contents') + end + + it 'returns binary data undisturbed' do + png = "\x89PNG\r\n\x1A\n\x00\xFF".b + stub_request(:get, download_url) + .to_return(status: 200, body: png, headers: { 'Content-Type' => 'image/png' }) + + expect(attachment.download).to eq(png) + end + + it 'uses binary encoding' do + stub_request(:get, download_url).to_return(status: 200, body: 'contents') + expect(attachment.download.encoding).to eq(Encoding::BINARY) + end + + it 'raises when the attachment is gone' do + stub_request(:get, download_url).to_return(json_response({ error: 'not found' }, status: 404)) + expect { attachment.download }.to raise_error(ZammadAPI::NotFoundError) + end + + it 'raises a helpful error when the metadata is incomplete' do + expect { described_class.new(transport, id: 3).download }.to raise_error(KeyError) + end + + it 'escapes a traversal in an id instead of reaching another endpoint' do + stub = stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/ticket_attachment/1%2F..%2F..%2F..%2Fapi%2Fv1%2Fusers/9/3") + .to_return(status: 200, body: 'contents') + escaping = described_class.new(transport, id: 3, ticket_id: '1/../../../api/v1/users', article_id: 9) + + escaping.download + + expect(stub).to have_been_requested + expect(a_request(:get, "#{ClientHelper::BASE_URL}api/v1/users/9/3")).not_to have_been_made + end + + it 'escapes the article id and the attachment id too' do + stub = stub_request(:get, "#{ClientHelper::BASE_URL}api/v1/ticket_attachment/42/9%2F..%2Fx/3%2F..%2Fy") + .to_return(status: 200, body: 'contents') + + described_class.new(transport, id: '3/../y', ticket_id: 42, article_id: '9/../x').download + + expect(stub).to have_been_requested + end + end + + it 'is read-only' do + attachment = described_class.new(transport, id: 3) + expect { attachment.filename = 'other.txt' }.to raise_error(NoMethodError, /read-only/) + end + + it 'does not claim a writer it would refuse' do + expect(described_class.new(transport, id: 3)).not_to respond_to(:filename=) + end + + describe '#inspect' do + it 'summarizes the attachment' do + attachment = described_class.new(transport, id: 3, filename: 'note.txt', size: '12') + expect(attachment.inspect) + .to eq('#') + end + end +end diff --git a/spec/unit/zammad_api/resources/ticket_spec.rb b/spec/unit/zammad_api/resources/ticket_spec.rb new file mode 100644 index 0000000..06cb557 --- /dev/null +++ b/spec/unit/zammad_api/resources/ticket_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Resources::Ticket do + subject(:ticket) { described_class.from_response(unit_transport, id: 42, title: 'Help') } + + let(:articles_url) { "#{ClientHelper::BASE_URL}api/v1/ticket_articles/by_ticket/42" } + let(:article_url) { "#{ClientHelper::BASE_URL}api/v1/ticket_articles" } + + describe '#articles' do + it 'requests the articles of this ticket' do + stub = stub_request(:get, articles_url).with(query: { 'expand' => 'true' }).to_return(json_response([])) + ticket.articles + expect(stub).to have_been_requested + end + + it 'returns article records' do + stub_request(:get, articles_url).with(query: hash_including({})) + .to_return(json_response([{ id: 1, body: 'first' }, { id: 2, body: 'second' }])) + + expect(ticket.articles.map(&:body)).to eq(%w[first second]) + end + + it 'returns persisted articles' do + stub_request(:get, articles_url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + expect(ticket.articles.first).to be_persisted + end + + it 'raises ParseError when the response is not a list' do + stub_request(:get, articles_url).with(query: hash_including({})).to_return(json_response({ id: 1 })) + + expect { ticket.articles }.to raise_error(ZammadAPI::ParseError, /expected a JSON array, got Hash/) + end + end + + describe 'once destroyed' do + let(:ticket_url) { "#{ClientHelper::BASE_URL}api/v1/tickets/42" } + + before do + stub_request(:delete, ticket_url).with(query: hash_including({})).to_return(json_response({})) + ticket.destroy + end + + # Each of these says what the caller gets, not which ivar was cleared. + # `destroy` dropped the `related` memo and was taken to have closed this, + # but the reader rebuilt one on the next call, so every path below went + # on reaching a ticket that is gone. + it 'refuses the articles reader rather than fetching a deleted ticket' do + expect { ticket.articles }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + it 'refuses the related proxy' do + expect { ticket.related }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + it 'refuses to add an article rather than POSTing a dead ticket_id' do + expect { ticket.article(body: 'hello') }.to raise_error(ZammadAPI::Error, /was destroyed/) + end + + it 'makes no request at all when an article is refused' do + stub = stub_request(:post, article_url).with(query: hash_including({})) + begin + ticket.article(body: 'hello') + rescue ZammadAPI::Error # rubocop:disable Lint/SuppressedException + end + + expect(stub).not_to have_been_requested + end + end + + describe '#article' do + it 'creates the article for this ticket' do + stub = stub_request(:post, article_url) + .with(query: { 'expand' => 'true' }, body: '{"body":"hello","ticket_id":42}') + .to_return(json_response({ id: 9, body: 'hello', ticket_id: 42 }, status: 201)) + + ticket.article(body: 'hello') + expect(stub).to have_been_requested + end + + it 'returns the created article' do + stub_request(:post, article_url).with(query: hash_including({})) + .to_return(json_response({ id: 9, body: 'hello' }, status: 201)) + + expect(ticket.article(body: 'hello')).to be_a(ZammadAPI::Resources::TicketArticle) + end + + it 'returns a persisted article' do + stub_request(:post, article_url).with(query: hash_including({})) + .to_return(json_response({ id: 9 }, status: 201)) + + expect(ticket.article(body: 'hello')).to be_persisted + end + + # An article belongs to a ticket by id. Unchecked, this POSTed + # `ticket_id: null` and left the caller reading Zammad's 422 to work out + # that the ticket had never been saved. + context 'when the ticket has not been saved' do + subject(:ticket) { described_class.new(unit_transport, title: 'Help') } + + it 'refuses locally' do + expect { ticket.article(body: 'hello') }.to raise_error(ZammadAPI::Error, /has no id, save it first/) + end + + it 'sends nothing' do + stub = stub_request(:post, article_url).with(query: hash_including({})) + + begin + ticket.article(body: 'hello') + rescue ZammadAPI::Error + nil + end + + expect(stub).not_to have_been_requested + end + end + end +end diff --git a/spec/unit/zammad_api/response_error_spec.rb b/spec/unit/zammad_api/response_error_spec.rb new file mode 100644 index 0000000..97ac85c --- /dev/null +++ b/spec/unit/zammad_api/response_error_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::ResponseError do + def response(status, body, headers = {}) + ZammadAPI::Response.new( + status: status, + headers: headers, + body: body, + raw_body: body.is_a?(String) ? body : JSON.generate(body), + json: !body.is_a?(String) + ) + end + + describe 'hierarchy' do + { + ZammadAPI::ConfigurationError => ZammadAPI::Error, + ZammadAPI::UnknownResourceError => ZammadAPI::Error, + ZammadAPI::ParseError => ZammadAPI::Error, + ZammadAPI::ConnectionError => ZammadAPI::TransportError, + ZammadAPI::TimeoutError => ZammadAPI::TransportError, + ZammadAPI::ClientError => described_class, + ZammadAPI::ServerError => described_class, + ZammadAPI::AuthenticationError => ZammadAPI::ClientError, + ZammadAPI::AuthorizationError => ZammadAPI::ClientError, + ZammadAPI::NotFoundError => ZammadAPI::ClientError, + ZammadAPI::ValidationError => ZammadAPI::ClientError, + ZammadAPI::RateLimitError => ZammadAPI::ClientError + }.each do |error_class, parent| + it "#{error_class} descends from #{parent}" do + expect(error_class.ancestors).to include(parent) + end + end + + it 'roots every error at ZammadAPI::Error' do + expect(ZammadAPI::TransportError.ancestors).to include(ZammadAPI::Error) + end + + it 'roots ZammadAPI::Error at StandardError' do + expect(ZammadAPI::Error.ancestors).to include(StandardError) + end + end + + describe '.build' do + { + 401 => ZammadAPI::AuthenticationError, + 403 => ZammadAPI::AuthorizationError, + 404 => ZammadAPI::NotFoundError, + 422 => ZammadAPI::ValidationError, + 429 => ZammadAPI::RateLimitError, + 400 => ZammadAPI::ClientError, + 408 => ZammadAPI::ClientError, + 418 => ZammadAPI::ClientError, + 500 => ZammadAPI::ServerError, + 502 => ZammadAPI::ServerError, + 503 => ZammadAPI::ServerError + }.each do |status, error_class| + it "maps #{status} to #{error_class}" do + result = described_class.build(response(status, {}), operation: 'find object') + expect(result).to be_an_instance_of(error_class) + end + end + + it 'falls back to the base class without a response' do + expect(described_class.build(nil, operation: 'find object')).to be_an_instance_of(described_class) + end + end + + describe '#message' do + it "uses the body's error key" do + error = described_class.build( + response(404, { error: 'User not found' }), + operation: 'find object', + resource_class: ZammadAPI::Resources::User + ) + expect(error.message).to eq("Can't find object (ZammadAPI::Resources::User): User not found") + end + + it 'prefers error_human, which Zammad intends for end users' do + error = described_class.build( + response(422, { error: 'Validation failed', error_human: 'Name is required' }), + operation: 'save object' + ) + expect(error.message).to eq("Can't save object: Name is required") + end + + it 'falls back to the status when the body is not JSON' do + error = described_class.build( + response(502, 'Bad Gateway'), + operation: 'find object', + resource_class: ZammadAPI::Resources::User + ) + expect(error.message).to eq("Can't find object (ZammadAPI::Resources::User): HTTP 502") + end + + it 'falls back to the status when the body has no error key' do + error = described_class.build(response(400, { foo: 'bar' }), operation: 'find object') + expect(error.message).to eq("Can't find object: HTTP 400") + end + + it 'falls back to the status when the error value is empty' do + error = described_class.build(response(500, { error: '' }), operation: 'find object') + expect(error.message).to eq("Can't find object: HTTP 500") + end + + it 'reports a missing response' do + expect(described_class.build(nil, operation: 'find object').message) + .to eq("Can't find object: no response") + end + + it 'omits the resource class when none was supplied' do + error = described_class.build(response(404, { error: 'nope' }), operation: 'find object') + expect(error.message).to eq("Can't find object: nope") + end + end + + describe 'accessors' do + subject(:error) do + described_class.build( + response(404, { error: 'nope' }, 'x-request-id' => 'abc'), + operation: 'find object', + resource_class: ZammadAPI::Resources::User + ) + end + + it 'exposes the status' do + expect(error.status).to eq(404) + end + + it 'exposes the decoded body' do + expect(error.body).to eq({ error: 'nope' }) + end + + it 'exposes the headers' do + expect(error.headers).to eq('x-request-id' => 'abc') + end + + it 'exposes the operation' do + expect(error.operation).to eq('find object') + end + + it 'exposes the resource class' do + expect(error.resource_class).to eq(ZammadAPI::Resources::User) + end + + it 'exposes the server message' do + expect(error.server_message).to eq('nope') + end + + it 'returns nil accessors without a response' do + bare = described_class.build(nil, operation: 'find object') + expect([bare.status, bare.body, bare.headers, bare.server_message]).to eq([nil, nil, {}, nil]) + end + + # A NotFoundError this gem raises itself - find_by! finding nothing - used + # to answer nil here, so `retry if e.status == 404` stopped retrying for + # the one NotFoundError that did not come from Zammad. + it 'reports the status its class is the name for, without a response' do + bare = ZammadAPI::NotFoundError.new(operation: 'find object by name', detail: 'no record matched') + expect(bare.status).to eq(404) + end + + it 'still has no body or headers to report without a response' do + bare = ZammadAPI::NotFoundError.new(operation: 'find object by name', detail: 'no record matched') + expect([bare.body, bare.headers]).to eq([nil, {}]) + end + + it 'leaves a generic ResponseError without a status' do + expect(described_class.new(operation: 'find object').status).to be_nil + end + end + + describe ZammadAPI::RateLimitError do + it 'exposes Retry-After as an integer' do + error = ZammadAPI::ResponseError.build( + ZammadAPI::Response.new(status: 429, headers: { 'retry-after' => '30' }, body: {}, raw_body: '{}', json: true), + operation: 'find object' + ) + expect(error.retry_after).to eq(30) + end + + it 'returns nil when the header is absent' do + error = ZammadAPI::ResponseError.build( + ZammadAPI::Response.new(status: 429, headers: {}, body: {}, raw_body: '{}', json: true), + operation: 'find object' + ) + expect(error.retry_after).to be_nil + end + + it 'returns nil when the header is not a number' do + error = ZammadAPI::ResponseError.build( + ZammadAPI::Response.new(status: 429, headers: { 'retry-after' => 'later' }, body: {}, raw_body: '{}', json: true), + operation: 'find object' + ) + expect(error.retry_after).to be_nil + end + end +end diff --git a/spec/unit/zammad_api/response_spec.rb b/spec/unit/zammad_api/response_spec.rb new file mode 100644 index 0000000..0439878 --- /dev/null +++ b/spec/unit/zammad_api/response_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Response do + def build(status: 200, body: { a: 1 }, raw_body: '{"a":1}', headers: {}, json: !body.is_a?(String)) + described_class.new(status: status, headers: headers, body: body, raw_body: raw_body, json: json) + end + + describe '#success?' do + [200, 201, 204, 299].each do |status| + it "is true for #{status}" do + expect(build(status: status)).to be_success + end + end + + [199, 300, 400, 500].each do |status| + it "is false for #{status}" do + expect(build(status: status)).not_to be_success + end + end + end + + describe '#json?' do + it 'is true when the body was decoded' do + expect(build).to be_json + end + + it 'is false when the body is the untouched raw body' do + raw = '' + expect(build(body: raw, raw_body: raw)).not_to be_json + end + + it 'reports what the decoder recorded, not whether the two bodies differ' do + expect(build(body: '"a string"', raw_body: '"a string"', json: true)).to be_json + end + end + + describe '#decoded' do + it 'returns an object body when one is expected' do + expect(build(body: { a: 1 }).decoded(:object, operation: 'find object')).to eq(a: 1) + end + + it 'returns an array body when one is expected' do + expect(build(body: [{ a: 1 }]).decoded(:array, operation: 'get list')).to eq([{ a: 1 }]) + end + + it 'raises when an object was expected but a list arrived' do + expect { build(body: []).decoded(:object, operation: 'find object') } + .to raise_error(ZammadAPI::ParseError, "Can't find object: expected a JSON object, got Array") + end + + it 'raises when a list was expected but an object arrived' do + expect { build(body: {}).decoded(:array, operation: 'get list') } + .to raise_error(ZammadAPI::ParseError, "Can't get list: expected a JSON array, got Hash") + end + + it 'raises when the body was never JSON' do + expect { build(body: '', raw_body: '').decoded(:object, operation: 'find object') } + .to raise_error(ZammadAPI::ParseError, /got String/) + end + + it 'names the resource class in the message' do + expect { build(body: []).decoded(:object, operation: 'find object', resource_class: ZammadAPI::Resources::User) } + .to raise_error(/\(ZammadAPI::Resources::User\)/) + end + + it 'accepts an empty list' do + expect(build(body: []).decoded(:array, operation: 'get list')).to eq([]) + end + + # An unexpanded search answers with ids. Passing them on stored an Integer + # as a record's attributes, and the first reader died with a TypeError + # from deep inside the gem. + it 'raises for a list of scalars rather than building records from them' do + expect { build(body: [1, 2, 3]).decoded(:array, operation: 'get list') } + .to raise_error(ZammadAPI::ParseError, "Can't get list: expected a JSON array of objects, got an array holding Integer") + end + + it 'raises for a list that is only partly objects' do + expect { build(body: [{ id: 1 }, 'nope']).decoded(:array, operation: 'get list') } + .to raise_error(ZammadAPI::ParseError, /an array holding String/) + end + + it 'names the resource class when a list element has the wrong shape' do + expect { build(body: [1]).decoded(:array, operation: 'get list', resource_class: ZammadAPI::Resources::User) } + .to raise_error(/\(ZammadAPI::Resources::User\)/) + end + end + + it 'is immutable' do + expect(build).to be_frozen + end +end diff --git a/spec/unit/zammad_api/test_spec.rb b/spec/unit/zammad_api/test_spec.rb new file mode 100644 index 0000000..f673653 --- /dev/null +++ b/spec/unit/zammad_api/test_spec.rb @@ -0,0 +1,670 @@ +# frozen_string_literal: true + +require 'zammad_api/test' + +RSpec.describe ZammadAPI::Test do + subject(:zammad) { described_class.new } + + let(:client) { zammad.client } + + describe '#client' do + it 'is a real client' do + expect(client).to be_a(ZammadAPI::Client) + end + + it 'opens no connection at all' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1 }) + client.group.find(1) + + expect(a_request(:any, //)).not_to have_been_made + end + + it 'reports the stand-in configuration' do + expect(client.config.url).to eq('https://zammad.test/') + end + + it 'reports the very configuration the stand-in was built with' do + expect(client.config).to be(zammad.config) + end + + # Going through Client.new assembled a Faraday stack - auth, JSON, + # retries, adapter - only for it to be swapped straight back out, once per + # stand-in. + it 'builds no HTTP stack on the way to the stand-in transport' do + allow(ZammadAPI::Transport).to receive(:new).and_call_original + + described_class.new + + expect(ZammadAPI::Transport).not_to have_received(:new) + end + + it 'accepts configuration overrides' do + expect(described_class.new(url: 'https://other.test/').client.config.url).to eq('https://other.test/') + end + + it 'is the same client every time, rather than a new HTTP stack per call' do + expect(zammad.client).to equal(zammad.client) + end + + describe 'a client derived with #with' do + it 'still answers from the stand-in instead of opening a connection' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Support' }) + + expect(zammad.client.with(timeout: 5).group.find(1).name).to eq('Support') + expect(a_request(:any, //)).not_to have_been_made + end + + it 'reports the derived option' do + expect(zammad.client.with(timeout: 5).config.timeout).to eq(5) + end + + it 'keeps an on_behalf_of scope' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1 }) + zammad.client.on_behalf_of('agent@example.com').with(timeout: 5).group.find(1) + + expect(zammad.requests.last.on_behalf_of).to eq('agent@example.com') + end + end + end + + describe '#stub' do + it 'answers a find with a record' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Users' }) + + expect(client.group.find(1).name).to eq('Users') + end + + it 'builds records that behave like fetched ones' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Users' }) + + group = client.group.find(1) + expect(group).to be_persisted + expect { group.attributes[:name] = 'x' }.to raise_error(FrozenError) + end + + it 'answers a collection with every page' do + zammad.stub(:get, 'api/v1/groups', body: [{ id: 1, name: 'Users' }, { id: 2, name: 'Support' }], query: { page: 1 }) + zammad.stub(:get, 'api/v1/groups', body: [], query: { page: 2 }) + + expect(client.group.pluck(:name)).to eq(%w[Users Support]) + end + + it 'ignores a leading slash on the stub' do + zammad.stub(:get, '/api/v1/groups/1', body: { id: 1 }) + + expect(client.group.find(1).id).to eq(1) + end + + it 'raises the mapped error class for a non-2xx status' do + zammad.stub(:get, 'api/v1/groups/1', status: 404, body: { error: 'not found' }) + + expect { client.group.find(1) }.to raise_error(ZammadAPI::NotFoundError, /not found/) + end + + it 'raises a validation error that save reports as false' do + zammad.stub(:post, 'api/v1/groups', status: 422, body: { error: 'Name is required' }) + + group = client.group.new(name: '') + expect(group.save).to be(false) + expect(group.error.server_message).to eq('Name is required') + end + + it 'serves a non-JSON body untouched, e.g. an attachment' do + zammad.stub(:get, 'api/v1/ticket_attachment/1/2/3', body: 'binary-ish') + + attachment = ZammadAPI::Resources::TicketArticleAttachment + .new(zammad.client.ticket.new.transport, id: 3, ticket_id: 1, article_id: 2) + + expect(attachment.download).to eq('binary-ish') + end + + it 'returns itself, so stubs can be chained' do + expect(zammad.stub(:get, 'api/v1/groups', body: [])).to be(zammad) + end + + it 'accepts string keys in the body' do + zammad.stub(:get, 'api/v1/groups/1', body: { 'id' => 1, 'name' => 'Users' }) + + expect(client.group.find(1).name).to eq('Users') + end + + it 'exposes response headers' do + zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'X-Total-Count' => '7' }) + + expect(client.get('api/v1/groups').headers['x-total-count']).to eq('7') + end + + # The stand-in exists so that the code under test cannot tell it from the + # real transport, and it carried its own copy of the verb loop - so a fifth + # verb added to one would have left the other unable to answer it. + it 'answers every verb the real transport answers' do + expect(ZammadAPI::Test::Transport.new(zammad)) + .to respond_to(*ZammadAPI::Transport::Verbs.instance_methods) + end + + # Transport#decode always hands over a response whose headers name the + # type - it is what the decode branches on - while this set `json: true` + # directly and left the headers as written. Code that branches on + # `response.headers['content-type']` passed against Zammad and failed + # against the stand-in, or the reverse. + it 'serves a JSON body with the content-type a real response carries' do + zammad.stub(:get, 'api/v1/groups', body: []) + + expect(client.get('api/v1/groups').headers['content-type']).to eq('application/json') + end + + it 'serves an object body with the same content-type' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1 }) + + expect(client.get('api/v1/groups/1').headers['content-type']).to eq('application/json') + end + + it 'lets a test say the endpoint answered with something else' do + zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'Content-Type' => 'application/json; charset=utf-8' }) + + expect(client.get('api/v1/groups').headers['content-type']).to eq('application/json; charset=utf-8') + end + + # The header used to be decorative: `json:` came from the Ruby type of the + # stub's body, so a stub could say `text/html` and still hand back a + # decoded Hash, where Zammad gives the raw string and `decoded` raises. + context 'when a stub declares a type that is not JSON' do + before { zammad.stub(:get, 'api/v1/groups/1', body: { id: 1 }, headers: { 'Content-Type' => 'text/html' }) } + + it 'does not decode the body' do + expect(client.get('api/v1/groups/1')).not_to be_json + end + + it 'hands back the raw body Zammad would have sent' do + expect(client.get('api/v1/groups/1').body).to eq('{"id":1}') + end + + it 'fails the way a real one would when a record is read from it' do + expect { client.group.find(1) }.to raise_error(ZammadAPI::ParseError) + end + end + + # A nil Content-Type stringified to '', which then beat the JSON default + # this kit supplies and served a Hash body undecoded - so the test failed + # inside the code under test with nothing to say the stub was at fault. + it 'refuses two spellings of one header rather than dropping a value' do + expect { zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'Content-Type' => 'text/html', 'content-type' => 'application/json' }) } + .to raise_error(ArgumentError, /header content-type was given twice/) + end + + it 'refuses a header value the wire could not carry' do + expect { zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'Content-Type' => nil }) } + .to raise_error(ArgumentError, /header Content-Type was stubbed as nil/) + end + + it 'names what a response header has to be' do + expect { zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'X-Total-Count' => [7] }) } + .to raise_error(ArgumentError, /a response header is always text: pass a String/) + end + + # Only the name used to be normalised, so a count written as an Integer + # reached the code under test as one, where the wire always carries "7". + it 'carries a header value as the String the wire would have sent' do + zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'X-Total-Count' => 7 }) + + expect(client.get('api/v1/groups').headers['x-total-count']).to eq('7') + end + + # Paging read the stub's body as written, which was the same thing only + # while a list could arrive as an Array. Once the content-type decided + # decoding, a list stubbed as a JSON string decoded to one and was never + # paged, so it answered every page with the same records and every full + # read raised PaginationError - where Zammad answers page two empty. + it 'pages a list stubbed as a JSON string the way it pages an Array' do + zammad.stub(:get, 'api/v1/groups', body: '[{"id":1,"name":"a"},{"id":2,"name":"b"}]', headers: { 'Content-Type' => 'application/json' }) + + expect(client.group.all.map(&:name)).to eq(%w[a b]) + end + + it 'answers a later page of a string-bodied list as an endpoint out of records would' do + zammad.stub(:get, 'api/v1/groups', body: '[{"id":1}]', headers: { 'Content-Type' => 'application/json' }) + + expect(client.get('api/v1/groups', query: { page: 2 }).body).to eq([]) + end + + it 'keeps the raw body of a page it did not replace' do + zammad.stub(:get, 'api/v1/groups/1', body: '{"id":1}', headers: { 'Content-Type' => 'application/json' }) + + expect(client.get('api/v1/groups/1').raw_body).to eq('{"id":1}') + end + + it 'decodes a string body a stub declares as JSON' do + zammad.stub(:get, 'api/v1/groups/1', body: '{"id":1,"name":"Users"}', headers: { 'Content-Type' => 'application/json' }) + + expect(client.group.find(1).name).to eq('Users') + end + + it 'claims no content-type for a body it does not serve as JSON' do + zammad.stub(:get, 'api/v1/groups/1/avatar', body: 'binary') + + expect(client.get('api/v1/groups/1/avatar').headers).not_to have_key('content-type') + end + + # The stub keeps serving after a response is built, and Response is a + # value. Handing out the stub's own Hash made every response from one stub + # share it, so writing to `response.headers` in one example rewrote the + # stand-in for every later request in it. + it 'gives each response its own headers rather than the stub\'s' do + zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'X-Total-Count' => '7' }) + + first = client.get('api/v1/groups') + second = client.get('api/v1/groups') + + expect(first.headers).not_to be(second.headers) + end + + it 'hands out headers a caller cannot write through' do + zammad.stub(:get, 'api/v1/groups', body: [], headers: { 'X-Total-Count' => '7' }) + + expect { client.get('api/v1/groups').headers['x-total-count'] = '999' }.to raise_error(FrozenError) + end + + describe 'a sequence' do + it 'serves stubs in the order they were declared' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'First' }) + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Second' }) + + expect(client.group.find(1).name).to eq('First') + expect(client.group.find(1).name).to eq('Second') + end + + it 'keeps answering with the last one' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'First' }) + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Second' }) + + 3.times { client.group.find(1) } + expect(client.group.find(1).name).to eq('Second') + end + + it 'reuses a single stub for any number of requests' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Users' }) + + expect(Array.new(3) { client.group.find(1).name }).to eq(%w[Users Users Users]) + end + end + + describe 'query matching' do + it 'matches a stub that names a subset of the parameters' do + zammad.stub(:get, 'api/v1/groups', body: [{ id: 1 }], query: { sort_by: 'name' }) + + expect(client.group.where(sort_by: 'name').first.id).to eq(1) + end + + it 'does not answer a request without those parameters' do + zammad.stub(:get, 'api/v1/groups', body: [{ id: 1 }], query: { sort_by: 'name' }) + + expect { client.group.all.to_a }.to raise_error(described_class::UnstubbedRequestError) + end + + it 'ignores the parameters the client adds itself' do + zammad.stub(:get, 'api/v1/groups', body: [], query: { sort_by: 'name' }) + + expect { client.group.where(sort_by: 'name').to_a }.not_to raise_error + end + + it 'keeps answering when a catch-all for the same endpoint follows it' do + zammad.stub(:get, 'api/v1/groups/search', body: { total_count: 42 }, query: { only_total_count: true }) + zammad.stub(:get, 'api/v1/groups/search', body: [{ id: 1 }]) + + expect(Array.new(3) { client.group.search('x').count }).to eq([42, 42, 42]) + end + + it 'leaves the catch-all answering the requests it does not match' do + zammad.stub(:get, 'api/v1/groups/search', body: { total_count: 42 }, query: { only_total_count: true }) + zammad.stub(:get, 'api/v1/groups/search', body: [], query: { page: 2 }) + zammad.stub(:get, 'api/v1/groups/search', body: [{ id: 1 }]) + + expect(client.group.search('x').count).to eq(42) + expect(client.group.search('x').map(&:id)).to eq([1]) + expect(client.group.search('x').count).to eq(42) + end + + it 'matches an array-valued parameter' do + zammad.stub(:get, 'api/v1/users/search', body: [{ id: 1 }], query: { ids: [1, 2], page: 1 }) + zammad.stub(:get, 'api/v1/users/search', body: [], query: { ids: [1, 2], page: 2 }) + + expect(client.user.search('x').where(ids: [1, 2]).map(&:id)).to eq([1]) + end + + it 'reports a nil query value against the stub that wrote it' do + expect { zammad.stub(:get, 'api/v1/users/search', body: [], query: { ids: nil }) } + .to raise_error(ArgumentError, /query parameter ids is nil/) + end + + it 'does not match an array whose values differ' do + zammad.stub(:get, 'api/v1/users/search', body: [{ id: 1 }], query: { ids: [1, 2] }) + + expect { client.user.search('x').where(ids: [3]).to_a } + .to raise_error(described_class::UnstubbedRequestError) + end + + it 'answers ahead of a catch-all declared before it' do + zammad.stub(:get, 'api/v1/groups/search', body: [{ id: 1 }]) + zammad.stub(:get, 'api/v1/groups/search', body: { total_count: 42 }, query: { only_total_count: true }) + + expect(client.group.search('x').count).to eq(42) + end + + it 'still sequences two stubs that share a scope' do + zammad.stub(:get, 'api/v1/groups', body: [{ id: 1 }], query: { sort_by: 'name' }) + zammad.stub(:get, 'api/v1/groups', body: [{ id: 2 }], query: { sort_by: 'name' }) + + expect(client.group.all.where(sort_by: 'name').page(1, of: 1).map(&:id)).to eq([1]) + expect(client.group.all.where(sort_by: 'name').page(1, of: 1).map(&:id)).to eq([2]) + expect(client.group.all.where(sort_by: 'name').page(1, of: 1).map(&:id)).to eq([2]) + end + end + end + + describe 'an unstubbed request' do + it 'raises rather than returning something empty' do + expect { client.group.find(1) }.to raise_error(described_class::UnstubbedRequestError) + end + + it 'names the request that was not stubbed' do + expect { client.group.find(1) } + .to raise_error(%r{GET api/v1/groups/1 was not stubbed}) + end + + it 'says so when nothing at all is stubbed' do + expect { client.group.find(1) }.to raise_error(/nothing is stubbed/) + end + + it 'lists what is stubbed, because a wrong path is the usual cause' do + zammad.stub(:get, 'api/v1/groups/2', body: { id: 2 }) + + expect { client.group.find(1) }.to raise_error(%r{stubbed: GET api/v1/groups/2}) + end + + it 'is outside ZammadAPI::Error, so code under test cannot rescue it as an API failure' do + expect(described_class::UnstubbedRequestError.ancestors).not_to include(ZammadAPI::Error) + expect(described_class::UnstubbedRequestError.ancestors).to include(StandardError) + end + + it 'escapes a rescue of the gem\'s errors, the way the examples write one' do + zammad.stub(:get, 'api/v1/groups/2', body: { id: 2 }) + + caller_with_a_rescue = lambda do + client.group.find(1) + rescue ZammadAPI::Error + :handled_as_an_api_failure + end + + expect { caller_with_a_rescue.call }.to raise_error(described_class::UnstubbedRequestError) + end + end + + describe '#requests' do + before do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'Users' }) + zammad.stub(:put, 'api/v1/groups/1', body: { id: 1, name: 'Renamed' }) + end + + it 'records the verb and path' do + client.group.find(1) + + expect(zammad.requests.last.verb).to eq(:get) + expect(zammad.requests.last.path).to eq('api/v1/groups/1') + end + + it 'records only what a save actually sends' do + client.group.find(1).update!(name: 'Renamed') + + expect(zammad.requests.last.body).to eq({ name: 'Renamed' }) + end + + # Recorded by reference, a test that built one payload, sent it, then + # changed it for a second call rewrote the first recorded request and + # asserted against a body that never went anywhere. + it 'records the body as it was sent, not as the test left it afterwards' do + zammad.stub(:post, 'api/v1/groups', body: { id: 2 }) + payload = { name: 'Support', note: { internal: 'yes' } } + client.post('api/v1/groups', body: payload) + + payload[:name] = 'Mutated' + payload[:note][:internal] = 'no' + + expect(zammad.requests.last.body).to eq({ name: 'Support', note: { internal: 'yes' } }) + end + + it 'hands out a recorded body that cannot be written through' do + zammad.stub(:post, 'api/v1/groups', body: { id: 2 }) + client.post('api/v1/groups', body: { name: 'Support' }) + + expect(zammad.requests.last.body).to be_frozen + end + + it 'records the query parameters the client sent' do + client.group.find(1) + + expect(zammad.requests.last.query).to eq({ 'expand' => 'true' }) + end + + it 'records them stringified, the way the transport sends them' do + zammad.stub(:get, 'api/v1/groups', body: [{ id: 1 }]) + client.group.all.page(2, of: 50).to_a + + expect(zammad.requests.last.query) + .to eq({ 'expand' => 'true', 'page' => '2', 'per_page' => '50' }) + end + + it 'rejects a nil query value the way the transport does' do + expect { client.group.where(sort_by: nil).to_a } + .to raise_error(ArgumentError, /query parameter sort_by is nil/) + end + + it 'records requests oldest first' do + client.group.find(1).update!(name: 'Renamed') + + expect(zammad.requests.map(&:verb)).to eq(%i[get put]) + end + + it 'records an on_behalf_of scope' do + client.on_behalf_of('agent@example.com').group.find(1) + + expect(zammad.requests.last.on_behalf_of).to eq('agent@example.com') + end + + it 'records an integer user id the way the wire carries it' do + client.on_behalf_of(42).group.find(1) + + expect(zammad.requests.last.on_behalf_of).to eq('42') + end + + it 'leaves on_behalf_of nil for an unscoped client' do + client.group.find(1) + + expect(zammad.requests.last.on_behalf_of).to be_nil + end + + # Through the real transport's own rules, so that a stand-in cannot accept + # a header the wire would refuse or record it in a shape the wire would + # not carry. + it 'records the headers a raw request asked for' do + zammad.stub(:get, 'api/v1/roles', body: []) + client.get('api/v1/roles', headers: { 'Accept-Language' => 'de-de' }) + + expect(zammad.requests.last.headers).to eq('accept-language' => 'de-de') + end + + it 'records a header value the way the wire would carry it' do + zammad.stub(:get, 'api/v1/roles', body: []) + client.get('api/v1/roles', headers: { 'X-Retry' => 3 }) + + expect(zammad.requests.last.headers).to eq('x-retry' => '3') + end + + it 'leaves the headers empty for a request that named none' do + client.group.find(1) + + expect(zammad.requests.last.headers).to eq({}) + end + + it 'refuses a header the real transport would refuse' do + expect { client.get('api/v1/roles', headers: { 'Authorization' => 'Token other' }) } + .to raise_error(ArgumentError, /header authorization is set by this client/) + end + + it 'records a request that was not stubbed, so the failure can be inspected' do + expect { client.group.find(2) }.to raise_error(described_class::UnstubbedRequestError) + expect(zammad.requests.map(&:path)).to eq(['api/v1/groups/2']) + end + + it 'hands out a frozen list' do + expect { zammad.requests << :nonsense }.to raise_error(FrozenError) + end + + it 'is recorded from several threads without losing any' do + threads = Array.new(4) { Thread.new { 5.times { client.group.find(1) } } } + threads.each(&:join) + + expect(zammad.requests.size).to eq(20) + end + end + + describe '#reset' do + it 'forgets the stubs' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1 }) + zammad.reset + + expect { client.group.find(1) }.to raise_error(described_class::UnstubbedRequestError) + end + + it 'forgets the recorded requests' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1 }) + client.group.find(1) + + expect(zammad.reset.requests).to be_empty + end + end + + describe '#inspect' do + it 'reports the stubs and the requests' do + zammad.stub(:get, 'api/v1/groups', body: []) + client.group.all.to_a + + expect(zammad.inspect).to eq('#') + end + + # The request count used to be read outside the monitor, so printing a + # stand-in from a failure message or a debugger raced a thread under test + # appending to it - which is the one thing the monitor is here to prevent. + it 'reads the recorded requests under the monitor' do + zammad.stub(:get, 'api/v1/groups', body: []) + writers = Array.new(4) { Thread.new { 25.times { client.group.all.to_a } } } + readers = Array.new(4) { Thread.new { 25.times { zammad.inspect } } } + + expect { (writers + readers).each(&:join) }.not_to raise_error + expect(zammad.requests.size).to eq(100) + end + end + + # A collection walks until a page repeats, comes back short, or comes back + # empty. A stub that served the same records to every page tripped the + # first of those, so the obvious way to stand in for a list endpoint made + # every full read of it raise PaginationError - against a real Zammad the + # same code works, because page 2 comes back empty. + describe 'a list endpoint stubbed once' do + subject(:zammad) { described_class.new } + + let(:client) { zammad.client } + + before { zammad.stub(:get, 'api/v1/groups', body: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }]) } + + it 'reads the whole collection without a second stub' do + expect(client.group.all.map(&:id)).to eq([1, 2]) + end + + it 'counts it' do + expect(client.group.all.count).to eq(2) + end + + it 'plucks from it' do + expect(client.group.all.pluck(:name)).to eq(%w[a b]) + end + + it 'answers page 1 with the records it holds' do + expect(client.group.all.page(1).map(&:id)).to eq([1, 2]) + end + + it 'answers a later page as an endpoint out of records would' do + expect(client.group.all.page(2).map(&:id)).to eq([]) + end + + it 'stops walking rather than reporting the stand-in as a broken paginator' do + expect { client.group.all.to_a }.not_to raise_error + end + end + + describe 'a list endpoint whose pages are stubbed by hand' do + subject(:zammad) { described_class.new } + + let(:client) { zammad.client } + + # A stub that names a page is left exactly as written - that is how a test + # says what the second page holds. + it 'serves each page as declared' do + zammad.stub(:get, 'api/v1/groups', body: [{ id: 1 }], query: { page: 1 }) + zammad.stub(:get, 'api/v1/groups', body: [{ id: 2 }], query: { page: 2 }) + zammad.stub(:get, 'api/v1/groups', body: [], query: { page: 3 }) + + expect(client.group.all.map(&:id)).to eq([1, 2]) + end + + it 'keeps a body that is not a list alone' do + zammad.stub(:get, 'api/v1/groups/1', body: { id: 1, name: 'a' }) + + expect(client.group.find(1).name).to eq('a') + end + end + + # Two stubs naming different parameters both match a request carrying all of + # them. Grouped only by whether they were scoped at all, they were read as a + # sequence and the first was consumed: the count ate the records stub, + # handed back an Array where a count belonged, and then reported the + # endpoint as unstubbed. + describe 'two stubs that describe one request equally well' do + subject(:zammad) { described_class.new } + + let(:client) { zammad.client } + + before do + zammad.stub(:get, 'api/v1/tickets/search', body: [{ id: 1 }], query: { query: 'foo' }) + zammad.stub(:get, 'api/v1/tickets/search', body: { total_count: 2 }, query: { only_total_count: true }) + end + + it 'refuses to guess which one was meant' do + expect { client.ticket.search('foo').count }.to raise_error(described_class::AmbiguousStubError) + end + + it 'names both scopes, so the fix is visible from the message' do + expect { client.ticket.search('foo').count } + .to raise_error(described_class::AmbiguousStubError, /only_total_count.*|.*only_total_count/) + end + + it 'says the test is wrong rather than that Zammad refused something' do + expect(described_class::AmbiguousStubError.ancestors).not_to include(ZammadAPI::Error) + end + + it 'answers the request a more specific stub names' do + zammad.reset + zammad.stub(:get, 'api/v1/tickets/search', body: [{ id: 1 }], query: { query: 'foo' }) + zammad.stub(:get, 'api/v1/tickets/search', body: { total_count: 2 }, query: { query: 'foo', only_total_count: true }) + + expect(client.ticket.search('foo').count).to eq(2) + end + + it 'leaves the less specific stub answering the requests it alone matches' do + zammad.reset + zammad.stub(:get, 'api/v1/tickets/search', body: [{ id: 1 }], query: { query: 'foo' }) + zammad.stub(:get, 'api/v1/tickets/search', body: { total_count: 2 }, query: { query: 'foo', only_total_count: true }) + + expect(client.ticket.search('foo').map(&:id)).to eq([1]) + end + end +end diff --git a/spec/unit/zammad_api/transport_spec.rb b/spec/unit/zammad_api/transport_spec.rb new file mode 100644 index 0000000..4a5ad1d --- /dev/null +++ b/spec/unit/zammad_api/transport_spec.rb @@ -0,0 +1,823 @@ +# frozen_string_literal: true + +RSpec.describe ZammadAPI::Transport do + let(:url) { "#{ClientHelper::BASE_URL}api/v1/groups" } + + describe 'authentication' do + it 'sends a Token header for an access token' do + stub = stub_request(:get, url).with(headers: { 'Authorization' => 'Token test-token' }).to_return(json_response([])) + unit_transport.get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + + it 'sends a Bearer header for an OAuth2 token' do + stub = stub_request(:get, url).with(headers: { 'Authorization' => 'Bearer oauth' }).to_return(json_response([])) + unit_transport(http_token: nil, oauth2_token: 'oauth').get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + + it 'sends basic auth for user and password' do + stub = stub_request(:get, url).with(basic_auth: %w[u p]).to_return(json_response([])) + unit_transport(http_token: nil, user: 'u', password: 'p').get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + end + + describe 'default headers' do + it 'identifies the client' do + stub = stub_request(:get, url) + .with(headers: { 'User-Agent' => "zammad_api-ruby/#{ZammadAPI::VERSION}" }) + .to_return(json_response([])) + unit_transport.get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + + it 'asks for JSON' do + stub = stub_request(:get, url).with(headers: { 'Accept' => 'application/json' }).to_return(json_response([])) + unit_transport.get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + + it 'allows overriding the user agent' do + stub = stub_request(:get, url).with(headers: { 'User-Agent' => 'my-app/1.0' }).to_return(json_response([])) + unit_transport(user_agent: 'my-app/1.0').get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + end + + describe 'base url handling' do + it 'keeps a sub-path prefix in front of the request path' do + stub = stub_request(:get, 'http://zammad.test/helpdesk/api/v1/groups').to_return(json_response([])) + unit_transport(url: 'http://zammad.test/helpdesk').get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + end + + describe 'query parameters' do + it 'encodes scalars as strings' do + stub = stub_request(:get, url).with(query: { 'page' => '1', 'expand' => 'true' }).to_return(json_response([])) + unit_transport.get('api/v1/groups', operation: 'test', query: { page: 1, expand: true }) + expect(stub).to have_been_requested + end + + it 'escapes values that need it' do + stub = stub_request(:get, "#{url}/search").with(query: { 'query' => 'a b&c' }).to_return(json_response([])) + unit_transport.get('api/v1/groups/search', operation: 'test', query: { query: 'a b&c' }) + expect(stub).to have_been_requested + end + + it 'encodes arrays' do + stub = stub_request(:get, url).with(query: { 'ids' => %w[1 2] }).to_return(json_response([])) + unit_transport.get('api/v1/groups', operation: 'test', query: { ids: [1, 2] }) + expect(stub).to have_been_requested + end + + # `condition` is what Zammad's search endpoints narrow by, and it is + # nested. Rendered with to_s it went out as a Ruby inspect string, which + # Zammad dropped, answering an unnarrowed search. + it 'encodes a nested hash the way Rails reads it back' do + stub = stub_request(:get, "#{url}/search") + .with(query: { 'condition' => { 'ticket.state_id' => { 'operator' => 'is', 'value' => %w[1 2] } } }) + .to_return(json_response([])) + unit_transport.get( + 'api/v1/groups/search', + operation: 'test', + query: { condition: { 'ticket.state_id' => { operator: 'is', value: [1, 2] } } } + ) + expect(stub).to have_been_requested + end + + it 'stringifies the scalars inside a nested hash' do + expect(described_class.stringify_query(condition: { open: { active: true, limit: 5 } })) + .to eq({ 'condition' => { 'open' => { 'active' => 'true', 'limit' => '5' } } }) + end + + it 'raises on a nil value rather than dropping the parameter' do + expect { unit_transport.get('api/v1/groups', operation: 'test', query: { page: 1, note: nil }) } + .to raise_error(ArgumentError, /query parameter note is nil/) + end + + it 'names the path to a nil buried in a nested value' do + expect { described_class.stringify_query(condition: { state: { value: nil } }) } + .to raise_error(ArgumentError, /parameter condition\[state\]\[value\] is nil/) + end + + it 'refuses two spellings of one parameter rather than sending whichever came last' do + expect { described_class.stringify_query(state_id: 1, 'state_id' => 2) } + .to raise_error(ArgumentError, /parameter state_id was given twice/) + end + + it 'refuses the collision whichever order it arrives in' do + expect { described_class.stringify_query('state_id' => 2, state_id: 1) } + .to raise_error(ArgumentError, /parameter state_id was given twice/) + end + + it 'names both spellings, so the caller does not have to guess the other' do + expect { described_class.stringify_query(state_id: 1, 'state_id' => 2) } + .to raise_error(ArgumentError, /as :state_id and as "state_id"/) + end + + # `condition` is a Hash the search endpoints read, and two spellings inside + # it collapsed exactly the way two at the top level did. + it 'refuses a collision nested inside a structured parameter' do + expect { described_class.stringify_query(condition: { 'state_id' => 1, state_id: 2 }) } + .to raise_error(ArgumentError, /parameter condition\[state_id\] was given twice/) + end + + it 'still sends a structured parameter whose keys only look alike' do + expect(described_class.stringify_query(condition: { 'ticket.state_id' => { operator: 'is' } })) + .to eq({ 'condition' => { 'ticket.state_id' => { 'operator' => 'is' } } }) + end + + it 'still accepts two parameters that only look alike' do + expect(described_class.stringify_query(state_id: 1, state_ids: [2])) + .to eq({ 'state_id' => '1', 'state_ids' => ['2'] }) + end + + it 'names the index of a nil inside an array' do + expect { described_class.stringify_query(ids: [1, nil]) } + .to raise_error(ArgumentError, /query parameter ids\[1\] is nil/) + end + + it 'makes no request for a query it rejects' do + stub = stub_request(:get, url).with(query: hash_including({})).to_return(json_response([])) + + expect { unit_transport.get('api/v1/groups', operation: 'test', query: { note: nil }) } + .to raise_error(ArgumentError) + expect(stub).not_to have_been_requested + end + end + + describe '.escape_path_segment' do + it 'leaves an ordinary id alone' do + expect(described_class.escape_path_segment(42)).to eq('42') + end + + it 'encodes a separator, so an id cannot walk out of its segment' do + expect(described_class.escape_path_segment('1/../../api/v1/users/1')) + .to eq('1%2F..%2F..%2Fapi%2Fv1%2Fusers%2F1') + end + + it 'encodes a query and fragment marker' do + expect(described_class.escape_path_segment('1?a=b#c')).to eq('1%3Fa%3Db%23c') + end + + it 'encodes a multibyte character one byte at a time' do + expect(described_class.escape_path_segment('ä')).to eq('%C3%A4') + end + + it 'raises for an id with nothing to send' do + expect { described_class.escape_path_segment('') }.to raise_error(ArgumentError, /record id is required/) + end + + it 'raises for a parent dot segment, which the unreserved set would carry through' do + expect { described_class.escape_path_segment('..') }.to raise_error(ArgumentError, /points at another endpoint/) + end + + it 'raises for a current-directory dot segment' do + expect { described_class.escape_path_segment('.') }.to raise_error(ArgumentError, /points at another endpoint/) + end + + it 'leaves an id that merely starts with dots alone' do + expect(described_class.escape_path_segment('..1')).to eq('..1') + end + + it 'encodes an already-encoded dot segment rather than passing it on' do + expect(described_class.escape_path_segment('%2e%2e')).to eq('%252e%252e') + end + end + + describe 'request bodies' do + it 'sends JSON with the matching content type' do + stub = stub_request(:post, url) + .with(body: '{"name":"Support"}', headers: { 'Content-Type' => 'application/json' }) + .to_return(json_response({ id: 1 }, status: 201)) + unit_transport.post('api/v1/groups', operation: 'test', body: { name: 'Support' }) + expect(stub).to have_been_requested + end + end + + describe 'response decoding' do + it 'decodes JSON with symbol keys' do + stub_request(:get, url).to_return(json_response({ id: 1, nested: { a: 'b' } })) + response = unit_transport.get('api/v1/groups', operation: 'test') + expect(response.body).to eq({ id: 1, nested: { a: 'b' } }) + end + + it 'exposes the raw body as well' do + stub_request(:get, url).to_return(json_response({ id: 1 })) + response = unit_transport.get('api/v1/groups', operation: 'test') + expect(response.raw_body).to eq('{"id":1}') + end + + it 'leaves non-JSON bodies untouched' do + stub_request(:get, url).to_return(status: 200, body: 'plain text', headers: { 'Content-Type' => 'text/plain' }) + response = unit_transport.get('api/v1/groups', operation: 'test') + expect(response.body).to eq('plain text') + end + + it 'keeps a malformed JSON body as a string instead of raising' do + stub_request(:get, url).to_return(status: 200, body: 'not json', headers: { 'Content-Type' => 'application/json' }) + response = unit_transport.get('api/v1/groups', operation: 'test') + expect(response.body).to eq('not json') + end + + it 'handles an empty body' do + stub_request(:get, url).to_return(status: 200, body: '', headers: { 'Content-Type' => 'application/json' }) + response = unit_transport.get('api/v1/groups', operation: 'test') + expect(response.body).to eq('') + end + + it 'downcases header names' do + stub_request(:get, url).to_return(json_response([], headers: { 'X-Request-Id' => 'abc' })) + response = unit_transport.get('api/v1/groups', operation: 'test') + expect(response.headers['x-request-id']).to eq('abc') + end + end + + describe 'error responses' do + it 'raises NotFoundError for 404' do + stub_request(:get, url).to_return(json_response({ error: 'nope' }, status: 404)) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::NotFoundError, "Can't find object: nope") + end + + it 'raises AuthenticationError for 401' do + stub_request(:get, url).to_return(json_response({ error: 'authentication failed' }, status: 401)) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::AuthenticationError) + end + + it 'raises ServerError with the status when a proxy returns HTML' do + stub_request(:get, url).to_return(status: 502, body: 'Bad Gateway', headers: { 'Content-Type' => 'text/html' }) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::ServerError, "Can't find object: HTTP 502") + end + + it 'includes the resource class in the message' do + stub_request(:get, url).to_return(json_response({ error: 'nope' }, status: 404)) + expect { unit_transport.get('api/v1/groups', operation: 'find object', resource_class: ZammadAPI::Resources::Group) } + .to raise_error(/\(ZammadAPI::Resources::Group\)/) + end + end + + describe 'network failures' do + it 'wraps a read timeout' do + stub_request(:get, url).to_raise(Net::ReadTimeout) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::TimeoutError, %r{Can't find object: request to api/v1/groups timed out}) + end + + it 'wraps a refused connection' do + stub_request(:get, url).to_raise(Errno::ECONNREFUSED) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::ConnectionError, /is unreachable/) + end + + it 'wraps a TLS failure' do + stub_request(:get, url).to_raise(OpenSSL::SSL::SSLError) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::ConnectionError, /TLS handshake/) + end + + it 'does not leak credentials from the url into the message' do + transport = unit_transport(url: 'https://admin:url-s3cret@zammad.test/') + stub_request(:get, /zammad\.test/).to_raise(Faraday::ConnectionFailed.new('down')) + + expect { transport.get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConnectionError) { |error| expect(error.message).not_to include('url-s3cret') } + end + + context 'with an adapter that does not wrap socket errors' do + # The middleware seam raises from inside the stack, past the point where + # an adapter would normally translate the error into a Faraday one. + def raising(error) + unit_transport(middleware: lambda { |faraday| + faraday.use(Class.new(Faraday::Middleware) { define_method(:call) { |_env| raise error } }) + }) + end + + it 'wraps a bare Errno::ETIMEDOUT' do + expect { raising(Errno::ETIMEDOUT).get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::TimeoutError, /timed out/) + end + + it 'wraps a bare Timeout::Error' do + expect { raising(Timeout::Error).get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::TimeoutError) + end + + it 'wraps a bare Errno::ECONNREFUSED' do + expect { raising(Errno::ECONNREFUSED).get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::ConnectionError, /is unreachable/) + end + + it 'wraps a SocketError' do + expect { raising(SocketError).get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::ConnectionError) + end + + it 'leaves an unrelated Errno alone rather than calling it a network problem' do + expect { raising(Errno::ENOSPC).get('api/v1/groups', operation: 'find object') } + .to raise_error(Errno::ENOSPC) + end + end + + it 'raises a TransportError subclass so both can be rescued together' do + stub_request(:get, url).to_raise(Errno::ECONNREFUSED) + expect { unit_transport.get('api/v1/groups', operation: 'find object') } + .to raise_error(ZammadAPI::TransportError) + end + end + + describe 'retries' do + it 'retries an idempotent request after a server error' do + stub_request(:get, url).to_return({ status: 500 }, json_response([{ id: 1 }])) + response = unit_transport(retries: 2, retry_interval: 0.01).get('api/v1/groups', operation: 'test') + expect(response.status).to eq(200) + end + + it 'retries after a rate limit response' do + stub_request(:get, url).to_return({ status: 429 }, json_response([])) + response = unit_transport(retries: 1, retry_interval: 0.01).get('api/v1/groups', operation: 'test') + expect(response.status).to eq(200) + end + + it 'gives up after the configured number of attempts' do + stub_request(:get, url).to_return(status: 500) + expect { unit_transport(retries: 1, retry_interval: 0.01).get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ServerError) + expect(a_request(:get, url)).to have_been_made.twice + end + + it 'does not retry POST, which could duplicate records' do + stub_request(:post, url).to_return(status: 500) + expect { unit_transport(retries: 2, retry_interval: 0.01).post('api/v1/groups', operation: 'test', body: { a: 1 }) } + .to raise_error(ZammadAPI::ServerError) + expect(a_request(:post, url)).to have_been_made.once + end + + it 'does not retry a client error' do + stub_request(:get, url).to_return(json_response({ error: 'nope' }, status: 404)) + expect { unit_transport(retries: 2, retry_interval: 0.01).get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::NotFoundError) + expect(a_request(:get, url)).to have_been_made.once + end + end + + # Most adapters wrap a socket failure into Faraday::ConnectionFailed, which + # was retried; the same failure raw was not, so how often a request was + # retried depended on which adapter the caller picked - through an option + # this gem offers. + describe 'retries through an adapter that does not wrap socket errors' do + before { BareSocketAdapter.attempts = [] } + + def bare_socket_transport(**overrides) + unit_transport(adapter: :bare_socket, retry_interval: 0.01, **overrides) + end + + it 'retries an unwrapped socket error as often as a wrapped one' do + expect { bare_socket_transport(retries: 2).get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConnectionError) + expect(BareSocketAdapter.attempts.size).to eq(3) + end + + it 'still maps it to ConnectionError once the retries are spent' do + expect { bare_socket_transport(retries: 1).get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConnectionError, /is unreachable/) + end + + it 'does not repeat a POST, which could duplicate records' do + expect { bare_socket_transport(retries: 2).post('api/v1/groups', operation: 'test', body: { a: 1 }) } + .to raise_error(ZammadAPI::ConnectionError) + expect(BareSocketAdapter.attempts).to eq([:post]) + end + + it 'retries every socket failure it maps to ConnectionError' do + expect(described_class::RETRIABLE_EXCEPTIONS).to include(*described_class::CONNECTION_ERRORS) + end + + it 'retries every socket failure it maps to TimeoutError' do + expect(described_class::RETRIABLE_EXCEPTIONS).to include(*described_class::TIMEOUT_ERRORS) + end + end + + describe 'the Faraday seam' do + it 'calls the middleware with the connection being built' do + seen = nil + unit_transport(middleware: ->(connection) { seen = connection }) + + expect(seen).to be_a(Faraday::Connection) + end + + it 'lets the middleware see a request this gem built' do + stub_request(:get, url).to_return(json_response([])) + + seen = nil + transport = unit_transport(middleware: lambda { |builder| + builder.use(Class.new(Faraday::Middleware) do + define_method(:on_request) { |env| seen = env.request_headers['User-Agent'] } + end) + }) + transport.get('api/v1/groups', operation: 'test') + + expect(seen).to eq("zammad_api-ruby/#{ZammadAPI::VERSION}") + end + + it 'lets the middleware see the response' do + stub_request(:get, url).to_return(json_response([{ id: 1 }])) + + seen = nil + transport = unit_transport(middleware: lambda { |builder| + builder.use(Class.new(Faraday::Middleware) do + define_method(:on_complete) { |env| seen = env.status } + end) + }) + transport.get('api/v1/groups', operation: 'test') + + expect(seen).to eq(200) + end + + # `c.response :json` is a reasonable thing to put through a documented + # seam, and Faraday then hands over a Hash rather than the bytes. `to_s` + # turned that into a Ruby inspect string, which JSON.parse refused, so + # every record built from the response died in Response#decoded with a + # ParseError naming Zammad for what the caller's own stack had done. + # + # Re-encoding the parsed structure was the other way out, and it is worse: + # `raw_body` is what an attachment download hands back as the file, so a + # re-encoding returns something Zammad never stored. The bytes are gone + # either way, so this names the cause while it is still visible. + context 'with middleware that decodes the body first' do + it 'refuses it when the stack is built, before anything is sent' do + expect { unit_transport(middleware: ->(builder) { builder.response(:json) }) } + .to raise_error(ZammadAPI::ConfigurationError, /decodes the response body before this gem can/) + end + + it 'names the middleware it found' do + expect { unit_transport(middleware: ->(builder) { builder.response(:json) }) } + .to raise_error(ZammadAPI::ConfigurationError, /Faraday::Response::Json/) + end + + it 'says which middleware to drop' do + expect { unit_transport(middleware: ->(builder) { builder.response(:json) }) } + .to raise_error(ZammadAPI::ConfigurationError, /c\.response :json/) + end + + it 'is catchable as the gem error every caller rescues' do + expect { unit_transport(middleware: ->(builder) { builder.response(:json) }) }.to raise_error(ZammadAPI::Error) + end + + # The check used to live in `decode`, which is one request too late: the + # POST went out, Zammad created the record, and only then did a + # "configuration" error come back - so a caller retrying it made a second. + it 'sends nothing before refusing' do + stub = stub_request(:post, url).with(query: hash_including({})) + + expect { unit_client(middleware: ->(builder) { builder.response(:json) }).group.create(name: 'X') } + .to raise_error(ZammadAPI::ConfigurationError) + expect(stub).not_to have_been_requested + end + end + + # The build-time check knows `Faraday::Response::Json` by name; anything + # else that consumes the body is caught only once a response is in hand. + context 'with a decoder the build-time check does not know by name' do + subject(:transport) do + unit_transport(middleware: lambda { |builder| + builder.use(Class.new(Faraday::Middleware) do + def on_complete(env) = env.body = { id: 1 } + end) + }) + end + + before { stub_request(:get, url).to_return(json_response([{ id: 1 }])) } + + it 'builds, because nothing named is in the stack' do + expect { transport }.not_to raise_error + end + + it 'refuses the response rather than stringifying what it got' do + expect { transport.get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConfigurationError, /decoded the response body before this gem could/) + end + + it 'names what it was handed instead of the body' do + expect { transport.get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConfigurationError, /Faraday handed over Hash rather than the raw body/) + end + end + + it 'leaves a middleware that does not touch the body alone' do + stub_request(:get, url).to_return(json_response([{ id: 1, name: 'Users' }])) + + response = unit_transport(middleware: ->(builder) { builder.response(:logger, Logger.new(File::NULL)) }) + .get('api/v1/groups', operation: 'test') + + expect(response.body).to eq([{ id: 1, name: 'Users' }]) + end + + it 'uses the configured adapter' do + transport = unit_transport(adapter: :test) + expect(transport.instance_variable_get(:@connection).adapter.name).to include('Adapter::Test') + end + + it 'reports an unregistered adapter as a configuration error' do + expect { unit_transport(adapter: :nonsense) } + .to raise_error(ZammadAPI::ConfigurationError, /is not registered on Faraday::Adapter/) + end + + it 'does not leak a Faraday error out of the client constructor' do + expect { unit_client(adapter: :nonsense) }.to raise_error(ZammadAPI::ConfigurationError) + end + + # Only Faraday::Error used to be wrapped, so the failures that do not come + # from Faraday - the ones a caller is least equipped to place - escaped + # raw, past the `rescue ZammadAPI::ConfigurationError` the constructor is + # documented to need. + it 'reports a proxy that is not a url as a configuration error' do + expect { unit_transport(proxy: 'http://user:pa ss@host') } + .to raise_error(ZammadAPI::ConfigurationError, /URI::InvalidURIError/) + end + + it 'reports a middleware that raises as a configuration error' do + expect { unit_transport(middleware: ->(_) { raise NameError, 'boom' }) } + .to raise_error(ZammadAPI::ConfigurationError, /NameError: boom/) + end + + it 'does not relabel an error this gem raised itself' do + failure = ZammadAPI::NotFoundError.new(operation: 'test', resource_class: nil, detail: 'gone') + + expect { unit_transport(middleware: ->(_) { raise failure }) } + .to raise_error(ZammadAPI::NotFoundError) + end + end + + describe '#with_on_behalf_of' do + it 'sends the From header' do + stub = stub_request(:get, url).with(headers: { 'From' => 'agent@example.com' }).to_return(json_response([])) + unit_transport.with_on_behalf_of('agent@example.com').get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + + it 'sends an integer user id, which Net::HTTP will not stringify itself' do + stub = stub_request(:get, url).with(headers: { 'From' => '42' }).to_return(json_response([])) + unit_transport.with_on_behalf_of(42).get('api/v1/groups', operation: 'test') + expect(stub).to have_been_requested + end + + it 'records the scope as the string the header carries' do + expect(unit_transport.with_on_behalf_of(42).on_behalf_of).to eq('42') + end + + it 'keeps an unscoped transport unscoped' do + expect(unit_transport.with_on_behalf_of(nil).on_behalf_of).to be_nil + end + + it 'returns a different transport' do + transport = unit_transport + expect(transport.with_on_behalf_of('someone')).not_to be(transport) + end + + it 'leaves the original transport unscoped' do + transport = unit_transport + transport.with_on_behalf_of('someone') + expect(transport.on_behalf_of).to be_nil + end + + it 'does not send a From header from the original transport' do + transport = unit_transport + transport.with_on_behalf_of('someone') + + stub_request(:get, url).to_return(json_response([])) + transport.get('api/v1/groups', operation: 'test') + + expect(a_request(:get, url).with { |request| request.headers.key?('From') }).not_to have_been_made + end + + it 'shares the underlying connection instead of rebuilding it' do + transport = unit_transport + scoped = transport.with_on_behalf_of('someone') + expect(scoped.instance_variable_get(:@connection)).to be(transport.instance_variable_get(:@connection)) + end + end + + describe '#with_config' do + it 'applies the new configuration' do + expect(unit_transport.with_config(ZammadAPI::Config.new(**unit_config(timeout: 7))).config.timeout).to eq(7) + end + + it 'carries the on_behalf_of scope over' do + derived = unit_transport.with_on_behalf_of('agent@example.com') + .with_config(ZammadAPI::Config.new(**unit_config(timeout: 7))) + + expect(derived.on_behalf_of).to eq('agent@example.com') + end + + it 'keeps a subclass on its own kind, rather than reverting to a real one' do + recording = Class.new(described_class) + stub_const('RecordingTransport', recording) + + derived = recording.new(ZammadAPI::Config.new(**unit_config)).with_config(ZammadAPI::Config.new(**unit_config(timeout: 7))) + + expect(derived).to be_a(recording) + end + + it 'returns a different transport' do + transport = unit_transport + expect(transport.with_config(transport.config)).not_to be(transport) + end + end + + describe 'logging' do + subject(:log) { log_device.string } + + let(:log_device) { StringIO.new } + let(:logger) { Logger.new(log_device, level: Logger::DEBUG) } + + before do + stub_request(:post, url).to_return(json_response({ id: 1 }, status: 201)) + unit_transport(logger: logger, user: 'u', password: 'pw-s3cret', http_token: nil) + .post('api/v1/groups', operation: 'test', body: { login: 'jane', password: 'pw-s3cret' }) + end + + it 'logs the request' do + expect(log).to include('Zammad API request: POST api/v1/groups') + end + + it 'logs the response status' do + expect(log).to include('Zammad API response: POST api/v1/groups -> 201') + end + + it 'never logs a password from the payload' do + expect(log).not_to include('pw-s3cret') + end + + it 'marks the redacted payload value' do + expect(log).to include('password: "[REDACTED]"') + end + + it 'keeps non-sensitive payload values' do + expect(log).to include('login: "jane"') + end + + it 'stays silent by default' do + stub_request(:get, url).to_return(json_response([])) + + expect { unit_transport.get('api/v1/groups', operation: 'test') } + .to output('').to_stdout.and output('').to_stderr + end + + context 'with credential-bearing payload keys' do + subject(:log) { log_device.string } + + let(:log_device) { StringIO.new } + + before do + stub_request(:post, url).to_return(json_response({ id: 1 }, status: 201)) + unit_transport(logger: Logger.new(log_device, level: Logger::DEBUG)).post( + 'api/v1/groups', + operation: 'test', + body: { + login: 'jane', + keyboard_layout: 'de', + password_confirm: 'confirm-s3cret', + passwd: 'passwd-s3cret', + access_token: 'access-s3cret', + refresh_token: 'refresh-s3cret', + client_secret: 'client-s3cret', + api_key: 'api-key-s3cret', + apikey: 'apikey-s3cret', + key: 'key-s3cret' + } + ) + end + + it 'redacts a password_confirm' do + expect(log).not_to include('confirm-s3cret') + end + + it 'redacts an access_token' do + expect(log).not_to include('access-s3cret') + end + + it 'redacts a refresh_token' do + expect(log).not_to include('refresh-s3cret') + end + + it 'redacts an api_key' do + expect(log).not_to include('api-key-s3cret') + end + + it 'redacts an apikey, which nothing separates the word in' do + expect(log).not_to include('apikey-s3cret') + end + + it 'redacts a bare key' do + expect(log).not_to include('key-s3cret') + end + + it 'redacts a passwd' do + expect(log).not_to include('passwd-s3cret') + end + + # Matched as a word, so that a key merely containing the letters stays + # readable in the log rather than being blanked for nothing. + it 'leaves a key whose name only contains one of the words alone' do + expect(log).to include('keyboard_layout: "de"') + end + + it 'redacts a client_secret' do + expect(log).not_to include('client-s3cret') + end + + it 'still keeps a non-sensitive value' do + expect(log).to include('login: "jane"') + end + end + end + + # `request` documents that every failure leaves it as a ZammadAPI::Error. + # Faraday::SSLError was handled and its unwrapped counterpart was not, so a + # certificate mismatch through an adapter that does not wrap - an adapter + # this gem lets a caller choose - went straight past the rescue. + describe 'a TLS failure through an adapter that does not wrap it' do + before { BareTlsAdapter.attempts = [] } + + def bare_tls_transport(**overrides) + unit_transport(adapter: :bare_tls, retry_interval: 0.01, **overrides) + end + + it 'maps it to ConnectionError rather than letting it out raw' do + expect { bare_tls_transport.get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConnectionError, /TLS handshake with .* failed/) + end + + it 'is catchable as the gem error every caller rescues' do + expect { bare_tls_transport.get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::Error) + end + + # A rejected certificate is a fact about the instance, not a transient + # failure: retrying only delays the error by the backoff. + it 'does not retry it' do + expect { bare_tls_transport(retries: 2).get('api/v1/groups', operation: 'test') } + .to raise_error(ZammadAPI::ConnectionError) + expect(BareTlsAdapter.attempts.size).to eq(1) + end + + it 'keeps the unwrapped TLS error out of the retry list' do + expect(described_class::RETRIABLE_EXCEPTIONS).not_to include(*described_class::SSL_ERRORS) + end + end + + # The error Faraday or URI raises quotes the value it rejected, and for a + # proxy that value carries its credentials - into a message that lands in + # every log and exception report. + describe 'a connection that cannot be built from a credential-bearing proxy' do + let(:proxy) { 'http://user:pa ss@proxyhost:3128' } + + it 'reports it as a configuration error' do + expect { unit_transport(proxy: proxy) }.to raise_error(ZammadAPI::ConfigurationError) + end + + it 'keeps the proxy password out of the message' do + expect { unit_transport(proxy: proxy) } + .to raise_error(ZammadAPI::ConfigurationError) { |error| expect(error.message).not_to include('pa ss') } + end + + it 'still says which value was rejected' do + expect { unit_transport(proxy: proxy) } + .to raise_error(ZammadAPI::ConfigurationError, %r{http://\[REDACTED\]@proxyhost:3128}) + end + + # The error quotes what it rejected through `inspect`, which escapes the + # backslash - so the text in the message stopped matching the configured + # value and the substring swap redacted nothing at all. + context 'when the value carries a backslash, which inspect escapes' do + let(:proxy) { 'http://user:secret@proxyhost/a\0b' } + + it 'still keeps the password out of the message' do + expect { unit_transport(proxy: proxy) } + .to raise_error(ZammadAPI::ConfigurationError) { |error| expect(error.message).not_to include('secret') } + end + end + + # `\0` in a gsub replacement String expands to the matched text, which is + # the unredacted value. + context 'when the value carries a sequence a gsub replacement would expand' do + let(:proxy) { 'http://user:secret@proxyhost/a\0b' } + + it 'does not put the credential back through the replacement' do + message = begin + unit_transport(proxy: proxy) + nil + rescue ZammadAPI::ConfigurationError => e + e.message + end + + expect(message.scan('user:secret')).to be_empty + end + end + end +end diff --git a/spec/zammad_api/client_spec.rb b/spec/zammad_api/client_spec.rb deleted file mode 100644 index 9f6b73f..0000000 --- a/spec/zammad_api/client_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -require 'spec_helper' -require 'logger' - -describe ZammadAPI::Client do - before(:all) do - WebMock.enable! - end - - after(:all) do - WebMock.disable! - end - - after do - WebMock.reset! - end - - let(:config) { Helper.config } - let(:instance) { described_class.new(config) } - - describe '.new' do - it 'raises ConfigurationError when url is missing' do - expect { described_class.new(config.merge(url: nil)) } - .to raise_error(ZammadAPI::ConfigurationError, 'missing url in config') - end - - it 'raises ConfigurationError when url scheme is unsupported' do - expect { described_class.new(config.merge(url: 'ftp://example.com')) } - .to raise_error(ZammadAPI::ConfigurationError, 'config url needs to start with http:// or https://') - end - - it 'raises ConfigurationError when user is missing' do - expect { described_class.new(config.merge(user: nil)) } - .to raise_error(ZammadAPI::ConfigurationError, 'missing user in config') - end - - it 'raises ConfigurationError when password is missing' do - expect { described_class.new(config.merge(password: nil)) } - .to raise_error(ZammadAPI::ConfigurationError, 'missing password in config') - end - - it 'does not require user/password when http_token is supplied' do - expect { described_class.new(url: config[:url], http_token: 'token') }.not_to raise_error - end - - it 'does not require user/password when oauth2_token is supplied' do - expect { described_class.new(url: config[:url], oauth2_token: 'token') }.not_to raise_error - end - end - - describe '#method_missing' do - it 'raises ResourceNotFoundError for unknown resources' do - expect { instance.does_not_exist }.to raise_error(ZammadAPI::ResourceNotFoundError, /Resource for DoesNotExist does not exist/) - end - - it 'attaches the underlying NameError as #cause' do - instance.does_not_exist - rescue ZammadAPI::ResourceNotFoundError => e - expect(e.cause).to be_a(NameError) - end - end - - describe '#perform_on_behalf_of' do - it 'performs a given block on behalft of a given user' do - on_behalf_of_identifier = 'some_login' - - stub_request(:get, /#{config[:url]}/) - .with(headers: { - 'From' => on_behalf_of_identifier - }) - .to_return(status: 200, body: '{}', headers: {}) - - instance.perform_on_behalf_of(on_behalf_of_identifier) do - instance.user.find(1) - end - end - - it "doesn't affect later requests outside of the block" do - # first perform request on behalf of a login - on_behalf_of_identifier = 'some_login' - - stub_request(:get, /#{config[:url]}/) - .with(headers: { - 'From' => on_behalf_of_identifier - }) - .to_return(status: 200, body: '{}', headers: {}) - - instance.perform_on_behalf_of(on_behalf_of_identifier) do - instance.user.find(1) - end - - # now without and check that - # the header isn't set anymore - stub = stub_request(:get, /#{config[:url]}/) - .to_return(status: 200, body: '{}', headers: {}) - - # this is kind of a hack/workaround to check if the - # header was not set/send since webmock doesn't support - # checks for not existing headers - request_pattern = stub.request_pattern - def request_pattern.matches?(request_signature) - return false if !super - return true if request_signature.headers.empty? - - !request_signature.headers.key?('From') - end - - instance.user.find(1) - end - end -end diff --git a/spec/zammad_api/errors_spec.rb b/spec/zammad_api/errors_spec.rb deleted file mode 100644 index b335088..0000000 --- a/spec/zammad_api/errors_spec.rb +++ /dev/null @@ -1,164 +0,0 @@ -require 'spec_helper' - -describe ZammadAPI do - describe ZammadAPI::Error do - it 'descends from RuntimeError' do - expect(described_class.ancestors).to include(RuntimeError) - end - end - - describe ZammadAPI::ConfigurationError do - it 'descends from ZammadAPI::Error' do - expect(described_class.ancestors).to include(ZammadAPI::Error) - end - - it 'descends from RuntimeError' do - expect(described_class.ancestors).to include(RuntimeError) - end - end - - describe ZammadAPI::ResourceNotFoundError do - it 'descends from ZammadAPI::Error' do - expect(described_class.ancestors).to include(ZammadAPI::Error) - end - - it 'descends from RuntimeError' do - expect(described_class.ancestors).to include(RuntimeError) - end - end - - describe ZammadAPI::ResponseError do - let(:fake_response) { Struct.new(:status, :body) } - - describe '.from' do - it 'returns a ClientError for 4xx responses' do - response = fake_response.new(404, '{}') - expect(described_class.from(response, operation: 'find object')).to be_a(ZammadAPI::ClientError) - end - - it 'returns a ClientError for 408 (Request Timeout)' do - response = fake_response.new(408, '{}') - expect(described_class.from(response, operation: 'find object')).to be_a(ZammadAPI::ClientError) - end - - it 'returns a ClientError for 429 (Too Many Requests)' do - response = fake_response.new(429, '{}') - expect(described_class.from(response, operation: 'find object')).to be_a(ZammadAPI::ClientError) - end - - it 'returns a ServerError for 5xx responses' do - response = fake_response.new(500, '{}') - expect(described_class.from(response, operation: 'find object')).to be_a(ZammadAPI::ServerError) - end - - it 'returns a ServerError for 502 (Bad Gateway)' do - response = fake_response.new(502, 'Bad Gateway') - expect(described_class.from(response, operation: 'find object')).to be_a(ZammadAPI::ServerError) - end - - it 'returns a base ResponseError when no response is supplied' do - result = described_class.from(nil, operation: 'find object') - expect(result.class).to eq(described_class) - end - end - - describe 'subclasses' do - it 'ClientError descends from ResponseError' do - expect(ZammadAPI::ClientError.ancestors).to include(described_class) - end - - it 'ServerError descends from ResponseError' do - expect(ZammadAPI::ServerError.ancestors).to include(described_class) - end - - it 'descends from ZammadAPI::Error' do - expect(described_class.ancestors).to include(ZammadAPI::Error) - end - - it 'descends from RuntimeError' do - expect(described_class.ancestors).to include(RuntimeError) - end - end - - describe '#message' do - it "uses the JSON body's 'error' key when present" do - response = fake_response.new(404, '{"error":"User not found"}') - error = described_class.from(response, operation: 'find object', resource_class: ZammadAPI::Resources::User) - expect(error.message).to eq("Can't find object (ZammadAPI::Resources::User): User not found") - end - - it 'preserves the original message format for the JSON-with-error case' do - response = fake_response.new(422, '{"error":"name can\'t be blank"}') - error = described_class.from(response, operation: 'save object', resource_class: ZammadAPI::Resources::Group) - expect(error.message).to eq("Can't save object (ZammadAPI::Resources::Group): name can't be blank") - end - - it 'falls back to HTTP status when the body is not JSON (issue #29 scenario)' do - response = fake_response.new(502, 'Bad Gateway') - error = described_class.from(response, operation: 'find object', resource_class: ZammadAPI::Resources::User) - expect(error.message).to eq("Can't find object (ZammadAPI::Resources::User): HTTP 502") - end - - it 'falls back to HTTP status when the body is empty' do - response = fake_response.new(500, '') - error = described_class.from(response, operation: 'destroy object', resource_class: ZammadAPI::Resources::User) - expect(error.message).to eq("Can't destroy object (ZammadAPI::Resources::User): HTTP 500") - end - - it "falls back to HTTP status when the JSON body has no 'error' key" do - response = fake_response.new(400, '{"foo":"bar"}') - error = described_class.from(response, operation: 'find object', resource_class: ZammadAPI::Resources::User) - expect(error.message).to eq("Can't find object (ZammadAPI::Resources::User): HTTP 400") - end - - it 'omits the resource_class segment when none is supplied' do - response = fake_response.new(404, '{"error":"nope"}') - error = described_class.from(response, operation: 'find object') - expect(error.message).to eq("Can't find object: nope") - end - - it 'does not attach the JSON parser error as cause (it is a symptom, not the cause)' do - response = fake_response.new(502, 'Bad Gateway') - error = described_class.from(response, operation: 'find object') - expect(error.cause).to be_nil - end - end - - describe 'accessors' do - let(:response) { fake_response.new(404, '{"error":"nope"}') } - let(:error) do - described_class.from(response, operation: 'find object', resource_class: ZammadAPI::Resources::User) - end - - it 'exposes the underlying response' do - expect(error.response).to be(response) - end - - it 'exposes the response status' do - expect(error.status).to eq(404) - end - - it 'exposes the response body' do - expect(error.body).to eq('{"error":"nope"}') - end - - it 'exposes the operation' do - expect(error.operation).to eq('find object') - end - - it 'exposes the resource_class' do - expect(error.resource_class).to eq(ZammadAPI::Resources::User) - end - - it 'returns nil for status when no response is supplied' do - error = described_class.from(nil, operation: 'find object') - expect(error.status).to be_nil - end - - it 'returns nil for body when no response is supplied' do - error = described_class.from(nil, operation: 'find object') - expect(error.body).to be_nil - end - end - end -end diff --git a/spec/zammad_api/json_helper_spec.rb b/spec/zammad_api/json_helper_spec.rb deleted file mode 100644 index 29eb956..0000000 --- a/spec/zammad_api/json_helper_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'spec_helper' - -describe ZammadAPI::JsonHelper do - subject(:helper) do - Class.new { include ZammadAPI::JsonHelper }.new - end - - describe '#safe_json_parse' do - it 'parses valid JSON' do - expect(helper.safe_json_parse('{"key":"value"}')).to eq('key' => 'value') - end - - it 'returns empty hash for invalid JSON' do - expect(helper.safe_json_parse('not json')).to eq({}) - end - - it 'returns empty hash for HTML responses' do - expect(helper.safe_json_parse('Bad Gateway')).to eq({}) - end - end -end diff --git a/spec/zammad_api/resources/list_base_spec.rb b/spec/zammad_api/resources/list_base_spec.rb deleted file mode 100644 index c7018e3..0000000 --- a/spec/zammad_api/resources/list_base_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'spec_helper' - -describe ZammadAPI::ListBase do - it 'is a Enumerable' do - expect(described_class.ancestors).to include(Enumerable) - end -end diff --git a/spec/zammad_api/transport_spec.rb b/spec/zammad_api/transport_spec.rb deleted file mode 100644 index 66032e5..0000000 --- a/spec/zammad_api/transport_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -require 'spec_helper' -require 'logger' - -describe ZammadAPI::Transport do - before(:all) do - WebMock.enable! - end - - after(:all) do - WebMock.disable! - end - - after do - WebMock.reset! - end - - let(:config) { Helper.config } - let(:logger) do - Logger.new($stderr).tap do |logger| - logger.level = Logger::ERROR - end - end - let(:instance) { described_class.new(config, logger) } - - context 'GET' do - it 'performs GET requests' do - stub_request(:get, "#{config[:url]}some/path") - .to_return(status: 200, body: '', headers: {}) - - instance.get(url: '/some/path') - end - end - - context 'on behalf of' do - it 'responds to #on_behalf_of' do - expect(instance).to respond_to(:on_behalf_of) - end - - it 'responds to #on_behalf_of=' do - expect(instance).to respond_to(:on_behalf_of=) - end - - it 'sets From header' do - on_behalf_of_identifier = 'some_login' - - instance.on_behalf_of = on_behalf_of_identifier - - stub_request(:get, "#{config[:url]}some/path") - .with(headers: { - 'From' => on_behalf_of_identifier - }) - .to_return(status: 200, body: '', headers: {}) - - instance.get(url: '/some/path') - end - - it 'unsets From header' do - on_behalf_of_identifier = 'some_login' - - instance.on_behalf_of = on_behalf_of_identifier - instance.on_behalf_of = nil - - stub = stub_request(:get, "#{config[:url]}some/path") - .to_return(status: 200, body: '', headers: {}) - - # this is kind of a hack/workaround to check if the - # header was not set/send since webmock doesn't support - # checks for not existing headers - request_pattern = stub.request_pattern - def request_pattern.matches?(request_signature) - return false if !super - return true if request_signature.headers.empty? - - !request_signature.headers.key?('From') - end - - instance.get(url: '/some/path') - end - end -end diff --git a/spec/zammad_api_spec.rb b/spec/zammad_api_spec.rb deleted file mode 100644 index 6616717..0000000 --- a/spec/zammad_api_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'spec_helper' - -describe ZammadAPI do - it 'has a version number' do - expect(ZammadAPI::VERSION).not_to be_nil - end - - context 'failing authentication' do - Helper.auto_wizard - client = Helper.client(user: 'not_existing', password: 'not_existing') - - it 'user' do - expect { client.user.find(1) }.to raise_error(ZammadAPI::ClientError) do |error| - expect(error.status).to eq(401) - expect(error.operation).to eq('find object') - expect(error.resource_class).to eq(ZammadAPI::Resources::User) - end - end - - it 'organization' do - expect { client.organization.find(1) }.to raise_error(ZammadAPI::ClientError) - end - - it 'group' do - expect { client.group.find(1) }.to raise_error(ZammadAPI::ClientError) - end - - it 'ticket_priority' do - expect { client.ticket_priority.find(1) }.to raise_error(ZammadAPI::ClientError) - end - - it 'ticket_state' do - expect { client.ticket_state.find(1) }.to raise_error(ZammadAPI::ClientError) - end - end -end diff --git a/zammad_api.gemspec b/zammad_api.gemspec index 7b09454..deddafe 100644 --- a/zammad_api.gemspec +++ b/zammad_api.gemspec @@ -1,28 +1,42 @@ -lib = File.expand_path('lib', __dir__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'zammad_api/version' +# frozen_string_literal: true + +require_relative 'lib/zammad_api/version' Gem::Specification.new do |spec| - spec.name = 'zammad_api' - spec.version = ZammadAPI::VERSION.dup - spec.authors = ['Martin Edenhofer', 'Martin Gruner'] - spec.email = ['support@zammad.org'] + spec.name = 'zammad_api' + spec.version = ZammadAPI::VERSION + spec.authors = ['Martin Edenhofer', 'Martin Gruner', 'Mantas Masalskis'] + spec.email = ['support@zammad.org'] - spec.summary = 'Zammad API v1.0 client.' - spec.description = 'Ruby wrapper for the Zammad API v1.0.' - spec.homepage = 'https://github.com/zammad/zammad-api-client-ruby' - spec.licenses = ['AGPL-3.0-only', 'MIT'] - spec.required_ruby_version = '>= 3.0' # Same as TargetRubyVersion in .rubocop.yml. + spec.summary = 'Zammad API v1.0 client.' + spec.description = 'Ruby wrapper for the Zammad API v1.0.' + spec.homepage = 'https://github.com/zammad/zammad-api-client-ruby' + spec.licenses = ['AGPL-3.0-only', 'MIT'] - spec.metadata['allowed_push_host'] = 'https://rubygems.org' + # Keep in sync with TargetRubyVersion in .rubocop.yml and the CI matrix. + spec.required_ruby_version = '>= 3.4' - spec.metadata['homepage_uri'] = spec.homepage - spec.metadata['source_code_uri'] = 'https://github.com/zammad/zammad-api-client-ruby' - spec.metadata['changelog_uri'] = 'https://github.com/zammad/zammad-api-client-ruby/blob/master/CHANGELOG.md' - spec.metadata['rubygems_mfa_required'] = 'true' + spec.metadata['allowed_push_host'] = 'https://rubygems.org' + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = spec.homepage + spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md" + spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" + spec.metadata['documentation_uri'] = "https://rubydoc.info/gems/zammad_api/#{ZammadAPI::VERSION}" + spec.metadata['rubygems_mfa_required'] = 'true' - spec.files = Dir['{lib}/**/*'] - spec.require_paths = ['lib'] + # sig/vendor holds the declarations that exist only for this repository's own + # type checking - stand-ins for dependencies that ship none, and the internal + # ones in sig/vendor/internal.rbs - and must not be published. + spec.files = Dir['lib/**/*.rb', 'sig/**/*.rbs'].grep_v(%r{\Asig/vendor/}) + %w[ + CHANGELOG.md + LICENSE.AGPL.txt + LICENSE.MIT.txt + LICENSE.md + README.md + ] + spec.require_paths = ['lib'] + spec.extra_rdoc_files = ['README.md', 'CHANGELOG.md'] - spec.add_dependency 'faraday', '~> 2' + spec.add_dependency 'faraday', '~> 2.9' + spec.add_dependency 'faraday-retry', '~> 2.2' end