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..a5c6493 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,27 +1,90 @@ 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 + + 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 +95,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..08a36f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +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 + - name: Verify the release candidate + run: bundle exec rake spec:unit rubocop steep + - 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..65d78d8 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,57 @@ 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. + 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..ca55e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,212 @@ +# 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 `where(active: true)`, + `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 — + `each`, `first`, `lazy`, `count` — fetches 100 per request. +- `where` rejects `page`, `per_page`, `expand` and `only_total_count` 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. +- `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 + read as unknown attributes and return `nil` rather than raising, because a Zammad + record carries administrator-defined attributes and a reader cannot tell a removed + method from a custom field — so `if record.new_instance` silently takes the else + branch. + +### Added + +- `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(...)`. +- 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 request a single record + rather than the page of 100 that `where(...).first` would have fetched, and + `client..exists?(id)`. +- `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`. +- 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. +- `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. +- `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. + +### Changed + +- `client..destroy(id)` deletes directly instead of fetching the record first. +- Resource dispatch is explicit rather than `method_missing` plus `const_get`. +- 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. + ## [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..44e3ad5 100644 --- a/README.md +++ b/README.md @@ -1,390 +1,803 @@ -# 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: - - $ bundle +Or: -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) +``` + +### 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 ``` -#### OAuth2 +`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. + +## Available resources + +`group`, `organization`, `ticket`, `ticket_article`, `ticket_priority`, `ticket_state`, `user` + +`client.resource_names` returns the current list. -Individual resources can be created, modified, saved, and destroyed. +Anything not in that list is reachable with [raw requests](#raw-requests). -### Create object +## Raw requests + +`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'}) +``` + +Each returns a `ZammadAPI::Response`, so the status and headers stay reachable: + +```ruby +response = client.get('api/v1/tickets') +response.status # => 200 +response.headers['x-total-count'] +response.body # decoded JSON, or the raw body for anything else +``` + +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') +``` -group.id # id of record -group.name # 'Support' +### Fetch + +```ruby +group = client.group.find(42) +group.name # => "Support" +group[:name] # same, without method_missing +group.fetch(:name) # raises KeyError if the attribute is absent +group.to_h # every attribute, as a Hash you may modify ``` -### Fetch object +Or by attribute, which asks for a single record rather than a whole page: ```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 + +Zammad records can carry administrator-defined custom attributes, so an unknown reader +returns `nil` rather than raising. Use `fetch` when a missing attribute should be an error. + +`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 = client.group.find(123) -group.name = 'Support 2' -group.save +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 ``` -### Destroy object +### Pattern matching + +Records implement `deconstruct_keys`, so they work with `case/in`: ```ruby -group = client.group.find(123) -group.destroy +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 ``` -## Collection management +`Config` and `Response` are `Data` objects, so their members match too: -A list of individual resources. +```ruby +case client.config +in {http_token: String} + :token_auth +in {user: String, password: String} + warn 'prefer an access token over basic auth' +end +``` -### All +### Update ```ruby -groups = client.group.all +group = client.group.find(42) +group.name = 'Support 2' -group1 = groups[0] -group1.note = 'Some note' -group1.save +group.changed? # => true +group.changes # => {name: ["Support", "Support 2"]} -groups.each {|group| - p "group: #{group.name}" -} +group.save # sends only the changed attributes ``` -### Search +Or in one call: + ```ruby -groups = client.group.search(query: 'some name') +group.update(name: 'Support 2', note: 'Renamed') # assigns, then saves +group.assign_attributes(name: 'Support 3') # assigns without saving +``` -group1 = groups[0] -group1.note = 'Some note' -group1.save +### Saving and validation failures -groups.each {|group| - p "group: #{group.name}" -} +`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 ``` -### All with pagination (beta) +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 -groups = client.group.all +group.save! # raises ZammadAPI::ValidationError +group.update!(name: '') # the same, in one call +``` -groups.page(1,3) {|group| - p "group: #{group.name}" +`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. - group.note = 'Some new note, inclued in page 1 with 3 per page' - group.save -} +### Associations -groups.page(2,3) {|group| - p "group: #{group.name}" +Zammad expands an association into a name under the plain attribute, so those reads are +already loaded and free: - group.note = 'Some new note, inclued in page 2 with 3 per page' - group.save -} +```ruby +ticket = client.ticket.find(1) + +ticket.customer # => "customer@example.com" +ticket.state # => "open" +ticket.group # => "Users" +ticket.customer_id # => 7 ``` -### Search with pagination (beta) +`related` reaches the whole record behind one of those, which costs a request: + ```ruby -groups = client.group.search(query: 'some name') +ticket.related.customer.firstname # => "Nicole" +ticket.related.group.note +ticket.related.articles # => [TicketArticle, ...] +ticket.related.created_by.email -groups.page(1,3) {|group| - p "group: #{group.name}" +client.user.find(7).related.organization +``` + +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. - group.note = 'Some new note, inclued in page 1 with 3 per page' - group.save -} +### Comparing and serializing -groups.page(2,3) {|group| - p "group: #{group.name}" +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: - group.note = 'Some new note, inclued in page 2 with 3 per page' - group.save -} +```ruby +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 ``` -## Perform actions on behalf of another user +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. -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: +`to_json` renders the attributes, so a record can be cached, queued or logged as it stands, +and nests inside a structure being generated: -> **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)). +```ruby +client.group.find(1).to_json # => "{\"id\":1,\"name\":\"Support\"}" +JSON.generate(group: client.group.find(1)) +``` + +`as_json` returns the same attributes as a Hash, for ActiveSupport and any encoder that +follows its convention. + +### Reload and destroy ```ruby -client.on_behalf_of = 'some_login' +group.reload # re-reads from Zammad, discarding unsaved changes +group.destroy # => true + +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 + +client.group.destroy(42) # delete by id, without fetching first ``` -All following actions with the client will be performed on behalf of the user with the `login` "some_login". +## Collections -To reset this back to regular requests just set `nil`: +`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.on_behalf_of = nil +client.ticket.each { |ticket| puts ticket.title } +client.ticket.first(5) +client.ticket.pluck(:title) +client.ticket.find_each(batch_size: 500) { |ticket| archive(ticket) } ``` -It's possible to perform only a block of actions on behalf of another user via: +`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 -client.perform_on_behalf_of('some_login') do - # ticket is created on behalf of the user with - # the login "some_login" - client.ticket.create( - ... - ) +# Walks every page automatically. +client.ticket.all.each do |ticket| + puts ticket.title end -# further actions are performed regularly. +# Stops after the first page, because Enumerable stops consuming. +first_five = client.ticket.all.first(5) + +# Lazy chains work as expected. +client.ticket.all.lazy.select { |t| t.state == 'open' }.first(10) + +# One array of records per request, e.g. for a bulk import. +client.ticket.all.in_batches(of: 500) do |tickets| + import(tickets) +end + +# Record by record, with the page size set inline. +client.ticket.all.find_each(batch_size: 500) do |ticket| + archive(ticket) +end ``` -## Examples +### Filters -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', - ], - }, -) +```ruby +client.ticket.where(state: 'open').first(10) # a filtered collection +client.group.all.where(active: true) # the same, from an existing collection +``` + +`where` takes Zammad query parameters, such as `sort_by` where the endpoint supports it. +Paging is not one of them: 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. + +A `nil` value raises too. There is no query string that means "this field is null", so +`where(owner_id: nil)` cannot ask for unassigned tickets — it would ask for all of them. + +### Search -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.organization.search('zammad').each do |organization| + puts organization.name +end ``` -List all new or open tickets: +### Explicit pages + ```ruby -tickets = client.ticket.search(query: 'state.name:new OR state.name:open') +tickets = client.ticket.all + +tickets.page(2) # page 2 of the default 100 per page +tickets.page(2, of: 10) # records 11 to 20 +``` -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' +Collections are immutable: `where` and `page` return a new collection and leave the +original untouched. -tickets.each {|ticket| - p "ticket: #{ticket.number} - #{ticket.title}" -} +### Page size + +A request fetches 100 records by default. Three calls take another size, each for its own +kind of work: + +```ruby +client.ticket.all.find_each(batch_size: 500) { |ticket| archive(ticket) } # walking +client.ticket.all.in_batches(of: 500) { |tickets| import(tickets) } # batching +client.ticket.all.page(2, of: 500) # one page ``` -Get all articles of a ticket: +`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: 500).first(7)`. + +Zammad caps the page size per endpoint — 100 for `/api/v1/tickets`, 200 for a search, 1000 +for the other index endpoints — and a larger size is reduced to what the endpoint serves. +That keeps a walk complete: a page size the server silently shrank would otherwise end the +iteration at the first page. + +### Reading single attributes + ```ruby -ticket = client.ticket.find(123) -articles = ticket.articles +client.user.all.pluck(:email) # => ["a@example.com", ...] +client.ticket.all.pluck(:id, :title) # => [[1, "Help"], ...] +``` + +Zammad cannot be asked for a subset of the fields, so this shapes the result rather than +shrinking the request. + +### Counting -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' +`count` walks the pages, except on a search, which Zammad can count in a single request: -p "ticket: #{ticket.number} - #{ticket.title}" -articles.each {|article| - p "article: #{article.from} - #{article.subject}" -} +```ruby +client.ticket.search('state.name:open').count # one request +client.ticket.all.count # one request per page of 100 ``` -Create an article for a ticket: +`size` and `length` are `count`, and cost the same. `empty?` asks for a single record +rather than a page: + ```ruby -ticket = client.ticket.find(123) +client.ticket.where(state: 'merged').empty? # one request, for one record +client.group.all.size # => 12 +``` -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 -) +## Deriving clients -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 -} +A client is immutable. `with` returns a new one with some options changed, re-validating +them and carrying over any `on_behalf_of` scope: -p "article: #{article.from} - #{article.subject}" +```ruby +bulk = client.with(timeout: 300, retries: 5) +bulk.ticket.all.each { |ticket| archive(ticket) } ``` -## Testing +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 -### Setup an (empty Zammad) test env +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) ``` -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 + +Or scoped to a block: + +```ruby +client.on_behalf_of('agent@example.com') do |scoped| + scoped.ticket.find(1) +end ``` -### Execute client tests +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 +``` -Run tests via `rake spec`. (Remember to export the vars above if you are running this in another shell.) +```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. -## Publishing +### Timeouts and retries -1. Update version in [version.rb](lib/zammad_api/version.rb). -2. Add release to [CHANGELOG.md](CHANGELOG.md) -3. Commit. -4. Test build. +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 +) ``` -> rake build -zammad_api 1.0.7 built to pkg/zammad_api-1.0.7.gem. + +## Logging + +```ruby +client = ZammadAPI::Client.new( + url: 'https://zammad.example.com/', + http_token: 'token', + logger: Logger.new($stdout) +) ``` -5. Release + +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 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 +`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 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` / `on_behalf_of`. | +| `reset` | Forgets the stubs and the recorded requests. | + +A request that was not stubbed raises `ZammadAPI::Test::UnstubbedRequestError`, listing +what is stubbed, rather than answering with something empty. + +## 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. + +- **`record.new_instance` and `record.url` return `nil`**, because an unknown attribute + reads as `nil` rather than raising — Zammad records carry administrator-defined + attributes, so a reader cannot tell a removed method from a custom field. `if + record.new_instance` now always takes the else branch. Use `new_record?` / + `persisted?`, and `Resource.resource_path` on the class. + +- **`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 | +| `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.where(active: true)` | Same: the filter never reached the request | +| `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.new_instance` | `record.new_record?` / `record.persisted?` | Internal flag is no longer public | +| `resource.url` (instance) | `Resource.resource_path` (class) | Clashed with an attribute named `url` | +| `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 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..dfa2dce 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,35 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' 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 -RSpec::Core::RakeTask.new(:spec) +desc 'Type-check lib/ against the signatures in sig/' +task :steep do + sh 'bundle exec steep check' +end -task default: :spec +desc 'Run everything that does not need a Zammad instance' +task default: ['spec:unit', :rubocop, :steep] diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..92cacd5 --- /dev/null +++ b/Steepfile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +target :lib do + signature 'sig' + check 'lib' + + library 'json', 'logger', 'timeout' + + 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..67166d0 --- /dev/null +++ b/examples/onboard_customer.rb @@ -0,0 +1,58 @@ +#!/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` asks for a single record rather than a page, and returns nil when +# there is none. +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..6cd0581 --- /dev/null +++ b/examples/pagination.rb @@ -0,0 +1,60 @@ +#!/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. 100 records per request by default. +# +# 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" +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}" + +# Filters are Zammad query parameters, and compose with all of the above. +puts "where(state:) #{client.ticket.where(state: 'open').first(5).size} open tickets" + +# Counting a search is one request, because Zammad answers it with a total. +# An index endpoint has to be walked page by page. +puts "search.count #{client.ticket.search('state.name:open').count}, one request" +puts "empty? #{client.ticket.where(state: 'merged').empty?}, asks for a single record" + +# `where` 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..d20b1d5 --- /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.where(state: '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..3034de1 100644 --- a/lib/zammad_api.rb +++ b/lib/zammad_api.rb @@ -1,6 +1,19 @@ -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/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..99ee610 --- /dev/null +++ b/lib/zammad_api/associations.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'resource_proxy' + +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] + @cache[name] = id.nil? ? nil : ResourceProxy.new(@record.transport, resolve(class_name)).find(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. + def has_many_target(name, class_name, path) + target_class = resolve(class_name) + operation = "get #{name}" + + response = @record.transport.get( + path.call(@record), + operation: operation, + resource_class: target_class, + query: { expand: true } + ) + response + .decoded(:array, operation: operation, resource_class: target_class) + .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..6774060 --- /dev/null +++ b/lib/zammad_api/attribute_access.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require 'json' + +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+. Use {#fetch} when a missing attribute should be + # an error rather than +nil+. + # + # 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 + + # 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] + + # @param key [Symbol, String] + # @param default [Object] returned instead of raising + # @yieldparam key [Symbol] called instead of raising + # @return [Object] + # @raise [KeyError] when the attribute is absent and no fallback was given + def fetch(key, *default) + 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 identifier.end_with?('=') + + attributes[name] + 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? if identifier.end_with?('=') + + attributes.key?(name) || super + end + + private + + # Whether this record stages attribute writes, so that {#respond_to?} and + # calling a writer agree. + def writable_attributes? = false + + # 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. + # + # The freezing is what makes {#attributes} safe to expose: a record that + # handed out 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. + def frozen_attributes(value) + case value + when Hash then value.to_h { |key, nested| [key.respond_to?(:to_sym) ? key.to_sym : key, frozen_attributes(nested)] }.freeze + when Array then value.map { frozen_attributes(it) }.freeze + when String then value.dup.freeze + else value + end + end + + # The inverse of {#frozen_attributes}, for handing out a copy that callers + # may treat as their own. + def deep_dup(value) + case value + when Hash then value.to_h { |key, nested| [key, deep_dup(nested)] } + when Array then value.map { deep_dup(it) } + when String then value.dup + else value + end + end + end +end diff --git a/lib/zammad_api/client.rb b/lib/zammad_api/client.rb index b1d6c85..4779d8c 100644 --- a/lib/zammad_api/client.rb +++ b/lib/zammad_api/client.rb @@ -1,65 +1,308 @@ -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) + # @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) + @transport = Transport.new(@config) end - private + # @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) } + ResourceProxy.new(@transport, resource_class) + 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]) + # @return [Array] every resource name this client supports + def resource_names = RESOURCES.keys - # check for token auth - return if @config[:http_token] && !@config[:http_token].empty? - return if @config[:oauth2_token] && !@config[:oauth2_token].empty? + # 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( + 'api/v1/users/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 - if !@config[:user] || @config[:user].empty? - raise ConfigurationError, 'missing user in config' - 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. + # + # @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['x-total-count'] + # + # @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 + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @raise [TransportError] when the request could not be completed + def get(path, query: nil) = raw(:get, path, query: query) + + # 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 + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @see #get + def post(path, query: nil, body: nil) = raw(:post, path, query: query, body: body) + + # 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 + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @see #get + def put(path, query: nil, body: nil) = raw(:put, path, query: query, body: body) + + # 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 + # @return [Response] + # @raise [ResponseError] for any non-2xx response + # @see #get + def delete(path, query: nil) = raw(:delete, path, query: query) - return if @config[:password] && !@config[:password].empty? + # @!endgroup - raise ConfigurationError, 'missing password in config' + # 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. + # + # @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) + derived = dup + derived.instance_variable_set(:@config, derived_config) + derived.instance_variable_set( + :@transport, + Transport.new(derived_config).with_on_behalf_of(@transport.on_behalf_of) + ) + derived 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 } + # Returns a new client that performs its requests through +transport+. + # + # The seam the test kit uses to put a double in place of the HTTP stack. + # + # @api private + # @param transport [Transport] anything with a {Transport} interface + # @return [Client] + def with_transport(transport) + derived = dup + derived.instance_variable_set(:@transport, transport) + derived end + + # 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 + scoped.instance_variable_set(:@transport, @transport.with_on_behalf_of(identifier)) + return scoped if !block_given? + + yield scoped + end + + def inspect = "#<#{self.class.name} url=#{config.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 + + # The path is joined onto Config#url, which always ends in a slash, so a + # leading slash would resolve against the host and drop the sub-path of a + # Zammad served from one. + def raw(method, path, query: nil, body: nil) + relative = path.to_s.sub(%r{\A/+}, '') + + @transport.request( + method, + relative, + operation: "#{method.to_s.upcase} #{relative}", + query: query, + body: body + ) + end + + def unknown_resource_message(name) = "Unknown resource #{name}, available resources are: #{RESOURCES.keys.join(', ')}" end end diff --git a/lib/zammad_api/collection.rb b/lib/zammad_api/collection.rb new file mode 100644 index 0000000..0436794 --- /dev/null +++ b/lib/zammad_api/collection.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +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. Combine it with + # +Enumerable+ methods such as +first+, +lazy+ or +find+ to stop early + # without downloading everything. + # + # @example Iterate every ticket + # client.ticket.all.each { |ticket| puts ticket.title } + # + # @example Filter, then stop after the first five matches + # client.ticket.where(state: 'open').first(5) + # + # @example Work in batches, e.g. for an import + # client.ticket.all.in_batches(of: 500) { |tickets| import(tickets) } + # + # @example One explicit page + # client.ticket.all.page(2, of: 50).to_a + class Collection + include Enumerable + + # Records fetched per request, unless a call asks for another size. + DEFAULT_PER_PAGE = 100 + + # Query parameters this collection owns. Passing them to {#where} would be + # silently overridden, so they are rejected instead. + RESERVED_QUERY_KEYS = %i[page per_page expand only_total_count].freeze + private_constant :RESERVED_QUERY_KEYS + + # @api private + def initialize(transport:, resource_class:, path:, operation:, max_per_page:, query: {}, per_page: DEFAULT_PER_PAGE, page: nil, countable: false) + @transport = transport + @resource_class = resource_class + @path = path + @operation = operation + @query = query + @max_per_page = max_per_page + @per_page = clamp_per_page(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: positive_integer!(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: positive_integer!(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. Zammad caps the page size per + # endpoint, so a larger size is reduced to what the endpoint serves. + # + # @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, {DEFAULT_PER_PAGE} by default + # @return [Collection] + def page(number, of: nil) + raise ArgumentError, 'page needs to be a positive integer' if !number.is_a?(Integer) || !number.positive? + + with(page: number, per_page: of ? positive_integer!(of, 'of') : @per_page) + end + + # Returns a new collection with additional query parameters applied. + # + # @param params [Hash] Zammad query parameters, e.g. +state:+ or +sort_by:+ + # @return [Collection] + # @raise [ArgumentError] for a parameter this collection controls itself + def where(**params) + reserved = params.keys & RESERVED_QUERY_KEYS + raise ArgumentError, "#{reserved.join(', ')} cannot be passed to where: use page, in_batches or find_each for paging, and leave expand and only_total_count to the collection" if !reserved.empty? + + with(query: @query.merge(params)) + 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 + + # Number of records in this collection. + # + # Zammad answers this in one request for a search; every other endpoint + # has to be walked. + # + # @return [Integer] + def count(*args, &block) + 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 one request, which 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 and so cannot be narrowed. + # + # @example + # client.ticket.where(state: 'merged').empty? + # + # @return [Boolean] + def empty? = (@page ? self : page(1, of: 1)).first.nil? + + def inspect + "#<#{self.class.name} #{@resource_class.name} path=#{@path.inspect} per_page=#{@per_page}#{" page=#{@page}" if @page}>" + end + + private + + def positive_integer!(value, name) + raise ArgumentError, "#{name} needs a positive integer" if !value.is_a?(Integer) || !value.positive? + + value + end + + def walk + page = @page || 1 + previous = nil + loop do + records = fetch(page, @per_page) + yield records if !records.empty? + + # A collection limited to a single page never advances. + break if @page + + # A short page means the server has no more records. The page size is + # clamped to what the endpoint serves, so it cannot be short because + # the server shrank it. + break if records.size < @per_page + + # 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. + current = records.map(&:attributes) + raise PaginationError.build(operation: @operation, page: page, resource_class: @resource_class) if current == previous + + previous = current + 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, + query: query, + per_page: per_page, + page: page, + countable: @countable + ) + end + + def fetch(page, per_page) + response = @transport.get( + @path, + operation: @operation, + resource_class: @resource_class, + query: @query.merge(page: page, per_page: per_page) + ) + records = response.decoded(:array, operation: @operation, resource_class: @resource_class) + records.map { @resource_class.from_response(@transport, it) } + end + + # @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) + ) + # Not every search endpoint honours only_total_count; one that ignores it + # answers with the usual array of records, which is a shape to walk + # rather than a reason to raise. + return nil if !response.body.is_a?(Hash) + + total = response.body[:total_count] + total.is_a?(Integer) ? total : nil + end + + 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..c966f8d --- /dev/null +++ b/lib/zammad_api/config.rb @@ -0,0 +1,222 @@ +# 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]' + + URL_PATTERN = %r{\Ahttps?://}i + + # 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. + USERINFO_PATTERN = %r{(?<=://)[^/@]+(?=@)} + + def initialize( + url:, + user: nil, + password: nil, + http_token: nil, + oauth2_token: nil, + user_agent: "zammad_api-ruby/#{ZammadAPI::VERSION}", + 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(user_agent), + timeout: timeout, + open_timeout: open_timeout, + retries: retries, + retry_interval: retry_interval, + ssl_verify: ssl_verify, + proxy: immutable(presence(proxy)), + adapter: adapter&.to_sym, + middleware: middleware, + logger: logger || Logger.new(IO::NULL) + ) + # steep:ignore:end + validate_credentials! + validate_numbers! + validate_middleware! + validate_logger! + end + + # @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 + + 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 value.sub(USERINFO_PATTERN, REDACTION).inspect if key == :proxy && value + + value.inspect + end + + def normalize_url(value) + raise ConfigurationError, 'missing url in config' if presence(value).nil? + raise ConfigurationError, 'config url needs to start with http:// or https://' 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. + value.end_with?('/') ? value : "#{value}/" + 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 + + 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 !value.is_a?(Numeric) || !value.positive? + end + + raise ConfigurationError, 'config retries needs to be a non-negative integer' if !retries.is_a?(Integer) || retries.negative? + 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/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/errors.rb b/lib/zammad_api/errors.rb index 4e07e1d..510e052 100644 --- a/lib/zammad_api/errors.rb +++ b/lib/zammad_api/errors.rb @@ -1,62 +1,186 @@ -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] +:object+ or +:array+ + # @param actual [Class] the class that was decoded instead + # @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 + # @return [Integer, nil] HTTP status code def status response&.status 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) + + value = body[:error_human] || body[:error] || 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 + value = headers['retry-after'] || 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..91dd7ee --- /dev/null +++ b/lib/zammad_api/resource_proxy.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +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.where(active: true).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 + + # Largest page size Zammad's search endpoints serve, from + # ApplicationController#model_search_render. + SEARCH_MAX_PER_PAGE = 200 + + # @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) + response = @transport.get( + "#{path}/#{id}", + operation: 'find object', + resource_class: resource_class, + query: { expand: true } + ) + resource_class.from_response( + @transport, + response.decoded(:object, operation: 'find object', resource_class: resource_class) + ) + end + + # Fetches the first record matching Zammad query parameters. + # + # Only one record is requested, not a whole page. + # + # @example + # client.user.find_by(email: 'someone@example.com')&.id + # + # @param params [Hash] Zammad query parameters + # @return [Resources::Base, nil] nil when nothing matched + def find_by(**params) = where(**params).page(1, of: 1).first + + # Fetches the first record matching Zammad query parameters, raising when + # nothing matched. + # + # @param params [Hash] Zammad query parameters + # @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("#{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 + + # Records matching Zammad query parameters, as a lazily paginated + # collection. Shorthand for +all.where(...)+. + # + # @param params [Hash] Zammad query parameters, e.g. +active:+ + # @return [Collection] + def where(**params) = all.where(**params) + + # @!group Collection shorthands + + # Each of these forwards to {Collection}, which decides for itself what a + # missing block means. The type checker cannot pick between the with-block + # and without-block signatures while forwarding one that may be nil, hence + # the annotations. + + # 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 + def each(&block) = all.each(&block) # steep:ignore BlockTypeMismatch + + # @param batch_size [Integer, nil] records fetched per request + # @yieldparam record [Resources::Base] + # @return [Enumerator] when no block is given + # @see Collection#find_each + def find_each(batch_size: nil, &block) = all.find_each(batch_size: batch_size, &block) # steep:ignore BlockTypeMismatch + + # @param of [Integer, nil] records fetched per request + # @yieldparam records [Array] + # @return [Enumerator] when no block is given + # @see Collection#in_batches + def in_batches(of: nil, &block) = all.in_batches(of: of, &block) # steep:ignore BlockTypeMismatch + + # @param number [Integer] one-based page number + # @param of [Integer, nil] records on the page + # @return [Collection] + # @see Collection#page + def page(number, of: nil) = all.page(number, of: of) + + # @param keys [Array] attribute names + # @return [Array] + # @see Collection#pluck + def pluck(*keys) = all.pluck(*keys) + + # Overrides +Enumerable#count+ so that a collection counts the way it + # knows how. + # + # @return [Integer] + # @see Collection#count + def count(*args, &block) = all.count(*args, &block) + + # @return [Integer] + # @see Collection#size + def size = all.size + + # @return [Integer] + # @see Collection#size + def length = all.length + + # @return [Boolean] + # @see Collection#empty? + def empty? = all.empty? + + # @!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 + def search(term) + 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, + countable: true + ) + end + + def inspect = "#<#{self.class.name} #{resource_class.name}>" + + private + + def collection(path, operation, query: {}, max_per_page: resource_class::MAX_PER_PAGE, countable: false) + Collection.new( + transport: @transport, + resource_class: resource_class, + path: path, + operation: operation, + max_per_page: max_per_page, + countable: countable, + query: { expand: true }.merge(query) + ) + end + + def path = resource_class.resource_path + 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..91aca20 100644 --- a/lib/zammad_api/resources/base.rb +++ b/lib/zammad_api/resources/base.rb @@ -1,140 +1,370 @@ -require 'cgi' -require 'zammad_api/json_helper' -require 'zammad_api/transport' +# frozen_string_literal: true + +require_relative '../associations' +require_relative '../attribute_access' +require_relative '../errors' 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 override this. + MAX_PER_PAGE = 1000 - def initialize(transport, attributes = {}) - @new_instance = true - @transport = transport - @changes = {} - @url = self.class.get_url + # 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?('=') + # @return [String] the API path of this resource + def resource_path + @path || raise(Error, "#{name} does not declare an API path") + end - method = method.to_s[0, method.length - 1].to_sym - @changes[method] = [@attributes[method], args[0]] - @attributes[method] = args[0] - nil - end + # Builds a record that is already stored in Zammad. + # + # @api private + # @param transport [Transport] + # @param attributes [Hash] + # @return [Base] + def from_response(transport, attributes) + record = new(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 - def new_record? - @new_instance + # The class carrying this resource's association readers, reached + # through {Base#related}. + # + # @api private + # @return [Class] + def related_class + # A resource's proxy inherits its parent's readers, so Base's + # created_by and updated_by reach every resource. + @related_class ||= Class.new(superclass.respond_to?(:related_class) ? superclass.related_class : Associations::Proxy) # steep:ignore NoMethod + end + + private + + 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. + # + # @param name [Symbol] name of the reader on {Base#related} + # @param class_name [String] the target resource + # @param path [Proc] called with the record, 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] + def initialize(transport, attributes = {}) + @transport = transport + @attributes = frozen_attributes(attributes || {}) + @changes = {} + @new_record = true + @destroyed = false + @error = nil + @related = nil 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 + + # @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] + # @see .associations + def related = @related ||= self.class.related_class.new(self) - raise ResponseError.from(response, operation: 'destroy object', resource_class: self.class) + # 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] + def assign_attributes(attributes) + attributes.each { |key, value| write_attribute(key.to_sym, value) } + self end + # 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 - attributes = saved_attributes - symbolize_keys_deep!(attributes) - attributes.delete(:article) - @attributes = attributes - @new_instance = false - @changes = {} - true + save! + rescue ValidationError => e + @error = e + false end - def self.get_url - @url - end + # 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 Error, "#{self.class.name} #{id} was destroyed, there is nothing to save" if destroyed? - def self.url(value) - @url = value - end + # 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 + response = new_record? ? create_record : update_record - def self.all(transport, _) - ZammadAPI::ListAll.new(self, transport, per_page: 100) + replace_attributes!(response, operation: 'save object') + true end - def self.search(transport, parameter) - ZammadAPI::ListSearch.new(self, transport, parameter) + # 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 [ResponseError] for any failure other than a validation error + # @see #save + def update(attributes) + 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 - - data = safe_json_parse(response.body) - item = new(transport, data) - item.new_instance = false - item + # Stages several attributes and saves in one call, raising on any + # failure. + # + # @param attributes [Hash] attribute names and their new values + # @return [true] + # @raise [ResponseError] when Zammad rejected the request + # @see #save! + def update!(attributes) + assign_attributes(attributes) + save! end - def self.create(transport, data) - item = new(transport, data) - item.save - item + # Re-reads the record from Zammad, discarding unsaved changes. + # + # @return [self] + # @raise [ResponseError] when Zammad rejected the request + # @raise [ParseError] when the response is not a JSON object + def reload + 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.destroy(transport, id) - item = find(transport, id) - item.destroy + # Deletes the record. + # + # The record is marked {#destroyed?} rather than left looking live, so + # that a later {#save} fails here with the reason rather than one call + # later as a 404 from Zammad. + # + # @return [true] + # @raise [ResponseError] when Zammad rejected the request + def destroy + transport.delete(member_path, operation: 'destroy object', resource_class: self.class) + @destroyed = true true end - private + def inspect = "#<#{self.class.name} id=#{id.inspect} new_record=#{new_record?}#{' destroyed=true' if destroyed?} attributes=#{attributes.inspect}>" - def saved_attributes - return save_new if @new_instance + private - 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 + def writable_attributes? = true - save_error(response) + # 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. + def replace_attributes!(response, operation:) + @attributes = frozen_attributes(response.decoded(:object, operation: operation, resource_class: self.class)) + @changes = {} + @new_record = false + @destroyed = false + @error = nil + @related = nil end - def save_existing - attributes_to_post = {} - @changes.each do |name, values| - attributes_to_post[name] = values[1] + # 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. + def write_attribute(key, value) + staged = frozen_attributes(value) + original = @changes.key?(key) ? @changes[key].first : @attributes[key] + + if original == staged + @changes.delete(key) + else + @changes[key] = [original, 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 + 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 + def member_path + raise Error, "#{self.class.name} has no id, save it first" if id.nil? - symbolize_keys_deep! hash[key_symbol] if hash[key_symbol].is_a? Hash - end + "#{self.class.resource_path}/#{id}" end end end diff --git a/lib/zammad_api/resources/group.rb b/lib/zammad_api/resources/group.rb index 3a6a768..b611e2b 100644 --- a/lib/zammad_api/resources/group.rb +++ b/lib/zammad_api/resources/group.rb @@ -1,3 +1,11 @@ -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' + end + end end diff --git a/lib/zammad_api/resources/organization.rb b/lib/zammad_api/resources/organization.rb index 671939f..4da98df 100644 --- a/lib/zammad_api/resources/organization.rb +++ b/lib/zammad_api/resources/organization.rb @@ -1,3 +1,11 @@ -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' + end + end end diff --git a/lib/zammad_api/resources/ticket.rb b/lib/zammad_api/resources/ticket.rb index 448209b..671e1f8 100644 --- a/lib/zammad_api/resources/ticket.rb +++ b/lib/zammad_api/resources/ticket.rb @@ -1,25 +1,46 @@ -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 + # /api/v1/tickets caps the page size at 100, unlike the generic index + # endpoints, see TicketsController#index. + MAX_PER_PAGE = 100 + + 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' - def article(data) - data[:ticket_id] = @attributes[:id] - item = ZammadAPI::Resources::TicketArticle.new(@transport, data) - item.save - item + has_many :articles, class_name: 'TicketArticle', path: ->(ticket) { "api/v1/ticket_articles/by_ticket/#{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 [ResponseError] when Zammad rejected the request + def article(attributes = {}) + record = TicketArticle.new(transport, attributes.merge(ticket_id: 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..2de47aa 100644 --- a/lib/zammad_api/resources/ticket_article.rb +++ b/lib/zammad_api/resources/ticket_article.rb @@ -1,11 +1,25 @@ -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 'base' +require_relative 'ticket_article_attachment' + +module ZammadAPI + module Resources + class TicketArticle < Base + path 'api/v1/ticket_articles' + + belongs_to :ticket, class_name: 'Ticket' + + # @return [Array] the article's attachments + def attachments + list = attributes[:attachments] || [] + list.map do |raw| + TicketArticleAttachment.new( + transport, + raw.merge(ticket_id: attributes[:ticket_id], article_id: id) + ) + end + 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..eca9c8b 100644 --- a/lib/zammad_api/resources/ticket_article_attachment.rb +++ b/lib/zammad_api/resources/ticket_article_attachment.rb @@ -1,18 +1,43 @@ -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' + +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 + response = @transport.get( + "api/v1/ticket_attachment/#{fetch(:ticket_id)}/#{fetch(:article_id)}/#{fetch(:id)}", + 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..59d006b 100644 --- a/lib/zammad_api/resources/user.rb +++ b/lib/zammad_api/resources/user.rb @@ -1,3 +1,13 @@ -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' + + 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..67daec7 --- /dev/null +++ b/lib/zammad_api/response.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +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) + + # @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 + + # 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 + else + raise ParseError.build( + operation: operation, + expected: shape, + actual: body.class, + resource_class: resource_class + ) + end + end + end +end diff --git a/lib/zammad_api/test.rb b/lib/zammad_api/test.rb new file mode 100644 index 0000000..88f3387 --- /dev/null +++ b/lib/zammad_api/test.rb @@ -0,0 +1,240 @@ +# 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. + class UnstubbedRequestError < Error; 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] on_behalf_of + # @return [String, Integer, nil] the +From+ scope in effect + Request = Data.define(:verb, :path, :query, :body, :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. `Client.new` validates the config and assembles a whole + # Faraday stack - auth, JSON, retries, adapter - that `with_transport` + # then replaces, and a suite that reaches for `zammad.client` in every + # example used to pay for that every time. + @client = Client.new(**@config.to_h.compact).with_transport(@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. + # + # @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 served as + # JSON, anything else as a raw body + # @param headers [Hash] response headers + # @param query [Hash, nil] only answer requests carrying these parameters + # @return [self] + def stub(method, path, status: 200, body: nil, headers: {}, query: nil) + @monitor.synchronize do + (@stubs[key(method, path)] ||= []) << { + status: status, + body: body, + headers: headers.to_h { |name, value| [name.to_s.downcase, value] }, + query: query + } + 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}" } } + + def inspect = "#<#{self.class.name} stubbed=#{stubbed.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, resource_class: nil, on_behalf_of: nil) + relative = path.to_s.sub(%r{\A/+}, '') + # Through the real transport's own stringification, so that {Request#query} + # holds 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. + params = ::ZammadAPI::Transport.stringify_query(query || {}) + + stub = @monitor.synchronize do + @requests << Request.new(verb: method, path: relative, query: params, body: body, on_behalf_of: on_behalf_of) + take(method, relative, params) + end + + raise UnstubbedRequestError, unstubbed_message(method, relative) if stub.nil? + + response = response_for(stub) + return response if response.success? + + raise ResponseError.build(response, operation: operation, resource_class: resource_class) + end + + private + + def key(method, path) = [method.to_sym, path.to_s.sub(%r{\A/+}, '')] + + # Keeps the last stub in place, so one stub can answer any number of + # requests while two describe a sequence. + def take(method, path, params) + queued = @stubs[key(method, path)] + return nil if queued.nil? + + index = queued.index { |stub| matches?(stub[:query], params) } + return nil if index.nil? + + index == queued.size - 1 ? queued[index] : queued.delete_at(index) + end + + def matches?(expected, params) + return true if expected.nil? + + expected.all? { |name, value| params[name.to_s].to_s == value.to_s } + end + + def response_for(stub) + case stub[:body] + in Hash | Array => structured + raw = JSON.generate(structured) + build_response(stub, JSON.parse(raw, symbolize_names: true), raw, json: true) + in nil + build_response(stub, '', '', json: false) + in other + raw = other.to_s + build_response(stub, raw, raw, json: false) + end + end + + def build_response(stub, body, raw_body, json:) + Response.new(status: stub[:status], headers: stub[:headers], 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 + 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 + + def with_on_behalf_of(identifier) = self.class.new(test, on_behalf_of: identifier) + + %i[get post put delete].each do |verb| + define_method(verb) do |path, **options| + request(verb, path, **options) # steep:ignore NoMethod + end + end + + def request(method, path, operation:, query: nil, body: nil, resource_class: nil) + test.answer( + method, + path, + operation: operation, + query: query, + body: body, + 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..c0f8082 100644 --- a/lib/zammad_api/transport.rb +++ b/lib/zammad_api/transport.rb @@ -1,63 +1,242 @@ +# frozen_string_literal: true + require 'faraday' -require 'openssl' +require 'faraday/retry' +require 'json' + +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 + # 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 + + # 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. + RETRIABLE_EXCEPTIONS = [ + Faraday::RetriableResponse, + Faraday::ConnectionFailed, + Faraday::TimeoutError, + Errno::ETIMEDOUT, + Timeout::Error + ].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. + SENSITIVE_KEY_PATTERN = /password|token|secret|private_key/i + + REDACTED = '[REDACTED]' + + # @return [Config] + attr_reader :config + + # @return [String, nil] login of the user requests are performed for + attr_reader :on_behalf_of + + # The query parameters a request actually carries. + # + # Zammad expects scalar values; booleans and integers are stringified so + # that Faraday does not encode them as unexpected types. + # + # A nil used to be dropped here, which turned `where(owner_id: nil)` - an + # entirely reasonable way to write "unassigned" - into an unfiltered index + # answering with every ticket. Wrong results, no error, nothing to see from + # the outside. There is no query string that means "this field is null", so + # saying so is the only answer that can be acted on. + # + # Public because {Test} records what a test's client sent, and a stand-in + # whose recorded values disagree with the wire makes green tests mean less + # than they appear to. + # + # @api private + # @param query [Hash] + # @return [Hash{String => String, Array}] + # @raise [ArgumentError] for a nil value + def self.stringify_query(query) + query.each_with_object({}) do |(key, value), result| + raise ArgumentError, "query parameter #{key} is nil, and Zammad has no way to read that: pass a value, or leave the parameter out" if value.nil? + + result[key.to_s] = value.is_a?(Array) ? value.map(&:to_s) : value.to_s 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 + + # @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. + # + # @param identifier [String, nil] login, email or user id + # @return [Transport] + def with_on_behalf_of(identifier) + copy = dup + copy.instance_variable_set(:@on_behalf_of, identifier) + copy + end + + # @!method get(path, operation:, query: nil, resource_class: nil) + # @!method post(path, operation:, query: nil, body: nil, resource_class: nil) + # @!method put(path, operation:, query: nil, body: nil, resource_class: nil) + # @!method delete(path, operation:, query: 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 - %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 + # 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 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 + def request(method, path, operation:, query: nil, body: nil, resource_class: nil) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = decode(perform(method, path, query, body)) + log_response(method, path, response, started) + + return response if response.success? + + raise ResponseError.build(response, operation: operation, resource_class: resource_class) + rescue Faraday::TimeoutError => e + raise TimeoutError, "Can't #{operation}: request to #{path} timed out (#{e.message})" + rescue Faraday::SSLError => e + raise ConnectionError, "Can't #{operation}: TLS handshake with #{config.url} failed (#{e.message})" + rescue Faraday::ConnectionFailed => e + raise ConnectionError, "Can't #{operation}: #{config.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) + # Built before the request is logged, so a rejected query does not leave + # a line claiming a request that was never made. + params = query && Transport.stringify_query(query) + log_request(method, path, query, body) - 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 + 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 + 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 + rescue Faraday::Error => e + # An unregistered adapter or a middleware that rejects its options is a + # configuration mistake, and Faraday is not part of this gem's surface. + raise ConfigurationError, "config could not be used to build a connection: #{e.message}" + end - if with_params - req.headers['Content-Type'] = 'application/json' - req.body = param[:params].to_json - end + 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 - if !on_behalf_of.nil? - req.headers['From'] = on_behalf_of - end + 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 - yield(req) if block_given? - end + def decode(faraday_response) + headers = faraday_response.headers.to_h.transform_keys { it.to_s.downcase } + raw_body = faraday_response.body.to_s + body, json = decode_body(headers['content-type'], raw_body) + + Response.new( + status: faraday_response.status, + headers: headers, + body: body, + raw_body: raw_body, + json: json + ) + end - @logger.debug "Response: #{response.body}" - response + # 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. + # + # Returns whether it decoded alongside the body, because this is the only + # place that knows. + def 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 + + def log_request(method, path, query, body) + logger.debug { "Zammad API request: #{method.to_s.upcase} #{path}#{" query=#{redact(query).inspect}" if query}" } + 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..12fbda6 --- /dev/null +++ b/script/check_connection.rb @@ -0,0 +1,253 @@ +#!/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" } + +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/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..60e5aff --- /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, ^(Resources::Base) -> 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..de0fd40 --- /dev/null +++ b/sig/zammad_api/attribute_access.rbs @@ -0,0 +1,30 @@ +module ZammadAPI + module AttributeAccess + NON_ATTRIBUTE_SUFFIXES: Array[String] + + @attributes: Hash[Symbol, untyped] + + attr_reader attributes: Hash[Symbol, untyped] + + def []: (Symbol | String key) -> 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_missing?: (Symbol, ?bool) -> bool + + private + + def writable_attributes?: () -> 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..06b271f --- /dev/null +++ b/sig/zammad_api/client.rbs @@ -0,0 +1,46 @@ +module ZammadAPI + class Client + RESOURCES: Hash[Symbol, untyped] + CONVERSION_METHODS: Array[Symbol] + ENV_OPTIONS: Hash[String, Symbol] + + attr_reader config: Config + + @transport: Transport + + def self.from_env: (**untyped overrides) -> 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]?) -> Response + def post: (String path, ?query: Hash[untyped, untyped]?, ?body: untyped) -> Response + def put: (String path, ?query: Hash[untyped, untyped]?, ?body: untyped) -> Response + def delete: (String path, ?query: Hash[untyped, untyped]?) -> Response + + def with: (**untyped options) -> Client + def with_transport: (untyped transport) -> 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) -> Response + def unknown_resource_message: (Symbol | String) -> String + end +end diff --git a/sig/zammad_api/collection.rbs b/sig/zammad_api/collection.rbs new file mode 100644 index 0000000..4582cda --- /dev/null +++ b/sig/zammad_api/collection.rbs @@ -0,0 +1,54 @@ +module ZammadAPI + class Collection + include Enumerable[untyped] + + DEFAULT_PER_PAGE: Integer + RESERVED_QUERY_KEYS: Array[Symbol] + + @transport: Transport + @resource_class: untyped + @path: String + @operation: String + @query: Hash[Symbol, untyped] + @max_per_page: Integer + @per_page: Integer + @page: Integer? + @countable: bool + + def initialize: ( + transport: Transport, + resource_class: untyped, + path: String, + operation: String, + max_per_page: Integer, + ?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 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 positive_integer!: (untyped, String) -> Integer + def walk: () { (Array[untyped]) -> void } -> void + def with: (?page: Integer?, ?per_page: Integer, ?query: Hash[Symbol, untyped]) -> Collection + def fetch: (Integer, Integer) -> Array[untyped] + 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..e433c34 --- /dev/null +++ b/sig/zammad_api/config.rbs @@ -0,0 +1,66 @@ +module ZammadAPI + # Built with Data.define; the generated readers are declared explicitly. + class Config < ::Data + DEFAULT_TIMEOUT: Integer + DEFAULT_OPEN_TIMEOUT: Integer + DEFAULT_RETRIES: Integer + DEFAULT_RETRY_INTERVAL: Float + REDACTED_ATTRIBUTES: Array[Symbol] + REDACTION: String + URL_PATTERN: Regexp + USERINFO_PATTERN: Regexp + + 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: ^(Faraday::Connection) -> void | nil + 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: (^(Faraday::Connection) -> void)?, + ?logger: Logger? + ) -> void + + def authentication_scheme: () -> Symbol + def with: (**untyped) -> Config + def inspect: () -> String + def to_s: () -> String + def to_h: () -> Hash[Symbol, untyped] + + private + + def render: (Symbol, untyped) -> String + def normalize_url: (untyped) -> String + def validate_credentials!: () -> void + def validate_numbers!: () -> 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/errors.rbs b/sig/zammad_api/errors.rbs new file mode 100644 index 0000000..81b631b --- /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, actual: Class, ?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..f4a112b --- /dev/null +++ b/sig/zammad_api/resource_proxy.rbs @@ -0,0 +1,42 @@ +module ZammadAPI + class ResourceProxy + include Enumerable[untyped] + + SEARCH_MAX_PER_PAGE: Integer + + 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 search: (String term) -> Collection + def inspect: () -> String + + private + + def collection: (String, String, ?query: Hash[Symbol, untyped], ?max_per_page: Integer, ?countable: bool) -> Collection + def path: () -> 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..1af1eef --- /dev/null +++ b/sig/zammad_api/resources/base.rbs @@ -0,0 +1,55 @@ +module ZammadAPI + module Resources + class Base + include AttributeAccess + + MAX_PER_PAGE: Integer + + attr_reader error: ValidationError? + attr_reader transport: Transport + + @new_record: bool + @destroyed: bool + @error: ValidationError? + @changes: Hash[Symbol, Array[untyped]] + @related: Associations::Proxy? + self.@related_class: untyped + self.@declared_associations: Hash[Symbol, Hash[Symbol, untyped]]? + + def self.path: (String) -> void + def self.resource_path: () -> String + def self.from_response: (Transport, Hash[untyped, untyped] attributes) -> Base + def self.associations: () -> Hash[Symbol, Hash[Symbol, untyped]] + def self.related_class: () -> 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: ^(Base) -> 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 mark_persisted!: () -> void + def writable_attributes?: () -> bool + def replace_attributes!: (Response, operation: String) -> void + def write_attribute: (Symbol, untyped) -> untyped + def create_record: () -> Response + def update_record: () -> Response + def member_path: () -> 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..957c0ad --- /dev/null +++ b/sig/zammad_api/resources/resources.rbs @@ -0,0 +1,44 @@ +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 + MAX_PER_PAGE: Integer + + 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 + + def attachments: () -> Array[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..199c9be --- /dev/null +++ b/sig/zammad_api/response.rbs @@ -0,0 +1,17 @@ +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] + + def success?: () -> bool + def json?: () -> bool + def decoded: (Symbol shape, operation: String, ?resource_class: Class?) -> untyped + end +end diff --git a/sig/zammad_api/test.rbs b/sig/zammad_api/test.rbs new file mode 100644 index 0000000..951daca --- /dev/null +++ b/sig/zammad_api/test.rbs @@ -0,0 +1,67 @@ +module ZammadAPI + class Test + class UnstubbedRequestError < Error + end + + class Request < ::Data + attr_reader verb: Symbol + attr_reader path: String + attr_reader query: Hash[String, untyped] + attr_reader body: untyped + attr_reader on_behalf_of: (String | Integer)? + + 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, + ?resource_class: Class?, + ?on_behalf_of: (String | Integer)? + ) -> Response + + private + + def key: (Symbol method, String path) -> [Symbol, String] + def take: (Symbol method, String path, Hash[String, untyped] params) -> Hash[Symbol, untyped]? + def matches?: (Hash[untyped, untyped]? expected, Hash[String, untyped] params) -> bool + def response_for: (Hash[Symbol, untyped] stub) -> 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 + attr_reader test: Test + attr_reader on_behalf_of: (String | Integer)? + + def initialize: (Test test, ?on_behalf_of: (String | Integer)?) -> void + def config: () -> Config + def with_on_behalf_of: ((String | Integer)? identifier) -> Test::Transport + + def get: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + def post: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + def put: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + def delete: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + + def request: (Symbol method, String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: 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..d6f8eff --- /dev/null +++ b/sig/zammad_api/transport.rbs @@ -0,0 +1,39 @@ +module ZammadAPI + class Transport + RETRIABLE_METHODS: Array[Symbol] + RETRIABLE_STATUSES: Array[Integer] + RETRIABLE_EXCEPTIONS: Array[untyped] + SENSITIVE_KEY_PATTERN: Regexp + REDACTED: String + + attr_reader config: Config + attr_reader on_behalf_of: (String | Integer)? + + @connection: Faraday::Connection + + def self.stringify_query: (Hash[untyped, untyped]) -> Hash[String, untyped] + + def initialize: (Config) -> void + def with_on_behalf_of: ((String | Integer)? identifier) -> Transport + + def get: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + def post: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + def put: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + def delete: (String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + + def request: (Symbol method, String path, operation: String, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?resource_class: Class?) -> Response + + private + + def perform: (Symbol, String, Hash[Symbol, untyped]?, untyped) -> Faraday::Response + def build_connection: () -> Faraday::Connection + def apply_authentication: (Faraday::Connection) -> void + def retry_options: () -> Hash[Symbol, untyped] + def decode: (Faraday::Response) -> Response + def decode_body: (String?, String) -> [untyped, bool] + def log_request: (Symbol, String, Hash[Symbol, untyped]?, untyped) -> void + def log_response: (Symbol, String, Response, Float) -> void + def redact: (untyped) -> untyped + 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 93% rename from spec/zammad_api/resources/group_spec.rb rename to spec/integration/group_spec.rb index 6caf0d3..4b0da14 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 @@ -123,7 +123,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 +134,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 diff --git a/spec/zammad_api/resources/organization_spec.rb b/spec/integration/organization_spec.rb similarity index 90% rename from spec/zammad_api/resources/organization_spec.rb rename to spec/integration/organization_spec.rb index 7851181..61fdab6 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 @@ -117,7 +117,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 +128,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 +141,7 @@ end it 'search' do - organizations = client.organization.search(query: name) + organizations = client.organization.search(name) organization_exists = nil organizations.each do |local_organization| @@ -171,9 +171,9 @@ end it 'pagination with search' do - organizations = client.organization.search(query: "#{name}-2") + 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 +194,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 diff --git a/spec/zammad_api/resources/ticket_priority_spec.rb b/spec/integration/ticket_priority_spec.rb similarity index 89% rename from spec/zammad_api/resources/ticket_priority_spec.rb rename to spec/integration/ticket_priority_spec.rb index 45f41a8..466e4e9 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 @@ -99,7 +99,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 +110,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 diff --git a/spec/zammad_api/resources/ticket_spec.rb b/spec/integration/ticket_spec.rb similarity index 93% rename from spec/zammad_api/resources/ticket_spec.rb rename to spec/integration/ticket_spec.rb index 1c95705..2bb0810 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 @@ -80,7 +80,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') @@ -188,7 +188,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 +198,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 diff --git a/spec/zammad_api/resources/ticket_state_spec.rb b/spec/integration/ticket_state_spec.rb similarity index 93% rename from spec/zammad_api/resources/ticket_state_spec.rb rename to spec/integration/ticket_state_spec.rb index d672398..a22e391 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 @@ -149,7 +149,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 +160,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 diff --git a/spec/zammad_api/resources/user_spec.rb b/spec/integration/user_spec.rb similarity index 93% rename from spec/zammad_api/resources/user_spec.rb rename to spec/integration/user_spec.rb index a82b94b..2c231c3 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 @@ -159,7 +159,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 +169,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 +188,7 @@ end it 'search' do - users = client.user.search(query: firstname) + users = client.user.search(firstname) user_exists = nil users.each do |local_user| @@ -224,9 +224,9 @@ end it 'pagination with search' do - users = client.user.search(query: firstname) + 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 +250,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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6c318c4..42f4281 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,50 +1,53 @@ -$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 -end - -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 - - def self.client(params = {}) - ZammadAPI::Client.new( - url: params[:url] || config[:url], - user: params[:user] || config[:user], - password: params[:password] || config[:password], - ) +# 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 - # 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 +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) - url_auto_wizard = '/api/v1/getting_started/auto_wizard' - response = conn.get url_auto_wizard - data = JSON.parse(response.body) +# Unit specs run entirely against stubs; integration specs opt back out below. +require 'webmock/rspec' +require 'zammad_api' - return true if data['auto_wizard_success'] +Dir[File.expand_path('support/**/*.rb', __dir__)].each { require it } - raise "Unable to start auto wizard: #{response.body}" +RSpec.configure do |config| + config.include ClientHelper + + 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. + config.define_derived_metadata(file_path: %r{/spec/unit/}) { it[:unit] = true } + config.define_derived_metadata(file_path: %r{/spec/integration/}) { it[:integration] = true } + + # 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/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..9968957 --- /dev/null +++ b/spec/support/integration_helper.rb @@ -0,0 +1,77 @@ +# 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. + def self.ensure_configured! + @ensure_configured ||= begin + auto_wizard? || verify_setup_done! + true + end + end + + # @return [Boolean] whether the auto wizard ran now + def self.auto_wizard? + response = connection.get('api/v1/getting_started/auto_wizard') + parse(response.body)['auto_wizard_success'] == true + 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 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..ae9c4de --- /dev/null +++ b/spec/unit/zammad_api/associations/proxy_spec.rb @@ -0,0 +1,195 @@ +# 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 + 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 + 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..a37ce7a --- /dev/null +++ b/spec/unit/zammad_api/attribute_access_spec.rb @@ -0,0 +1,271 @@ +# 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 + + it 'returns nil for an unknown attribute, since Zammad allows custom fields' do + expect(record.custom_field).to be_nil + 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 + 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..648e7ef --- /dev/null +++ b/spec/unit/zammad_api/client_spec.rb @@ -0,0 +1,503 @@ +# 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 + + 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 + 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 + 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 + 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 + 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..edbbac6 --- /dev/null +++ b/spec/unit/zammad_api/collection_spec.rb @@ -0,0 +1,423 @@ +# 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" } + + def stub_page(page, records, per_page: ZammadAPI::Collection::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(ZammadAPI::Collection::DEFAULT_PER_PAGE) { { id: first_id + it } } + 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: 101 }]) + + expect(collection.map(&:id)).to eq((1..101).to_a) + end + + it 'stops on a page that is shorter than the page size' do + stub_page(1, [{ id: 1 }]) + + expect(collection.map(&:id)).to eq([1]) + expect(a_request(:get, url).with(query: hash_including('page' => '2'))).not_to have_been_made + end + + it 'stops on an empty page' do + stub_page(1, full_page) + stub_page(2, []) + + expect(collection.map(&:id)).to eq((1..100).to_a) + end + + it 'yields persisted records' do + stub_page(1, [{ id: 1 }]) + + expect(collection.first).to be_persisted + end + + it 'yields records of the right class' do + stub_page(1, [{ id: 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, full_page) + + 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(ZammadAPI::Collection::DEFAULT_PER_PAGE) { { name: "a#{it}" } }) + stub_page(2, [{ name: 'b0' }]) + + expect(collection.map(&:name)).to eq(Array.new(100) { "a#{it}" } + ['b0']) + end + + it 'raises PaginationError when records without an id repeat' do + page = Array.new(ZammadAPI::Collection::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_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_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 + 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_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 '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_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 to what a generic index endpoint serves' do + expect(client.group.all.page(1, of: 5000).inspect).to include('per_page=1000') + end + + it 'clamps to what the ticket index endpoint serves' do + expect(client.ticket.all.page(1, of: 5000).inspect).to include('per_page=100') + end + + it 'clamps to what a search endpoint serves' do + expect(client.user.search('smith').page(1, of: 5000).inspect).to include('per_page=200') + 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 + + describe '#where' do + it 'adds query parameters' do + stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => '100', 'active' => 'true' }) + .to_return(json_response([{ id: 1 }])) + + expect(collection.where(active: true).map(&:id)).to eq([1]) + end + + it 'returns a new collection' do + expect(collection.where(active: true)).not_to be(collection) + end + + it 'rejects parameters the collection controls itself' do + expect { collection.where(page: 2) }.to raise_error(ArgumentError, /cannot be passed to where/) + expect { collection.where(per_page: 2) }.to raise_error(ArgumentError, /cannot be passed to where/) + expect { collection.where(expand: false) }.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_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_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_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_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: 101 }]) + + expect(collection.pluck(:id)).to eq((1..101).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 + + it 'walks the pages when the endpoint cannot count' do + stub_page(1, full_page) + stub_page(2, [{ id: 101 }]) + + expect(collection.count).to eq(101) + end + + it 'walks the pages when a search endpoint reports no total' do + stub_request(:get, search_url).with(query: hash_including('only_total_count' => 'true')) + .to_return(json_response({})) + stub_request(:get, search_url).with(query: hash_including('page' => '1')) + .to_return(json_response([{ id: 1 }])) + + expect(client.user.search('smith').count).to eq(1) + end + + it 'walks the pages when a search endpoint ignores only_total_count' do + stub_request(:get, search_url).with(query: hash_including('only_total_count' => 'true')) + .to_return(json_response([{ id: 1 }, { id: 2 }])) + stub_request(:get, search_url).with(query: hash_including('page' => '1')) + .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_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_page(1, full_page) + stub_page(2, [{ id: 101 }]) + + expect(collection.size).to eq(101) + 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_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 + + 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 +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..2ace579 --- /dev/null +++ b/spec/unit/zammad_api/config_spec.rb @@ -0,0 +1,262 @@ +# 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 + 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 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 + 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 'leaves a proxy without credentials alone' do + expect(build(proxy: 'http://proxy.test:8080').inspect).to include('proxy="http://proxy.test:8080"') + 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 +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..47225d5 --- /dev/null +++ b/spec/unit/zammad_api/resource_proxy_spec.rb @@ -0,0 +1,367 @@ +# 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 '#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 + it 'asks for a single record rather than a whole page' do + stub = stub_request(:get, url) + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => '1', 'name' => 'Users' }) + .to_return(json_response([{ id: 1, name: 'Users' }])) + + proxy.find_by(name: 'Users') + expect(stub).to have_been_requested + end + + it 'returns the matching record' do + stub_request(:get, 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 'returns a persisted record' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + expect(proxy.find_by(name: 'Users')).to be_persisted + end + + it 'returns nil when nothing matched' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([])) + + expect(proxy.find_by(name: 'Nope')).to be_nil + end + + it 'costs a single request' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + proxy.find_by(name: 'Users') + expect(a_request(:get, url).with(query: hash_including({}))).to have_been_made.once + end + end + + describe '#find_by!' do + it 'returns the matching record' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([{ id: 1 }])) + + expect(proxy.find_by!(name: 'Users').id).to eq(1) + end + + it 'raises NotFoundError when nothing matched' do + stub_request(:get, url).with(query: hash_including({})).to_return(json_response([])) + + expect { proxy.find_by!(name: 'Nope') }.to raise_error(ZammadAPI::NotFoundError) + end + + it 'names the query and the resource in the message' do + stub_request(:get, 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, url).with(query: hash_including({})).to_return(json_response([])) + + expect { proxy.find_by!(name: 'secret-ish') }.to raise_error(/^(?!.*secret-ish)/) + 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 + + 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' => ZammadAPI::Collection::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' => '100', 'active' => 'true' }) + .to_return(json_response([])) + + proxy.where(active: true).to_a + expect(stub).to have_been_requested + end + end + + describe 'enumerating a proxy directly' do + def stub_page(page, records, per_page: ZammadAPI::Collection::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 + + it 'is Enumerable' do + expect(proxy).to be_a(Enumerable) + end + + it 'yields every record from #each' do + stub_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(ZammadAPI::Collection::DEFAULT_PER_PAGE) { { id: it + 1 } }) + stub_page(2, [{ id: 101 }]) + + expect(proxy.count).to eq(101) + end + + it 'stops early for #first, without walking everything' do + stub_page(1, [{ id: 1 }, { id: 2 }]) + + 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_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_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_page(1, [{ id: 1, name: 'Users' }]) + + expect(proxy.pluck(:name)).to eq(['Users']) + end + + it 'forwards #size' do + stub_page(1, [{ id: 1 }]) + + expect(proxy.size).to eq(1) + end + + it 'forwards #length' do + stub_page(1, [{ id: 1 }]) + + expect(proxy.length).to eq(1) + 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 '#search' do + it 'requests the search endpoint' do + stub = stub_request(:get, "#{url}/search") + .with(query: { 'expand' => 'true', 'page' => '1', 'per_page' => '100', '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', 'limit' => '5')) + .to_return(json_response([])) + + proxy.search('support').where(limit: 5).to_a + expect(stub).to have_been_requested + 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..4f362c4 --- /dev/null +++ b/spec/unit/zammad_api/resources/base_spec.rb @@ -0,0 +1,563 @@ +# 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 a previously unset attribute is set back to nil' do + group.active = true + group.active = nil + expect(group.changes).to be_empty + 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 an empty payload when nothing changed' do + stub = stub_request(:put, "#{url}/1").with(query: hash_including({}), body: '{}') + .to_return(json_response({ id: 1 })) + + group.save + expect(stub).to have_been_requested + 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 without an id' do + expect { ZammadAPI::Resources::Group.new(transport).reload } + .to raise_error(ZammadAPI::Error, /has no id, save it first/) + 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 '#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 without an id' do + expect { client.group.new.destroy }.to raise_error(ZammadAPI::Error, /has no id, save it first/) + 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 + 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 +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..a5f2892 --- /dev/null +++ b/spec/unit/zammad_api/resources/ticket_article_attachment_spec.rb @@ -0,0 +1,94 @@ +# 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 + 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 + 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..2fbf058 --- /dev/null +++ b/spec/unit/zammad_api/resources/ticket_spec.rb @@ -0,0 +1,60 @@ +# 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 '#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 + 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..26c4061 --- /dev/null +++ b/spec/unit/zammad_api/response_error_spec.rb @@ -0,0 +1,180 @@ +# 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 + 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..cbbb6a2 --- /dev/null +++ b/spec/unit/zammad_api/response_spec.rb @@ -0,0 +1,70 @@ +# 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 + 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..600f9df --- /dev/null +++ b/spec/unit/zammad_api/test_spec.rb @@ -0,0 +1,268 @@ +# 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 '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 + 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' }]) + + 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 + + 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: { active: true }) + + expect(client.group.where(active: true).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: { active: true }) + + 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: { active: true }) + + expect { client.group.where(active: true).to_a }.not_to raise_error + 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 a ZammadAPI::Error, so a suite can rescue it with the rest' do + expect(described_class::UnstubbedRequestError.ancestors).to include(ZammadAPI::Error) + 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 + + 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(note: nil).to_a } + .to raise_error(ArgumentError, /query parameter note 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 '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 + + 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 + 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..d0b61f8 --- /dev/null +++ b/spec/unit/zammad_api/transport_spec.rb @@ -0,0 +1,390 @@ +# 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 + + 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 '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 '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 '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 + + 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 + + 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 + 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 '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 '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', + password_confirm: 'confirm-s3cret', + access_token: 'access-s3cret', + refresh_token: 'refresh-s3cret', + client_secret: 'client-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 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 +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..e3c11b0 100644 --- a/zammad_api.gemspec +++ b/zammad_api.gemspec @@ -1,28 +1,41 @@ -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 stand-in signatures for dependencies that ship none; they + # are for this repository's own type checking 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