diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6f0a5bc9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + cooldown: + default-days: 7 + # The root, x-core, x-uploader, x-streaming, and x-objects each have a Gemfile and a bundle of their own + - package-ecosystem: bundler + directories: + - / + - /x-core + - /x-uploader + - /x-streaming + - /x-objects + schedule: + interval: daily + cooldown: + default-days: 7 + groups: + minor-and-patch: + update-types: + - minor + - patch diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml index 27a289f6..8f086524 100644 --- a/.github/workflows/jekyll-gh-pages.yml +++ b/.github/workflows/jekyll-gh-pages.yml @@ -26,16 +26,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Build with Jekyll uses: actions/jekyll-build-pages@v1 with: source: ./ destination: ./_site + # The API documentation of every gem, built after the site, so that Jekyll does not read the bundle the gems are + # installed into, and outside it, as the Jekyll action writes _site as root + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - name: Build the API documentation with YARD + run: bundle exec rake yard + env: + YARD_OUTPUT_DIR: ${{ runner.temp }}/api + - name: Add the API documentation to the site under api/ + run: sudo cp -R "$RUNNER_TEMP/api" _site/api - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 # Deployment job deploy: @@ -47,4 +60,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a9ba22f5..59e52dd1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,12 +1,55 @@ name: linter -on: [push, pull_request] +on: + push: + # A tag names a commit whose push ran this workflow already, and a push of a tag skips the paths filter + branches: + - "**" + paths: + - "**.rb" + - "**.rbs" + - "**/Gemfile" + - "**/Rakefile" + - "**/Steepfile" + - "**.gemspec" + - "**/sig/manifest.yaml" + - "bin/**" + - ".rubocop.yml" + - ".standard.yml" + - "VERSION" + - ".github/workflows/lint.yml" + # Run on a commit that changes none of the paths above, such as documentation alone, before it is tagged, since the + # workflow that pushes the gems waits for this one to pass on the tagged commit: gh workflow run --ref main + workflow_dispatch: + pull_request: + paths: + - "**.rb" + - "**.rbs" + - "**/Gemfile" + - "**/Rakefile" + - "**/Steepfile" + - "**.gemspec" + - "**/sig/manifest.yaml" + - "bin/**" + - ".rubocop.yml" + - ".standard.yml" + - "VERSION" + - ".github/workflows/lint.yml" jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: - ruby-version: "3.2" + ruby-version: "3.4" bundler-cache: true - run: bundle exec rake lint + signatures: + 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 rake signatures diff --git a/.github/workflows/mutant.yml b/.github/workflows/mutant.yml deleted file mode 100644 index 1caa6d7e..00000000 --- a/.github/workflows/mutant.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: mutation tests -on: [push, pull_request] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - bundler-cache: true - - run: bundle exec rake mutant diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 00000000..16345c04 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,106 @@ +name: Push gem to RubyGems + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + push: + if: github.repository == 'sferik/x-ruby' + runs-on: ubuntu-latest + environment: + name: rubygems.org + url: https://rubygems.org/gems/x + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0 + with: + ruby-version: ruby + bundler-cache: true + - name: Update RubyGems + run: gem update --system 4.0.21 + - name: Check that the tag and each gem version.rb hold the version in VERSION + run: | + version=$(cat VERSION) + if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then + echo "Tag $GITHUB_REF_NAME does not name the version in VERSION, $version" + exit 1 + fi + bundle exec rake check_versions + - name: Check that the tagged commit is on main + env: + GH_TOKEN: ${{ github.token }} + run: | + # main is identical to the commit, or ahead of it, once the commit is on main + status=$(gh api "repos/$GITHUB_REPOSITORY/compare/$GITHUB_SHA...main" --jq .status) + case "$status" in + identical | ahead) echo "$GITHUB_SHA is on main" ;; + *) + echo "::error::$GITHUB_REF_NAME names $GITHUB_SHA, which is not on main" + exit 1 + ;; + esac + - name: Check that CI passed on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + # Every workflow that must have passed before the gems are pushed. A release commit triggers each of them, + # since `rake update_versions` writes the version into a version.rb of every gem, and the linter runs on any + # Ruby file; a commit after it that changes documentation alone triggers none, and each is run on it by hand, + # as CONTRIBUTING.md says. This workflow is left out, since it is the one running. + REQUIRED_WORKFLOWS: x-core x-uploader x-streaming x-objects x linter + run: | + deadline=$((SECONDS + 1800)) + while :; do + runs=$(gh run list --commit "$GITHUB_SHA" --limit 100 --json name,conclusion,createdAt) + failed="" + waiting="" + for workflow in $REQUIRED_WORKFLOWS; do + # The latest run of the workflow decides, since a run that failed or was cancelled may have been run + # again; a run of any other workflow on the commit, such as the deploy of the Pages, decides nothing + conclusion=$(jq -r --arg workflow "$workflow" \ + '[.[] | select(.name == $workflow)] | sort_by(.createdAt) | last | if . == null then "missing" else .conclusion // "" end' <<<"$runs") + case "$conclusion" in + success) ;; + missing | "") waiting="$waiting $workflow" ;; + *) failed="$failed $workflow ($conclusion)" ;; + esac + done + if [ -n "$failed" ]; then + echo "::error::CI did not pass on $GITHUB_SHA:$failed" + exit 1 + fi + [ -n "$waiting" ] || break + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Timed out waiting for CI on $GITHUB_SHA:$waiting" + exit 1 + fi + echo "Waiting for CI on $GITHUB_SHA:$waiting" + sleep 30 + done + echo "CI passed on $GITHUB_SHA" + - name: Build gems + run: bundle exec rake build + # The credentials are short-lived, so they are fetched once CI has passed and the gems are built + - uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 + - name: Sign, push, and await each gem in dependency order + run: | + version=$(cat VERSION) + for name in x-core x-uploader x-streaming x-objects x; do + file=pkg/$name-$version.gem + # A rerun after a failed push skips the gems the failed run already published + if curl --silent --fail --output /dev/null "https://rubygems.org/api/v2/rubygems/$name/versions/$version.json"; then + echo "$name $version is already published" + continue + fi + gem exec --version 0.2.3 sigstore-cli sign "$file" --bundle "pkg/$name.gem.sigstore.json" + gem push "$file" --attestation "pkg/$name.gem.sigstore.json" + gem exec --version 0.5.4 rubygems-await "$file" + done diff --git a/.github/workflows/steep.yml b/.github/workflows/steep.yml deleted file mode 100644 index 1df3c5dd..00000000 --- a/.github/workflows/steep.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: type checker -on: [push, pull_request] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - bundler-cache: true - - run: bundle exec rake steep diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index bcdabbe1..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: tests -on: [push, pull_request] -jobs: - build: - strategy: - matrix: - ruby: ["3.2", "3.3", "3.4"] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - run: bundle exec rake test diff --git a/.github/workflows/x-core.yml b/.github/workflows/x-core.yml new file mode 100644 index 00000000..6ff32239 --- /dev/null +++ b/.github/workflows/x-core.yml @@ -0,0 +1,78 @@ +name: x-core +on: + push: + # A tag names a commit whose push ran this workflow already, and a push of a tag skips the paths filter + branches: + - "**" + paths: + - "x-core/**" + - "!x-core/**.md" + - "VERSION" + - ".github/workflows/x-core.yml" + # Run on a commit that changes none of the paths above, such as documentation alone, before it is tagged, since the + # workflow that pushes the gems waits for this one to pass on the tagged commit: gh workflow run --ref main + workflow_dispatch: + pull_request: + paths: + - "x-core/**" + - "!x-core/**.md" + - "VERSION" + - ".github/workflows/x-core.yml" +defaults: + run: + working-directory: x-core +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # JRuby is left out: its line coverage misses lines it runs, such as an empty array literal, so the + # suite cannot hold to the 100% floor there + ruby: ["3.4", "4.0", "ruby-head"] + include: + - os: macos-latest + ruby: "3.4" + - os: windows-latest + ruby: "3.4" + runs-on: ${{ matrix.os }} + # A head Ruby is informational: it reports a break before the release that carries it, without blocking a merge + continue-on-error: ${{ endsWith(matrix.ruby, '-head') }} + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + working-directory: x-core + - run: bundle exec rake test + mutant: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-core + - run: bundle exec rake mutant + steep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-core + - run: bundle exec rake steep + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-core + - run: bundle exec rake yardstick diff --git a/.github/workflows/x-objects.yml b/.github/workflows/x-objects.yml new file mode 100644 index 00000000..525ef54d --- /dev/null +++ b/.github/workflows/x-objects.yml @@ -0,0 +1,84 @@ +name: x-objects +on: + push: + # A tag names a commit whose push ran this workflow already, and a push of a tag skips the paths filter + branches: + - "**" + paths: + - "x-objects/**" + - "!x-objects/**.md" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "VERSION" + - ".github/workflows/x-objects.yml" + # Run on a commit that changes none of the paths above, such as documentation alone, before it is tagged, since the + # workflow that pushes the gems waits for this one to pass on the tagged commit: gh workflow run --ref main + workflow_dispatch: + pull_request: + paths: + - "x-objects/**" + - "!x-objects/**.md" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "VERSION" + - ".github/workflows/x-objects.yml" +defaults: + run: + working-directory: x-objects +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # JRuby is left out: its line coverage misses lines it runs, such as an empty array literal, so the + # suite cannot hold to the 100% floor there + ruby: ["3.4", "4.0", "ruby-head"] + include: + - os: macos-latest + ruby: "3.4" + - os: windows-latest + ruby: "3.4" + runs-on: ${{ matrix.os }} + # A head Ruby is informational: it reports a break before the release that carries it, without blocking a merge + continue-on-error: ${{ endsWith(matrix.ruby, '-head') }} + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + working-directory: x-objects + - run: bundle exec rake test + mutant: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-objects + - run: bundle exec rake mutant + steep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-objects + - run: bundle exec rake steep + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-objects + - run: bundle exec rake yardstick diff --git a/.github/workflows/x-streaming.yml b/.github/workflows/x-streaming.yml new file mode 100644 index 00000000..44252125 --- /dev/null +++ b/.github/workflows/x-streaming.yml @@ -0,0 +1,84 @@ +name: x-streaming +on: + push: + # A tag names a commit whose push ran this workflow already, and a push of a tag skips the paths filter + branches: + - "**" + paths: + - "x-streaming/**" + - "!x-streaming/**.md" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "VERSION" + - ".github/workflows/x-streaming.yml" + # Run on a commit that changes none of the paths above, such as documentation alone, before it is tagged, since the + # workflow that pushes the gems waits for this one to pass on the tagged commit: gh workflow run --ref main + workflow_dispatch: + pull_request: + paths: + - "x-streaming/**" + - "!x-streaming/**.md" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "VERSION" + - ".github/workflows/x-streaming.yml" +defaults: + run: + working-directory: x-streaming +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # JRuby is left out: its line coverage misses lines it runs, such as an empty array literal, so the + # suite cannot hold to the 100% floor there + ruby: ["3.4", "4.0", "ruby-head"] + include: + - os: macos-latest + ruby: "3.4" + - os: windows-latest + ruby: "3.4" + runs-on: ${{ matrix.os }} + # A head Ruby is informational: it reports a break before the release that carries it, without blocking a merge + continue-on-error: ${{ endsWith(matrix.ruby, '-head') }} + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + working-directory: x-streaming + - run: bundle exec rake test + mutant: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-streaming + - run: bundle exec rake mutant + steep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-streaming + - run: bundle exec rake steep + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-streaming + - run: bundle exec rake yardstick diff --git a/.github/workflows/x-uploader.yml b/.github/workflows/x-uploader.yml new file mode 100644 index 00000000..1edcaa26 --- /dev/null +++ b/.github/workflows/x-uploader.yml @@ -0,0 +1,84 @@ +name: x-uploader +on: + push: + # A tag names a commit whose push ran this workflow already, and a push of a tag skips the paths filter + branches: + - "**" + paths: + - "x-uploader/**" + - "!x-uploader/**.md" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "VERSION" + - ".github/workflows/x-uploader.yml" + # Run on a commit that changes none of the paths above, such as documentation alone, before it is tagged, since the + # workflow that pushes the gems waits for this one to pass on the tagged commit: gh workflow run --ref main + workflow_dispatch: + pull_request: + paths: + - "x-uploader/**" + - "!x-uploader/**.md" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "VERSION" + - ".github/workflows/x-uploader.yml" +defaults: + run: + working-directory: x-uploader +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # JRuby is left out: its line coverage misses lines it runs, such as an empty array literal, so the + # suite cannot hold to the 100% floor there + ruby: ["3.4", "4.0", "ruby-head"] + include: + - os: macos-latest + ruby: "3.4" + - os: windows-latest + ruby: "3.4" + runs-on: ${{ matrix.os }} + # A head Ruby is informational: it reports a break before the release that carries it, without blocking a merge + continue-on-error: ${{ endsWith(matrix.ruby, '-head') }} + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + working-directory: x-uploader + - run: bundle exec rake test + mutant: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-uploader + - run: bundle exec rake mutant + steep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-uploader + - run: bundle exec rake steep + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: x-uploader + - run: bundle exec rake yardstick diff --git a/.github/workflows/x.yml b/.github/workflows/x.yml new file mode 100644 index 00000000..337e137c --- /dev/null +++ b/.github/workflows/x.yml @@ -0,0 +1,98 @@ +name: x +on: + push: + # A tag names a commit whose push ran this workflow already, and a push of a tag skips the paths filter + branches: + - "**" + paths: + - "lib/**" + - "sig/**" + - "test/**" + - "examples/**" + - "Gemfile" + - "Rakefile" + - "Steepfile" + - "x.gemspec" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "x-uploader/lib/**" + - "x-uploader/sig/*.rbs" + - "x-uploader/x-uploader.gemspec" + - "x-streaming/lib/**" + - "x-streaming/sig/*.rbs" + - "x-streaming/x-streaming.gemspec" + - "x-objects/lib/**" + - "x-objects/sig/*.rbs" + - "x-objects/x-objects.gemspec" + - "VERSION" + - ".github/workflows/x.yml" + # Run on a commit that changes none of the paths above, such as documentation alone, before it is tagged, since the + # workflow that pushes the gems waits for this one to pass on the tagged commit: gh workflow run --ref main + workflow_dispatch: + pull_request: + paths: + - "lib/**" + - "sig/**" + - "test/**" + - "examples/**" + - "Gemfile" + - "Rakefile" + - "Steepfile" + - "x.gemspec" + - "x-core/lib/**" + - "x-core/sig/*.rbs" + - "x-core/x-core.gemspec" + - "x-uploader/lib/**" + - "x-uploader/sig/*.rbs" + - "x-uploader/x-uploader.gemspec" + - "x-streaming/lib/**" + - "x-streaming/sig/*.rbs" + - "x-streaming/x-streaming.gemspec" + - "x-objects/lib/**" + - "x-objects/sig/*.rbs" + - "x-objects/x-objects.gemspec" + - "VERSION" + - ".github/workflows/x.yml" +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # JRuby is left out: its line coverage misses lines it runs, such as an empty array literal, so the + # suite cannot hold to the 100% floor there + ruby: ["3.4", "4.0", "ruby-head"] + include: + - os: macos-latest + ruby: "3.4" + - os: windows-latest + ruby: "3.4" + runs-on: ${{ matrix.os }} + # A head Ruby is informational: it reports a break before the release that carries it, without blocking a merge + continue-on-error: ${{ endsWith(matrix.ruby, '-head') }} + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake test:x + 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 rake steep:x + docs: + 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 rake yardstick:x diff --git a/.gitignore b/.gitignore index 4645aeb8..6652cc69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ -/.bundle/ -/.yardoc +.bundle/ +.mutant/ +.yardoc /_yardoc/ -/coverage/ -/doc/ -/pkg/ +coverage/ +doc/ +pkg/ /spec/reports/ /tmp/ Gemfile.lock diff --git a/.rubocop.yml b/.rubocop.yml index b6e412d7..df0978a1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,59 +1,53 @@ -require: - - standard - plugins: - - rubocop-minitest - - rubocop-performance - - rubocop-rake - - standard-performance + - rubocop-minitest + - rubocop-performance + - rubocop-rake + - standard-custom + - standard-performance + +inherit_gem: + standard: config/base.yml + standard-custom: config/base.yml + standard-performance: config/base.yml AllCops: NewCops: enable - TargetRubyVersion: 3.2 -Layout/ArgumentAlignment: - EnforcedStyle: with_fixed_indentation - IndentationWidth: 2 +# Standard disables the Metrics cops; enforce them at RuboCop's defaults +Metrics/AbcSize: + Enabled: true + +Metrics/BlockLength: + Enabled: true -Layout/CaseIndentation: - EnforcedStyle: end +Metrics/BlockNesting: + Enabled: true -Layout/EndAlignment: - EnforcedStyleAlignWith: start_of_line +Metrics/ClassLength: + Enabled: true -Layout/LineLength: - Max: 140 +Metrics/CollectionLiteralLength: + Enabled: true -Layout/ParameterAlignment: - EnforcedStyle: with_fixed_indentation - IndentationWidth: 2 +Metrics/CyclomaticComplexity: + Enabled: true -Layout/SpaceInsideHashLiteralBraces: - EnforcedStyle: no_space +Metrics/MethodLength: + Enabled: true + +Metrics/ModuleLength: + Enabled: true Metrics/ParameterLists: + Enabled: true CountKeywordArgs: false +Metrics/PerceivedComplexity: + Enabled: true + Minitest/MultipleAssertions: Max: 5 -Style/Alias: - EnforcedStyle: prefer_alias_method - -Style/Documentation: - Enabled: false - +# Standard disables this cop; require the magic comment so that every string literal is frozen Style/FrozenStringLiteralComment: - EnforcedStyle: never - -Style/OpenStructUse: - Enabled: false - -Style/StringLiterals: - EnforcedStyle: double_quotes - -Style/StringLiteralsInInterpolation: - EnforcedStyle: double_quotes - -Style/TernaryParentheses: - EnforcedStyle: require_parentheses + Enabled: true diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 00000000..65c3e05f --- /dev/null +++ b/.standard.yml @@ -0,0 +1 @@ +ruby_version: 3.4 diff --git a/.yardopts b/.yardopts new file mode 100644 index 00000000..fc33d67e --- /dev/null +++ b/.yardopts @@ -0,0 +1,9 @@ +--markup markdown +--readme README.md +--hide-api private +--embed-mixins +lib/**/*.rb +- +UPGRADING.md +CHANGELOG.md +LICENSE.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b32b597..28708ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,647 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-10-06 + +See [UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) for the changes that code written for 0.19 needs. + +### Added +* Split the gem into five gems released in lockstep: `x-core`, `x-uploader`, `x-streaming`, `x-objects`, and `x` + * `x-core` is the HTTP client and declares `X::Error`, the base of every error the gems raise + * `x-uploader` uploads media, profile images, and banners; `x-streaming` reads streams and filtered-stream rules + * `x-objects` holds the resource objects and makes its requests through the client it is given + * `x` depends on all four and mixes the object, upload, and streaming methods into `X::Client` + * Every public class is named directly under `X` + * `x` depends on exactly its own version of the other four; each of them depends on `x-core` with `>= 1.0.0, < 2` +* Add `X::Objects::Error`, `X::Uploader::Error`, and `X::Streaming::Error` to rescue the failures of one gem alone +* Ship a `CHANGELOG.md` with `x-core`, `x-uploader`, `x-streaming`, and `x-objects`, which each gemspec's `changelog_uri` names +* Publish the API documentation of every gem at https://sferik.github.io/x-ruby/api/, which the `documentation_uri` of `x` names + * Each gem ships a `.yardopts`, so its documentation on rubydoc.info leaves out the private API +* Add `X.gem_version` and the `gem_version` of `X::Core`, `X::Uploader`, `X::Streaming`, and `X::Objects`, each a `Gem::Version` +* Load `x-uploader` from `require "x"`, so `X::Uploader::MediaUpload` and `X::Uploader::Account` need no further require +* Add `X::Client#get_stream`, which sends a GET and yields an `X::StreamResponse` before its body is read + * It reads the `status`, `headers`, and `rate_limit` of the response, and its body with `read_body` + * Sends the client's credentials, headers, timeouts, and proxy, and refreshes a rejected token as any request does + * Asks for a body that is not compressed, which Net::HTTP would hold back in blocks, unless a header names another encoding + * Raises the `X::HTTPError` of a failed response; otherwise returns what the block returns + * A socket error while reading the body raises `X::NetworkError`; an error of the block reaches the caller as raised + * `x-streaming` reads the streams of the API with it +* Add `X::Client#with_retries`, `memoized`, and `memoize`, kept throughout 1.x for the gems that extend a client + * `x-uploader` sends each chunk of an upload with `with_retries`; `x-objects` keeps the authenticated user with `memoize` +* Pass a block to `get`, `post`, `put`, and `delete` to receive the `X::Response` of each response the request gets + * It is passed what `on_response` is passed, at the same points, refused responses and retries included + * The client's `on_response` runs first +* Pass query parameters to `get`, `post`, `put`, `delete`, and `stream` as `params:` + * nil values are dropped, Arrays are joined with commas, and a `Time` is sent in UTC as ISO 8601 +* Encode a `post` or `put` body that is a Hash or an Array as JSON + * A body that is not a String, a Hash, or an Array, such as an IO or a Symbol, raises `ArgumentError` before any request +* Send a form body with the `form:` of `post` and `put`, whose fields are encoded as `params:` are + * Passing both a body and `form:` raises `ArgumentError` +* Add the `headers:` option of `X::Client.new`, headers sent with every request and stream of the client + * A Hash of String or Symbol names to String values; anything else raises `ArgumentError` when the client is built + * A header passed to a request or `get_stream` replaces the client's, which replaces the gem's default of that name + * Names are case-insensitive, copies made with `with` keep them, and credential headers are dropped on a cross-origin redirect + * `X::Client#headers` reads them back frozen, each named by the String it is sent with, as `{"user-agent" => ...}` + * Token requests carry them too: the app-only token fetch, OAuth 2.0 refreshes, and the code exchange of `X::OAuth2Authorization` + * The token request's own `Authorization`, `Content-Type`, and `Accept` win, and a client's `Authorization` header is never sent with one +* Pass an `X::Response` to the `on_response` of a client after every request, and for each object a stream delivers + * `X::Response#resource_counts`, `rate_limits`, and `rate_limit` read the resources the response returned and the rate limits it reported + * An `on_response` that is neither nil nor responds to `call` raises `ArgumentError` when the client is built +* Add `X::Response#headers` and `X::HTTPError#headers`, frozen Hashes with lowercase names and repeated fields comma-joined + * Read each `Set-Cookie` with `http_response.get_fields("set-cookie")` +* Retry a request refused for a rate limit up to `max_rate_limit_retries` times (default 0) + * It waits as long as the refusal asks, up to `max_rate_limit_wait` seconds (default 900), plus up to 5 seconds of jitter + * A refusal that asks no wait and names no reset waits a minute, doubling for each retry after + * A refusal for the usage cap of the project, which `X::Problem#usage_capped?` tells, raises at once +* Retry a failed request up to the `max_retries:` of `X::Client.new` (default 2; 0 raises at once) + * A `GET`, `PUT`, or `DELETE` is retried after `X::ServerError`, `X::RequestTimeout`, or a network error before it was sent + * The wait starts at up to a second and doubles, up to a minute, with jitter; a longer `Retry-After` is waited out + * A `Retry-After` of more than a minute raises at once; a `POST` and any other 4xx are not retried for these, though a 429 and a rejected OAuth 2.0 token are, as the bullets on them say + * An error raised by `on_response`, a request's block, or `from_response` is never retried +* Add the defaults of `X::Client` as constants: `DEFAULT_MAX_REDIRECTS`, `DEFAULT_MAX_RATE_LIMIT_RETRIES`, `DEFAULT_MAX_RATE_LIMIT_WAIT`, `DEFAULT_MAX_RETRIES` + * `X::Client::DEFAULT_OPEN_TIMEOUT`, `DEFAULT_READ_TIMEOUT`, `DEFAULT_WRITE_TIMEOUT`, and `DEFAULT_KEEP_ALIVE_TIMEOUT` replace those of `X::Connection` + * `X::StreamingClient::DEFAULT_MAX_RECONNECTS` sits beside its `DEFAULT_READ_TIMEOUT` +* Keep up to 16 idle connections open per host between requests, for `keep_alive_timeout` seconds (default 30) + * `keep_alive_timeout` is a setting of `X::Client` and `X::OAuth2Authorization` + * Copies made with `with` that connect the same way share the client's connections + * `X::Client#close` closes them, and those of the copies that share them; a later request opens one again +* Copy a client with some options changed with `X::Client#with`, which takes the options of `X::Client.new` + * e.g. `client.with(base_url: "https://api.x.com/1.1/")`, or `client.with(access_token: nil, access_token_secret: nil)` for app-only + * A copy given no new credentials shares the client's authenticator, and so its fetched or refreshed tokens + * A copy that shares an OAuth 2.0 authenticator raises `ArgumentError` for an `expires_at` + * A copy with other credentials holds `refresh_token`, `expires_at`, `scopes`, `save_tokens`, and `load_tokens` only when given them + * `X::Client#expires_at` reads the expiration of the last refresh +* Authenticate a client with an authenticator built elsewhere, given as the `authenticator:` of `X::Client.new` or `with` + * Raises `ArgumentError` beside credentials or an `expires_at`, or for anything that is not an `X::Authenticator` + * A custom authenticator subclasses `X::Authenticator` and overrides its public `headers` + * `headers` is passed a request answering `http_method`, `uri`, `body`, and `[]`, typed as `X::_AuthenticatorRequest` + * The refreshes of a shared `X::OAuth2Authenticator` reach the `save_tokens` of every client that uses it + * A client given an `X::OAuth2Authenticator` holds no app credentials, so its `app_only` raises `X::UnsupportedOperation` +* Add `X::Client#app_only`, a copy of a client that authenticates as the app + * It sends the client's bearer token, or one it fetches once with the API key and secret, shared with copies + * It raises `X::UnsupportedOperation` for an OAuth 2.0 user client that holds no bearer token, API key, or secret +* Authenticate as the app given an `api_key` and `api_key_secret` without access tokens, through `X::AppOnlyAuthenticator` + * It fetches a bearer token on the first request, and fetches another and resends once when the API answers 401 +* Refresh an OAuth 2.0 access token when it expires, given the `expires_at:` (a `Time`) of `X::Client`, or when the API answers 401 + * The request is sent again with the new token; a token refreshed less than a minute ago is not refreshed again + * A 401 from another origin refreshes nothing +* Store the tokens of each OAuth 2.0 refresh with the `save_tokens:` of `X::Client.new`, a callable passed an `X::OAuth2Tokens` + * It is called once the refresh releases its lock, in order, skipping a refresh another has replaced + * If a hook raises, the others still run, then `X::TokenReportFailed` is raised, holding the `tokens` and `client` + * So does a `Timeout::Error` a hook raises, even one `Timeout.timeout(5, Timeout::Error)` raises around the request + * A `save_tokens` that does not respond to `call` raises `ArgumentError` when the client is built + * An authenticator built by hand reports its refreshes to no hook of its own, only to those of the clients it is given to +* Add `X::OAuth2Tokens`, the frozen `access_token`, `refresh_token`, `expires_at`, and `scopes` of a refresh or code exchange + * `as_json`/`to_json` write it, and `X::OAuth2Tokens.from_json` reads it back, with String or Symbol keys + * It marshals, and writes YAML, in a versioned format every 1.x release reads + * `X::OAuth2Tokens.new` raises `ArgumentError` for an empty or non-String token or an `expires_at` that is not a `Time` +* Share a user's tokens among processes with `load_tokens:`, a callable that returns the stored `X::OAuth2Tokens` or nil + * Taken by `X::Client.new`, `X::Client#with`, `X::OAuth2Authorization#client`, and `X::OAuth2Authenticator.new` + * A refresh reads the store first and takes newer tokens there rather than spend its own refresh token + * A refresh refused with `invalid_request` or `invalid_grant` reads the store again before raising + * One that does not respond to `call` raises `ArgumentError`; one that returns anything else raises `TypeError` +* Read the scopes X granted an OAuth 2.0 token with `scopes` on `X::OAuth2Tokens`, `X::OAuth2Authenticator`, and `X::Client` + * A frozen Array of Strings, or nil when not known + * `X::Client.new`, `with`, and `X::OAuth2Authenticator.new` take `scopes:`, so `X::Client.new(client_id:, **tokens.to_h)` works + * Scopes that are not an Array of scope names, or given beside other than OAuth 2.0 credentials, raise `ArgumentError` +* Authorize an app with the OAuth 2.0 authorization code flow and PKCE with `X::OAuth2Authorization` + * It builds the authorization URL, and exchanges the code of the redirect for `tokens` or a `client` + * The tokens hold no app credentials; build a client as `X::Client.new(client_id:, client_secret:, **tokens.to_h)` + * It raises `X::AuthorizationDenied` when the user declines, the state does not match, or the redirect is invalid + * It raises `X::AuthorizationError` when X refuses the code, and `ArgumentError` for an empty state, client ID, or redirect URI + * `client` checks its options before spending the code, and passes the first tokens to its `save_tokens` + * It takes `headers:`, which the code is exchanged with and the client it builds is given, as a gateway may require +* Refresh the tokens of a public OAuth 2.0 client, given a `client_id`, `access_token`, and `refresh_token` without a `client_secret` +* Authenticate with an OAuth 2.0 access token that is not refreshed, given a `client_id` and `access_token` without a `refresh_token` + * An access token the API rejects raises `X::Unauthorized`, and `refresh!` raises `X::UnsupportedOperation` +* Add `X::Authenticator#user_id`, the user an OAuth 1.0a access token acts for, or nil +* Add `inspect` to `X::Client` and the authenticators that never reveals credentials +* Add `X::HTTPError#status`, `body`, `problems`, and `problem`, and `http_response`, an escape hatch for the transport's response + * It is a `Net::HTTPResponse` today, and its class is not part of what 1.x promises + * `status` is an Integer, as `X::Response#status` is + * `problem` is the problem the body describes, or else the first error it names +* Add `X::Problem`, what the API said of a request, answered by `X::HTTPError#problem` and reported by `x-objects` + * Reads `title`, `detail`, `type`, `resource_type`, `resource_id`, `parameter`, `value`, and `message` + * `about?` tells whether it names a resource, or an Integer or String identifier, comparing them as Strings + * Equals a problem of the same attributes, writes JSON with `as_json`/`to_json`, and marshals in a versioned format +* Add `X::UnsupportedFormat`, an `X::Error` raised when reading back a Marshal, YAML, or JSON format a release does not read +* Raise `X::PaymentRequired` (402), `X::MethodNotAllowed` (405), `X::RequestTimeout` (408), `X::UnsupportedMediaType` (415), and `X::UnavailableForLegalReasons` (451) + * Each is an `X::ClientError`; X sends 402 when the account that pays for the app has no credit left +* Name the request an error was raised for: `X::HTTPError`, `X::NetworkError`, `X::InvalidResponse`, and `X::TooManyRedirects` read `http_method` and `uri` + * The message names it without its query, as `GET /2/users/1: Could not find user` + * A redirected request is named by the last request sent +* Add `X::InvalidResponse#body`, `status`, and `headers` for a successful response that is not JSON + * `body` falls back to the body of the response once it has been read whole, and never reads a stream +* Add `X::TooManyRequests#exhausted_rate_limits` and `#limiting_rate_limit` +* Add `X::UnsupportedOperation`, an `X::Error` declared by `x-core`, raised for anything the API offers no way to do + * e.g. hydrating or refreshing an `X::Poll` or an `X::Place` that is not hydrated, as in "X::Poll cannot be fetched by id" + * A finder the API lacks is not defined: `X::Poll` and `X::Place` answer none, `X::List`, `X::Community`, and `X::DirectMessage` only `find` and `find!` +* Add the upload methods to `X::Client`: `upload_media`, `chunked_upload_media`, `await_media_processing`, and `await_media_processing!` + * And `add_alt_text`, `add_subtitles`, `update_profile_image`, and `update_profile_banner`, each calling an uploader with the client + * As in `client.create_post("Look", media_ids: [client.upload_media("cat.jpg")])` + * `chunked_upload_media` returns the `X::UploadedMedia` once it is finalized, without waiting for processing + * `await_media_processing!` raises if processing failed, where `await_media_processing` returns the status + * Media that already says its processing ended, or an image's upload response, is returned without a request + * They come from `X::Uploader::API`, which code using `x-core` and `x-uploader` alone can include; a `client:` option raises `ArgumentError` +* Return an `X::UploadedMedia` in place of a Hash from the uploaders and their client methods + * From `upload`, `chunked_upload`, `await_processing`, `await_processing!`, `add_alt_text`, and `add_subtitles` + * It reads `id` and `media_id` (Integers), `media_key`, `bytesize`, `expires_after_secs`, `processing_info`, `state`, and `check_after_secs` + * It tells `processing?`, `failed?`, and `ready?`, and still reads as a Hash with `[]`, `fetch`, `dig`, `key?`, and `to_h` + * It is frozen, writes `as_json`/`to_json`, and marshals in a versioned format; one without an `"id"` of 1 to 19 digits raises `ArgumentError` + * `add_alt_text` and `add_subtitles` return the media they describe, ready to attach to a post +* Infer the media category of an upload from the bytes the media begins with, or else its extension, so `upload` takes any file + * Videos and large GIFs upload in chunks, and `upload` waits for X to process them + * `upload` takes `media_type:`, `chunk_size:`, and `concurrency:`, and raises `ArgumentError` for any other unknown keyword +* Take the media as the positional argument of the `X::Uploader::MediaUpload` and `X::Uploader::Account` methods, with `client:` as a keyword +* Describe uploaded media with the `alt_text:` of `X::Uploader::MediaUpload.upload` or with `X::Uploader::Metadata.add_alt_text` + * Alt text that is not a String, not convertible to UTF-8, empty, or over 1,000 characters raises `ArgumentError` before a request + * It is sent again after a server or network error + * An upload that cannot add its alt text raises `X::AltTextFailed`, which holds the uploaded `media`, for any `StandardError` but a `Timeout::Error` or an `X::TokenReportFailed`, which are raised as they are, as an interrupt is, but a timeout raised with its class, as by `Timeout.timeout(5, Timeout::Error)`, that lands in `save_tokens` is an `X::TokenReportFailed`, holding the tokens +* Attach uploaded subtitles to a video with `X::Uploader::Metadata.add_subtitles` + * `media_category:` defaults to `"tweet_video"`, and takes `"amplify_video"` in any case, or `TweetVideo`/`AmplifyVideo` + * Any other category raises `ArgumentError` + * It is sent again after a server or network error +* Share uploaded media, or give it other owners, with the `shared:` and `additional_owners:` of `upload`, `chunked_upload`, and `upload_media` + * Media given `shared: true` uploads in chunks; an image of unknown type, such as HEIC, given an image `media_category:` is sent as JPEG + * A `shared` other than true, false, or nil, or `additional_owners` that are not user IDs, raise `ArgumentError` +* Add `X::Uploader::MediaUpload::DEFAULT_CONCURRENCY` (4) and `MAX_CONCURRENCY` (16), the chunks a chunked upload sends at once +* Add `X::Uploader::MediaUpload::DEFAULT_PROCESSING_TIMEOUT` (600 seconds) and `AMPLIFY_VIDEO`, beside `TWEET_VIDEO` +* Name the media category of an upload with a Symbol, in any case, as in `media_category: :tweet_video` +* Rescue the errors `x-uploader` raises of its own with `X::Uploader::Error` + * It is the base of `X::AltTextFailed`, `X::ChunkedUploadFailed`, `X::InvalidMedia`, and `X::MissingMediaData` + * And of `X::MediaProcessingCheckFailed`, `X::MediaProcessingFailed`, and `X::MediaProcessingTimeout` + * `X::InvalidMediaType`, an `X::InvalidMedia`, is raised for media of a type the API does not take + * A bad category, chunk size, concurrency, alt text, or processing timeout raises `ArgumentError` instead + * Each error that holds media takes an optional message and `media:`; `X::MediaProcessingTimeout` takes `timeout:` too +* Raise `X::MediaProcessingCheckFailed`, which holds the uploaded `media`, when a check of its processing fails +* Raise `X::ChunkedUploadFailed`, which holds the initialized `media`, when a chunk or the finalize fails + * Such as a server error, a file deleted, closed, or shrunk mid-upload, or a finalize response without media + * It and `X::MediaProcessingCheckFailed` are raised for any `StandardError`, as the `cause`, such as one `on_response` raises + * A `Timeout::Error`, an `X::MediaProcessingTimeout`, or an interrupt is raised as it is, but one raised with its class, as by `Timeout.timeout(5, Timeout::Error)`, that lands in `save_tokens` is an `X::TokenReportFailed`, holding the tokens + * An `X::TokenReportFailed` is raised as it is too, from a chunk, the finalize, or a check, so `rescue X::TokenReportFailed` stores its `tokens` +* Take an upload's result, a media ID, a media key, or anything that answers `media_key` in `await_processing`, `await_processing!`, `add_alt_text`, and `add_subtitles` + * Anything else, or an ID that is not 1 to 19 digits, raises `ArgumentError` before a request + * The signatures type what answers `media_key` as `X::Uploader::_MediaKeyed` +* Upload a GIF with a single frame as an image, since X fails to process it as a GIF +* Stream with `X::StreamingClient`, which `X::Client#streaming` builds with the client's credentials, base URL, classes, and `on_response` + * `streaming` takes `read_timeout:`, `max_reconnects:`, and `on_reconnect:`; other settings come from a copy, as `client.with(open_timeout: 2).streaming` + * `stream` raises `ArgumentError` when given no block, before it opens the stream +* Stop the streams of a streaming client from any thread, or the trap of a signal, with `X::StreamingClient#stop` + * Each stream stops the next time it waits on the API, at once if idle or waiting to reconnect + * A stream a Fiber scheduler runs, as in an `Async` task, stops once its block next returns, at the next keep-alive, or within a second as it waits to reconnect + * A stopped streaming client stays stopped: a stream asked of it later returns nil at once, without a request + * Each stopped `stream` call returns nil, `stop` returns nil, and `stopped?` tells a stopped streaming client + * A block, `on_response`, or `on_reconnect` running at that moment runs to its end first + * `streaming` builds a new streaming client each time, so keep the one you stop in a variable +* Stop a stream from its block: `break` stops it and returns its value, and `throw` unwinds past it + * An error of the block, `on_response`, or the object class stops it and reaches the caller, `StopIteration` included +* Read and change the rules of the filtered stream with `rules`, `add_rules`, and `delete_rules` on `X::StreamingClient` + * A rule is a frozen `X::StreamRule` of its `value`, `tag`, and `id` (an Integer, or nil for a rule to add) + * An `id` is an Integer that is not negative or a String of digits alone; anything else raises `ArgumentError` + * Add an `X::StreamRule`, a Hash of `value` and `tag`, or a String; delete by rule, `id`, or value + * An `X::MatchingRule` deletes the rule it names, so `delete_rules(post.matching_rules)` deletes the rules a post matched + * Anything else, such as a post, raises `ArgumentError` before a request + * `dry_run: true` checks the rules and changes none; `rules` takes `params:` and reads every page + * Rules the API does not add or delete are yielded as `X::Problem`s, or without a block raise `X::RulesRejected` + * A rule the app already has is returned by `add_rules`, with the `id` and `value` X reports, and raises nothing; a block is still yielded it + * `X::RulesRejected#added`, the rules added and those the app already had, and `#deleted_count` hold what was done; no rules send no request +* Stream, and read and change stream rules, as the app from a client that signs with OAuth 1.0a, which the stream endpoints refuse + * A client of an OAuth 2.0 user without app credentials streams as the user, so X's 403 raises `X::Forbidden` +* Reconnect a stream that ends, drops, or sends a line that is not JSON, backing off as X recommends + * Up to the `max_reconnects` of the streaming client in a row (unlimited by default), reset by an object or keep-alive read from a connection open for a minute + * So a stream whose connections each deliver something and drop backs off, and runs out of reconnects + * Then it raises the last error: `X::NetworkError` for a stream that ended or dropped, `X::InvalidResponse` for a non-JSON line + * A rate limit backs off from a minute, doubling, but raises `X::TooManyRequests` past `max_rate_limit_wait` or for the usage cap + * A server error, 408, or 409 backs off from 5 seconds, up to 320 seconds, or waits longer for its `Retry-After` + * A `Retry-After` past `max_rate_limit_wait` raises the error at once + * What arrived of a line the stream ended within is dropped, unparsed and unreported, and the stream reconnects as one that ended + * An error `on_response` raises for a failed response stops the stream and reaches the caller, as one for an object does + * A certificate that does not verify raises its `X::NetworkError` at once, since it would not verify on the next attempt +* Report each reconnect of a stream to `on_reconnect:`, a callable passed the error and the seconds it waits + * It is passed an error every time, never nil, and is called with those two arguments and no others throughout 1.x + * It can call `stop` to give up, as on a host that never resolves, and the stream returns nil + * An error it raises stops the stream and reaches the caller +* Read a stream with the `read_timeout` of the streaming client, 30 seconds by default, so a quiet stream reconnects +* Raise `X::StreamError`, an `X::Streaming::Error`, for a stream line that holds errors and no data + * It holds the `problems`, `http_method`, and `uri`; `on_response` is passed the line first + * A line of `operational-disconnect`s alone, which `X::Problem#disconnect?` tells, reconnects; other problems stop the stream +* Add immutable, thread-safe resource classes, which descend from `X::Resource` + * `X::User`, `X::Post` (aliased `X::Tweet`), `X::List`, `X::DirectMessage`, `X::Space`, `X::Media`, `X::Poll`, `X::Place`, and `X::Community` + * Lists of resources or identifiers are frozen Arrays; copy one to change it + * A list the response omits, such as `post.urls`, reads as an empty Array; `user.connection_status` reads nil unless requested + * Text reads as the API sends it, so `post.text` and `direct_message.text` hold `&`, `<`, and `>` throughout 1.x + * Nested data, such as `post.entities` and `post.public_metrics`, reads as frozen Hashes keyed by String throughout 1.x + * Nested data the API sends as anything but an object, or a list of them, raises `X::InvalidAttribute` +* Rescue the failures of the object layer with `X::Objects::Error` + * It is the base of `X::MissingResource`, `X::UnreadableResponse`, `X::InvalidAttribute`, `X::MissingClient`, and `X::PageLimitReached` +* Include the object methods in a class of your own with `X::Objects::API` + * The class answers `get`, `post`, `put`, and `delete` as `X::Objects::_Client` types them, taking keywords it does not read + * The other modules and constants of `X::Objects` are private +* Compare resources by class and ID with `==`, `eql?`, and `hash`, so the same resource from different requests is equal +* Resolve references such as `post.author` and `post.replied_to` to included objects or ID stubs, one object per resource in a response +* Add `hydrate`, which fetches and memoizes the full resource, and `refresh`, which fetches it again + * A resource fetched with fewer than the default fields or expansions is not hydrated; one fetched with more is + * A resource without a client, such as one read back with `Marshal`, raises `X::MissingClient` from any request + * `FIELDS` and `EXPANSIONS` may grow in a minor release; add to `default_params` rather than list every value +* Hydrate the stubs of a page of `stubs`, or of a cursor that requests identifiers alone, together, in batch lookups of up to 100 + * Lists, communities, and direct messages hydrate one at a time, as does a reference a page did not include; `hydrate_all` batches any +* Refer to a resource without a request with `X::User.from_id` and its equivalents, and tell such stubs apart with `stub?` +* Add `X::Cursor`, an `Enumerable` collection that fetches pages lazily at the maximum page size and caches them + * `refresh` and `prefetch` fetch pages again or ahead + * A page a prefetch failed to fetch raises the prefetch's error once it is reached, rather than be requested again + * A page that names as its next a token already read raises `X::UnreadableResponse` + * `page` takes an Integer index, raising `TypeError` for any other and `ArgumentError` for a negative one + * `X::Cursor.new` is private; cursors come from the collections, searches, and lookups +* Answer `X::Cursor#first`, `take`, `any?`, `none?`, `one?`, and `empty?` from as few resources as they need + * They read from pages already fetched, and request a page no larger than needed, raised to the endpoint's minimum + * So `any?`, `none?`, and `empty?` request one resource and `one?` two, or the 5 or 10 an endpoint such as a search takes at least + * A count that is not an Integer is converted as `Array#first` does; a String, or nil to `take`, raises `TypeError` +* Count a collection without paging it with `X::Cursor#published_count`, the number the API publishes + * Followers, followed users, and list memberships of a user, and members and followers of a list; nil for others +* Read a collection a page at a time with `X::Cursor#each_page`, yielding `X::Page`s + * A page holds `items`, `meta`, `result_count`, `next_token`, `previous_token`, and `problems`, and reads like an Array + * `X::Page.new` raises `ArgumentError` for problems that are not `X::Problem`s +* Request identifiers alone from a cursor with `ids`, as in `user.followers.ids`, and scan stubs with `stubs` +* Type-check the resources of a collection: `X::Cursor` and `X::Page` are generic in their signatures +* Read the collections of a resource as cursors + * A user's `followers`, `following`, `affiliates`, `posts`, `mentions`, `liked_posts`, `owned_lists`, `list_memberships`, and `followed_lists` + * A list's `members`, `followers`, and `posts`, a post's `quotes`, and a space's `posts`, and its `buyers` (OAuth 2.0 user only) +* Add `home_timeline`, `blocking`, and `muting` cursors to `X::User` +* Read the authenticated user's reposted posts with `reposts_of_me` on the client and `X::Post`, aliased as `retweets_of_me` +* Tell a post that replies, quotes, or reposts with `reply?`, `quote?`, and `repost?`, and read its target with `replied_to`, `quoted`, and `reposted` +* Add `X::Post#liked_by`, `reposted_by`, and `reposts`, and `references` on posts and direct messages +* Look up users and posts by ID in parallel batches of 100 with `X::User.find_all` and `X::Post.find_all` + * They return one resource per ID found, in the order asked, an ID given twice coming back twice + * Once a batch fails, no further batch is sent and its error is raised +* Say how many batches a lookup requests at once with `concurrency:` (default 4) + * Taken by `find_all`, `find_all_by_username`, `hydrate_all`, and `X::Space.find_all_by_creator` + * And by `find_all_users`, `find_all_users_by_username`, `find_all_posts`, `find_all_spaces`, `find_all_media`, and `find_all_spaces_by_creator` + * Anything but an Integer of at least 1 raises `ArgumentError` +* Hydrate many resources in parallel batches with `hydrate_all` on `X::User`, `X::Post`, `X::Space`, and `X::Media` + * It drops nil and resources not found, skips hydrated ones, and stores what it found in each resource + * A resource of another class raises `ArgumentError` before a request +* Add lookup, search, and action methods for the resources to `X::Client` + * `find_user`, `find_all_users`, `current_user!`, `find_post`, `find_all_posts`, `find_list`, and `find_space` + * `search_posts`, `search_all_posts`, `create_post`, `delete_post`, `direct_messages`, and `create_direct_message` + * `follow`, `unfollow`, `like`, `unlike`, `repost`, and `unrepost` + * The finders have `find_tweet` aliases + * Actions return true or false; `follow` returns true once it has asked to follow a protected user +* Look up a mix of IDs and usernames with `find_all_users`, and usernames alone, even all-digit ones, with `find_all_users_by_username` +* Look up a user by ID when given an Integer and by username when given a String + * Say which with `X::User.find_by_username`, `find_by_username!`, `find_all_by_username`, `find_by_id`, `find_by_id!`, and `find_all_by_id` + * Or on the client with `find_user_by_username`, `find_user_by_username!`, `find_all_users_by_username`, `find_user_by_id`, `find_user_by_id!`, and `find_all_users_by_id` + * Elsewhere, such as `follow` or `find_post`, an ID that is not a number raises `ArgumentError` + * So does a resource of another class, as `client.like(user)`, or any other object that answers `id` +* Accept a username with a leading `@` in `find_user`, `find_all_users`, and `X::User.find_all_by_username` +* Search users with `X::User.search` and `client.search_users` +* Request the largest page each search allows: 500 posts from `search_all_posts` (100 with context annotations), and 1,000 users from `search_users` +* Look up direct messages with `find_direct_message`, many spaces with `find_all_spaces`, and a conversation with `direct_messages_with` +* Look up the spaces of many creators with `X::Space.find_all_by_creator` and `find_all_spaces_by_creator`, in batches of 100 +* Search spaces with `X::Space.search` and `search_spaces` on the client, a cursor over live or scheduled spaces +* Look up and search spaces, and read their posts, whatever the client authenticates with + * An OAuth 1.0a client, which the space endpoints refuse, requests them as the app; an OAuth 2.0 user client as the user unless it holds app credentials, when it too requests them as the app +* Read the topics of a space with `X::Space#topics`, each an `X::Topic` with a `name` and `description` +* Look up media by media key with `X::Media.find`, `find!`, and `find_all`, and `find_media`, `find_media!`, and `find_all_media` + * Each takes a media key, media, or an upload's result; a numeric media ID raises `ArgumentError` +* Read the numeric ID of media with `X::Media#media_id`, as `X::UploadedMedia#media_id` reads it; `X::Media#id` is the media key +* Add `X::Community`, with `find_community`, `find_community!`, `search_communities`, `post.community`, and the `community:` of `create_post` +* Alias every client method named for direct messages with `dm`: `find_dm`, `find_dm!`, `dms`, `dms_with`, `dms_in`, `create_dm`, `create_group_dm`, `create_dm_in`, and `delete_dm` +* Raise `X::MissingResource`, an `X::Objects::Error`, from `current_user!`, `X::User.current!`, `find!`, and the bang finders of the client + * `find_user!`, `find_user_by_username!`, `find_post!`, `find_list!`, `find_space!`, `find_media!`, `find_community!`, and `find_direct_message!` + * The message names what was looked up, as "Could not find X::User @sferik"; `problems` explains why + * It is not `X::NotFound`, which is the 404 of an endpoint that is not there +* Add `current_user` and `current_user!` to the client, as `X::User.current` and `.current!`; `current_user` returns nil if not found +* Take the authenticated user's ID from an OAuth 1.0a access token with `current_user_id`, without a request +* Name the interface after posts: `post_count`, `pinned_post_id`, `most_recent_post_id`, `edit_history_post_ids`, `note_post`, `referenced_posts`, and `repost_count` + * The tweet-named methods, such as `create_tweet`, `tweets`, and `retweet_count`, remain as aliases + * A response that names fields after tweets, as a stream does, is still read +* Request fields and expansions by the names the X API documentation gives, such as `post.fields` and `referenced_posts` +* Request no `edit_history_post_ids` or `entities.mentions.username` expansion, which the object layer never reads +* Read the IDs of users, posts, lists, direct messages, communities, and polls, and the attributes that refer to them, as Integers + * The IDs of spaces, places, and media, media keys, `dm_conversation_id`, and usernames are Strings + * `X::Problem#resource_id` and `#value` are Strings, so match a problem to a resource with `problem.about?(user)` +* Raise `X::MissingResource`, holding the response's problems, when a request that creates a resource is answered without it + * From `X::Post.create`, `X::List.create`, `X::DirectMessage.create`, `create_group`, `create_in`, and their client methods + * So `create_post`, `create_list`, `create_dm`, and the rest never return nil + * A response whose data names no identifier raises it too +* Build the `reply`, `media`, and quote of a new post with the `reply_to:`, `media_ids:`, and `quote:` of `create_post` and `X::Post.create` + * A field passed with a String key, such as `"reply"` or `"attachments"`, is read as its Symbol, so it is sent once +* Take an upload's result, media, or a media key in the `media_ids:` of `create_post`, reading its media ID + * An ID that is not 1 to 19 digits, or anything else, raises `ArgumentError` before the request +* Post media without text: the text of `create_post`, `create_direct_message`, and their equivalents is optional + * A post or message with neither text nor any other field raises `ArgumentError` +* Attach uploaded media to a direct message with `media_ids:` + * Passing both `media_ids:` and `attachments:` raises `ArgumentError`; an empty `media_ids:` attaches nothing +* Start a group conversation with `X::DirectMessage.create_group` and `create_group_direct_message`, aliased `create_group_dm` +* Send to and read any conversation with `X::DirectMessage.create_in` and `.in`, and `create_direct_message_in` and `direct_messages_in` + * The client methods have `create_dm_in` and `dms_in` aliases +* Add `X::DirectMessage.delete`, `message.delete`, and `delete_direct_message` on the client +* Add `X::DirectMessage#peer(user)`, the other participant of a one-to-one conversation, nil for a group conversation +* Add `X::DirectMessage#from?`, false for a message that does not name its sender +* Add `X::Post#coordinates`, and `permalink` and `uri`, the x.com address of a post, user, list, or community +* Add `X::Post#urls` and `X::Post#expanded_text`, the text with each shortened link expanded, every link in one pass + * `expanded_text` HTML-escapes each URL it puts in, so the whole text reads escaped, as `text` does +* Read the full text of a post longer than 280 characters with `X::Post#text`, from its `note_post` + * `entities` and `urls` of a long post read from the note alone +* Add `X::Post#matching_rules`, the filtered-stream rules a post matched, each a frozen `X::MatchingRule` with an `id` and `tag` + * Building one whose `id` is not a String of digits or a non-negative Integer raises `ArgumentError`; `post.matching_rules` raises `X::InvalidAttribute` for one +* Add `X::User#profile_banner_url`, `parody?`, `identity_verified?`, `subscription_type`, `verified_followers_count`, `subscriber_count`, and `media_count` +* Add `X::User#receives_your_dm?`, `subscribes_to_you?`, and `subscription`; the predicates are false, and `subscription` nil, unless `user.fields` names them +* Add `X::Post#media_source_posts`, aliased `media_source_tweets`, the posts its attached media was first posted with + * Resolved from the `attachments.media_source_tweet` expansion, which lookups request +* Add `X::Post#display_text_range`, an exclusive `Range`, nil when absent, and `scopes`, `card_uri`, `article`, `article_title`, `media_metadata`, and `paid_partnership?`, and `X::DirectMessage#entities` +* Add `X::User#affiliation`, `affiliated_with_ids`, and `affiliated_with`; a user included in another resource holds none until hydrated +* Read `is_identity_verified` and `is_ticketed` as `X::User#identity_verified` and `X::Space#ticketed`, with `?` predicates +* Match resources against `case/in` patterns with `deconstruct_keys`, as in `post in {like_count: 100..}` + * Tweet-named aliases match too, as in `user in {pinned_tweet_id: Integer}` + * `X::Trend`, `X::PersonalizedTrend`, and `X::PostUsage` match by their readers +* Write a resource, problem, trend, usage, matching rule, or page as JSON with `as_json` and `to_json`, never with credentials + * Each marshals, and writes YAML, in a versioned format every 1.x release reads, coming back frozen + * A resource keeps the included objects it refers to, but not its client + * Read each but a page as a Hash with `to_h` + * A cursor raises `X::UnsupportedOperation` from `as_json`, `to_json`, and `to_h`, and `TypeError` from `Marshal.dump`; serialize `to_a` +* Report the partial errors of a successful response as `X::Problem`s + * `problems` on a resource holds those about it, the resources it refers to directly, or no identifier + * `problems` on a page holds every problem of its response; a finder's block receives each one +* Count posts matching a query with `X::Post.count`, `count_all`, `count_by_period`, and `count_all_by_period` + * On the client as `count_posts`, `count_all_posts`, `count_posts_by_period`, and `count_all_posts_by_period`, with tweet-named aliases + * Counts by period are keyed by the `Range` of `Time` each period spans + * A count that is not a String of digits or a non-negative Integer raises `X::InvalidAttribute` + * An OAuth 1.0a client counts as the app; an OAuth 2.0 user client without app credentials counts as the user +* Report the post usage of the app's project with `X::PostUsage.current` and `client.post_usage` + * They return nil, yielding the response's problems to a block, when it holds no usage + * `X::PostUsage.current!` and `client.post_usage!` raise `X::MissingResource` instead + * Includes its monthly cap, reset day, and usage by day and by app +* Read the trends of a place with `X::Trend.at` and `trends` on the client, given its WOEID, such as 1 for the world + * An ID that is not a number raises `ArgumentError`; `max_trends` limits the 50 returned + * An `X::Trend` reads its `name` and `post_count`; an OAuth 1.0a client requests trends as the app +* Read the trends X picks for the authenticated user with `X::PersonalizedTrend.all` and `personalized_trends` + * An `X::PersonalizedTrend` reads its `name`, `category`, `post_count_text`, and `trending_since_text` +* Bookmark a post and remove the bookmark with `bookmark` and `unbookmark` on `X::Client` +* Read bookmark folders with `X::User#bookmark_folders`, a cursor of `X::BookmarkFolder`, and a folder's posts with `bookmarks(folder:)` +* Add `block`, `unblock`, `mute`, and `unmute` to `X::Client` +* Add `X::List.create`, `.update`, `.delete`, `list.add_member`, `remove_member`, `update`, and `delete`, with client equivalents + * `create_list`, `update_list`, `delete_list`, `add_list_member`, and `remove_list_member` + * An update with no field to change raises `ArgumentError` before a request +* Follow, unfollow, pin, and unpin a list with `follow_list`, `unfollow_list`, `pin_list`, and `unpin_list` on `X::Client`, and read `X::User#pinned_lists` +* Hide and show a reply with `X::Post#hide_reply` and `#unhide_reply`, their class methods, and the client's `hide_reply` and `unhide_reply` +* Check whether a user follows another with `user.follows?`, and a list's membership with `list.member?`, without fetching every page + * `follows?` looks up `connection_status` once when either user is the authenticated user, and scans otherwise + * `member?` scans the smaller of a public list's members and the user's list memberships +* Limit the pages a scan or a count of posts reads with `max_pages:` (default nil, no limit) + * Taken by `X::List#member?`, `X::User#follows?`, `X::Post.count`, `count_all`, `count_by_period`, `count_all_by_period`, and their client methods + * Past the limit, with another page named, it raises `X::PageLimitReached`, an `X::Objects::Error` + * A value that is neither an Integer of at least 1 nor nil raises `ArgumentError` before a request +* Raise `X::InvalidAttribute`, an `X::Objects::Error`, for a response value that cannot be read as the API documents it + * Such as a timestamp that is not ISO 8601, a count that is not a whole number, or a flag that is not a boolean + * Its cause is the `ArgumentError` that refused the value +* Validate the identifier of a resource when it is built, so `X::User.new({"id" => "abc"})` raises `ArgumentError` + * A space or place ID is word characters alone; a `dm_conversation_id` that is not digits, or two numbers joined by a hyphen, raises `X::InvalidAttribute` +* Validate a username before building a path from it, so `find_user("../tweets/20")` raises `ArgumentError` without a request + +### Changed +* Require Ruby 3.4 or later +* Hold `VERSION` in a String rather than a `Gem::Version`; use `gem_version` to compare versions +* Send requests to `api.x.com` rather than `api.twitter.com` by default +* Name the gem in the `User-Agent` header of every request, token requests included, as `x-ruby/1.0.0 ruby/3.4.0 (arm64-darwin24)`, where it named `X-Client` +* Move the HTTP client into `x-core` and the uploaders into `x-uploader` +* Rename `X::MediaUploader` to `X::Uploader::MediaUpload`, `X::AccountUploader` to `X::Uploader::Account`, and `X::MediaUploadValidator` to `X::Uploader::Validator` +* Rename `X::OAuthAuthenticator` to `X::OAuth1Authenticator` +* Rename `X::ConnectionException`, the error for 409 Conflict, to `X::Conflict` +* Rename `X::HTTPError#response` to `http_response`, and make that of `X::RateLimit` private +* Move `stream` from `X::Client` to `X::StreamingClient` +* Refresh OAuth 2.0 tokens with `X::OAuth2Authenticator#refresh!`, in place of `refresh_token!` + * It returns the frozen `X::OAuth2Tokens` of the refresh rather than the Hash of the token response + * It raises `X::UnsupportedOperation` for an authenticator that holds no refresh token +* Keep frozen copies of the credentials, tokens, header values, base URL, and proxy URL a client, an authenticator, an `X::OAuth2Authorization`, or `X::OAuth2Tokens` is given, so neither the caller nor what a reader returns can change what is sent, or where +* Keep secret credentials private on a client and its authenticators + * `api_key_secret`, `access_token`, `access_token_secret`, `bearer_token`, `client_secret`, and `refresh_token` no longer read off a client + * Nor do the secrets and tokens of the authenticators; `api_key`, `client_id`, and `expires_at` remain public + * An authenticator's `headers` still returns the `Authorization` header it sends, which holds a bearer or OAuth 2.0 token + * Store refreshed tokens from the `X::OAuth2Tokens` that `save_tokens` is passed +* Raise `TypeError` from `Marshal.dump`, `YAML.dump`, `as_json`, and `to_json` of a client, streaming client, authenticator, or `X::OAuth2Authorization` +* Keep the proxy of a client, streaming client, and connection private: `proxy_url` and the `proxy_*` readers are gone + * `inspect` leaves the proxy out +* Send credentials only to the origin of the `base_url` + * An endpoint or stream naming another scheme, host, or port gets no `Authorization`, `Cookie`, or `Proxy-Authorization` header + * Another API version on the same origin, such as `https://api.x.com/1.1/account/settings.json`, still gets them +* Send a header named by a Symbol with its underscores as hyphens, wherever headers are taken + * `headers: {content_type: "text/plain"}` sends `content-type`, replacing the default `Content-Type` + * It is dropped on a cross-origin redirect like the header it names +* Raise `ArgumentError` from `X::Client.new` for a `base_url` that is not an absolute http or https URL + * Or that holds a user, password, query, or fragment +* Raise `ArgumentError` from `X::Client.new` for credentials that do not form a complete set + * Or for a credential that belongs to no complete set, such as a `client_id` beside a `bearer_token` + * Or for OAuth 2.0 credentials beside a complete set of OAuth 1.0a credentials + * Or for an empty String credential, or one that is neither a String nor nil + * Or for an `expires_at` beside credentials it does not belong to +* Raise `ArgumentError` from the authenticators for a required credential that is nil or empty, or an `expires_at` that is not a `Time` +* Raise `ArgumentError` from `X::Client.new`, `with`, and `streaming` for settings out of range, naming the setting + * `max_redirects`, `max_rate_limit_retries`, and `max_retries` must be Integers of at least 0 + * `max_rate_limit_wait` must be a number of at least 0, not NaN; `max_reconnects` an Integer of at least 0 or `Float::INFINITY` + * `open_timeout`, `read_timeout`, and `write_timeout` must be finite numbers of at least 0, or nil; `keep_alive_timeout` a finite number + * A stream's `read_timeout` must be at least 25 seconds, or nil + * `default_array_class` must be a Class, and `default_object_class` a Class or respond to `from_response` +* Raise `ArgumentError` from `get`, `post`, `put`, `delete`, `get_stream`, and `stream` for an endpoint that is not a valid URL + * Such as one that holds a space or bad `%` escape, or does not resolve to an http or https URL with a host + * An endpoint that is not a String, such as a Symbol or a URI, raises `ArgumentError` naming its class +* Raise `ArgumentError` from `post` and `put` for an unknown keyword, as `client.post("tweets", text: "Hello")` +* Resolve an endpoint that begins with a slash against the base URL, so `client.get("/users/me")` requests `/2/users/me` +* Open a connection with a 10-second timeout rather than 60; `read_timeout` and `write_timeout` stay 60 +* Send each request once, turning off the automatic retry of `Net::HTTP`; `max_retries` decides what is sent again +* Send an idempotent request again on a new connection when a kept-alive connection had gone stale + * Only before the status and headers of its response are read; a response cut off after that is never sent again +* Raise `X::NetworkError` for a body cut off before its end, or shorter than its `Content-Length`, rather than read what arrived +* Wrap every network failure in `X::NetworkError`, so a stream reconnects after it + * Including `IOError`, `SystemCallError`, Net::HTTP's open, read, and write timeouts, `Net::ProtocolError`, `Zlib::Error`, `Net::HTTPBadResponse`, `OpenSSL::SSL::SSLError`, and `SocketError`, but not a `Timeout::Error` that `Timeout.timeout` raises around a request, which is raised as it is, but one raised with its class, as by `Timeout.timeout(5, Timeout::Error)`, that lands in `save_tokens` is an `X::TokenReportFailed`, holding the tokens +* Stop following redirects after exactly `max_redirects` hops instead of one more, so 0 follows none +* Request the token endpoints at the origin of the base URL, under the path it serves the API at, rather than at `api.x.com` + * The path is the base URL's minus a trailing API version segment + * With `base_url: "https://gateway.example/x/2/"`, an app-only token comes from `/x/oauth2/token` and a refresh goes to `/x/2/oauth2/token` + * `X::OAuth2Authorization` exchanges its code at the base URL of the client it builds +* Raise `X::AuthorizationError`, an `X::ClientError`, when X refuses a token refresh, code exchange, or app-only token + * It holds the OAuth 2.0 `error_code`, such as `invalid_request`, and the `status`, `headers`, and `body` of the response + * A token endpoint that fails to answer raises the `X::HTTPError` of its status, such as `X::ServerError`, instead + * A refresh refused after a 401 raises it with the `X::Unauthorized` as its cause +* Raise `X::AuthorizationDenied`, an `X::Error` with the `error_code` the redirect reported, for a redirect back from X that refuses +* Raise `X::InvalidResponse`, an `X::HTTPError`, for a successful response whose body is not JSON, where it returned nil +* Tag the bodies of responses, errors, and stream lines as UTF-8, so comparing them with non-ASCII text works + * A body that is not valid UTF-8 keeps its bytes; `valid_encoding?` tells it apart +* Read only rate limits a response reports in full, in base 10 + * `X::TooManyRequests#retry_after` no longer raises `KeyError` or `ArgumentError` for a missing or malformed header +* Return nil from `X::TooManyRequests#reset_at`, `#reset_in`, and `#retry_after` when the response does not say when the limit resets +* Read `Retry-After` with `X::HTTPError#retry_after`, in seconds or as an HTTP date, or nil + * `X::TooManyRequests#retry_after` reads it, falling back on `#reset_in`; retries and reconnects wait for it +* Report every rate limit a response names from `X::TooManyRequests#rate_limits`, not just the exhausted ones + * `#rate_limit` reads the 15-minute limit; the exhausted limit that resets last is now `#limiting_rate_limit` +* Make `X::RateLimit.new` and `.reported?` private +* Make public, documented constructors for the errors of `x-core` and `X::Response`, so code that rescues them can be tested + * `X::HTTPError.new(status:, headers:, body:, http_method: nil, uri: nil)`, or `http_response:` in their place + * `raise X::NotFound` and `raise X::TooManyRequests, "slow down"` work as for any exception + * `X::Response.new(http_method:, uri:, status:, headers:, body:)`, or `http_response:` in their place + * A status outside 100 to 599, or headers that are not a Hash, raise `ArgumentError` +* Name the internal classes of `x-core` under `X::Core` and those of `x-streaming` under `X::Streaming`, as private constants +* Make `X::Connection` the internal `X::Core::Connection` + * The authenticators take no `connection:` + * `X::OAuth2Authorization` takes `base_url`, `proxy_url`, `open_timeout`, `read_timeout`, `write_timeout`, `debug_output`, and `headers` instead +* Make the constants that hold messages, patterns, or internal defaults of `x-core` private + * `X::HTTPError::JSON_CONTENT_TYPE_REGEXP`, `X::OAuth2Authenticator::EXPIRATION_BUFFER`, and its token endpoint (`TOKEN_HOST`, `TOKEN_PATH`) +* Make `X::Uploader::Validator` and the MIME type and size constants of `X::Uploader::MediaUpload` private + * `MIME_TYPES`, `MIME_TYPE_MAP`, the `*_MIME_TYPE` constants, `BYTES_PER_MB`, `MAX_SIMPLE_UPLOAD_BYTES`, and the endpoints of `X::Uploader::Account` + * The media category constants, such as `TWEET_IMAGE`, remain public +* Make `infer_media_type` of `X::Uploader::MediaUpload` internal; pass `media_type:` to override the type an upload infers +* Ship the signatures of the public interface alone in each gem +* Document every error class of `x-core` and draw the whole hierarchy on `X::Error` +* Sign OAuth 1.0a requests, and build OAuth 2.0 token refreshes, with the [simple_oauth](https://github.com/laserlemon/simple_oauth) gem +* Take media as a path (`String` or `Pathname`) or an IO in `upload_media`, `upload`, `chunked_upload`, `update_profile_image`, and `update_profile_banner` + * A `File` or `Tempfile` is read a chunk at a time; another IO, such as a `StringIO`, is read whole + * A String holding a NUL byte or a line break raises `ArgumentError`; pass a path or a `StringIO` + * An IO that cannot be read raises `X::InvalidMedia` +* Infer the type of media from the bytes it begins with before the name of its file + * GIF, PNG, JPEG, BMP, TIFF, WebP, MP4, QuickTime, WebM, MPEG-TS, and WebVTT are recognized + * Media of a type nothing names, such as HEIC or SubRip in a `StringIO`, raises `X::InvalidMediaType` without `media_category:` + * A file named as a type every file of which begins with a signature, but without it, such as TypeScript named `.ts`, raises `X::InvalidMediaType`; otherwise the bytes win over the name + * A type the media category does not take, such as MP4 with `"tweet_gif"`, raises `X::InvalidMediaType` +* Raise `X::MissingMediaData` instead of `KeyError` or nil when an upload, status check, or metadata response holds no media + * Its `problems` hold the problems the response reported, the first named in its message +* Raise `X::InvalidMedia` for a file that does not exist, and `X::MediaProcessingFailed` for media that fails to process, instead of `RuntimeError` + * `X::MediaProcessingFailed#media` holds what X reported as an `X::UploadedMedia` +* Raise `ArgumentError` from `await_processing`, `add_alt_text`, and `add_subtitles` for media without an `"id"` +* Raise `ArgumentError` from `add_subtitles` for a language code that is not two letters +* Upload in chunks of 4 MB, `X::Uploader::MediaUpload::DEFAULT_CHUNK_SIZE`, rather than 1 MB, so a video takes a quarter of the requests + * A file larger than the 16 GB the API takes raises `X::InvalidMedia` before the upload + * `chunk_size:` replaces `chunk_size_mb:`, takes bytes, and defaults to nil, which uploads in chunks of 4 MB +* Upload an animated GIF larger than 5 MB in chunks, which the API takes up to 15 MB of +* Validate the `alt_text:` of `upload` before uploading, so media is not lost to a refused alt text +* Post profile images and banners to the API v1.1 through the client's base URL, credentials, and connections +* Keep the state and helpers of `X::Client` in an internal object, so mixed-in methods never collide with them + +### Removed +* Remove `X::MediaUploader.upload_binary`; pass a `StringIO` to `X::Uploader::MediaUpload.upload` +* Remove `upload_profile_image_binary` and `upload_profile_banner_binary`; pass an IO to `update_profile_image` and `update_profile_banner` +* Remove `require "x/media_uploader"` and `require "x/account_uploader"`; require `x`, `x/uploader/media_upload`, or `x/uploader/account` +* Remove the `boundary:` of the upload methods, which each upload now generates for itself +* Remove `X::AccountUploader::MIME_TYPE_MAP` +* Remove `X::Uploader::MediaUpload::PROCESSING_INFO_STATES`; use `X::UploadedMedia#processing?` +* Remove `X::Uploader::MediaUpload::MAX_RETRIES`; a chunk is retried up to the client's `max_retries` +* Remove `X::HTTPError#code`; use `#status`, an Integer, or `error.http_response.code` +* Remove `X::HTTPError#error_message` and `#message_from_json_response`, and make `#json?` private +* Remove `X::RateLimit#retry_after`; read `#reset_in` +* Remove the setters of `X::Client`; derive a client that differs with `X::Client#with` + * `api_key=`, `api_key_secret=`, `access_token=`, `access_token_secret=`, `bearer_token=`, `client_id=`, `client_secret=`, `refresh_token=` + * `base_url=`, `default_array_class=`, `default_object_class=`, `open_timeout=`, `read_timeout=`, `write_timeout=`, `proxy_url=`, `debug_output=`, `max_redirects=` +* Remove the setters of `X::Connection`, `X::RateLimit`, `X::BearerTokenAuthenticator`, `X::OAuth1Authenticator`, and `X::OAuth2Authenticator` +* Remove `X::Connection::DEFAULT_HOST` and `DEFAULT_PORT` +* Remove `X::OAuth1Authenticator#access_token`; `user_id` reads the user the token acts for +* Remove `X::OAuthAuthenticator::OAUTH_SIGNATURE_ALGORITHM`, `OAUTH_VERSION`, and `OAUTH_SIGNATURE_METHOD` +* Remove `X::OAuth2Authenticator::REFRESH_GRANT_TYPE` +* Remove the `base64` dependency + +### Fixed +* Keep the method and body of a `PUT` or `DELETE` that a 301 or 302 redirects, as RFC 9110 has it + * A `POST` redirected by a 301 or 302, and any request redirected by a 303, is still followed with a `GET` +* Resolve a relative redirect against the URL of the request, rather than the base URL +* Raise `X::HTTPError` for a redirect that cannot be followed, instead of `KeyError`, `URI::InvalidURIError`, or `ArgumentError` + * A 300, 304, or 305, or one whose `Location` is missing, invalid, or not HTTP or HTTPS +* Send the credentials on every redirected request, not only the first +* Drop the credentials and any `Authorization`, `Cookie`, or `Proxy-Authorization` header on a redirect to another origin +* Preserve the headers passed to `get`, `post`, `put`, and `delete` across redirects +* Send no `Authorization` header from a client without credentials, rather than an empty one +* Send a `Content-Type` header only with a request that carries a body +* Build the message of an `X::HTTPError` from a body that is not JSON, or whose errors have no `message` + * Instead of raising `JSON::ParserError`, `KeyError`, or `TypeError` in place of the error +* Raise `X::ClientError` or `X::ServerError` for an unnamed 4xx or 5xx status, such as 411 or 501, instead of `X::HTTPError` +* Raise the errors of `on_response`, a request's block, or the object class as they were raised + * The request is not retried, rate-limited, or refreshed, and a stream does not reconnect for them +* Send a query parameter without a value, as the `flag` of `get("users?flag")`, without `=` +* Connect to a host or proxy named by an IPv6 literal, such as `http://[::1]:8080/`; `x-core` depends on net-http 0.9.1 or later +* End a `base_url` without a trailing slash with one, so `https://api.x.com/2` requests `/2/users/me` +* Take the proxy from `https_proxy` for HTTPS requests and `http_proxy` for HTTP, and honor `no_proxy` +* Connect to an `https://` proxy over TLS +* Decode a percent-encoded proxy user and password +* Leave the proxy user and password out of the message of an invalid proxy URL, and raise `ArgumentError` for one +* Sign a form-encoded request body with OAuth 1.0a +* Sign every value of a repeated query parameter +* Sign the normalized URL, so a request to a host with no path signs `/` +* Form-encode the client credentials before Basic authentication on token refresh, as RFC 6749 Section 2.3.1 requires +* Refresh an OAuth 2.0 token through the connection of the client that sends the request, with its proxy and timeouts +* Declare `X::BadGateway` and `X::GatewayTimeout` as `X::ServerError`s in the signatures +* Declare the standard libraries each gem's signatures refer to in its `sig/manifest.yaml` +* Link each gem's `changelog_uri` to the `main` branch rather than `master` +* Parse uploader responses into Hashes and Arrays whatever the client's `default_object_class` and `default_array_class` +* Return nil from `update_profile_image` and `update_profile_banner`; look the user up to read what it holds +* Raise `X::InvalidMedia` from `update_profile_image` and `update_profile_banner` for an empty or oversized file + * A profile image larger than 700 KB or a banner larger than 5 MB + * `X::InvalidMediaType` for one that is not a GIF, JPEG, or PNG; `ArgumentError` for a non-integer banner offset or size +* Accept the `amplify_video` media category, and subtitle such a video with `add_subtitles` +* Upload every media type the API documents: WebM, QuickTime, and MPEG-TS videos, WebVTT subtitles, and BMP, TIFF, and progressive JPEG images +* Upload an `.m4v` file as an MP4 video, in chunks +* Raise `X::InvalidMediaType` before a request for `.glb` and `.usdz` files, and for `.avi` and `.mkv` files unless they begin with the signature of a type the API documents, as WebM does +* Upload subtitles in chunks as `text/srt`, the type the API names +* Send the media category in lowercase, as the API documents it +* Raise `X::InvalidMedia` from the uploaders, before any request, for media that cannot be uploaded + * An empty file, a directory, a file that cannot be read, or an IO open for writing alone + * A `Pathname` of a missing file, as for a `String`, instead of `TypeError` + * An image over 5 MB, a GIF over 15 MB, or subtitles over 1 MB +* Raise `X::MissingMediaData` before a chunk is uploaded when the initialize response holds no media, instead of `NoMethodError` +* Give up waiting for media to process after the `processing_timeout:` (default 600 seconds), raising `X::MediaProcessingTimeout` + * Taken by `upload`, `await_processing`, `await_processing!`, `upload_media`, and `await_media_processing(!)` + * `processing_timeout:` takes a finite number of seconds of at least 0, or nil for no limit + * Anything else, `Float::INFINITY` included, raises `ArgumentError` before the first request + * Checks wait at least a second apart when X asks for no wait + * Only `succeeded` and `failed` end the wait; any other state, even one X does not document, is still `processing?` +* Retry a failed chunk, and the finalize request, with the client's `max_retries`, backing off rather than retrying at once +* Upload the chunks of a video four at a time, or the `concurrency:` of `chunked_upload` (at most 16), instead of a thread per chunk + * A chunk that fails stops the chunks not yet begun +* Stop the chunks of a chunked upload when the waiting thread is interrupted, such as by a timeout + * Including a `ThreadError` or interrupt while the chunk threads are being started + * A chunk storing the tokens of a refresh it made with `save_tokens` finishes storing them first + * A chunk reading from the file finishes reading first, so the file is closed rather than left open +* Raise `ArgumentError` from `chunked_upload`, before any request, for an invalid `chunk_size` or `concurrency` + * `chunk_size` must be a positive Integer of at most 5,242,880 bytes, within the 10,000 segments the API numbers + * `concurrency` must be an Integer from 1 to `MAX_CONCURRENCY` (16) +* Read the size of chunked upload media once, so a growing file uploads the declared `total_bytes` +* Give a class that includes `X::Uploader::MediaUpload` or `X::Uploader::Account` their public methods alone, so its own methods do not break uploads + +## [0.19.0] - 2026-03-01 +* Add streaming support for filtered stream and volume stream endpoints + +## [0.18.0] - 2026-01-06 +* Add OAuth 2.0 authentication with token refresh support (d4c03cb) +* Add AccountUploader for profile image and banner uploads (7833dd2) +* Raise InvalidMediaType error for unsupported file extensions (2b6eacc) +* Prioritize errors array over title/detail in error messages (75279b9) + +## [0.17.0] - 2025-12-02 +* Add MediaUploader.upload_binary method (9f2f108) +* Don't forward filename during media upload (492214d) + +## [0.16.0] - 2025-06-24 +* Remove media_type parameter from non-chunked upload and append methods (f1f38b5) +* Fix media upload (dcb418a) +* Add await_processing! method to handle media upload failures (6cfc973) +* Move media_category in body for media upload (b790636) + +## [0.15.4] - 2025-05-02 +* Use dedicated endpoints for chunked media upload (d54d0d0) + +## [0.15.3] - 2025-04-24 +* Add missing base64 dependency (3ca8512) +* Set binary read for media files to be uploaded (fd066e6) + +## [0.15.2] - 2025-03-28 +* Use media_id instead of media_key to upload media (f1dd577) + +## [0.15.1] - 2025-03-24 +* Fix bug in MediaUploader#await_processing (136dff8) +* Refactor RedirectHandler#build_request (fd379c3) +* Escape space in query string as %20, not + (2d2df75) +* Don't escape commas in query parameters (e7d9056) + ## [0.15.0] - 2025-02-06 * Change media upload to use the API v2 endpoints (eca2b88) @@ -21,7 +665,6 @@ * Add `code` attribute to error classes (b003639) ## [0.11.0] - 2023-10-24 - * Add base Authenticator class (8c66ce2) * Consistently use keyword arguments (3beb271) * Use patern matching to build request (4d001c7) @@ -34,71 +677,88 @@ * Make Connection class threadsafe (d95d285) ## [0.10.0] - 2023-10-08 - * Add media upload helper methods (6c6a267) * Add PayloadTooLargeError class (cd61850) ## [0.9.1] - 2023-10-06 - * Allow successful empty responses (06bf7db) * Update default User-Agent string (296b36a) * Move query parameter escaping into RequestBuilder (56d6bd2) ## [0.9.0] - 2023-09-26 - * Add support for HTTP proxies (3740f4f) ## [0.8.1] - 2023-09-20 - * Fix bug where setting Connection#base_uri= doesn't update the HTTP client (d5a89db) ## [0.8.0] - 2023-09-14 - * Add (back) bearer token authentication (62e141d) * Follow redirects (90a8c55) * Parse error responses with Content-Type: application/problem+json (0b697d9) ## [0.7.1] - 2023-09-02 - * Fix bug in X::Authenticator#split_uri (ebc9d5f) ## [0.7.0] - 2023-09-02 - * Remove OAuth gem (7c29bb1) ## [0.6.0] - 2023-08-30 - * Add configurable debug output stream for logging (fd2d4b0) * Remove bearer token authentication (efff940) * Define RBS type signatures (d7f63ba) ## [0.5.1] - 2023-08-16 - * Fix bearer token authentication (1a1ca93) ## [0.5.0] - 2023-08-10 - * Add configurable write timeout (2a31f84) * Use built-in Gem::Version class (066e0b6) ## [0.4.0] - 2023-08-06 - * Refactor Client into Authenticator, RequestBuilder, Connection, ResponseHandler (6bee1e9) * Add configurable open timeout (1000f9d) * Allow configuration of content type (f33a732) ## [0.3.0] - 2023-08-04 - * Add accessors to X::Client (e61fa73) * Add configurable read timeout (41502b9) * Handle network-related errors (9ed1fb4) * Include response body in errors (a203e6a) ## [0.2.0] - 2023-08-02 - * Allow configuration of base URL (4bc0531) * Improve error handling (14dc0cd) ## [0.1.0] - 2023-08-02 - * Initial release + +[1.0.0]: https://github.com/sferik/x-ruby/compare/v0.19.0...v1.0.0 +[0.19.0]: https://github.com/sferik/x-ruby/compare/v0.18.0...v0.19.0 +[0.18.0]: https://github.com/sferik/x-ruby/compare/v0.17.0...v0.18.0 +[0.17.0]: https://github.com/sferik/x-ruby/compare/v0.16.0...v0.17.0 +[0.16.0]: https://github.com/sferik/x-ruby/compare/v0.15.4...v0.16.0 +[0.15.4]: https://github.com/sferik/x-ruby/compare/v0.15.3...v0.15.4 +[0.15.3]: https://github.com/sferik/x-ruby/compare/v0.15.2...v0.15.3 +[0.15.2]: https://github.com/sferik/x-ruby/compare/v0.15.1...v0.15.2 +[0.15.1]: https://github.com/sferik/x-ruby/compare/v0.15.0...v0.15.1 +[0.15.0]: https://github.com/sferik/x-ruby/compare/v0.14.1...v0.15.0 +[0.14.1]: https://github.com/sferik/x-ruby/compare/v0.14.0...v0.14.1 +[0.14.0]: https://github.com/sferik/x-ruby/compare/v0.13.0...v0.14.0 +[0.13.0]: https://github.com/sferik/x-ruby/compare/v0.12.1...v0.13.0 +[0.12.1]: https://github.com/sferik/x-ruby/compare/v0.12.0...v0.12.1 +[0.12.0]: https://github.com/sferik/x-ruby/compare/v0.11.0...v0.12.0 +[0.11.0]: https://github.com/sferik/x-ruby/compare/v0.10.0...v0.11.0 +[0.10.0]: https://github.com/sferik/x-ruby/compare/v0.9.1...v0.10.0 +[0.9.1]: https://github.com/sferik/x-ruby/compare/v0.9.0...v0.9.1 +[0.9.0]: https://github.com/sferik/x-ruby/compare/v0.8.1...v0.9.0 +[0.8.1]: https://github.com/sferik/x-ruby/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/sferik/x-ruby/compare/v0.7.1...v0.8.0 +[0.7.1]: https://github.com/sferik/x-ruby/compare/v0.7.0...v0.7.1 +[0.7.0]: https://github.com/sferik/x-ruby/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/sferik/x-ruby/compare/v0.5.1...v0.6.0 +[0.5.1]: https://github.com/sferik/x-ruby/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/sferik/x-ruby/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/sferik/x-ruby/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/sferik/x-ruby/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/sferik/x-ruby/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/sferik/x-ruby/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..fb003e25 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# Contributing + +## Getting started + +1. Clone the repo: + + git clone git@github.com:sferik/x-ruby.git + +2. Enter the repo’s directory: + + cd x-ruby + +3. Install dependencies via Bundler: + + bin/setup + + The root, `x-core`, `x-uploader`, `x-streaming`, and `x-objects` each have their own bundle, so `bundle update` in the root updates only the root bundle. To update them all: + + bin/update + +4. Run the default Rake task to ensure all tests pass: + + bundle exec rake + + Each of `x-core`, `x-uploader`, `x-streaming`, and `x-objects` has its own `Gemfile`, `Rakefile`, `Steepfile`, signatures, test suite, and mutation config, and can be checked on its own: + + cd x-core && bundle exec rake + + From the root, `rake test`, `rake mutant`, `rake steep`, and `rake yardstick` run each gem's task inside that gem's directory, with that gem's bundle. Append a gem's name to run one, as in `rake test:x-core`, `rake steep:x-objects`, or `rake yardstick:x`. + + On GitHub, each gem's workflow runs only when that gem, or a gem it depends on, changes. The `x` workflow runs when the meta-gem or the code and signatures of any gem change, and the linter runs when any Ruby file changes. Every workflow runs when `VERSION` changes as well, so the commit that prepares a release runs them all, which the workflow that pushes the gems waits for. A commit that changes none of the files a workflow watches, such as a fix to the documentation made after that commit, runs none of them, so run each on it before tagging it, with `gh workflow run x-core.yml --ref main`, and `x-uploader.yml`, `x-streaming.yml`, `x-objects.yml`, `x.yml`, and `lint.yml` the same way. + +5. To release, write the new version to `VERSION`, run `rake update_versions` to write it into each gem's `version.rb`, record the release in each of the five changelogs, `CHANGELOG.md`, `x-core/CHANGELOG.md`, `x-uploader/CHANGELOG.md`, `x-streaming/CHANGELOG.md`, and `x-objects/CHANGELOG.md`, with the link to its changes at the foot of each, commit on `main`, and run `rake release`, which checks that the versions agree and that the branch is `main`, builds every gem, and tags the release. + +6. Create a new branch for your feature or bug fix: + + git checkout -b my-new-branch + +## Pull requests + +Bug reports and pull requests are welcome on GitHub at https://github.com/sferik/x-ruby. + +Pull requests will only be accepted if they meet all the following criteria: + +1. Code must conform to [Standard Ruby](https://github.com/standardrb/standard#readme). This can be verified with: + + bundle exec rake standard + +2. Code must conform to the [RuboCop rules](https://github.com/rubocop/rubocop#readme). This can be verified with: + + bundle exec rake rubocop + +3. 100% line, branch, and method coverage in each gem. This can be verified with: + + bundle exec rake test + +4. 100% mutation coverage in `x-core`, `x-uploader`, `x-streaming`, and `x-objects`. This can be verified with: + + bundle exec rake mutant + +5. RBS type signatures (in each gem's `sig` directory). This can be verified with: + + bundle exec rake steep + +6. 100% documentation coverage. This can be verified with: + + bundle exec rake yardstick + + `bundle exec rake yard` builds the documentation of every gem together into `doc`, or the directory `YARD_OUTPUT_DIR` names, as the Pages workflow publishes it. Each gem's `.yardopts` hides the API tagged `@api private`, so a module whose methods are public API of the class that includes it is tagged `@api semipublic`. diff --git a/Gemfile b/Gemfile index 887f0379..828851b7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,22 +1,33 @@ +# frozen_string_literal: true + source "https://rubygems.org" -# Specify your gem's dependencies in x.gemspec +# Specify the meta-gem's dependencies in x.gemspec gemspec -gem "base64", ">= 0.2" +gem "x-core", path: "x-core" +gem "x-uploader", path: "x-uploader" +gem "x-streaming", path: "x-streaming" +gem "x-objects", path: "x-objects" + gem "fiddle", ">= 1.1.2" gem "irb", ">= 1.14.1" -gem "minitest", ">= 5.19" -gem "mutant", ">= 0.12" -gem "mutant-minitest", ">= 0.11.24" +gem "minitest", ">= 6" gem "ostruct", ">= 0.6" gem "rake", ">= 13.0.6" -gem "rbs", ">= 3.2.1" gem "rubocop", ">= 1.21" gem "rubocop-minitest", ">= 0.31" gem "rubocop-performance", ">= 1.18" gem "rubocop-rake", ">= 0.6" -gem "simplecov", ">= 0.22" +gem "simplecov", ">= 1" gem "standard", ">= 1.35.1" -gem "steep", ">= 1.5.3" gem "webmock", ">= 3.18.1" +gem "yard", ">= 0.9" +gem "yardstick", ">= 0.9" + +# RBS and Steep run on CRuby alone, so they are left out of the bundle on any other engine, where the tests still +# run. Every CI job runs on CRuby, so the guard is for a contributor working on JRuby or TruffleRuby. +platforms :mri do + gem "rbs", ">= 4.0" + gem "steep", ">= 2.0" +end diff --git a/README.md b/README.md index cfa5c18b..2230bc94 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -[![tests](https://github.com/sferik/x-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/test.yml) -[![mutation tests](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml) +[![x-core](https://github.com/sferik/x-ruby/actions/workflows/x-core.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/x-core.yml) +[![x-uploader](https://github.com/sferik/x-ruby/actions/workflows/x-uploader.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/x-uploader.yml) +[![x-streaming](https://github.com/sferik/x-ruby/actions/workflows/x-streaming.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/x-streaming.yml) +[![x-objects](https://github.com/sferik/x-ruby/actions/workflows/x-objects.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/x-objects.yml) +[![x](https://github.com/sferik/x-ruby/actions/workflows/x.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/x.yml) [![linter](https://github.com/sferik/x-ruby/actions/workflows/lint.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/lint.yml) -[![typer checker](https://github.com/sferik/x-ruby/actions/workflows/steep.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/steep.yml) -[![maintainability](https://api.codeclimate.com/v1/badges/40bbddf2c9170742ca9e/maintainability)](https://codeclimate.com/github/sferik/x-ruby/maintainability) +[![maintainability](https://qlty.sh/gh/sferik/projects/x-ruby/maintainability.svg)](https://qlty.sh/gh/sferik/projects/x-ruby) [![gem version](https://badge.fury.io/rb/x.svg)](https://rubygems.org/gems/x) # A [Ruby](https://www.ruby-lang.org) interface to the [X API](https://developer.x.com) @@ -13,7 +15,7 @@ For updates and announcements, follow [this gem](https://x.com/gem) and [its cre ## Installation -Install the gem and add to the application's Gemfile: +The gems require Ruby 3.4 or later. Install the gem and add to the application's Gemfile: bundle add x @@ -21,9 +23,29 @@ Or, if Bundler is not being used to manage dependencies: gem install x +## Documentation + +The API documentation of every gem, `x-core`, `x-uploader`, `x-streaming`, and `x-objects` together, is at [sferik.github.io/x-ruby/api](https://sferik.github.io/x-ruby/api/). + +## Architecture + +The `x` gem is a thin meta-gem that combines four gems, which are released from this repository in lockstep: + +| Gem | What it does | Runtime dependencies | +| --- | --- | --- | +| [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core) | HTTP: authentication, requests, redirects, errors, and rate limits | `simple_oauth`, and `net-http`, a default gem | +| [`x-uploader`](https://github.com/sferik/x-ruby/tree/main/x-uploader) | Uploads: images, GIFs, videos, and subtitles, with videos and subtitles in chunks, plus profile images and banners | `x-core` | +| [`x-streaming`](https://github.com/sferik/x-ruby/tree/main/x-streaming) | Streams: the sample and filtered streams, reconnected as X recommends, and the rules of the filtered stream | `x-core` | +| [`x-objects`](https://github.com/sferik/x-ruby/tree/main/x-objects) | Resources: `User`, `Post`, `List`, `DirectMessage`, `Space`, `Community`, `Media`, `Poll`, `Place`, and cursors | `x-core` | + +`require "x"` loads all four, and mixes the object methods (`find_user`, `find_all_posts`, `search_posts`, …), the upload methods (`upload_media`, `add_alt_text`, `update_profile_image`, …), and `streaming` into `X::Client`. Any other request can return objects too, given a resource class as its `object_class`. If you only want raw JSON, depend on `x-core` alone. If you want the objects with an HTTP client of your own, depend on `x-objects` alone: it makes no request itself, and takes `x-core` for the errors the X gems share. It asks the client you give it for `get`, `post`, `put`, and `delete`, each taking a path that already carries the query, an optional body for the two that send one, and `array_class:` and `object_class:`, and may be passed any other keyword `X::Client` takes within 1.x; [the client contract](https://github.com/sferik/x-ruby/tree/main/x-objects#the-client-contract) has the whole of it, and the `X::Objects::_Client` interface in [`sig/x-objects.rbs`](https://github.com/sferik/x-ruby/blob/main/x-objects/sig/x-objects.rbs) states it for a type checker. + +Every class you write is named directly under `X`, whatever gem declares it: `X::Client` and `X::NotFound` from `x-core`, `X::User` and `X::MissingResource` from `x-objects`, `X::UploadedMedia` and `X::MediaProcessingFailed` from `x-uploader`, `X::StreamingClient` and `X::StreamError` from `x-streaming`. Each gem's module holds its own mixins and internals. `X::Objects::Error`, `X::Uploader::Error`, and `X::Streaming::Error` are the three exceptions, since each is the name a `rescue` reaches for to catch the failures of that gem alone. + ## Usage -First, obtain X credentails from . +> [!NOTE] +> First, obtain X credentials from . ```ruby require "x" @@ -37,21 +59,279 @@ x_credentials = { # Initialize an X API client with your OAuth credentials x_client = X::Client.new(**x_credentials) +``` + +### Objects + +Every lookup requests all public fields and expansions, and returns immutable, thread-safe objects. + +```ruby +user = x_client.find_user("sferik") # a String is a username, an Integer is an ID + # find_user_by_username and find_user_by_id say which you mean +user.name # => "Erik Berlin" +user.followers_count # => 12345 + +post = x_client.find_post(1234567890) # X::Post +post.text # the full text, even of a post longer than 280 characters +post.created_at # => 2026-09-11 12:00:00 UTC +``` + +**Text.** Objects read every String as the API sends it, and the API escapes `&`, `<`, and `>` as `&`, `<`, and `>` in the text of a post and of a direct message, so `post.text`, `post.expanded_text`, and `message.text` hold them, and will throughout 1.x. Unescape the text to display it: + +```ruby +require "cgi/escape" + +post.text # => "Ruby & Rails" +CGI.unescapeHTML(post.text) # => "Ruby & Rails" +``` + +**Nested data.** An object the API nests in a resource, or a list of them, such as `post.entities`, `post.urls`, `post.public_metrics`, `post.attachments`, `media.variants`, or `poll.options`, reads as the API sends it, a frozen Hash keyed by String, or an Array of them, and will throughout 1.x; a response that holds anything else in its place raises `X::InvalidAttribute`. A later 1.x release may add a reader that returns an object for some of it, as `post.matching_rules` returns `X::MatchingRule`s, but under a new name. + +```ruby +post.public_metrics["like_count"] # => 3, which post.like_count reads too +post.urls.map { |url| url["expanded_url"] } +``` + +**Any endpoint.** For an endpoint without a method, pass a resource class as the `object_class` of a request. A response that holds one resource comes back as an object, and one that holds a list comes back as an `X::Page` of the page you requested, which reads as an array does, and holds the `next_token` of the page after it. The object holds only the fields you asked for, so `hydrate` fetches the rest. + +```ruby +user = x_client.get("users/by/username/sferik", object_class: X::User) +user.followers_count # => nil, since the request didn't ask for it +user.hydrate.followers_count # => 12345 + +blocked = x_client.get("users/#{user.id}/blocking", object_class: X::User) # => # +blocked.first # => # +x_client.get("users/#{user.id}/blocking", params: {pagination_token: blocked.next_token}, object_class: X::User) +``` + +**Identity.** Resources with the same class and ID are equal (`==`, `eql?`, and `hash`), even when they come from different requests. + +```ruby +post.author == x_client.find_user("sferik") # => true +[post.author, user].uniq.size # => 1 +``` + +**References.** Foreign keys have accessors that return objects. When the response included the referenced object, you get it with its fields. Otherwise, you get a stub that holds only the ID. `hydrate` fetches the full object once and memoizes it. `refresh` fetches it again. + +```ruby +post.author # => # +post.replied_to # => #, or a stub of the ID if left out +post.replied_to.hydrate.text # one request +post.replied_to.hydrate.text # memoized, no request +post.replied_to.refresh.text # forces a new request +``` + +Within one response, every reference to the same resource is the same object, so hydrating a user from one post hydrates it for every post on that page. + +**Pagination.** Collections are `X::Cursor` objects, which include `Enumerable`, remember the client that fetched them, and fetch pages lazily. Iterating requests the maximum page size, and pages are cached, so iterating twice costs no extra requests. `first(n)` and `take(n)` request a page of `n` instead, raised to the endpoint's minimum, and keep it, so an iteration after them pays for none of it again, and the first page of `each_page` holds what they read. `published_count` reads the number the API publishes for a collection, such as a user's `followers_count`, without paging through it, while `count` pages through the whole collection, as `Enumerable` does. A cursor has no `size`, so sizing an enumerator over it, such as `each_slice(2).size`, never pages it. `refresh` returns a cursor with an empty cache, and `ids` fetches nothing but identifiers. + +```ruby +followers = user.followers # max_results=1000 per page +followers.first(10) # one request for ten users +followers.empty? # one request for one user, as any? and none? make +followers.to_a # fetches the remaining pages +followers.to_a # cached, no requests +followers.refresh.to_a # starts over +followers.published_count # => 12345, the user's followers_count, with no request +followers.ids # => [14100886, ...], requesting only identifiers + +x_client.search_posts("ruby -is:retweet").each { |post| puts post.text } +x_client.search_users("ruby").first(10) +x_client.search_communities("ruby").first(10) +x_client.search_spaces("ruby", state: "live").first(10) # a client that signs with OAuth 1.0a searches as the app +x_client.find_all_spaces_by_creator([user, other]) # the live and scheduled spaces the users created +x_client.trends(1).first(10) # the topics trending worldwide, by WOEID, as X::Trend +x_client.personalized_trends # the topics X picks for the authenticated user +user.affiliates.first(10) # the users whose affiliation names the user +x_client.current_user!.home_timeline.first(10) +``` +**Stubs.** `from_id` refers to a resource without a request, so a cursor can be built from an identifier alone. A reference the response did not expand is a stub too, and `stub?` tells the two apart. + +```ruby +X::User.from_id(7505382, client: x_client).followers.ids +post.author.stub? # => false when the response included the author +X::User.hydrate_all(posts.map(&:author), client: x_client) # looks up what is not hydrated, included authors too, in one batch +X::User.hydrate_all(posts.filter_map(&:author).select(&:stub?), client: x_client) # looks up only the authors not included +message.peer(x_client.current_user!) # the other participant of a direct message +``` + +**Costs.** The X API bills each resource a request returns, so the size of a page is the size of the bill. `first(n)` and `take(n)` read a page of `n`, raised to the smallest page the endpoint takes, such as 5 for a user's posts and 10 for a search, so a smaller `n` is billed for that page. Iterating a cursor, `to_a`, and `ids` read the whole collection, up to 1,000 users a page for followers and following. `count` reads the whole collection too, a request per page, billed for every resource, so counting a user with a million followers reads a million users in a thousand requests. `published_count` reads the number the API publishes instead, for a user's followers, followed users, and list memberships, and for a list's members and followers, which costs nothing when the user or list holds it and one lookup when it is a stub, and it returns nil for any other collection. The published number counts what the collection holds, which can differ from what reading it finds, since the API leaves out what the authenticated user cannot see. Other `Enumerable` methods, such as `find`, `include?`, and `lazy`, cannot know how much they will read, so they request the largest page too; pass `max_results:` to read less per request. `user.follows?(other)` takes one lookup when either user is the authenticated user, by reading the other's `connection_status`, and otherwise scans the users `user` follows. The API has no lookup for a list membership, so `list.member?(user)` scans: the members of a private list, and for a public list, whichever is smaller of its members and the lists the user is on, as `member_count` and `listed_count` tell. Pass either scan `max_pages:` to read no more pages than that: one that reaches the limit with pages left raises `X::PageLimitReached`, rather than answer from the pages it read. + +```ruby +user.followers.first(10) # ten users +user.followers.published_count # the user's followers_count, reading no follower +user.followers.count # every follower, a request per 1,000 +user.muting.published_count # => nil, since the API publishes no number +x_client.current_user!.follows?(other) # one lookup +other.follows?(x_client.current_user!) # one lookup +other.follows?(someone) # scans everyone other follows +other.follows?(someone, max_pages: 5) # scans five pages at most, or raises X::PageLimitReached +user.followers(max_results: 100).find { |follower| follower.verified? } +``` + +The API bills a resource once per UTC day, however often it is read, and bills only the resources a response returns as data, not the ones it includes, so expanding `post.author` costs nothing extra. Batch lookups ask for each ID once. Reads of the authenticated user's own data cost $0.001 a resource, less than most other reads, when that user owns the app: their posts, mentions, likes, bookmarks, followers, following, blocks, mutes, and lists. So `x_client.current_user!.posts` is cheaper than searching for `from:sferik`. Actions take the authenticated user's ID from the prefix of an OAuth 1.0a access token, and so need no request to `users/me`. + +Writes are billed by the request, and cost more than reads. Creating a post costs $0.015, or $0.20 when its text holds a URL, so a link costs more than ten times as much as the post around it. A like, repost, follow, or direct message costs $0.015, and undoing one costs $0.01. Prices change, so check the [pricing page](https://docs.x.com/x-api/getting-started/pricing) before a large run. + +**Post usage.** `post_usage` reports how many posts the app's project has read this billing cycle, against its monthly cap, and how many it read each day. It returns nil when the response holds no usage, yielding the problems the API reported to a block, as `current_user` does, and `post_usage!` raises `X::MissingResource` instead. + +```ruby +usage = x_client.post_usage!(days: 30) +usage.project_cap - usage.project_usage # the posts left to read this cycle +usage.daily # => {2026-09-14 00:00:00 UTC => 1234, ...} +usage.daily_by_app # the same, keyed by the ID of each of the project's apps +``` + +**Counting posts.** `count_posts` counts the posts from the last seven days that match a query, and `count_all_posts` counts every post, which needs full-archive access. Each costs one request per page of periods, whatever the count, and a count of many years of the archive takes many pages, so every count, `count_posts`, `count_all_posts`, and their `_by_period` forms, takes `max_pages:`, past which it raises `X::PageLimitReached`. The counts and usage endpoints refuse OAuth 1.0a, so a client that signs with it makes those requests through `app_only`, a copy of itself that authenticates as the app. The client fetches the app's bearer token the first time and reuses it. + +```ruby +x_client.count_posts("ruby") # => 12345 +x_client.count_posts_by_period("ruby", granularity: "hour") # => {2026-09-14 12:00:00 UTC...2026-09-14 13:00:00 UTC => 42, ...} +X::Post.count_all("ruby", client: x_client, start_time: "2020-01-01T00:00:00Z") +X::Post.count_all("ruby", client: x_client, max_pages: 3) # three requests at most +``` + +**Missing resources.** A finder returns nil when the resource does not exist, and its bang form raises `X::MissingResource`, an `X::Error`. The API answers a lookup of a resource that is not there with 200 OK and no data, so this is not `X::NotFound`, which is the 404 of an endpoint that is not there. A request that creates a resource, such as `create_post`, `create_list`, or `create_dm`, raises `X::MissingResource`, holding the response's problems, if the API answers with success and no resource, so it never returns nil. A value a response holds that cannot be read as what the API documents it to be, such as a timestamp that is not ISO 8601, raises `X::InvalidAttribute`, an `X::Error` too, from the reader that reads it. A page that names the token of a page already fetched as its next raises `X::UnreadableResponse`, the base of `X::InvalidAttribute`, rather than page forever. A list the response omitted, such as `space.host_ids` or `post.urls`, reads as empty rather than nil. + +```ruby +x_client.find_user("nobody") # => nil +x_client.find_user!("nobody") # raises X::MissingResource +x_client.find_user("not a username") # raises ArgumentError, before any request +post.permalink # => "https://x.com/sferik/status/1234567890" +post.expanded_text # the text with every t.co link replaced by the URL it stands for +post.display_text_range # => 0...140, the Range of the text that is shown, or nil +``` + +**Partial errors.** A response can succeed and still report problems, such as a pinned post that was deleted or one ID of a batch that does not exist. `problems` returns them as `X::Problem` objects: on a resource, the ones whose `resource_id` or `value` is its identifier or that of a resource it refers to, such as the author of a post, and the ones that name no identifier, and on each page of a cursor, every one the response reported. A finder yields each one to a block, and `X::MissingResource#problems` holds the ones that explain a missing resource. A request the API refuses raises an `X::HTTPError` whose `problems` are every one the response named, such as each parameter it refused, and whose `problem` is the one the response describes as a whole, or, for a response that names errors alone, as the v1.1 API sends, the first of them; a 402 Payment Required, which X sends for a request the account has no credit left to pay for, raises `X::PaymentRequired`. + +```ruby +user = x_client.find_user("sferik") +user.problems # => [#] +x_client.find_all_users(ids) { |problem| warn problem.detail if problem.not_found? } +x_client.find_user!("nobody") # raises X::MissingResource: Could not find X::User @nobody: Could not find user with username: [nobody]. +``` + +**Parallel requests.** Batch lookups split the IDs into groups of 100, the API maximum, and request four groups at a time, as the chunks of an upload are sent four at a time. Pass a `concurrency` to request more or fewer at once: a lower number spends a rate limit more slowly. Paginated endpoints return a token for the next page with each page, so their pages must be fetched in order. `prefetch` fetches the next page in a background thread while you process the current one. It is opt-in because it spends one extra request when you stop iterating early. + +```ruby +x_client.find_all_users(follower_ids) # parallel batches of 100, in the order asked for +x_client.find_all_users(follower_ids, concurrency: 1) # one batch at a time +X::Post.find_all(ids, client: x_client, concurrency: 8) +user.followers.prefetch.each { |follower| process(follower) } +``` + +**Actions.** Actions are taken as the authenticated user, so the client takes them. They take a resource of the class they act on, or its identifier, an Integer or a String of digits, and raise `ArgumentError` for anything else, such as a username, or a resource of another class, such as a list passed to `follow`, before any request but the lookup of the authenticated user a client's action may make first, so look a user up with `find_user` first. + +```ruby +x_client.follow(user) +x_client.block(user) +x_client.like(post) +x_client.repost(post) # => true, as each action reports whether it took +x_client.bookmark(post) # bookmarking takes OAuth 2.0 user context, which this client does not hold +me = x_client.current_user! # looked up each time it is called, so keep it +folder = me.bookmark_folders.first +me.bookmarks(folder:).first(10) if folder # the posts in a bookmark folder, when the user has one + +post = x_client.create_post("Hello, World! (from @gem)") +reply = x_client.create_post("Hello back!", reply_to: post, media_ids: [media]) +quote = x_client.create_post("Worth reading", quote: post) +photo = x_client.create_post(media_ids: [media]) # a post needs no text when it has media +x_client.hide_reply(reply) # as the author of the post it replies to +post.delete + +list = x_client.create_list("Rubyists", private: true) +list.update(description: "People who write Ruby") # also name and private +list.add_member(user) +x_client.pin_list(list) # also unpin_list, follow_list, and unfollow_list +list.delete + +message = x_client.create_dm(user, "Hello!") # create_direct_message, shortened +x_client.dms_with(user).first(10) # the conversation with a user + +group = x_client.create_group_dm([user, other], "Hello, both of you!") # create_group_direct_message, shortened +x_client.create_dm_in(group, "Anyone free on Friday?") # to the conversation of a message, or its identifier +x_client.dms_in(group).first(10) # the messages of a conversation, one-to-one or group +``` + +### Raw JSON + +`X::Client` still speaks raw JSON, for endpoints without objects or when you want full control. + +```ruby # Get data about yourself x_client.get("users/me") # {"data"=>{"id"=>"7505382", "name"=>"Erik Berlin", "username"=>"sferik"}} -# Post -post = x_client.post("tweets", '{"text":"Hello, World! (from @gem)"}') -# {"data"=>{"edit_history_tweet_ids"=>["1234567890123456789"], "id"=>"1234567890123456789", "text"=>"Hello, World! (from @gem)"}} +# Query parameters drop nil values and join arrays with commas +x_client.get("users", params: {ids: [7505382, 12], "user.fields": %w[id username]}) + +# Post, with a Hash encoded as JSON +post = x_client.post("tweets", {text: "Hello, World! (from @gem)"}) +# {"data"=>{"edit_history_post_ids"=>["1234567890123456789"], "id"=>"1234567890123456789", "text"=>"Hello, World! (from @gem)"}} # Delete the post x_client.delete("tweets/#{post["data"]["id"]}") # {"data"=>{"deleted"=>true}} -# Initialize an API v1.1 client -v1_client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **x_credentials) +# Derive an API v1.1 client +v1_client = x_client.with(base_url: "https://api.x.com/1.1/") + +# Post a form +v1_client.post("account/settings.json", form: {lang: "en"}) + +# Authenticate as the app, with a bearer token fetched with the API key and secret the first time, and again once X +# rejects it +x_client.app_only.get("tweets/search/stream/rules") + +# Authenticate with OAuth 2.0, refreshing the access token when it expires or the API rejects it, +# and store the X::OAuth2Tokens of each refresh, since X accepts a refresh token only once; X::AuthorizationError +# means X refused to refresh, so rescue it beside X::Unauthorized to ask the user to authorize the app again, and a +# token endpoint that fails to answer raises the X::HTTPError of its status, such as the X::ServerError the client +# retries, or X::InvalidResponse for the page of a captive portal +oauth2_client = X::Client.new(client_id: "ID", client_secret: "SECRET", access_token: "TOKEN", refresh_token: "REFRESH", + expires_at: Time.now + 7200, save_tokens: ->(tokens) { store(tokens.access_token, tokens.refresh_token, tokens.expires_at) }) + +# Share the tokens of a user among processes: a refresh reads the store with load_tokens first, which returns the +# X::OAuth2Tokens stored, or nil, and takes the tokens another process stored in place of refreshing with a refresh +# token that process already spent +shared_tokens_client = X::Client.new(client_id: "ID", **load(user).to_h, + save_tokens: ->(tokens) { store(user, tokens) }, load_tokens: -> { load(user) }) + +# Store the tokens as JSON, which X::OAuth2Tokens.from_json reads back, the expiration time and all +json_tokens_client = X::Client.new(client_id: "ID", **X::OAuth2Tokens.from_json(redis.get(user)).to_h, + save_tokens: ->(tokens) { redis.set(user, tokens.to_json) }) + +# An access token issued without offline.access comes with no refresh token, and acts for the user until it expires +short_lived_client = X::Client.new(client_id: "ID", access_token: "TOKEN") + +# Authenticate with an authenticator built elsewhere, in place of credentials; clients given the same one share its +# tokens, and each refresh reaches the save_tokens of every one of them +authenticator = X::OAuth2Authenticator.new(client_id: "ID", access_token: "TOKEN", refresh_token: "REFRESH") +shared_client = X::Client.new(authenticator:, save_tokens: ->(tokens) { store(tokens.refresh_token) }) + +# Authenticate with a scheme of your own: subclass X::Authenticator and return the headers that authenticate a request, +# reading of the request its http_method, uri, body, and [] for a header, which are all it answers +class VaultAuthenticator < X::Authenticator + def headers(_request) = {AUTHENTICATION_HEADER => "Bearer #{Vault.read("x/bearer_token")}"} +end +vault_client = X::Client.new(authenticator: VaultAuthenticator.new) + +# Ask a user to authorize the app with OAuth 2.0 and PKCE, keeping the state and code verifier until X redirects back +authorization = X::OAuth2Authorization.new(client_id: "ID", redirect_uri: "https://example.com/callback", + scopes: %w[tweet.read tweet.write users.read offline.access]) +session[:state] = authorization.state +session[:code_verifier] = authorization.code_verifier +redirect_to authorization.url + +# Then, where X redirects back, exchange the code for a client that acts for the user; save_tokens is passed the +# tokens of the exchange, whose refresh_token is nil without offline.access, and those of each refresh after, and +# X::TokenReportFailed holds the client and the tokens when it raises for either, so neither is lost with the code or +# the refresh token they replaced +authorization = X::OAuth2Authorization.new(client_id: "ID", redirect_uri: "https://example.com/callback", + state: session[:state], code_verifier: session[:code_verifier]) +user_client = authorization.client(request.url, save_tokens: ->(tokens) { store(tokens.refresh_token) }) +user_client.scopes # => the scopes the user granted, which may be fewer than the app asked for # Define a custom response object Language = Struct.new(:code, :name, :local_name, :status, :debug) @@ -64,17 +344,215 @@ languages = v1_client.get("help/languages.json", object_class: Language, array_c languages.first.local_name # Initialize an Ads API client -ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **x_credentials) +ads_client = X::Client.new(base_url: "https://ads-api.x.com/12/", **x_credentials) # Get your ad accounts ads_client.get("accounts") + +# Keep a connection open for the next request for five seconds rather than 30, behind a proxy that closes idle +# connections sooner than X does +patient_client = X::Client.new(keep_alive_timeout: 5, **x_credentials) + +# Send headers with every request, such as one that names your application. They are defaults: a header of the same +# name passed to a request, or to a stream, is sent in place of the client's, and each of the client's is sent in +# place of a default of the gem, such as its User-Agent, whatever the case each is named in. They, and the gem's +# User-Agent, are sent with the requests that fetch and refresh the client's tokens too, as a gateway may require. +named_client = X::Client.new(headers: {"User-Agent" => "my-app/1.0 (+https://example.com)"}, **x_credentials) +named_client.get("users/me", headers: {"X-Trace" => "abc"}) # sends both +# with(headers:) replaces the headers of the client rather than adding to them, so give the copy every header it sends +traced_client = named_client.with(headers: named_client.headers.merge("X-Trace" => "abc")) # sharing its connections + +# Send requests through a proxy. A client that is given none takes the proxy the environment names for the scheme of +# each request, in https_proxy or http_proxy, and reaches the hosts that no_proxy names directly. +proxied_client = X::Client.new(proxy_url: "http://user:password@proxy.example.com:8080", **x_credentials) + +# Close the connections a client keeps open between requests, which the copies `with` makes of it share when they +# open their connections alike; a later request opens one again +x_client.close ``` +**A client never changes.** It keeps the credentials and settings it was built with for as long as it lives, so a request never runs against a setting another thread is halfway through changing. `with` derives a client that differs, and takes anything `X::Client.new` takes. + +```ruby +v1_client = x_client.with(base_url: "https://api.x.com/1.1/") +rotated = x_client.with(access_token: "new_token", access_token_secret: "new_secret") +``` + +**A client keeps its credentials to its own origin.** An endpoint that names a whole URL is sent to that URL, and it carries the client's credentials only when it names the scheme, host, and port of the `base_url`, which the API version above does. An endpoint, or a stream, of any other origin is sent without the `Authorization` header the authenticator signs and without any `Authorization`, `Cookie`, or `Proxy-Authorization` header of the client or the request, as a redirect that leads off the origin is, so the credentials of the API never reach a host they were not meant for. A client built with another `base_url`, such as the Ads API client above, carries its credentials to that origin. + +**A client keeps its credentials to itself.** It has no reader for a secret it holds, and `inspect` names its authenticator without revealing what it signs with, so nothing that reflects over a client reads a credential out of one. `with` carries them to a client derived from it, and the hook given to `save_tokens` is passed the `X::OAuth2Tokens` of the refresh it reports. + +```ruby +x_client.inspect # => #> +x_client.api_key # the API key, the client ID, and the expiration time are public +x_client.api_key_secret # raises NoMethodError, as every secret a client holds does +``` + +### Media + +```ruby +# The media category is inferred from the file: an image, an animated GIF, a video, or subtitles. +# A GIF with a single frame is uploaded as an image, since X processes only animated GIFs as GIFs. +media = x_client.upload_media("cat.jpg", alt_text: "A cat asleep on a keyboard") +media.id # => 1880028106020515840, where media["id"] is the String the API gave +media.expires_after_secs # => 86400, the seconds after the upload within which a post can attach it +x_client.create_post("Look at this cat", media_ids: [media]) +x_client.find_media(media).url # the X::Media it became, looked up by media key +x_client.find_all_media(post.media) # the media of a post, up to 100 keys per request + +# A video, and an animated GIF larger than 5 MB, is uploaded in chunks, four at a time unless concurrency says +# otherwise, up to 16, in chunks of up to 5 MB sized so that the upload fits the 10,000 segments the API numbers, and +# upload waits until a video or an animated GIF has been processed, for up to ten minutes unless processing_timeout says +# otherwise. An image larger than 5 MB, a GIF larger than 15 MB, or subtitles larger than 1 MB raise X::InvalidMedia +# before any request, since X takes no more of them, as does a video larger than the 16 GB X takes of an upload. +video = x_client.upload_media("cat.mp4") +video.ready? # => true, since upload_media waited; state is "succeeded" +subtitles = x_client.upload_media("cat.srt") +x_client.add_subtitles(video, subtitles, "EN", display_name: "English") # returns the video, an X::UploadedMedia +x_client.create_post("Look at this cat move", media_ids: [video]) + +# Update the profile image and banner of the authenticated user. These two call the API v1.1, which the API v2 has +# no endpoint for, and return nothing to read: look the user up to read the image and banner it now has. +x_client.update_profile_image("avatar.png") +x_client.update_profile_banner("banner.png") + +# Media is a path, or an IO open on it. Media given as a String or a Pathname is read from the file it names, and +# media given as a File or a Tempfile through that IO, even once the Tempfile is unlinked, or from the file it names +# once it is closed, a chunk at a time, so media of any size uploads without being held in memory. A String that holds +# the contents of media, rather than its path, raises ArgumentError: wrap the contents in a StringIO. A String is +# read as a path on this machine, so never pass one a user gave, such as a parameter of a form, which could name any +# file the process can read, and upload it to X: pass the IO of the file the user uploaded instead. +x_client.upload_media(Pathname("cat.jpg")) +File.open("cat.mp4", "rb") { |file| x_client.upload_media(file) } + +# Media given as any other IO, such as a StringIO, is read to its end and held. Its category is read from the bytes +# it begins with, as the category of a file is before its name: a GIF, PNG, JPEG, BMP, TIFF, WebP, MP4, QuickTime, WebM, MPEG transport +# stream, or WebVTT file is recognized by its signature. Pass media_category for anything else, such as SubRip subtitles, which begin +# with nothing a text file could not. +x_client.upload_media(StringIO.new(png)) +x_client.upload_media(StringIO.new(srt), media_category: "subtitles") +``` + +Each of these methods calls an uploader with the client: `upload_media`, `chunked_upload_media`, which uploads in chunks and returns before X processes the media, `await_media_processing`, and `await_media_processing!`, which raises `X::MediaProcessingFailed` where the other returns the status of processing that failed, call `X::Uploader::MediaUpload`, `add_alt_text` and `add_subtitles` call `X::Uploader::Metadata`, and `update_profile_image` and `update_profile_banner` call `X::Uploader::Account`. Media that already says its processing ended, as the response to the upload of an image does, is awaited without a request. The uploaders take an `X::Client` as `client:`, as in `X::Uploader::MediaUpload.chunked_upload("cat.mp4", client: x_client, chunk_size: 4 * 1024 * 1024)`, which `x_client.chunked_upload_media("cat.mp4", chunk_size: 4 * 1024 * 1024)` calls. + +`upload_media` uploads an image in a single request, which takes no chunks, so it ignores `chunk_size:`, `concurrency:`, and `media_type:` for one; `chunked_upload_media` uploads in chunks whatever the media, and is the way to upload without waiting for X to process it. + +A video uploads in chunks of 4 MB, each a request of its own, which a rate limit can refuse. A client retries a request refused for a rate limit only `max_rate_limit_retries` times, 0 by default, so a chunk refused fails the upload with `X::ChunkedUploadFailed`, which holds the media it initialized. Upload a large video with a client whose `max_rate_limit_retries` is set, such as `X::Client.new(**x_credentials, max_rate_limit_retries: 3)`, so that a rate limit is waited out, up to the client's `max_rate_limit_wait`, rather than fail the upload. + +What each returns: `upload_media`, `chunked_upload_media`, `await_media_processing`, and `await_media_processing!` return an `X::UploadedMedia`, which reads as the Hash the API answered with as well as by its own methods; `add_alt_text` and `add_subtitles` return the media they describe as an `X::UploadedMedia`, the video for `add_subtitles`, so either can be passed on to `create_post`; and `update_profile_image` and `update_profile_banner` return nothing to read, `nil`: they are the only methods in these gems that call the v1.1 API, whose user no object of the object layer reads, so `current_user!` reads the image and banner the user now has. + +### Streaming + +A stream holds a connection open instead of answering a request, so `X::StreamingClient`, from `x-streaming`, handles one, and `streaming` builds it from a client. It shares the client's credentials, base URL, parsing classes, and `on_response` hook, and keeps the settings a long-lived connection needs: `read_timeout`, 30 seconds by default, `max_reconnects`, and `on_reconnect`. + +The stream endpoints take app-only authentication, so a client that authenticates as a user streams with the bearer token that `app_only` holds. A client that authenticates with OAuth 2.0 as a user and holds neither the app's bearer token nor its API key and secret has no credentials of the app, so `app_only` raises `X::UnsupportedOperation` for it, and a stream it opens goes out as the user, which X refuses with 403 Forbidden, raising `X::Forbidden`; give the client one of them, or stream with a client built from the app's bearer token, or its API key and secret, instead. + +X holds a stream open indefinitely, but drops it for deploys, network trouble, and slow readers. A stream that ends, drops, is refused a connection, or that X disconnects with an `operational-disconnect` reconnects at once, then waits a quarter second longer each attempt, up to 16 seconds; one that ends within a line drops what it sent of the line, which is neither parsed nor passed to `on_response`, and reconnects the same way. A server error, a 408 Request Timeout, a 409 Conflict, or a line that is not JSON waits 5 seconds, doubling each attempt, up to 320 seconds, and longer when the response asks for longer in its `Retry-After` header, but raises at once when that header asks for longer than the `max_rate_limit_wait` of the client. A rate limit backs off from a minute, doubling each attempt, as X asks, and waits longer for a limit that resets later, but raises `X::TooManyRequests` at once rather than wait longer than the `max_rate_limit_wait` of the client, 900 seconds by default, so that a limit on the connections of a day does not hold a stream closed for hours, and a stream X keeps refusing gives up after waits of 60, 120, 240, and 480 seconds. Each of the three backs off on a count of its own, so the dropped connections before a server error do not lengthen the wait after it. A post, or the keep-alive X sends every 20 seconds, read from a connection that has been open for a minute starts every count over, so a filtered stream whose rules rarely match, connected for hours between drops, never runs out of reconnects; one read from a younger connection starts none over, so a stream whose connections each deliver a post and drop backs off, and runs out of reconnects, rather than reconnect at once without end; an `operational-disconnect` or a line that is not JSON does not, so a stream X keeps refusing backs off. A stream reconnects without limit by default; set `max_reconnects` to give up after that many attempts in a row, when it raises the error of the last, an `X::NetworkError` for a stream that ended or dropped, so a stream never returns but for a `break` from its block, or a `stop`. Pass `on_reconnect:`, a callable given the error that dropped the stream and the seconds it waits, to hear of each reconnect, since a stream that can never connect, through a host that does not resolve or a proxy set up wrong, otherwise reconnects in silence; call `stop` from it to give up, and the stream returns nil. An error it raises stops the stream and reaches the caller. + +> [!IMPORTANT] +> Reconnects are unlimited by default, and silent, so a stream that cannot connect at all, as for a host that does not resolve or a network that is down, keeps trying every 16 seconds for as long as it runs. Only a certificate that does not verify, which will not verify on the next attempt either, raises at once, an `X::NetworkError` whose `cause` is the `OpenSSL::SSL::SSLError`. To give up on the rest, set `max_reconnects`, or pass `on_reconnect`, which is called before each reconnect with the error that dropped the stream, never nil, and the seconds it waits, and can `stop` the stream or raise. It is called with those two arguments, and no others, by every release of 1.x. + +**The rules of the filtered stream.** The filtered stream delivers the posts that match the rules of the app, which belong to the stream and are read and changed through a streaming client: `rules` reads them, every page of them, `add_rules` adds them, and `delete_rules` deletes them, each authenticating as the app as a stream does. A rule is an `X::StreamRule`, a frozen value of the `value` it matches, the `tag` it is labelled with, and the `id` the API gave it, read as an Integer; `rules` returns them, and the API adds the rules it can, so `add_rules` returns the ones it added, then the ones the app already had, each with the `id` and `value` X reports and no `tag`, and yields each one it did not add, one the app already has included, as the `X::Problem` the API reported, and `delete_rules` returns the number it deleted and yields each problem the API reported of the rules it did not, such as a rule the app does not have. Without a block, each raises `X::RulesRejected` for the rules the API rejected rather than drop them, which a rule the app already has is not, so `add_rules` can run at every boot, whose `problems` are the problems and whose `added` or `deleted_count` is what the method would have returned, so a rule that was not added is never passed over in silence. A rule to add is an `X::StreamRule`, a Hash of a `value` and a `tag`, or a String, which is the value of a rule with no tag, so the rules of one app add themselves to another. A rule is deleted by the identifier it was given, which an `X::StreamRule` the API returned, a Hash holding an `id`, an Integer, or an `X::MatchingRule` names, so what `rules` returned deletes itself, as do the `matching_rules` of a post of `x-objects`, or by the value it matches, which a rule without an identifier or a String names, so what `add_rules` was given deletes what it added. Anything else raises `ArgumentError` before a request, even a post or another resource with an `id`, which would otherwise delete whichever rule shared its identifier. No rules add or delete none, and send no request. `dry_run: true` has the API check the rules and change none of them. + +**Stopping a stream.** A stream runs until its block stops it. `break` out of the block to stop the stream and return a value, or `throw` to unwind to a `catch` further out; neither reconnects. An error raised by the block stops the stream too, even one a dropped connection would have reconnected after, and reaches the caller unchanged, as does an error raised by `on_response` or by the class that builds each object. A `StopIteration` is an error like any other here, so a block that exhausts an `Enumerator` of its own hears about it rather than ending the stream in silence. A block runs only when a post arrives, so it cannot stop a stream that delivers nothing; `stop` can, from any thread or the trap of a signal: it stops every stream the streaming client runs, the next time each waits on the API, and each returns nil. A stopped streaming client stays stopped: a stream it is asked to run later, such as one a thread had not yet opened when `stop` was called, returns nil at once, without a request, and `stopped?` tells. `streaming` builds a new streaming client each time it is called, so keep the one you stop in a variable, and call `streaming` again to stream after a stop. A block, an `on_response`, an `on_reconnect`, or a `save_tokens` that is running when the stream is stopped runs to its end first. A stream that a Fiber scheduler runs, as in an `Async` task, waits on the API in the scheduler, so `stop` ends it once its block next returns, at the next keep-alive, within 20 seconds, or within a second as it waits to reconnect; `break` out of its block, or stop its task, to end it at once. + +**Errors in a stream.** X sends some problems in a line of their own, holding errors and no data, such as the `operational-disconnect` it sends before it closes a stream. Such a line is not a post, so a stream never yields it, whatever it builds objects as: it raises `X::StreamError`, whose `problems` are the `X::Problem` objects of the line. A line of `operational-disconnect`s alone is X asking the stream to reconnect, so the stream reconnects after it, as after a dropped connection, and raises the error once it has no reconnects left; after a line of any other problems it does not reconnect, so you decide whether to open it again. `X::Problem#disconnect?` tells a disconnect apart. A post of the filtered stream names the rules it matched, which `matching_rules` reads as `X::MatchingRule` objects, each with the `id` and `tag` of a rule. + +X sends a blank line, a CRLF, every 20 seconds to keep an idle stream alive, so a stream reads with a 30-second timeout of its own, which a keep-alive that arrives a little late does not trip. A connection that goes quiet is dropped and reconnected rather than held open until the 60-second `read_timeout` of an ordinary request. The `read_timeout` given to `streaming` must be at least 25 seconds, or nil for none, since one no longer than the 20 seconds between keep-alives would drop a stream that is quiet but connected whenever a keep-alive arrived a little late. + +```ruby +# Set up rules for the filtered stream +streaming = x_client.streaming +streaming.add_rules([{value: "ruby -is:retweet", tag: "ruby"}, "crystal"]) # a rule the app already has raises nothing +streaming.rules # => [#, ...] + +# Stream matching posts in real time, until interrupted +x_client.streaming.stream("tweets/search/stream") do |post| + puts post["data"]["text"] +end + +# Stream posts as X::Post objects, reading the tags of the rules each matched; a line of errors alone, other than the +# operational-disconnect X sends before it closes a stream, after which it reconnects, raises X::StreamError and stops it +begin + x_client.streaming.stream("tweets/search/stream", object_class: X::Post) do |post| + puts "#{post.matching_rules.map(&:tag).join(", ")}: #{post.text}" + end +rescue X::StreamError => e + warn e.message +end + +# Stop the stream from its block, which returns what break was given +first = x_client.streaming.stream("tweets/search/stream") { |post| break post } + +# Stop a stream that runs in another thread, even one that delivers nothing +streaming = x_client.streaming +reader = Thread.new { streaming.stream("tweets/search/stream") { |post| queue << post } } +streaming.stop # => nil +reader.value # => nil + +# Stop on Ctrl-C +streaming = x_client.streaming +Signal.trap("INT") { streaming.stop } + +# Give up after five reconnects in a row +streaming_client = x_client.streaming(max_reconnects: 5) + +# Report each reconnect +streaming_client = x_client.streaming(on_reconnect: ->(error, wait) { warn "#{error.message}; reconnecting in #{wait}s" }) + +# Log each reconnect, and give up on a stream that has failed to connect for minutes +streaming_client = x_client.streaming(on_reconnect: lambda do |error, wait| + warn "#{error.class}: #{error.message}; reconnecting in #{wait} seconds" + streaming_client.stop if error.is_a?(X::NetworkError) && wait >= 16 +end) + +# Delete the rules that were read, or the ones that match a value +streaming.delete_rules(streaming.rules) # => 2 +``` + +### Responses + +A client calls its `on_response` after every request with an `X::Response`, which counts the resources the response returned and reads its rate limits. The API returns rate limit headers with nearly every response, and some writes add limits on the requests of a day. The API bills the posts a stream delivers, each once a UTC day, so a stream calls `on_response` for each one, with that post as the body. + +An `X::Response` reads the response itself with `status`, `headers`, and `body`, and an `X::HTTPError` reads a refused one the same three ways. Every error a request raises names the request: `http_method` and `uri` are the method it was sent with and the URL it was sent to, on `X::HTTPError`, `X::NetworkError`, `X::InvalidResponse`, and `X::TooManyRedirects` alike, and the message names it too, as `GET /2/users/1: Could not find user` does, so a log of failures says which endpoint each one came from. The message leaves the query out, since a lookup asks for every field of a resource; `uri` keeps it. Header names are lowercase, whatever case the API sent them in, and a header it sent more than once is joined with a comma. `http_response` is the escape hatch for what those do not read: it holds the response of the transport, a `Net::HTTPResponse` today, whose class is not part of what 1.x promises. An `X::InvalidResponse`, raised for a successful response whose body is not JSON, is an `X::HTTPError` too, and reads that response the same three ways; its `problem` is nil and its `problems` are empty. + +```ruby +x_client = X::Client.new(**x_credentials, on_response: lambda { |response| + puts "#{response.http_method} #{response.uri.path}: #{response.resource_counts}" # {"data" => 100, "users" => 42} + limit = response.rate_limit + puts "#{limit.remaining} of #{limit.limit} left, resetting in #{limit.reset_in} seconds" if limit +}) +``` + +**One request.** A block passed to `get`, `post`, `put`, or `delete` receives the same `X::Response`, for code that reads the response of one request rather than of every request a client makes. It is passed what `on_response` is passed, at the same points: a response the API refused, before the error is raised, and each attempt of a request that was sent again. A request with both reports to the client's hook first, and both receive the one summary. + +```ruby +remaining = nil +user = x_client.get("users/me") { |response| remaining = response.rate_limit&.remaining } +``` + +**Rate limits.** A request the API refuses for a rate limit raises `X::TooManyRequests`, whose `retry_after` is the number of seconds the response asks a request to wait in its `Retry-After` header, or else the number until the limit that refused it resets, or nil when the response says neither; the header counts from when the response was sent, so it holds however far your clock is from the API's. Its `rate_limits` and `rate_limit` read the response's limits as `X::Response` reads them; the limits with no requests left are `exhausted_rate_limits`, and the one that resets last, which `reset_in` counts down to, is `limiting_rate_limit`. A client can instead wait and retry, up to `max_rate_limit_retries` times, which is 0 by default. It waits only as long as `max_rate_limit_wait`, which is 900 seconds by default, the length of a 15-minute window. A request whose limit resets later, such as a limit on the requests of a day, raises at once, as does one refused for the usage cap of the project, which lasts until the month ends, and which `error.problem&.usage_capped?` tells apart from a rate limit. A refusal that does not say when its limit resets waits a minute before the first retry, doubling the wait for each retry after, as X recommends. Up to five seconds are added at random to each wait, since every request of an app shares the app's limits, and so the moment they reset: without the random share, the requests one reset releases would be sent again in one burst, to be refused together once more. They are added to the wait rather than taken off it, since a request sent before the limit resets is refused again, so `max_rate_limit_wait` is the longest reset a request waits for, not the longest it sleeps. Each retry signs the request afresh and passes its response to `on_response`. The API refuses a rate-limited request without acting on it, so retrying a write does not repeat it. A stream is the exception to the default: `X::StreamingClient` reconnects after a rate limit however `max_rate_limit_retries` is set, though it too waits no longer than `max_rate_limit_wait`, and does not reconnect after the usage cap. + +```ruby +x_client = X::Client.new(**x_credentials, max_rate_limit_retries: 3, max_rate_limit_wait: 60) +patient_client = x_client.with(max_rate_limit_retries: 0) # raise X::TooManyRequests at once again +``` + +**Failures the request did not cause.** A 5xx response, a 408 Request Timeout, which says the API gave up waiting for the request, and a request whose answer never arrived say nothing about the request itself, so the same request may pass a moment later. A client sends one again after an `X::ServerError` or an `X::RequestTimeout`, or an `X::NetworkError` for a request that never reached the API, such as a connection refused or one that timed out opening, `max_retries` times, which is twice by default, waiting up to a second before the first retry and up to twice as long before each after, but never more than a minute, with a random share of up to half of each wait taken off: a failure of the API fails every request in flight at once, and requests that waited the same time would be sent again together, to fail together once more. Only a `GET`, `PUT`, or `DELETE` is sent again: the API may have acted on a `POST` whose answer never arrived, so that one is left to you. A request that reached the API and whose answer never arrived, such as one that timed out reading its response, is left to you too, since the API bills a read it answered whether or not the answer arrived. `x-uploader` sends each chunk of an upload, its finalize, and alt text or subtitles again after any server error, 408, or network error, even a timeout, up to the `max_retries` of the client, since each is safe to send twice; X bills nothing for a chunk, but bills each alt text or subtitles request, so one whose answer was lost may be billed twice. Any other 4xx is raised at once, since the request is most often the reason for it, and one refused for a state that can change, such as a 409 for a filtered stream with no rules, passes only once that changes (a stream of `X::StreamingClient` reconnects after a 409, as under Streaming); a 429 waits for its rate limit as above, and a 401 for an app-only token, or an OAuth 2.0 token with a refresh token, is refreshed and sent once more, whatever `max_retries` is. When the response carries a `Retry-After` header, such as a 503 that names the time its endpoint is expected back, the client waits as long as it asks, or as long as the backoff of the attempt, whichever is longer, so a request is never sent again before the API asked for it. A response that asks to be left alone for longer than a minute raises at once rather than hold you for minutes; `error.retry_after` reads the wait it asked for. + +```ruby +x_client = X::Client.new(**x_credentials, max_retries: 4) # or 0, to raise at once +``` + +Whatever `max_retries` is, an idempotent request that fails on a connection the client had kept open for it, which X or a proxy between can close while it is idle, is sent again on a connection opened for it, when the failure says the connection was closed before the request was sent: that request never reached the API, so nothing is repeated. A request that timed out after it was sent is not sent again, this way or after `max_retries`, since the API may have acted on it; one that timed out opening its connection never reached the API, and is. + See other common usage [examples](https://github.com/sferik/x-ruby/tree/main/examples). ## History and Philosophy -This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years of development, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests), not counting dependencies. This library is about 500 lines of code (plus 1000 test lines) and has no runtime dependencies. That doesn’t mean new features won’t be added over time, but the benefits of more code must be weighed against the benefits of less: +This library is a from-scratch rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Rather than carry that library’s design forward, it aims to be more minimal and modular: a lightweight core, `x-core`, that handles HTTP and little else, with optional gems, `x-uploader`, `x-streaming`, and `x-objects`, built on top of it. You pay only for what you use. An application that needs nothing but raw JSON can depend on `x-core` alone and never load the code for uploads, streams, or objects. + +That doesn’t mean new features won’t be added over time, but the benefits of more code must be weighed against the benefits of less: * Less code is easier to maintain. * Less code means fewer bugs. @@ -84,26 +562,43 @@ In the immortal words of [Ezra Zygmuntowicz](https://github.com/ezmobius) and hi > No code is faster than no code. -The tests for the previous version of this library executed in about 2 seconds. That sounds pretty fast until you see that tests for this library run in one-twentieth of a second. This means you can automatically run the tests any time you write a file and receive immediate feedback. For such of workflows, 2 seconds feels painfully slow. - -This code is not littered with comments that are intended to generate documentation. Rather, this code is intended to be simple enough to serve as its own documentation. If you want to understand how something works, don’t read the documentation—it might be wrong—read the code. The code is always right. - ## Features -If this entire library is implemented in just 500 lines of code, why should you use it at all vs. writing your own library that suits your needs? If you feel inspired to do that, don’t let me discourage you, but this library has some advanced features that may not be apparent without diving into the code: +If this entire library is implemented in about 7,000 lines of code, why should you use it at all vs. writing your own library that suits your needs? If you feel inspired to do that, don’t let me discourage you, but this library has some advanced features that may not be initially apparent, including: * OAuth 1.0 Revision A -* OAuth 2.0 Bearer Token +* OAuth 2.0 +* App-only authentication * Thread safety +* Persistent HTTP connections, reused across requests to the same host * HTTP redirect following -* HTTP proxy support +* HTTP and HTTPS proxy support, configured or taken from the environment * HTTP logging * HTTP timeout configuration +* HTTP headers set per client, per request, or both * HTTP error handling * Rate limit handling -* Parsing JSON into custom response objects (e.g. OpenStruct) +* Retrying a request after waiting for its rate limit to reset +* Sending an idempotent request again after a 5xx response, a 408, or a network failure before it reached the API +* Sending an idempotent request again on a new connection when one kept open had been closed at the other end +* Streaming (filtered stream, sample stream) +* Reconnecting a dropped stream +* Immutable resource objects with identity, references, and hydration +* Lazy, cached, Enumerable cursors that request the maximum page size * Configurable base URLs for accessing different APIs/versions +* Query strings, JSON bodies, and form bodies built from Ruby values +* Uploading any file with one call, with alt text and subtitles * Parallel uploading of large media files in chunks +* Parallel batch lookups, as many at a time as you ask for +* Partial errors reported by a response that otherwise succeeded + +## Versioning + +The gems follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and 1.x promises their public interface: every class, module, constant, method, and keyword argument whose documentation says `@api public`, with the types the RBS signatures each gem ships give them, which declare that interface alone; the signatures of its internals, in `sig/internal`, are not shipped. A minor release may add to that interface and a patch release may fix it, but only a major release removes or changes what it promises. + +Anything documented `@api private` is internal to the gem that declares it, even where Ruby lets you call it, as is every private constant, and either may change or go away in any release. Most of it sits in the module of its gem, such as `X::Core`, beside mixins that are public, such as `X::Objects::API` and `X::Uploader::MediaUpload`, so it is the documentation, not the namespace, that says which is which. + +The five gems are released together at the same version. `x` depends on exactly that version of the other four, such as `= 1.0.0` for 1.0.0, so it installs the set that was built and tested together, and its version names the version of each. `x-uploader`, `x-streaming`, and `x-objects` each depend on `x-core` with a constraint of their own version or a later one of the same major version, such as `>= 1.0.0, < 2` for 1.0.0, so an app that depends on them without `x` may install a later release of 1.x of one beside the others, but never an earlier release of `x-core` than their own. They are built and tested together at each version, so upgrade them together, as depending on `x` does. ## Sponsorship @@ -129,54 +624,6 @@ Many thanks to our sponsors (listed in order of when they sponsored this project IFTTT -## Development - -1. Checkout and repo: - - git checkout git@github.com:sferik/x-ruby.git - -2. Enter the repo’s directory: - - cd x-ruby - -3. Install dependencies via Bundler: - - bin/setup - -4. Run the default Rake task to ensure all tests pass: - - bundle exec rake - -5. Create a new branch for your feature or bug fix: - - git checkout -b my-new-branch - -## Contributing - -Bug reports and pull requests are welcome on GitHub at https://github.com/sferik/x-ruby. - -Pull requests will only be accepted if they meet all the following criteria: - -1. Code must conform to [Standard Ruby](https://github.com/standardrb/standard#readme). This can be verified with: - - bundle exec rake standard - -2. Code must conform to the [RuboCop rules](https://github.com/rubocop/rubocop#readme). This can be verified with: - - bundle exec rake rubocop - -3. 100% C0 code coverage. This can be verified with: - - bundle exec rake test - -4. 100% mutation coverage. This can be verified with: - - bundle exec rake mutant - -5. RBS type signatures (in `sig/x.rbs`). This can be verified with: - - bundle exec rake steep - ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile index 67525445..d08befc9 100644 --- a/Rakefile +++ b/Rakefile @@ -1,34 +1,245 @@ +# frozen_string_literal: true + require "bundler/gem_tasks" + +# The gems in this repository, in dependency order, released in lockstep +GEMS = {"x-core" => "x-core", "x-uploader" => "x-uploader", "x-streaming" => "x-streaming", "x-objects" => "x-objects", "x" => "."}.freeze + +# The one file that holds the version, which every gemspec reads +VERSION_FILE = File.expand_path("VERSION", __dir__) + +# The files that hold each gem's runtime version constant, written from VERSION_FILE +VERSION_CONSTANT_FILES = %w[lib/x/version.rb x-core/lib/x/core/version.rb x-uploader/lib/x/uploader/version.rb + x-streaming/lib/x/streaming/version.rb x-objects/lib/x/objects/version.rb].map { |path| File.expand_path(path, __dir__) }.freeze + +# The version in VERSION_FILE +def version + File.read(VERSION_FILE).strip +end + +# The version a version.rb file holds +def version_in(file) + File.read(file)[/VERSION = "([^"]+)"/, 1] +end + +desc "Write the version in VERSION into each gem's version.rb" +task :update_versions do + VERSION_CONSTANT_FILES.each do |file| + File.write(file, File.read(file).sub(/VERSION = "[^"]+"/, %(VERSION = "#{version}"))) + end +end + +desc "Check that each gem's version.rb holds the version in VERSION" +task :check_versions do + stale = VERSION_CONSTANT_FILES.reject { |file| version_in(file).eql?(version) } + abort "Run `rake update_versions`: #{stale.join(", ")} do not hold #{version}" unless stale.empty? +end + +# Build every gem into the pkg directory (gem push is handled by GitHub Actions with attestations) +Rake::Task["build"].clear +desc "Build x-core, x-uploader, x-streaming, x-objects, and x into the pkg directory" +task :build do + mkdir_p "pkg" + GEMS.each do |name, dir| + path = Bundler::GemHelper.new(File.expand_path(dir, __dir__), name).build_gem + mv path, "pkg" unless File.dirname(path).eql?(File.expand_path("pkg", __dir__)) + end +end + +# The path of a gem that build writes into the pkg directory +def gem_path(name) + File.expand_path("pkg/#{name}-#{version}.gem", __dir__) +end + +# Bundler's install, checksum, and push tasks know only the gem of the root gemspec, x, which would install the gems +# it depends on from RubyGems rather than pkg, and push only x +%w[install install:local build:checksum release:rubygem_push].each { |name| Rake::Task[name].clear } + +desc "Build and install x-core, x-uploader, x-streaming, x-objects, and x into system gems" +task install: :build do + GEMS.each_key { |name| Bundler.with_original_env { sh "gem", "install", gem_path(name) } } +end + +desc "Build and install x-core, x-uploader, x-streaming, x-objects, and x into system gems without network access" +task "install:local" => :build do + GEMS.each_key { |name| Bundler.with_original_env { sh "gem", "install", gem_path(name), "--local" } } +end + +desc "Check that the current branch is main, which alone is released" +task "release:guard_main" do + branch = `git rev-parse --abbrev-ref HEAD`.strip + abort "Release from main, not #{branch}: the workflow that pushes the gems refuses a tag that names another commit" unless branch.eql?("main") +end + +Rake::Task["release"].clear +desc "Build gems and create tag (gem push handled by CI)" +task release: %w[check_versions release:guard_main build release:guard_clean release:source_control_push] + require "rake/testtask" -Rake::TestTask.new(:test) do |t| - t.libs << "test" - t.libs << "lib" - t.options = "--pride" - t.test_files = FileList["test/**/*_test.rb"] +# The gems with their own directory, Gemfile, test suite, and mutation config +SUBGEMS = %w[x-core x-uploader x-streaming x-objects].freeze + +# Run a command inside a gem's directory with that gem's own bundle, installing the bundle if needed +def in_gem(dir, *command) + Bundler.with_unbundled_env do + Dir.chdir(File.expand_path(dir, __dir__)) do + sh "bundle", "install" unless system("bundle", "check", out: File::NULL) + sh(*command) + end + end +end + +namespace :test do + Rake::TestTask.new(:x) do |t| + t.description = "Run the x meta-gem tests" + t.libs << "test" + t.pattern = "test/**/*_test.rb" + end + + SUBGEMS.each do |name| + desc "Run the #{name} tests" + task name do + in_gem(name, "bundle", "exec", "rake", "test") + end + end +end + +desc "Run the tests for every gem" +task test: SUBGEMS.map { |name| "test:#{name}" } + ["test:x"] + +namespace :mutant do + SUBGEMS.each do |name| + desc "Run the #{name} mutation tests" + task name do + in_gem(name, "bundle", "exec", "rake", "mutant") + end + end end +desc "Run the mutation tests for every gem" +task mutant: SUBGEMS.map { |name| "mutant:#{name}" } + require "standard/rake" require "rubocop/rake_task" RuboCop::RakeTask.new -require "steep" -require "steep/cli" +namespace :steep do + desc "Type check the x meta-gem" + task :x do + sh "bundle", "exec", "steep", "check" + end + + SUBGEMS.each do |name| + desc "Type check #{name}" + task name do + in_gem(name, "bundle", "exec", "rake", "steep") + end + end +end + +desc "Type check every gem" +task steep: SUBGEMS.map { |name| "steep:#{name}" } + ["steep:x"] + +require "yard" -desc "Type check with Steep" -task :steep do - Steep::CLI.new(argv: ["check"], stdout: $stdout, stderr: $stderr, stdin: $stdin).run +# The documentation of every gem together, published at https://sferik.github.io/x-ruby/api/, written to the +# directory YARD_OUTPUT_DIR names, or doc. The .yardopts of the x gem documents the meta-gem alone, so the options of +# each gem's .yardopts are given here, and the extra files are those of the repository, as each gem names its own +# README.md and CHANGELOG.md. +YARD::Rake::YardocTask.new(:yard) do |t| + t.files = ["lib/**/*.rb", "x-core/lib/**/*.rb", "x-uploader/lib/**/*.rb", "x-streaming/lib/**/*.rb", "x-objects/lib/**/*.rb", + "-", "UPGRADING.md", "CHANGELOG.md", "LICENSE.txt"] + t.options = ["--no-yardopts", "--no-save", "--markup", "markdown", "--readme", "README.md", "--hide-api", "private", + "--embed-mixins", "--title", "X API Ruby gems", "--output-dir", ENV.fetch("YARD_OUTPUT_DIR", "doc")] end -require "mutant" +require "yardstick/rake/measurement" +require "yardstick/rake/verify" -desc "Run mutant" -task :mutant do - system(*%w[bundle exec mutant run]) or raise "Mutant task failed" +Yardstick::Rake::Measurement.new(:yardstick_measure) do |measurement| + measurement.output = "doc/coverage.txt" end +namespace :yardstick do + Yardstick::Rake::Verify.new(:x) do |verify| + verify.threshold = 100 + end + + SUBGEMS.each do |name| + desc "Measure #{name} documentation coverage" + task name do + in_gem(name, "bundle", "exec", "rake", "yardstick") + end + end +end + +desc "Measure documentation coverage of every gem" +task yardstick: SUBGEMS.map { |name| "yardstick:#{name}" } + ["yardstick:x"] + desc "Run linters" task lint: %i[rubocop standard] -task default: %i[test lint mutant steep] +require "yaml" + +# The directory each gem keeps its signatures in, by the name of the gem +SIGNATURE_DIRS = GEMS.to_h { |name, dir| [name, File.expand_path("#{dir}/sig", __dir__)] }.freeze + +# The signature files a gem ships, as its gemspec globs them +def shipped_signatures(name) + Dir.glob("#{SIGNATURE_DIRS.fetch(name)}/*.rbs").sort +end + +# The standard libraries a gem's manifest declares, which rbs collection loads with its signatures +def manifest_dependencies(name) + YAML.load_file(File.join(SIGNATURE_DIRS.fetch(name), "manifest.yaml")).fetch("dependencies").map { |dependency| dependency.fetch("name") } +end + +# The gems of this repository a gem depends on at runtime, whose signatures rbs collection installs from its gemspec +def gem_dependencies(name) + Gem::Specification.load(File.expand_path("#{GEMS.fetch(name)}/#{name}.gemspec", __dir__)).runtime_dependencies + .map(&:name).select { |dependency| GEMS.key?(dependency) } +end + +# The signatures to load for a gem, and the libraries to load them with +# +# A gem of this repository contributes the signatures it ships, and the standard libraries its own manifest declares, +# and the gems of this repository it depends on contribute theirs, as rbs collection installs the signatures of each +# gem a gemspec depends on and reads the manifest of each. +# +# @return [Array(Array, Array)] the signature files and the library names +def signature_sources(name, seen = []) + return [[], []] if seen.include?(name) + + seen << name + gem_dependencies(name).each_with_object([shipped_signatures(name), manifest_dependencies(name)]) do |dependency, (files, libraries)| + dependency_files, dependency_libraries = signature_sources(dependency, seen) + files.concat(dependency_files) + libraries.concat(dependency_libraries) + end +end + +# Check that the signatures a gem ships refer to nothing the libraries its manifest declares leave undefined +# +# Code that depends on the gem loads them through rbs collection, which reads the manifest for the libraries to load +# them with, so a library the manifest leaves out is one the signatures do not resolve without. An error that names +# the file of another library is that library's to fix, and is left to it. +# +# @return [Boolean] true if every type the signatures refer to resolves +def signatures_resolve?(name) + files, libraries = signature_sources(name) + arguments = files.flat_map { |file| ["-I", file] } + libraries.uniq.flat_map { |library| ["-r", library] } + output = IO.popen(["rbs", *arguments, "validate"], err: %i[child out], &:read) + errors = output.lines.grep(/#{Regexp.escape(SIGNATURE_DIRS.fetch(name))}/) + errors.each { |error| warn error } + errors.empty? +end + +desc "Check that the signatures each gem ships resolve against the libraries its manifest declares" +task :signatures do + unresolved = GEMS.each_key.reject { |name| signatures_resolve?(name) } + abort "Declare what the signatures of #{unresolved.join(", ")} refer to in sig/manifest.yaml" unless unresolved.empty? +end + +task default: %i[test lint mutant steep yardstick signatures] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..cd4ff606 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,66 @@ +# Security Policy + +## Supported versions + +`x`, `x-core`, `x-uploader`, `x-streaming`, and `x-objects` are released from this repository in lockstep, at the +version in [VERSION](https://github.com/sferik/x-ruby/blob/main/VERSION). Security fixes are released for the latest 1.x +version of each gem. Versions before 1.0 are not supported; +[UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) covers what code written for 0.19 needs. + +| Version | Supported | +| --- | --- | +| 1.x | ✅ | +| 0.x | ❌ | + +## Reporting a vulnerability + +Report a vulnerability privately, with [Report a +vulnerability](https://github.com/sferik/x-ruby/security/advisories/new), rather than by opening an issue or a pull +request, which are public. Reports are acknowledged as soon as possible, and a fix is released with an advisory that +credits the reporter, unless the reporter asks otherwise. + +Include what you can: + +* which gem, and which version +* what an attacker can do, and what they need in order to do it +* the smallest script that shows the problem +* the Ruby version and platform you saw it on + +A vulnerability in the X API itself, rather than in these gems, belongs to X, and should be reported to X rather +than here. + +## Handling credentials + +The gems hold API keys, access tokens, and bearer tokens for as long as a client lives, and send them in the +`Authorization` header of every request. Four things are worth knowing: + +* `X::Client#inspect` prints the base URL and the class of the authenticator, and no token or secret, so a client + is safe to log or to show in a backtrace. An OAuth 2.0 authenticator adds its client ID and expiration time, which + are not secret. A client, a streaming client, an authenticator, and an `X::OAuth2Authorization` raise `TypeError` + from `Marshal.dump`, `YAML.dump`, `as_json`, and `to_json` rather than write their credentials in the clear, so a + client held in a Hash is not cached with them, nor written with the arguments of a job a queue keeps as YAML, nor + rendered as JSON into a response or a log, and a resource or a page of them writes plain data to any of them + without the client it holds. The `X::OAuth2Tokens` that `save_tokens` is passed do marshal, and write themselves + as YAML, tokens and all, since they are what is stored; keep what they write where you would keep the tokens. +* `debug_output` is not. It writes every request and response to the IO it is given, headers included, so it writes + the `Authorization` header of each request, and the tokens in the body of an OAuth 2.0 token refresh. Send it to a + file you control, never to a log that is shipped elsewhere, and leave it unset in production. +* A client that is given no `proxy_url` takes the proxy the environment names, in `https_proxy` or `http_proxy`, as + most HTTP clients do. Requests then reach X through whatever that names: a proxy of an HTTPS request is asked to + tunnel it, so it sees the host and not the credentials, but one that terminates TLS, with a certificate the + process trusts, sees every header. Set `no_proxy`, or pass a `proxy_url` of your own, where the environment is not + yours to trust. +* A request that leaves the origin of the `base_url`, and a redirect that leads off it, is sent without the + `Authorization` header the authenticator signs and without any `Authorization`, `Cookie`, or + `Proxy-Authorization` header of the client or the request. Those three names are the whole of what is dropped. A + credential carried in a header of another name, such as one a gateway of your own reads, is sent wherever the + request goes, so pass it to the request that needs it rather than to `X::Client.new`, which sends the headers it + is given with every request the client makes. + +An OAuth 2.0 refresh token is accepted once: a refresh returns a new one, and `save_tokens` is passed the +`X::OAuth2Tokens` of each refresh so that the new tokens can be stored. Dropping them leaves the stored refresh token +useless, and the user has to authorize the app again. A `save_tokens` that raises, as one whose store is +briefly down may, raises `X::TokenReportFailed`, whose `tokens` are the new ones, so they can be stored again. +Processes that share the tokens of a user pass `load_tokens` as well, so that a refresh reads the tokens another +process stored rather than spend a refresh token already spent. The authenticator keeps its access token and refresh +token private, as a client does, so neither reads off `client.authenticator`. diff --git a/Steepfile b/Steepfile index 2784a599..c13844fe 100644 --- a/Steepfile +++ b/Steepfile @@ -1,14 +1,24 @@ +# frozen_string_literal: true + +# Type checks the x meta-gem against the signatures that x-core, x-uploader, x-streaming, and x-objects ship. +# Each of those gems type checks itself with its own Steepfile. target :lib do signature "sig" + signature "x-core/sig/x-core.rbs" + signature "x-uploader/sig/x-uploader.rbs" + signature "x-streaming/sig/x-streaming.rbs" + signature "x-objects/sig/x-objects.rbs" check "lib" - library "base64" - library "cgi" library "forwardable" library "json" + library "monitor" library "net-http" library "openssl" library "securerandom" + library "simple_oauth" + library "time" library "tmpdir" library "uri" - configure_code_diagnostics(Steep::Diagnostic::Ruby.default) # strict or all_error + library "zlib" + configure_code_diagnostics(Steep::Diagnostic::Ruby.strict) end diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 00000000..651a34b2 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,647 @@ +# Upgrading + +## From 0.19 to 1.0 + +Version 1.0 splits the gem into `x-core`, `x-uploader`, `x-streaming`, and `x-objects`. The `x` gem depends on all four, and `require "x"` loads them. + +Version 1.0 renames or removes what 0.19 had under old names, without deprecating them first. This guide covers what code written for 0.19 needs to change. See [CHANGELOG.md](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) for everything that was added. + +### Ruby + +Version 1.0 requires Ruby 3.4 or later. + +### Gems and requires + +The files of 0.19 moved into the gem each belongs to, so a `require` of one of them raises `LoadError`: + +* `require "x"` loads everything, as it did. Replace a require of a single file, such as `x/client`, `x/connection`, `x/oauth_authenticator`, `x/rate_limit`, or `x/errors/too_many_requests`, with it. +* Code that depends on one gem alone requires that gem: `x/core`, `x/uploader`, `x/streaming`, or `x/objects`. Each of the last three loads `x/core`. + +`x-core` depends on two gems that 0.19 did not: + +* [net-http](https://rubygems.org/gems/net-http) `~> 0.9, >= 0.9.1`. An app that pins `net-http` to an earlier version raises the pin, or Bundler cannot resolve `x`. +* [simple_oauth](https://rubygems.org/gems/simple_oauth) `~> 1.0`, which signs OAuth 1.0a requests. + +`x` depends on exactly its own version of the other four gems, so `bundle update x` updates all five together. + +### Credentials + +`X::Client.new` checks its credentials, where 0.19 sent requests without the ones it could not use: + +* Credentials that do not form a complete set raise `ArgumentError`. Pass one of these sets: + * OAuth 1.0a: `api_key`, `api_key_secret`, `access_token`, and `access_token_secret`. + * OAuth 2.0: `client_id` and `access_token`, plus the `refresh_token` that refreshes it and, for a confidential client, `client_secret`. + * App-only: `api_key` and `api_key_secret` alone. + * App-only: `bearer_token`. +* An OAuth 2.0 token issued without the `offline.access` scope has no refresh token. Pass `client_id` and `access_token` alone. + * Such a client acts for the user and refreshes nothing. 0.19 sent its requests without credentials. +* A `bearer_token` is the app's. Endpoints that take app-only authentication, such as streams, are sent it. +* A credential outside a complete set raises `ArgumentError`, rather than being ignored. Leave it out. + * For example, a `client_id` or an `api_key` beside a `bearer_token`. +* A client may hold several complete sets, such as an app's bearer token beside its API key and secret. It authenticates with the first, in the order above. + * A `bearer_token` beside an `api_key` and `api_key_secret` is sent until the API rejects it. The client then fetches another with the key and secret. +* OAuth 2.0 credentials beside a complete OAuth 1.0a set raise `ArgumentError`, since they would share its access token. +* `expires_at` is allowed only beside the OAuth 2.0 `client_id` and `access_token` the client authenticates with. Anywhere else it raises `ArgumentError`. +* A credential that is an empty String raises `ArgumentError`. 0.19 sent it for the API to refuse. + * Watch for an unset environment variable read as `ENV.fetch("X_BEARER_TOKEN", "")`. + +A client, its authenticators, and `X::RateLimit` are read-only, and the Strings their readers return, such as `base_url`, `api_key`, and the values of `headers`, are frozen copies, so changing one in place raises `FrozenError`. Derive a changed client with `with`, which takes anything `X::Client.new` takes and checks it the same way: + +```ruby +# 0.19 +client.authenticator.access_token = "new_token" + +# 1.0 +rotated = client.with(access_token: "new_token", access_token_secret: "new_secret") +``` + +* A client keeps its credentials and settings for life, so a request never signs with a mix of old and new credentials. +* These setters are gone. Pass the option to `with` instead, as `client.with(read_timeout: 30)`: + * On `X::Client`: `base_url=`, `default_array_class=`, `default_object_class=`, `open_timeout=`, `read_timeout=`, `write_timeout=`, `debug_output=`, `proxy_url=`, `max_redirects=`, and the setter of each credential. + * On the authenticators: the setter of each credential, and `connection=`. + * On `X::RateLimit`: `type=` and `response=`. +* The credentials of a copy must form a complete set. Clearing one so that the set is incomplete raises `ArgumentError`. +* A client and its copies with the same OAuth 2.0 credentials share one authenticator, so a refresh by any of them reaches all. +* A copy given other OAuth 2.0 credentials, such as another user's access token, drops the client's `refresh_token`, `expires_at`, `scopes`, `save_tokens`, and `load_tokens`. + * Before: a setter changed one credential and kept the rest. + * After: pass the refresh token, `save_tokens:`, and `load_tokens:` beside the access token they belong to. +* Clients built separately share an authenticator when each is given it as `authenticator:`, in place of credentials. + +A client refreshes an OAuth 2.0 access token itself, where 0.19 never did: + +* It refreshes when the `expires_at` it was given passes, or when X rejects the token. +* X issues a new refresh token with each refresh and no longer accepts the old one. +* Store the new tokens from `save_tokens:`, as below, or the stored refresh token stops working. +* Processes that share a user's tokens also pass `load_tokens:`, a callable that reads the store, so none refreshes with a refresh token another already spent. + +A client and its authenticator no longer reveal secret credentials: + +* These are private on `X::Client`, where 0.19 read them: `api_key_secret`, `access_token`, `access_token_secret`, `bearer_token`, `client_secret`, and `refresh_token`. +* These are private on the authenticators, so `client.authenticator` reads none of them by name: + * `api_key_secret`, `access_token_secret`, and `client_secret`. + * `bearer_token` of `X::BearerTokenAuthenticator` and `X::AppOnlyAuthenticator`. + * `access_token` and `refresh_token` of `X::OAuth2Authenticator`, which 0.19 read. + * `access_token` of `X::OAuth1Authenticator`, which 0.19 read off `X::OAuthAuthenticator`. +* `inspect` reveals no secret. +* An authenticator's `headers` still returns the headers it signs a request with, since a custom authenticator overrides it. + * The `Authorization` header of a bearer token, an app-only token, and an OAuth 2.0 access token holds the token itself, so guard an authenticator as you guard its client. +* Still public on a client: `api_key`, `client_id`, and the new `expires_at`. On an OAuth 2.0 authenticator: `client_id` and `expires_at`. +* Read the user an OAuth 1.0a access token names with `X::Authenticator#user_id`, rather than parsing the token. +* Read refreshed tokens from the frozen `X::OAuth2Tokens` passed to `save_tokens`, and carry credentials to another client with `with`: + +```ruby +# 0.19 +store(client.access_token, client.refresh_token) + +# 1.0 +X::Client.new(**credentials, save_tokens: ->(tokens) { store(tokens.access_token, tokens.refresh_token, tokens.expires_at) }) +``` + +Clients and credentials refuse to be serialized, where 0.19 wrote them, credentials and all, in the clear: + +* A client, a streaming client, an authenticator, and an `X::OAuth2Authorization` raise `TypeError` from `Marshal.dump`, `YAML.dump`, `as_json`, and `to_json`. + * This catches a client held in a cached Hash, a job argument a queue writes as YAML, and `render json:`. +* Keep credentials in a secret store, and build the client again from them. +* `X::OAuth2Tokens` still marshal, tokens and all, since they are meant to be stored. + * They write themselves as JSON with `to_json`, and `X::OAuth2Tokens.from_json` reads it back. + +An OAuth 2.0 authenticator's `refresh_token!` is now `refresh!`: + +* Before: it returned the Hash of the token response. +* After: it returns the frozen `X::OAuth2Tokens` of that refresh, the object `save_tokens` is passed. +* Read the new tokens from what it returns. The authenticator's tokens are private, and another thread's refresh may replace them. +* A refresh X refuses raises `X::AuthorizationError`, an `X::ClientError`, where 0.19 raised an `X::Error` whose message was the `error_description`. + * Its `error_code` is the OAuth 2.0 error, such as `invalid_grant`. Read it rather than the message. + * A token endpoint that fails to answer raises the `X::HTTPError` of its status, such as `X::ServerError`. + * `rescue X::Error` catches both, as it did. + +```ruby +# 0.19 +store(client.authenticator.refresh_token!["refresh_token"]) + +# 1.0 +store(client.authenticator.refresh!.refresh_token) +``` + +Token requests go to the origin of the base URL, where 0.19 sent them to `api.x.com` whatever the base URL: + +* This is the app-only bearer token, an OAuth 2.0 refresh, and the code exchange of `X::OAuth2Authorization`. +* The path is the base URL's minus a trailing API version segment. + * With `base_url: "https://gateway.example/x/2/"`, an app-only token comes from `/x/oauth2/token` and a refresh goes to `/x/2/oauth2/token`. +* A client whose base URL names a gateway or proxy sends its tokens there, so it must forward the token endpoints to X too. + +A custom authenticator overrides `headers`, where 0.19 overrode `header`: + +* It returns the headers that authenticate a request, as `header` did. +* Before: it was passed the `Net::HTTPRequest`, whose `method` is a String such as `"POST"`. +* After: the request it is passed answers only `http_method` (a Symbol such as `:post`), `uri`, `body`, and `[]` for a header. + +```ruby +# 0.19 +def header(request) = {"Authorization" => sign(request.method, request.uri)} + +# 1.0 +def headers(request) = {"Authorization" => sign(request.http_method.to_s.upcase, request.uri)} +``` + +### Requests + +An endpoint that begins with a slash is relative to the base URL, like one without: + +* Before: `/1.1/...` resolved against the host, dropping the `/2/` path of the base URL. +* After: pass a whole URL, or derive a client with another `base_url`, to reach another API version. + +```ruby +# 0.19 +client.get("/1.1/account/settings.json") + +# 1.0 +client.with(base_url: "https://api.x.com/1.1/").get("account/settings.json") +client.get("https://api.x.com/1.1/account/settings.json") +``` + +Credentials are sent only to the origin of the `base_url`: + +* The default `base_url` is `https://api.x.com/2/`, where 0.19 defaulted to `https://api.twitter.com/2/`. + * Pass whole URLs on `api.x.com`, or a `base_url` on `api.twitter.com`, so whole URLs keep the client's credentials. +* An endpoint or stream is signed only when it has the scheme, host, and port of the `base_url`. 0.19 signed a request to any host. +* A request to another origin is sent without any `Authorization`, `Cookie`, or `Proxy-Authorization` header, the client's or the request's. +* A redirect off the origin drops them too. 0.19 signed the first redirect for whatever host it named. +* To reach another host with its own credentials, build a client for it with its own `base_url`. +* Token endpoints (app-only and OAuth 2.0) are requested at the origin of the `base_url`. 0.19 always used `api.x.com`. + * So a client pointed at a test server or recording proxy fetches and refreshes its tokens there. + +Invalid endpoints raise `ArgumentError` before any request: + +* An endpoint that is not a valid URL (such as one holding a space), or not an http or https URL with a host, raises `ArgumentError` naming it. + * Before: `URI::InvalidURIError`, or a `Net::HTTP` `ArgumentError` that named nothing. + * After: rescue `ArgumentError` where code rescued `URI::InvalidURIError`. +* An endpoint that is not a String, such as a Symbol or a `URI`, raises `ArgumentError`. Pass `uri.to_s`. + +Requests are retried by the client, not by `Net::HTTP`: + +* Before: `Net::HTTP` resent a GET, PUT, or DELETE after a timeout or dropped connection, reusing the OAuth 1.0a nonce and signature. A 5xx raised at once. +* After: the client resends a GET, PUT, or DELETE after `X::ServerError`, `X::RequestTimeout`, or an `X::NetworkError` for a request that never reached the API. + * It does not resend after a read timeout, since the API bills a read it answered. + * It retries `max_retries` times, twice by default, signing each attempt afresh. + * It waits as long as a `Retry-After` header asks, up to a minute. A response asking for longer raises at once. +* Pass `max_retries: 0` to raise at once, as code with its own retry loop may want. See the retries in [README.md](https://github.com/sferik/x-ruby/blob/main/README.md). + +### Renamed classes + +| 0.19 | 1.0 | +| --- | --- | +| `X::OAuthAuthenticator` | `X::OAuth1Authenticator` | +| `X::ConnectionException` | `X::Conflict` | +| `X::MediaUploader` | `X::Uploader::MediaUpload` | +| `X::AccountUploader` | `X::Uploader::Account` | + +Remove `require "x/media_uploader"` and `require "x/account_uploader"`. `require "x"` loads the uploaders. + +`X::MediaUploadValidator` is gone, with no public replacement: + +* The uploaders validate their own arguments, raising `ArgumentError` for an invalid category. +* Pass a file to `upload`. It raises `X::InvalidMedia` for a missing file, and infers the type it sends. +* The media category constants of `X::Uploader::MediaUpload`, such as `TWEET_IMAGE`, remain public. +* These are now private constants: `X::Uploader::Validator`, `X::Uploader::Chunks`, `X::Uploader::Multipart`, and `X::Uploader::Utils`. +* These constants of `X::Uploader::MediaUpload` are private: `MIME_TYPES`, `MIME_TYPE_MAP`, `BYTES_PER_MB`, and the MIME type constants, such as `GIF_MIME_TYPE`, `MP4_MIME_TYPE`, and `SUBRIP_MIME_TYPE`. +* `PROCESSING_INFO_STATES` is gone. Use `X::UploadedMedia#processing?`. + +### Uploads + +The uploaders take the file, content, or media as their first argument, and the client as a keyword. `upload_profile_image_binary` and `upload_profile_banner_binary` are gone: pass the content to `update_profile_image` or `update_profile_banner` as a `StringIO`. + +```ruby +# 0.19 +media = X::MediaUploader.upload(client:, file_path: "cat.jpg", media_category: "tweet_image") +video = X::MediaUploader.chunked_upload(client:, file_path: "cat.mp4", media_category: "tweet_video") +X::MediaUploader.await_processing!(client:, media: video) +X::AccountUploader.update_profile_image(client:, file_path: "avatar.png") +X::AccountUploader.upload_profile_image_binary(client:, content:) +X::AccountUploader.upload_profile_banner_binary(client:, content:) + +# 1.0 +media = X::Uploader::MediaUpload.upload("cat.jpg", client:) +video = X::Uploader::MediaUpload.upload("cat.mp4", client:) # uploads in chunks and awaits processing +X::Uploader::Account.update_profile_image("avatar.png", client:) +X::Uploader::Account.update_profile_image(StringIO.new(content), client:) +X::Uploader::Account.update_profile_banner(StringIO.new(content), client:) + +# 1.0, as methods of a client, which require "x" adds +media = client.upload_media("cat.jpg") +client.update_profile_image("avatar.png") +``` + +* `upload` infers the media category from the media, and still takes `media_category:`. +* No upload method takes `boundary:`. Each upload generates its own multipart boundary. +* `await_processing` and `await_processing!` take the media as one argument: an upload response, a media ID, or anything that answers `media_key`, such as an `X::Media`. +* A class that includes `X::Uploader::MediaUpload` gains only its public methods. + * Private ones it gained in 0.19, such as `init`, `append`, and `construct_upload_body`, are gone from it. +* `client.chunked_upload_media` uploads in chunks and returns without awaiting processing, as `chunked_upload` does. + +Media can be a path or an IO, where 0.19 took a path alone: + +* `upload` and `chunked_upload` take a `String` or `Pathname` path, or an IO open on the media. +* A path, `File`, or `Tempfile` is read a chunk at a time, so media of any size uploads without being held in memory. +* Any other IO, such as a `StringIO`, is read to its end and held in memory. +* Media that names no file is categorized by its leading bytes, so `client.upload_media(StringIO.new(png))` uploads an image. +* Media whose type neither its bytes nor its file name reveal raises `X::InvalidMediaType` unless `media_category:` names it. + * For example, SubRip subtitles in a `StringIO`, or a PDF. + +The profile image and banner of `X::Uploader::Account` are checked before any request: + +* They take a path or an IO, where 0.19 took a path alone. +* They must begin with the signature of a GIF, JPEG, or PNG, whatever the file name, or raise `X::InvalidMediaType`. + * Before: a file was taken by its extension, so a video named `.png` was sent. +* A profile image over 700 KB, or a banner over 5 MB, raises `X::InvalidMedia`. +* A banner's `width:` or `height:` that is not a positive `Integer` raises `ArgumentError`. 0.19 sent it as given. +* A banner's `offset_left:` or `offset_top:` that is not an `Integer` of at least 0, such as `"1500"`, raises `ArgumentError`. +* Content given as a `StringIO` raises `X::InvalidMedia` if empty, and `X::InvalidMediaType` if not a GIF, JPEG, or PNG. +* They are posted with the client given, and its credentials, to API v1.1 at the host of its `base_url`. + * Before: they built their own client for `https://api.x.com/1.1/` from the OAuth 1.0a credentials. +* `update_profile_image` returns nil, as `update_profile_banner` does. 0.19 returned the API v1.1 user Hash. + * Look the user up, as with `client.current_user!` of `x-objects`, to read the new profile image. + +`upload_binary` is gone. A client has no `upload_media_binary`: + +* Pass content in memory as a `StringIO` to `upload_media` or `X::Uploader::MediaUpload.upload`. They infer its category, or take `media_category:`. +* An image, or a GIF small enough, uploads in a single request, as `upload_binary` did. +* A video, subtitles, or a larger GIF uploads in chunks, which sends their media type and has no 5 MB limit. 0.19 sent them in a single request. +* They await processing of media that X processes. + +```ruby +# 0.19 +X::MediaUploader.upload_binary(client:, content:, media_category: "tweet_image") + +# 1.0 +client.upload_media(StringIO.new(content)) # the category read from the signature +client.upload_media(StringIO.new(content), media_category: "tweet_image") +X::Uploader::MediaUpload.upload(StringIO.new(content), client:, media_category: "tweet_image") +``` + +`upload`, `chunked_upload`, `await_processing`, and `await_processing!` return a frozen `X::UploadedMedia`, rather than a Hash: + +* It reads like the Hash did, with `[]`, `fetch`, `dig`, and `key?`, so `media["size"]` and `media.dig("processing_info", "state")` still work. +* `to_h` returns the Hash, for code that compares it with a Hash or calls `merge`. +* It also answers `id`, `media_key`, `bytesize` (what `media["size"]` holds), `expires_after_secs`, `state`, `processing?`, `failed?`, and `ready?`. +* `media.id` is an Integer. `media["id"]`, `to_h`, `as_json`, and `to_json` keep the String the API gave, as 0.19 did. +* `media_ids:` still sends each ID as a String. +* For media X processes, such as a video or an animated GIF, `upload` returns the processing status rather than the upload response. Both hold the ID. +* The other uploaders return Hashes and Arrays, whatever the client's `default_object_class` and `default_array_class`. + * Before: `X::MediaUploader` parsed its responses with the client's classes. + +The media type sent is read from the media: + +* `infer_media_type` is internal, where 0.19 documented it. Pass `media_type:` to `upload` or `chunked_upload` to send another type. +* An upload sends the type the bytes name, or else the extension's. + * Before: `video/mp4` for every video. After: for example, `video/quicktime` for `clip.mov`. + * Before: `application/x-subrip` for SubRip subtitles. After: `text/srt`. +* Media of a type its category does not take raises `X::InvalidMediaType` before any request. + * For example, an MP4 uploaded as `tweet_gif`, `dm_gif`, or `subtitles`, or a PNG uploaded as `tweet_video`. + * Before: it was sent as the category's first type, such as an MP4 sent as a GIF. + * After: pass the right category, or `media_type:` to `chunked_upload` to send it as another type. +* Content in memory raises it too, when its signature names a type its category does not take, or names none for a GIF category. +* A file named as a type whose files all begin with a signature, such as `.png`, `.gif`, or `.ts`, raises `X::InvalidMediaType` if it lacks it. + * For example, TypeScript named `.ts`. +* Bytes win over the name, so a PNG named `.gif` uploads as an image. +* These raise `X::InvalidMediaType`, whatever `media_category:` says, unless uploaded in chunks with both a `media_category:` and a `media_type:` naming the type: + * An `.avi` or `.mkv` file that does not begin with the signature of a documented type, such as MP4 or WebM. + * Media whose name names no type, such as a `StringIO`, that begins with the header of Matroska and is not WebM. + * A `.glb` or `.usdz` file. + * Before: any of them uploaded as MP4 when told it was a video. + +Processing and failures raise errors of their own: + +* `await_processing!` raises `X::MediaProcessingFailed`, rather than `RuntimeError`. +* `await_processing` raises `X::MediaProcessingTimeout` after 600 seconds, rather than waiting forever. + * Pass `processing_timeout: nil` to wait as long as processing takes, as 0.19 did. + * Otherwise pass a finite number of seconds. `Float::INFINITY` raises `ArgumentError`. + * The same goes for `upload` and the client's `upload_media`, `await_media_processing`, and `await_media_processing!`. +* It waits until X reports processing succeeded or failed, as 0.19 did, but no longer than `processing_timeout:`. + * Media in any other state, even one X does not document, or in none, is still `processing?` and is checked again, each second if X asks for no wait. + * `await_processing!` and `upload` raise `X::MediaProcessingFailed` for failed processing alone. +* Media that already says its processing ended, or an image's upload response, is returned without a request. 0.19 checked it again. +* An upload response that describes no media, or has a nil or empty ID, raises `X::MissingMediaData`. +* Media given with no ID, such as nil or `{}`, raises `ArgumentError` before any request, as does `X::UploadedMedia.new`, which also refuses an ID that is neither an Integer nor a String of 1 to 19 digits. + * Before: `NoMethodError` or `KeyError`, or an empty `media_id` was sent. +* A missing file, or media that cannot be read, is empty, or is too large, raises `X::InvalidMedia` before any request. + * Before: the uploaders raised `RuntimeError` "File not found" for a missing file. + * After: rescue `X::InvalidMedia` where code rescued `RuntimeError`. +* A chunked upload that fails once initialized, at a chunk or at finalize, raises `X::ChunkedUploadFailed`. + * Its `media` is the media the upload initialized, and its `cause` is the error that failed it. + * Before: the request's error, such as `X::ServerError`, was raised, and the media was lost. + * After: rescue `X::ChunkedUploadFailed`, or read `error.cause`. +* An upload whose media is uploaded, but whose check of its processing fails, raises `X::MediaProcessingCheckFailed`. + * Its `media` is the media that was uploaded, and its `cause` is the error that failed the check. + * Before: `upload` did not await processing. Code that called `await_processing` itself saw the request's error, as it still does. + * After: rescue `X::MediaProcessingCheckFailed` around `upload`, and await `error.media` again. +* An upload given `alt_text:` whose media is uploaded, but whose alt text cannot be added, raises `X::AltTextFailed`. + * Its `media` is the media that was uploaded, and its `cause` is the error that failed to add the alt text. + * After: rescue `X::AltTextFailed`, and attach `error.media` or call `add_alt_text` with it again. +* An `X::TokenReportFailed`, which a `save_tokens` that raises during a request of an upload raises, is raised as it is, not as the `cause` of any of these. + * Rescue it around the upload to store `error.tokens`, as around any other request. + +The errors x-uploader raises descend from `X::Uploader::Error`, an `X::Error`: + +* These are `X::Uploader::Error`s: `X::MissingMediaData`, `X::MediaProcessingFailed`, `X::MediaProcessingTimeout`, `X::MediaProcessingCheckFailed`, `X::ChunkedUploadFailed`, `X::AltTextFailed`, and `X::InvalidMedia`. +* `X::InvalidMediaType` descends from `X::InvalidMedia`. + * So code that uploads user input can rescue it without rescuing the `ArgumentError` of its own mistakes. +* An `X::Error` the API raises before there is media, such as an `X::BadRequest` of the INIT request, is not an `X::Uploader::Error`, as in 0.19. + * `rescue X::Error` catches both. + +Chunk sizes are in bytes, and sizes are checked before any request: + +* `chunk_size_mb:` is now `chunk_size:`, in bytes. Pass `chunk_size: 4 * 1024 * 1024` where 0.19 code passed `chunk_size_mb: 4`. +* A `chunk_size:` that is not a positive `Integer`, such as a Float, raises `ArgumentError`. +* `chunk_size:` above 5,242,880 bytes (5 MB), or one needing more than 10,000 segments, raises `ArgumentError`. + * 0.19 sent it for the server to refuse above 8 MB. +* `chunk_size:` defaults to nil, which uploads in chunks of 4 MB, `X::Uploader::MediaUpload::DEFAULT_CHUNK_SIZE`, where 0.19 used 1 MB chunks. + * Why: each chunk is a request a rate limit can refuse, and the API takes at most 10,000 segments, so 0.19's 1 MB chunks failed for files over 10,000 MiB. +* A file over 16 GB (17,179,869,184 bytes) raises `X::InvalidMedia` before anything is uploaded. +* An `alt_text:` that is empty or over 1,000 characters raises `ArgumentError`. +* An animated GIF over 5 MB uploads in chunks, up to 15 MB. 0.19 sent it in a single request for X to refuse. +* These raise `X::InvalidMedia` before any request, where 0.19 sent them for X to refuse: + * An image over 5 MB. + * A GIF over 15 MB. + * Subtitles over 1 MB. +* A megabyte is 1,048,576 bytes. A video's limit depends on the account and is left to X, up to 16 GB. + +### Objects + +`require "x"` loads `x-objects`, which has no counterpart in 0.19. `get`, `post`, `put`, and `delete` return what they did, so code written for 0.19 changes only where one of its own names is now one of the gem's: + +* These constants are new under `X`: + * Resources: `X::User`, `X::Post` and its alias `X::Tweet`, `X::Media`, `X::List`, `X::Space`, `X::Community`, `X::DirectMessage`, `X::Poll`, `X::Place`, `X::Topic`, `X::BookmarkFolder`, and their base, `X::Resource`. + * Values: `X::Trend`, `X::PersonalizedTrend`, `X::MatchingRule`, and `X::PostUsage`. + * Collections: `X::Page` and `X::Cursor`. + * Errors: `X::MissingResource`, `X::MissingClient`, `X::UnreadableResponse`, `X::InvalidAttribute`, and `X::PageLimitReached`. + * `x-uploader` and `x-streaming` add `X::UploadedMedia`, `X::StreamRule`, and the errors named under Uploads and Streaming. + * An app that defined one of these itself, such as its own `X::User`, now reopens the gem's class. Rename it, or move it out of `X`. +* `X::Client` has about a hundred new public methods, which `X::Objects::API` documents: + * Lookups, such as `find_user`, `find_post`, `find_all_users`, `current_user`, and `current_user_id`. + * Searches and counts, such as `search_posts`, `search_users`, `count_posts`, and `trends`. + * Actions, such as `create_post`, `delete_post`, `follow`, `like`, `repost`, `bookmark`, `block`, `mute`, `create_list`, and `create_dm`. + * `x-uploader` adds `upload_media` and seven more, and `x-streaming` adds `streaming`. + * A subclass of `X::Client`, or code that reopens it, that defines a method of one of these names replaces the gem's. Rename it. +* Code that depends on `x-core` alone, and requires `x/core`, gets none of them. + +### Timeouts + +`open_timeout` defaults to 10 seconds, rather than the 60 of 0.19: + +* Why: a reachable host finishes the TCP and TLS handshakes in well under a second, so 10 seconds gives up on an unresponsive host sooner. +* `read_timeout` and `write_timeout` are still 60 seconds. +* Pass `open_timeout: 60` for the old default on a network where connecting is slow. + +Timeouts are checked when the client is built: + +* `open_timeout`, `read_timeout`, and `write_timeout` take a finite number of seconds of at least 0, or nil for none, as in 0.19. +* Anything else, such as a String read from an environment variable, raises `ArgumentError` from `X::Client.new`. + * Before: `Net::HTTP` raised once a request waited. + +### Headers + +A client takes `headers:`, which it sends with every request and stream: + +* They are defaults. A header of the same name passed to a request replaces the client's. +* The client's headers replace the gem's defaults, such as its `User-Agent`. +* The gem's default `User-Agent` names the gem, Ruby, and the platform, as `x-ruby/1.0.0 ruby/3.4.0 (arm64-darwin24)`, where 0.19 sent `X-Client`. + * Code that tells the gem's requests apart by it, such as a proxy rule, matches the new value, or sends its own with `headers:`. +* A header that carries credentials is dropped on a redirect to another origin, whether given to the client or to the request. +* A header named by a Symbol is sent with its underscores as hyphens. `content_type:` names `content-type`, so it replaces that header. +* `client.headers` names each header by a String, the way it is sent, whether it was given by a String or a Symbol. +* They are sent with the client's token requests too, the app-only bearer token, an OAuth 2.0 refresh, and the code exchange, beneath the token request's own `Authorization`, `Content-Type`, and `Accept`. + +```ruby +# 1.0 +client = X::Client.new(headers: {"User-Agent" => "my-app/1.0"}, **x_credentials) +traced = client.with(headers: {"User-Agent" => "my-app/1.0", "X-Trace" => "abc"}) +``` + +### Proxies + +A client given no `proxy_url` picks the proxy for each request's scheme from the environment: + +* It uses `https_proxy` for HTTPS requests, which is all these gems make, and `http_proxy` for plain HTTP. +* It connects directly to hosts listed in `no_proxy`. +* Before: `Net::HTTP` read `http_proxy` alone, whatever the scheme, so `https_proxy` was ignored. +* Set `no_proxy`, or pass a `proxy_url`, where this changes which proxy a process reaches X through. + +A proxy URL can hold a user and password, so a client keeps it private: + +* `proxy_url` no longer reads off `X::Client` or `X::Connection`, nor off the new `X::StreamingClient`. +* `proxy_uri`, `proxy_host`, `proxy_port`, `proxy_user`, and `proxy_pass` no longer read off a connection. +* `inspect` leaves the proxy out, and `with` carries the proxy to a copy. +* Keep the URL you built the client with if your code needs to read it again. + +### Streaming + +`stream` moved from `X::Client` to `X::StreamingClient`, from `x-streaming`, which `streaming` builds from a client: + +```ruby +# 0.19 +client.stream("tweets/search/stream") { |post| puts post } + +# 1.0 +client.streaming.stream("tweets/search/stream") { |post| puts post } +``` + +A stream reconnects when it ends or drops: + +* Before: a stream that ended returned, and one that dropped raised. +* After: it reconnects, and once it has no reconnects left raises `X::NetworkError`, whether it ended or dropped. + * Rescue `X::NetworkError` where 0.19 code waited for `stream` to return. +* Pass `max_reconnects: 0` to `streaming` to reconnect no stream, as 0.19 did. +* Pass `on_reconnect: ->(error, wait) { ... }` to `streaming` to hear of each reconnect, or to give up with `stop`. +* To stop a stream from another thread or the trap of a signal, call `stop` on its streaming client. Each stream it stops returns nil. + * Keep the streaming client in a variable, since each `streaming` call builds a new one. + * The streaming client stays stopped; build another with `streaming` to stream again. + * A block, `on_response`, or `on_reconnect` that is running finishes first. + +A stream has its own `read_timeout`, 30 seconds by default: + +* Before: it read with the client's 60 seconds. Pass `streaming(read_timeout: 60)` for the old timeout. +* A `read_timeout` under 25 seconds raises `ArgumentError`. Pass nil for no timeout. + * Why: X sends a quiet stream a keep-alive every 20 seconds, so a shorter timeout would drop a live stream whenever one ran late. + +Streams authenticate as the app, since the stream endpoints take app-only authentication: + +* A client that signs with OAuth 1.0a fetches the app's bearer token with its API key and secret. +* A client that authenticates with OAuth 2.0 as a user streams with the app's `bearer_token`, or its `api_key` and `api_key_secret`, given beside the user's credentials. +* An OAuth 2.0 user client with neither streams as the user. X refuses it with 403, raising `X::Forbidden`. + * The same goes for reading and changing filtered-stream rules. + +`require "x"` loads `x-streaming` and gives every client `streaming`. Code that depends on `x-core` alone, and streamed with 0.19, adds `x-streaming`: + +* Include its methods into the client, as `x` does. +* Or build a streaming client of a client itself: + +```ruby +require "x/core" +require "x/streaming" + +X::Client.include(X::Streaming::API) +client.streaming.stream("tweets/search/stream") { |post| puts post } + +# or, without changing X::Client +X::StreamingClient.new(client).stream("tweets/search/stream") { |post| puts post } +``` + +### Errors + +Some statuses raise new or different errors. `rescue X::Error` still catches them all: + +* New, each an `X::ClientError`: `X::MethodNotAllowed` (405), `X::RequestTimeout` (408), `X::UnsupportedMediaType` (415), and `X::UnavailableForLegalReasons` (451). +* A 4xx or 5xx status without a class of its own raises `X::ClientError` or `X::ServerError`, rather than `X::HTTPError`. +* `X::NetworkError` wraps every network failure: + * `IOError`, including `EOFError`. + * `SystemCallError`, including every `Errno` error. + * Net::HTTP's open, read, and write timeouts, `Net::ProtocolError`, `Net::HTTPBadResponse`, `Zlib::Error`, `OpenSSL::SSL::SSLError`, and `SocketError`. + +Error messages name the request: + +* Before: the API's message alone. After: `GET /2/users/1: Could not find user`. + * Code that matches a message against a String should allow for that prefix, or read `error.problem` instead. +* `X::TooManyRedirects` reads `GET /2/users/2: Too many redirects`, rather than `Too many redirects`. +* `error.http_method` and `error.uri` read the request on `X::HTTPError`, `X::NetworkError`, and `X::InvalidResponse`. + +A successful response whose body is not JSON, such as a proxy's page, raises `X::InvalidResponse`: + +* Before: it returned nil. +* `X::InvalidResponse` is an `X::HTTPError`. +* A successful response without a body still returns nil. +* `error.body` reads the body of the response when the error was built without one, once that response has been read whole. + +A response body, which 0.19 read as `error.response.body`, is tagged UTF-8, where 0.19 left it binary: + +* `error.body` and `error.http_response.body` can be joined with non-ASCII Strings without `Encoding::CompatibilityError`. +* Code that forced a body's encoding no longer needs to. +* A body that is not valid UTF-8 keeps its bytes. `valid_encoding?` tells it apart. + +`X::TooManyRequests#reset_at`, `#reset_in`, and `#retry_after` return nil when the response does not say when the limit resets: + +* Before: they returned `Time.at(0)` and 0, which retried at once. +* After: fall back to a wait of your own. X recommends a minute: + +```ruby +# 0.19 +sleep error.retry_after + +# 1.0 +sleep(error.retry_after || 60) +``` + +`retry_after` reads the `Retry-After` header: + +* `X::HTTPError#retry_after` reads it from any refused response. +* `X::TooManyRequests#retry_after` reads it in place of the limit's reset time, which 0.19 read alone. + * It counts seconds from when the response was sent, so the wait is right however far the local clock is off. + * It answers for a refusal that reports no limit, such as a daily cap. +* `#reset_in` still reads the limit alone. + +`X::RateLimit#retry_after` is gone: + +* It was an alias for `#reset_in`, so one name would have meant two waits. +* Read a limit with `#reset_in`, and the wait a refusal asks for with `X::TooManyRequests#retry_after`: + +```ruby +# 0.19 +sleep error.rate_limit.retry_after + +# 1.0 +sleep(error.limiting_rate_limit&.reset_in || error.retry_after || 60) +``` + +Several readers of `X::HTTPError` are renamed or gone: + +* `#error_message` and `#message_from_json_response` are gone. Read `message`. +* `#json?` is private. +* `#code` is gone. It read the status as the String `"404"`. + * `#status` reads it as the Integer `404`, as `X::Response#status` does. + * For the String, call `status.to_s` or read `error.http_response.code`. +* The `Net::HTTP` response is `http_response` on `X::HTTPError` and `X::InvalidResponse`, rather than `response`. + * `X::Response#http_response` reads the same object. + * It is an escape hatch: its class is the transport's, and not part of what 1.x promises. +* `X::RateLimit#response` is gone. Read `limit`, `remaining`, and `reset_at`, or the response of the error or `X::Response`. +* `#problem` is new, and returns the response's `X::Problem`, with `detail`, `title`, and `to_h`. + +Building errors and limits changed: + +* `X::HTTPError.new` no longer takes `response:`, so code that builds an error, such as a test double, raises `ArgumentError`. + * Pass `http_response:` with the `http_method:` and `uri:` of the request. + * Or pass `status:`, `headers:`, and `body:` without a response. + * Or raise it with a message alone. +* `X::RateLimit.new`, which took `type:` and `response:`, is private. Read the limits of a response or an error instead. + +```ruby +# 0.19 +X::NotFound.new(response: response) + +# 1.0 +X::NotFound.new(http_response: response, http_method: :get, uri: URI("https://api.x.com/2/users/1")) +X::NotFound.new(status: 404, headers: {"content-type" => "application/json"}, body: %({"title":"Not Found Error"})) +raise X::TooManyRequests, "Too Many Requests" +``` + +`X::TooManyRequests#rate_limits` and `#rate_limit` mean what they mean on `X::Response`: + +* `#rate_limits` reports every limit the response names. 0.19 reported only the exhausted ones. +* `#rate_limit` is the 15-minute limit. 0.19 gave the exhausted limit that resets last. +* The exhausted limits are now `#exhausted_rate_limits`. +* The one a request waits for is `#limiting_rate_limit`. + * `reset_at` and `reset_in` read it, and so does `retry_after` without a `Retry-After` header, so code that sleeps for `retry_after` is unchanged. + +```ruby +# 0.19 +error.rate_limits # the exhausted limits +error.rate_limit # the exhausted limit that resets last + +# 1.0 +error.exhausted_rate_limits +error.limiting_rate_limit +``` + +### Version + +`X::VERSION` is a String, rather than a `Gem::Version`: + +* So are the `VERSION` constants of `X::Core`, `X::Uploader`, `X::Streaming`, and `X::Objects`. +* Code that logs or sends a version reads the constant itself. +* Code that compares versions calls `gem_version`, which builds the `Gem::Version`: + +```ruby +# 0.19 +X::VERSION >= Gem::Version.new("0.19") +X::VERSION.segments.first + +# 1.0 +X.gem_version >= Gem::Version.new("1.0") +X.gem_version.segments.first +``` + +### Internals + +Internal classes and modules are private constants under `X::Core` or `X::Streaming`, so they can change within 1.x: + +* `X::RequestBuilder`, `X::RedirectHandler`, `X::ResponseParser`, and `X::ClientCredentials` are under `X::Core`. +* `X::StreamParser` is `X::Streaming::StreamParser`. +* `X::Connection` is `X::Core::Connection`. + * Build a client with the settings you gave a connection. + * Read timeout defaults from `X::Client::DEFAULT_OPEN_TIMEOUT`, `DEFAULT_READ_TIMEOUT`, `DEFAULT_WRITE_TIMEOUT`, and `DEFAULT_KEEP_ALIVE_TIMEOUT`. +* `X::OAuth2Authenticator` has no `connection`. A client refreshes its tokens over its own connection. +* Configure the internals through the settings of `X::Client` and `X::StreamingClient`, such as `max_redirects`. +* Read their defaults from `X::Client::DEFAULT_MAX_REDIRECTS`, `DEFAULT_MAX_RATE_LIMIT_RETRIES`, `DEFAULT_MAX_RATE_LIMIT_WAIT`, and `X::StreamingClient::DEFAULT_MAX_RECONNECTS`. +* `X::Client`, `X::BearerTokenAuthenticator`, `X::OAuth2Authenticator`, `X::RateLimit`, and the errors of 0.19 keep their names, except as the table above shows. + +### Removed constants + +* Gone, since [simple_oauth](https://github.com/laserlemon/simple_oauth) signs requests and builds token refreshes: + * `X::OAuthAuthenticator::OAUTH_VERSION`, `OAUTH_SIGNATURE_METHOD`, and `OAUTH_SIGNATURE_ALGORITHM`. + * `X::OAuth2Authenticator::REFRESH_GRANT_TYPE`. +* `X::OAuth2Authenticator::TOKEN_HOST` and `TOKEN_PATH` are gone. Tokens are requested at the origin of the client's `base_url`. +* `X::MediaUploader::MAX_RETRIES` is gone. A chunk is retried up to the client's `max_retries`. +* `X::AccountUploader::MIME_TYPE_MAP` and `SUPPORTED_EXTENSIONS` are gone. A profile image or banner is checked by its signature. +* `X::AccountUploader::V1_BASE_URL` is gone. The endpoints are private, relative to the client's `base_url`. +* `X::HTTPError::JSON_CONTENT_TYPE_REGEXP` and `X::OAuth2Authenticator::EXPIRATION_BUFFER` are private. +* `X::Connection::DEFAULT_HOST` and `DEFAULT_PORT` are gone, since every request names its host. +* The gems no longer depend on `base64`, which `x` 0.19 did. Add it to your own Gemfile if you use it. diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/bin/console b/bin/console index fa3b8637..ddebfd10 100755 --- a/bin/console +++ b/bin/console @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require "bundler/setup" require "x" diff --git a/bin/setup b/bin/setup index cf4ad25e..ea6a6ee0 100755 --- a/bin/setup +++ b/bin/setup @@ -4,3 +4,7 @@ IFS=$'\n\t' set -vx bundle install +(cd x-core && bundle install) +(cd x-uploader && bundle install) +(cd x-streaming && bundle install) +(cd x-objects && bundle install) diff --git a/bin/update b/bin/update new file mode 100755 index 00000000..62e84acb --- /dev/null +++ b/bin/update @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Update the root bundle and the separate bundles of x-core, x-uploader, x-streaming, and x-objects +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle update --all +(cd x-core && bundle update --all) +(cd x-uploader && bundle update --all) +(cd x-streaming && bundle update --all) +(cd x-objects && bundle update --all) diff --git a/examples/chunked_media_upload.rb b/examples/chunked_media_upload.rb index 64d63e5e..d8773966 100644 --- a/examples/chunked_media_upload.rb +++ b/examples/chunked_media_upload.rb @@ -1,5 +1,6 @@ +# frozen_string_literal: true + require "x" -require "x/media_uploader" x_credentials = { api_key: "INSERT YOUR X API KEY HERE", @@ -8,16 +9,19 @@ access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" } -client = X::Client.new(**x_credentials) +# A large video uploads in many chunks, each a request a rate limit can refuse, so the client waits a rate limit out +# rather than fail the upload +client = X::Client.new(**x_credentials, max_rate_limit_retries: 3) file_path = "path/to/your/media.mp4" -media_category = "tweet_video" # other options include: tweet_image, tweet_gif, dm_image, dm_video, dm_gif, subtitles - -media = X::MediaUploader.chunked_upload(client:, file_path:, media_category:) -X::MediaUploader.await_processing(client:, media:) +# client.upload_media uploads a video in chunks of 4 MB and waits for it to be processed. The steps it takes can also +# be run one at a time, to choose the media category, the size of each chunk, and how many are sent at once. +media_category = "tweet_video" # or amplify_video or dm_video: a GIF or subtitles category refuses an MP4 video +media = X::Uploader::MediaUpload.chunked_upload(file_path, client:, media_category:, chunk_size: 5 * 1024 * 1024, concurrency: 2) -tweet_body = {text: "Posting media from @gem!", media: {media_ids: [media["media_id_string"]]}} +# Wait up to five minutes, raising X::MediaProcessingFailed if processing fails +client.await_media_processing!(media, processing_timeout: 300) -tweet = client.post("tweets", tweet_body.to_json) +post = client.create_post("Posting media from @gem!", media_ids: [media]) -puts tweet["data"]["id"] +puts post.id diff --git a/examples/filtered_stream.rb b/examples/filtered_stream.rb new file mode 100644 index 00000000..6a1595ad --- /dev/null +++ b/examples/filtered_stream.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "x" + +# The filtered stream and its rules take app-only authentication, which a client built from a bearer token, or from +# an API key and secret, has. A client that signs with OAuth 1.0a fetches one for itself. +client = X::Client.new(bearer_token: "INSERT YOUR BEARER TOKEN HERE") +streaming = client.streaming + +# View existing rules +rules = streaming.rules +puts "Existing rules: #{rules}" + +# Delete all existing rules (if any) +deleted = streaming.delete_rules(rules) +puts "Deleted #{deleted} rule(s)" + +# Add new rules, each a value to match and the tag to label it with +added = streaming.add_rules([{value: "ruby lang", tag: "ruby"}, {value: "#opensource", tag: "opensource"}]) +puts "Added rules: #{added}" + +# Connect to the filtered stream, which reconnects when X drops it, until the block stops it +puts "Streaming..." +streaming.stream("tweets/search/stream", params: {"tweet.fields": "created_at", expansions: "author_id"}) do |post| + author = post.dig("includes", "users")&.first + puts "@#{author&.fetch("username")}: #{post["data"]["text"]}" +end diff --git a/examples/followers.rb b/examples/followers.rb new file mode 100644 index 00000000..eb39c109 --- /dev/null +++ b/examples/followers.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "x" + +x_credentials = { + api_key: "INSERT YOUR X API KEY HERE", + api_key_secret: "INSERT YOUR X API KEY SECRET HERE", + access_token: "INSERT YOUR X ACCESS TOKEN HERE", + access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" +} + +# Wait for a rate limit to reset, up to 15 minutes, and retry, up to three times for each request +client = X::Client.new(**x_credentials, max_rate_limit_retries: 3) +user = client.find_user("sferik") + +# Each page requests 1,000 followers, the maximum. Pages are fetched lazily and cached. +# With prefetch, the next page is fetched in the background while the current one is printed. +followers = user.followers.prefetch +followers.each { |follower| puts "#{follower.username}: #{follower.followers_count} followers" } + +# Iterating again uses the cached pages, so this makes no requests +puts followers.to_a.size + +# Count the followers the profile reports, without paging through them +puts user.followers.published_count + +# Posts reference their authors. Every reference to the same user in one page is the same object. +user.posts.first(10).each do |post| + puts "#{post.author.username}: #{post.text}" +end diff --git a/examples/pagination.rb b/examples/pagination.rb index 28179dce..d1a61798 100644 --- a/examples/pagination.rb +++ b/examples/pagination.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "x" x_credentials = { @@ -7,21 +9,22 @@ access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" } -client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **x_credentials) +# Wait for a rate limit to reset, up to 15 minutes, and retry, up to three times for each request +client = X::Client.new(**x_credentials, max_rate_limit_retries: 3) -screen_name = "sferik" -count = 5000 -cursor = -1 +# Page through raw JSON by passing each page's next_token as the pagination_token of the next request +user_id = client.get("users/by/username/sferik").dig("data", "id") +params = {max_results: 1000, "user.fields": "id"} follower_ids = [] loop do - response = client.get("followers/ids.json?screen_name=#{screen_name}&count=#{count}&cursor=#{cursor}") - follower_ids << response["ids"] - cursor = response["next_cursor"] - break if cursor.zero? -rescue X::TooManyRequests => e - # NOTE: Your process could go to sleep for up to 15 minutes but if you - # retry any sooner, it will almost certainly fail with the same exception. - sleep e.retry_after - retry + response = client.get("users/#{user_id}/followers", params:) + follower_ids.concat(Array(response["data"]).map { |follower| follower["id"] }) + next_token = response.dig("meta", "next_token") or break + params = params.merge(pagination_token: next_token) end + +puts follower_ids.size + +# A cursor does the same, requesting nothing but identifiers +puts client.find_user("sferik").followers.ids.size diff --git a/examples/post_media_upload.rb b/examples/post_media_upload.rb index 74662e69..e077e2ed 100644 --- a/examples/post_media_upload.rb +++ b/examples/post_media_upload.rb @@ -1,5 +1,6 @@ +# frozen_string_literal: true + require "x" -require "x/media_uploader" x_credentials = { api_key: "INSERT YOUR X API KEY HERE", @@ -10,12 +11,11 @@ client = X::Client.new(**x_credentials) file_path = "path/to/your/media.jpg" -media_category = "tweet_image" # other options are: dm_image or subtitles; for videos or GIFs use chunked_upload - -media = X::MediaUploader.upload(client:, file_path:, media_category:) -tweet_body = {text: "Posting media from @gem!", media: {media_ids: [media["media_id_string"]]}} +# The media category is inferred from the file: an image, an animated GIF, a video, which is uploaded in chunks and +# processed, or subtitles. Pass media_category: to choose another, such as dm_image. +media = client.upload_media(file_path, alt_text: "Describe the image for people who cannot see it") -tweet = client.post("tweets", tweet_body.to_json) +post = client.create_post("Posting media from @gem!", media_ids: [media]) -puts tweet["data"]["id"] +puts post.id diff --git a/examples/profile_upload.rb b/examples/profile_upload.rb new file mode 100644 index 00000000..d7557bfe --- /dev/null +++ b/examples/profile_upload.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "x" + +x_credentials = { + api_key: "INSERT YOUR X API KEY HERE", + api_key_secret: "INSERT YOUR X API KEY SECRET HERE", + access_token: "INSERT YOUR X ACCESS TOKEN HERE", + access_token_secret: "INSERT YOUR X ACCESS TOKEN SECRET HERE" +} + +client = X::Client.new(**x_credentials) + +# Update profile image (avatar) +# Supported formats: GIF, JPEG, PNG, told by the bytes the image begins with, whatever its file is named +# The image must be no larger than 700 KB, or X::InvalidMedia is raised before any request +profile_image_path = "path/to/your/avatar.png" + +client.update_profile_image(profile_image_path) +puts "Profile image updated for @#{client.current_user!.username}" + +# Update profile banner +# Recommended dimensions: 1500x500 pixels, of no more than 5 MB +banner_path = "path/to/your/banner.png" + +client.update_profile_banner(banner_path) +puts "Profile banner updated successfully" + +# Update profile banner with custom dimensions and offset +client.update_profile_banner( + banner_path, + width: 1500, + height: 500, + offset_left: 0, + offset_top: 0 +) +puts "Profile banner updated with custom dimensions" diff --git a/lib/x.rb b/lib/x.rb index 3ad40fda..fd5b3525 100644 --- a/lib/x.rb +++ b/lib/x.rb @@ -1 +1,13 @@ -require_relative "x/client" +# frozen_string_literal: true + +require "x/core" +require "x/uploader" +require "x/streaming" +require "x/objects" +require_relative "x/version" + +module X + Client.include(Objects::API) + Client.include(Uploader::API) + Client.include(Streaming::API) +end diff --git a/lib/x/authenticator.rb b/lib/x/authenticator.rb deleted file mode 100644 index 1592031f..00000000 --- a/lib/x/authenticator.rb +++ /dev/null @@ -1,9 +0,0 @@ -module X - class Authenticator - AUTHENTICATION_HEADER = "Authorization".freeze - - def header(_request) - {AUTHENTICATION_HEADER => ""} - end - end -end diff --git a/lib/x/bearer_token_authenticator.rb b/lib/x/bearer_token_authenticator.rb deleted file mode 100644 index a7c005a5..00000000 --- a/lib/x/bearer_token_authenticator.rb +++ /dev/null @@ -1,15 +0,0 @@ -require_relative "authenticator" - -module X - class BearerTokenAuthenticator < Authenticator - attr_accessor :bearer_token - - def initialize(bearer_token:) # rubocop:disable Lint/MissingSuper - @bearer_token = bearer_token - end - - def header(_request) - {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"} - end - end -end diff --git a/lib/x/client.rb b/lib/x/client.rb deleted file mode 100644 index 29d055cf..00000000 --- a/lib/x/client.rb +++ /dev/null @@ -1,122 +0,0 @@ -require "forwardable" -require_relative "bearer_token_authenticator" -require_relative "connection" -require_relative "oauth_authenticator" -require_relative "redirect_handler" -require_relative "request_builder" -require_relative "response_parser" - -module X - class Client - extend Forwardable - - DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze - DEFAULT_ARRAY_CLASS = Array - DEFAULT_OBJECT_CLASS = Hash - - attr_accessor :base_url, :default_array_class, :default_object_class - attr_reader :api_key, :api_key_secret, :access_token, :access_token_secret, :bearer_token - - def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output - def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output= - def_delegators :@redirect_handler, :max_redirects - def_delegators :@redirect_handler, :max_redirects= - - def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil, - bearer_token: nil, - base_url: DEFAULT_BASE_URL, - open_timeout: Connection::DEFAULT_OPEN_TIMEOUT, - read_timeout: Connection::DEFAULT_READ_TIMEOUT, - write_timeout: Connection::DEFAULT_WRITE_TIMEOUT, - debug_output: Connection::DEFAULT_DEBUG_OUTPUT, - proxy_url: nil, - default_array_class: DEFAULT_ARRAY_CLASS, - default_object_class: DEFAULT_OBJECT_CLASS, - max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS) - initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token) - initialize_authenticator - @base_url = base_url - initialize_default_classes(default_array_class, default_object_class) - @connection = Connection.new(open_timeout:, read_timeout:, write_timeout:, debug_output:, proxy_url:) - @request_builder = RequestBuilder.new - @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder, max_redirects:) - @response_parser = ResponseParser.new - end - - def get(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class) - execute_request(:get, endpoint, headers:, array_class:, object_class:) - end - - def post(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class) - execute_request(:post, endpoint, body:, headers:, array_class:, object_class:) - end - - def put(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class) - execute_request(:put, endpoint, body:, headers:, array_class:, object_class:) - end - - def delete(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class) - execute_request(:delete, endpoint, headers:, array_class:, object_class:) - end - - def api_key=(api_key) - @api_key = api_key - initialize_authenticator - end - - def api_key_secret=(api_key_secret) - @api_key_secret = api_key_secret - initialize_authenticator - end - - def access_token=(access_token) - @access_token = access_token - initialize_authenticator - end - - def access_token_secret=(access_token_secret) - @access_token_secret = access_token_secret - initialize_authenticator - end - - def bearer_token=(bearer_token) - @bearer_token = bearer_token - initialize_authenticator - end - - private - - def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token) - @api_key = api_key - @api_key_secret = api_key_secret - @access_token = access_token - @access_token_secret = access_token_secret - @bearer_token = bearer_token - end - - def initialize_default_classes(default_array_class, default_object_class) - @default_array_class = default_array_class - @default_object_class = default_object_class - end - - def initialize_authenticator - @authenticator = if api_key && api_key_secret && access_token && access_token_secret - OAuthAuthenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:) - elsif bearer_token - BearerTokenAuthenticator.new(bearer_token:) - elsif @authenticator.nil? - Authenticator.new - else - @authenticator - end - end - - def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: default_array_class, object_class: default_object_class) - uri = URI.join(base_url, endpoint) - request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator: @authenticator) - response = @connection.perform(request:) - response = @redirect_handler.handle(response:, request:, base_url:, authenticator: @authenticator) - @response_parser.parse(response:, array_class:, object_class:) - end - end -end diff --git a/lib/x/connection.rb b/lib/x/connection.rb deleted file mode 100644 index 809bd594..00000000 --- a/lib/x/connection.rb +++ /dev/null @@ -1,80 +0,0 @@ -require "forwardable" -require "net/http" -require "openssl" -require "uri" -require_relative "errors/network_error" - -module X - class Connection - extend Forwardable - - DEFAULT_HOST = "api.twitter.com".freeze - DEFAULT_PORT = 443 - DEFAULT_OPEN_TIMEOUT = 60 # seconds - DEFAULT_READ_TIMEOUT = 60 # seconds - DEFAULT_WRITE_TIMEOUT = 60 # seconds - DEFAULT_DEBUG_OUTPUT = File.open(File::NULL, "w") - NETWORK_ERRORS = [ - Errno::ECONNREFUSED, - Errno::ECONNRESET, - Net::OpenTimeout, - Net::ReadTimeout, - OpenSSL::SSL::SSLError - ].freeze - - attr_accessor :open_timeout, :read_timeout, :write_timeout, :debug_output - attr_reader :proxy_url, :proxy_uri - - def_delegator :proxy_uri, :host, :proxy_host - def_delegator :proxy_uri, :port, :proxy_port - def_delegator :proxy_uri, :user, :proxy_user - def_delegator :proxy_uri, :password, :proxy_pass - - def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, - write_timeout: DEFAULT_WRITE_TIMEOUT, debug_output: DEFAULT_DEBUG_OUTPUT, proxy_url: nil) - @open_timeout = open_timeout - @read_timeout = read_timeout - @write_timeout = write_timeout - @debug_output = debug_output - self.proxy_url = proxy_url unless proxy_url.nil? - end - - def perform(request:) - host = request.uri.host || DEFAULT_HOST - port = request.uri.port || DEFAULT_PORT - http_client = build_http_client(host, port) - http_client.use_ssl = request.uri.scheme.eql?("https") - http_client.request(request) - rescue *NETWORK_ERRORS => e - raise NetworkError, "Network error: #{e}" - end - - def proxy_url=(proxy_url) - @proxy_url = proxy_url - proxy_uri = URI(proxy_url) - raise ArgumentError, "Invalid proxy URL: #{proxy_uri}" unless proxy_uri.is_a?(URI::HTTP) - - @proxy_uri = proxy_uri - end - - private - - def build_http_client(host = DEFAULT_HOST, port = DEFAULT_PORT) - http_client = if proxy_uri - Net::HTTP.new(host, port, proxy_host, proxy_port, proxy_user, proxy_pass) - else - Net::HTTP.new(host, port) - end - configure_http_client(http_client) - end - - def configure_http_client(http_client) - http_client.tap do |c| - c.open_timeout = open_timeout - c.read_timeout = read_timeout - c.write_timeout = write_timeout - c.set_debug_output(debug_output) - end - end - end -end diff --git a/lib/x/errors/bad_gateway.rb b/lib/x/errors/bad_gateway.rb deleted file mode 100644 index d2e460ba..00000000 --- a/lib/x/errors/bad_gateway.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "server_error" - -module X - class BadGateway < ServerError; end -end diff --git a/lib/x/errors/bad_request.rb b/lib/x/errors/bad_request.rb deleted file mode 100644 index 9adc29a5..00000000 --- a/lib/x/errors/bad_request.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class BadRequest < ClientError; end -end diff --git a/lib/x/errors/client_error.rb b/lib/x/errors/client_error.rb deleted file mode 100644 index 9be538b2..00000000 --- a/lib/x/errors/client_error.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "http_error" - -module X - class ClientError < HTTPError; end -end diff --git a/lib/x/errors/connection_exception.rb b/lib/x/errors/connection_exception.rb deleted file mode 100644 index 18bf8a6c..00000000 --- a/lib/x/errors/connection_exception.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class ConnectionException < ClientError; end -end diff --git a/lib/x/errors/error.rb b/lib/x/errors/error.rb deleted file mode 100644 index 2362f23e..00000000 --- a/lib/x/errors/error.rb +++ /dev/null @@ -1,3 +0,0 @@ -module X - class Error < StandardError; end -end diff --git a/lib/x/errors/forbidden.rb b/lib/x/errors/forbidden.rb deleted file mode 100644 index c91b3b83..00000000 --- a/lib/x/errors/forbidden.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class Forbidden < ClientError; end -end diff --git a/lib/x/errors/gateway_timeout.rb b/lib/x/errors/gateway_timeout.rb deleted file mode 100644 index b58f2a81..00000000 --- a/lib/x/errors/gateway_timeout.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "server_error" - -module X - class GatewayTimeout < ServerError; end -end diff --git a/lib/x/errors/gone.rb b/lib/x/errors/gone.rb deleted file mode 100644 index 9b284018..00000000 --- a/lib/x/errors/gone.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class Gone < ClientError; end -end diff --git a/lib/x/errors/http_error.rb b/lib/x/errors/http_error.rb deleted file mode 100644 index 9f54aeb3..00000000 --- a/lib/x/errors/http_error.rb +++ /dev/null @@ -1,41 +0,0 @@ -require "json" -require_relative "error" - -module X - class HTTPError < Error - JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json} - - attr_reader :response, :code - - def initialize(response:) - super(error_message(response)) - @response = response - @code = response.code - end - - def error_message(response) - if json?(response) - message_from_json_response(response) - else - response.message - end - end - - def message_from_json_response(response) - response_object = JSON.parse(response.body) - if response_object.key?("title") && response_object.key?("detail") - "#{response_object.fetch("title")}: #{response_object.fetch("detail")}" - elsif response_object.key?("error") - response_object.fetch("error") - elsif response_object["errors"].instance_of?(Array) - response_object.fetch("errors").map { |error| error.fetch("message") }.join(", ") - else - response.message - end - end - - def json?(response) - JSON_CONTENT_TYPE_REGEXP === response["content-type"] - end - end -end diff --git a/lib/x/errors/internal_server_error.rb b/lib/x/errors/internal_server_error.rb deleted file mode 100644 index f6de05e9..00000000 --- a/lib/x/errors/internal_server_error.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "server_error" - -module X - class InternalServerError < ServerError; end -end diff --git a/lib/x/errors/network_error.rb b/lib/x/errors/network_error.rb deleted file mode 100644 index 3b7149d2..00000000 --- a/lib/x/errors/network_error.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "error" - -module X - class NetworkError < Error; end -end diff --git a/lib/x/errors/not_acceptable.rb b/lib/x/errors/not_acceptable.rb deleted file mode 100644 index 602edd21..00000000 --- a/lib/x/errors/not_acceptable.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class NotAcceptable < ClientError; end -end diff --git a/lib/x/errors/not_found.rb b/lib/x/errors/not_found.rb deleted file mode 100644 index 2a613579..00000000 --- a/lib/x/errors/not_found.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class NotFound < ClientError; end -end diff --git a/lib/x/errors/payload_too_large.rb b/lib/x/errors/payload_too_large.rb deleted file mode 100644 index 64e7749b..00000000 --- a/lib/x/errors/payload_too_large.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class PayloadTooLarge < ClientError; end -end diff --git a/lib/x/errors/server_error.rb b/lib/x/errors/server_error.rb deleted file mode 100644 index 60726b56..00000000 --- a/lib/x/errors/server_error.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "http_error" - -module X - class ServerError < HTTPError; end -end diff --git a/lib/x/errors/service_unavailable.rb b/lib/x/errors/service_unavailable.rb deleted file mode 100644 index 09a48909..00000000 --- a/lib/x/errors/service_unavailable.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "server_error" - -module X - class ServiceUnavailable < ServerError; end -end diff --git a/lib/x/errors/too_many_redirects.rb b/lib/x/errors/too_many_redirects.rb deleted file mode 100644 index 7e57c737..00000000 --- a/lib/x/errors/too_many_redirects.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "error" - -module X - class TooManyRedirects < Error; end -end diff --git a/lib/x/errors/too_many_requests.rb b/lib/x/errors/too_many_requests.rb deleted file mode 100644 index afd3a71b..00000000 --- a/lib/x/errors/too_many_requests.rb +++ /dev/null @@ -1,26 +0,0 @@ -require_relative "client_error" -require_relative "../rate_limit" - -module X - class TooManyRequests < ClientError - def rate_limit - rate_limits.max_by(&:reset_at) - end - - def rate_limits - @rate_limits ||= RateLimit::TYPES.filter_map do |type| - RateLimit.new(type:, response:) if response["x-#{type}-remaining"].eql?("0") - end - end - - def reset_at - rate_limit&.reset_at || Time.at(0) - end - - def reset_in - [(reset_at - Time.now).ceil, 0].max - end - - alias_method :retry_after, :reset_in - end -end diff --git a/lib/x/errors/unauthorized.rb b/lib/x/errors/unauthorized.rb deleted file mode 100644 index 2aeaceda..00000000 --- a/lib/x/errors/unauthorized.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class Unauthorized < ClientError; end -end diff --git a/lib/x/errors/unprocessable_entity.rb b/lib/x/errors/unprocessable_entity.rb deleted file mode 100644 index 46d8daa2..00000000 --- a/lib/x/errors/unprocessable_entity.rb +++ /dev/null @@ -1,5 +0,0 @@ -require_relative "client_error" - -module X - class UnprocessableEntity < ClientError; end -end diff --git a/lib/x/media_uploader.rb b/lib/x/media_uploader.rb deleted file mode 100644 index b4092662..00000000 --- a/lib/x/media_uploader.rb +++ /dev/null @@ -1,119 +0,0 @@ -require "securerandom" -require "tmpdir" - -module X - module MediaUploader - extend self - - MAX_RETRIES = 3 - BYTES_PER_MB = 1_048_576 - MEDIA_CATEGORIES = %w[dm_gif dm_image dm_video subtitles tweet_gif tweet_image tweet_video].freeze - DM_GIF, DM_IMAGE, DM_VIDEO, SUBTITLES, TWEET_GIF, TWEET_IMAGE, TWEET_VIDEO = MEDIA_CATEGORIES - DEFAULT_MIME_TYPE = "application/octet-stream".freeze - MIME_TYPES = %w[image/gif image/jpeg video/mp4 image/png application/x-subrip image/webp].freeze - GIF_MIME_TYPE, JPEG_MIME_TYPE, MP4_MIME_TYPE, PNG_MIME_TYPE, SUBRIP_MIME_TYPE, WEBP_MIME_TYPE = MIME_TYPES - MIME_TYPE_MAP = {"gif" => GIF_MIME_TYPE, "jpg" => JPEG_MIME_TYPE, "jpeg" => JPEG_MIME_TYPE, "mp4" => MP4_MIME_TYPE, - "png" => PNG_MIME_TYPE, "srt" => SUBRIP_MIME_TYPE, "webp" => WEBP_MIME_TYPE}.freeze - PROCESSING_INFO_STATES = %w[failed succeeded].freeze - - def upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category), - boundary: SecureRandom.hex) - validate!(file_path:, media_category:) - upload_body = construct_upload_body(file_path:, media_type:, boundary:) - headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"} - client.post("media/upload?media_category=#{media_category}", upload_body, headers:) - end - - def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, - media_category), boundary: SecureRandom.hex, chunk_size_mb: 1) - validate!(file_path:, media_category:) - media = init(client:, file_path:, media_type:, media_category:) - chunk_size = chunk_size_mb * BYTES_PER_MB - append(client:, file_paths: split(file_path, chunk_size), media:, media_type:, boundary:) - client.post("media/upload?command=FINALIZE&media_key=#{media["media_key"]}")&.fetch("data") - end - - def await_processing(client:, media:) - loop do - status = client.get("media/upload?command=STATUS&media_key=#{media["media_key"]}")&.fetch("data") - return status if !status["processing_info"] || PROCESSING_INFO_STATES.include?(status["processing_info"]["state"]) - - sleep status["processing_info"]["check_after_secs"].to_i - end - end - - private - - def validate!(file_path:, media_category:) - raise "File not found: #{file_path}" unless File.exist?(file_path) - - return if MEDIA_CATEGORIES.include?(media_category.downcase) - - raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{MEDIA_CATEGORIES.join(", ")}" - end - - def infer_media_type(file_path, media_category) - case media_category.downcase - when TWEET_GIF, DM_GIF then GIF_MIME_TYPE - when TWEET_VIDEO, DM_VIDEO then MP4_MIME_TYPE - when SUBTITLES then SUBRIP_MIME_TYPE - else MIME_TYPE_MAP[File.extname(file_path).delete(".").downcase] || DEFAULT_MIME_TYPE - end - end - - def split(file_path, chunk_size) - file_number = -1 - file_paths = [] # @type var file_paths: Array[String] - - File.open(file_path, "rb") do |f| - while (chunk = f.read(chunk_size)) - path = "#{Dir.mktmpdir}/x#{format("%03d", file_number += 1)}" - File.binwrite(path, chunk) - file_paths << path - end - end - file_paths - end - - def init(client:, file_path:, media_type:, media_category:) - total_bytes = File.size(file_path) - query = "command=INIT&media_type=#{media_type}&media_category=#{media_category}&total_bytes=#{total_bytes}" - client.post("media/upload?#{query}")&.fetch("data") - end - - def append(client:, file_paths:, media:, media_type:, boundary: SecureRandom.hex) - threads = file_paths.map.with_index do |file_path, index| - Thread.new do - upload_body = construct_upload_body(file_path:, media_type:, boundary:) - query = "command=APPEND&media_key=#{media["media_key"]}&segment_index=#{index}" - headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"} - upload_chunk(client:, query:, upload_body:, file_path:, headers:) - end - end - threads.each(&:join) - end - - def upload_chunk(client:, query:, upload_body:, file_path:, headers: {}) - client.post("media/upload?#{query}", upload_body, headers:) - rescue NetworkError, ServerError - retries ||= 0 - ((retries += 1) < MAX_RETRIES) ? retry : raise - ensure - cleanup_file(file_path) - end - - def cleanup_file(file_path) - dirname = File.dirname(file_path) - File.delete(file_path) - Dir.delete(dirname) if Dir.empty?(dirname) - end - - def construct_upload_body(file_path:, media_type:, boundary: SecureRandom.hex) - "--#{boundary}\r\n" \ - "Content-Disposition: form-data; name=\"media\"; filename=\"#{File.basename(file_path)}\"\r\n" \ - "Content-Type: #{media_type}\r\n\r\n" \ - "#{File.read(file_path)}\r\n" \ - "--#{boundary}--\r\n" - end - end -end diff --git a/lib/x/oauth_authenticator.rb b/lib/x/oauth_authenticator.rb deleted file mode 100644 index a2f9eeaa..00000000 --- a/lib/x/oauth_authenticator.rb +++ /dev/null @@ -1,85 +0,0 @@ -require "base64" -require "cgi" -require "json" -require "openssl" -require "securerandom" -require "uri" -require_relative "authenticator" - -module X - class OAuthAuthenticator < Authenticator - OAUTH_VERSION = "1.0".freeze - OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze - OAUTH_SIGNATURE_ALGORITHM = "sha1".freeze - - attr_accessor :api_key, :api_key_secret, :access_token, :access_token_secret - - def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) # rubocop:disable Lint/MissingSuper - @api_key = api_key - @api_key_secret = api_key_secret - @access_token = access_token - @access_token_secret = access_token_secret - end - - def header(request) - method, url, query_params = parse_request(request) - {AUTHENTICATION_HEADER => build_oauth_header(method, url, query_params)} - end - - private - - def parse_request(request) - uri = request.uri - query_params = parse_query_params(uri.query.to_s) - [request.method, uri_without_query(uri), query_params] - end - - def parse_query_params(query_string) - URI.decode_www_form(query_string).to_h - end - - def uri_without_query(uri) - "#{uri.scheme}://#{uri.host}#{uri.path}" - end - - def build_oauth_header(method, url, query_params) - oauth_params = default_oauth_params - all_params = query_params.merge(oauth_params) - oauth_params["oauth_signature"] = generate_signature(method, url, all_params) - format_oauth_header(oauth_params) - end - - def default_oauth_params - { - "oauth_consumer_key" => api_key, - "oauth_nonce" => SecureRandom.hex, - "oauth_signature_method" => OAUTH_SIGNATURE_METHOD, - "oauth_timestamp" => Integer(Time.now).to_s, - "oauth_token" => access_token, - "oauth_version" => OAUTH_VERSION - } - end - - def generate_signature(method, url, params) - base_string = signature_base_string(method, url, params) - hmac_signature(base_string) - end - - def hmac_signature(base_string) - hmac = OpenSSL::HMAC.digest(OAUTH_SIGNATURE_ALGORITHM, signing_key, base_string) - Base64.strict_encode64(hmac) - end - - def signature_base_string(method, url, params) - "#{method}&#{CGI.escapeURIComponent(url)}&#{CGI.escapeURIComponent(URI.encode_www_form(params.sort).gsub("+", "%20"))}" - end - - def signing_key - "#{api_key_secret}&#{access_token_secret}" - end - - def format_oauth_header(params) - "OAuth #{params.sort.map { |k, v| "#{k}=\"#{CGI.escapeURIComponent(v)}\"" }.join(", ")}" - end - end -end diff --git a/lib/x/rate_limit.rb b/lib/x/rate_limit.rb deleted file mode 100644 index 1107b48a..00000000 --- a/lib/x/rate_limit.rb +++ /dev/null @@ -1,33 +0,0 @@ -module X - class RateLimit - RATE_LIMIT_TYPE = "rate-limit".freeze - APP_LIMIT_TYPE = "app-limit-24hour".freeze - USER_LIMIT_TYPE = "user-limit-24hour".freeze - TYPES = [RATE_LIMIT_TYPE, APP_LIMIT_TYPE, USER_LIMIT_TYPE].freeze - - attr_accessor :type, :response - - def initialize(type:, response:) - @type = type - @response = response - end - - def limit - Integer(response.fetch("x-#{type}-limit")) - end - - def remaining - Integer(response.fetch("x-#{type}-remaining")) - end - - def reset_at - Time.at(Integer(response.fetch("x-#{type}-reset"))) - end - - def reset_in - [(reset_at - Time.now).ceil, 0].max - end - - alias_method :retry_after, :reset_in - end -end diff --git a/lib/x/redirect_handler.rb b/lib/x/redirect_handler.rb deleted file mode 100644 index bda63ddc..00000000 --- a/lib/x/redirect_handler.rb +++ /dev/null @@ -1,56 +0,0 @@ -require "net/http" -require "uri" -require_relative "authenticator" -require_relative "connection" -require_relative "errors/too_many_redirects" -require_relative "request_builder" - -module X - class RedirectHandler - DEFAULT_MAX_REDIRECTS = 10 - - attr_accessor :max_redirects - attr_reader :connection, :request_builder - - def initialize(connection: Connection.new, request_builder: RequestBuilder.new, - max_redirects: DEFAULT_MAX_REDIRECTS) - @connection = connection - @request_builder = request_builder - @max_redirects = max_redirects - end - - def handle(response:, request:, base_url:, authenticator: Authenticator.new, redirect_count: 0) - if response.is_a?(Net::HTTPRedirection) - raise TooManyRedirects, "Too many redirects" if redirect_count > max_redirects - - new_uri = build_new_uri(response, base_url) - - new_request = build_request(request, new_uri, Integer(response.code), authenticator) - new_response = connection.perform(request: new_request) - - handle(response: new_response, request: new_request, base_url:, redirect_count: redirect_count + 1) - else - response - end - end - - private - - def build_new_uri(response, base_url) - location = response.fetch("location") - # If location is relative, it will join with the original base URL, otherwise it will overwrite it - URI.join(base_url, location) - end - - def build_request(request, uri, response_code, authenticator) - http_method, body = case response_code - in 307 | 308 - [request.method.downcase.to_sym, request.body] - else - [:get, nil] - end - - request_builder.build(http_method:, uri:, body:, authenticator:) - end - end -end diff --git a/lib/x/request_builder.rb b/lib/x/request_builder.rb deleted file mode 100644 index c6bfaf74..00000000 --- a/lib/x/request_builder.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "net/http" -require "uri" -require_relative "authenticator" -require_relative "version" - -module X - class RequestBuilder - DEFAULT_HEADERS = { - "Content-Type" => "application/json; charset=utf-8", - "User-Agent" => "X-Client/#{VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} (#{RUBY_PLATFORM})" - }.freeze - HTTP_METHODS = { - get: Net::HTTP::Get, - post: Net::HTTP::Post, - put: Net::HTTP::Put, - delete: Net::HTTP::Delete - }.freeze - - def build(http_method:, uri:, body: nil, headers: {}, authenticator: Authenticator.new) - request = create_request(http_method:, uri:, body:) - add_headers(request:, headers:) - add_authentication(request:, authenticator:) - request - end - - private - - def create_request(http_method:, uri:, body:) - http_method_class = HTTP_METHODS[http_method] - - raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class - - escaped_uri = escape_query_params(uri) - request = http_method_class.new(escaped_uri) - request.body = body - request - end - - def add_authentication(request:, authenticator:) - authenticator.header(request).each do |key, value| - request.add_field(key, value) - end - end - - def add_headers(request:, headers:) - DEFAULT_HEADERS.merge(headers).each do |key, value| - request.delete(key) - request.add_field(key, value) - end - end - - def escape_query_params(uri) - URI(uri).tap do |u| - u.query = URI.encode_www_form(URI.decode_www_form(u.query)).gsub("%2C", ",") if u.query - end - end - end -end diff --git a/lib/x/response_parser.rb b/lib/x/response_parser.rb deleted file mode 100644 index 166a0d59..00000000 --- a/lib/x/response_parser.rb +++ /dev/null @@ -1,60 +0,0 @@ -require "json" -require "net/http" -require_relative "errors/bad_gateway" -require_relative "errors/bad_request" -require_relative "errors/connection_exception" -require_relative "errors/http_error" -require_relative "errors/forbidden" -require_relative "errors/gateway_timeout" -require_relative "errors/gone" -require_relative "errors/internal_server_error" -require_relative "errors/not_acceptable" -require_relative "errors/not_found" -require_relative "errors/payload_too_large" -require_relative "errors/service_unavailable" -require_relative "errors/too_many_requests" -require_relative "errors/unauthorized" -require_relative "errors/unprocessable_entity" - -module X - class ResponseParser - ERROR_MAP = { - 400 => BadRequest, - 401 => Unauthorized, - 403 => Forbidden, - 404 => NotFound, - 406 => NotAcceptable, - 409 => ConnectionException, - 410 => Gone, - 413 => PayloadTooLarge, - 422 => UnprocessableEntity, - 429 => TooManyRequests, - 500 => InternalServerError, - 502 => BadGateway, - 503 => ServiceUnavailable, - 504 => GatewayTimeout - }.freeze - - def parse(response:, array_class: nil, object_class: nil) - raise error(response) unless response.is_a?(Net::HTTPSuccess) - - return if response.instance_of?(Net::HTTPNoContent) - - begin - JSON.parse(response.body, array_class:, object_class:) - rescue JSON::ParserError - nil - end - end - - private - - def error(response) - error_class(response).new(response:) - end - - def error_class(response) - ERROR_MAP[Integer(response.code)] || HTTPError - end - end -end diff --git a/lib/x/version.rb b/lib/x/version.rb index ee31079a..3b22ae71 100644 --- a/lib/x/version.rb +++ b/lib/x/version.rb @@ -1,5 +1,21 @@ +# frozen_string_literal: true + require "rubygems/version" module X - VERSION = Gem::Version.create("0.15.0") + # The current version of the X gem + # @api public + VERSION = "1.0.0" + + # The version as a Gem::Version, which compares one release with another + # + # VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it, or + # send it wherever a String belongs, such as the release a crash reporter is told. This builds the Gem::Version + # that compares it with another version, which a String compares by character rather than by segment. + # + # @api public + # @return [Gem::Version] the version + # @example Take a path that a later release opened + # X.gem_version >= Gem::Version.new("1.1") + def self.gem_version = Gem::Version.new(VERSION) end diff --git a/sig/manifest.yaml b/sig/manifest.yaml new file mode 100644 index 00000000..c1391fbf --- /dev/null +++ b/sig/manifest.yaml @@ -0,0 +1,5 @@ +# The standard libraries the signatures of x refer to, which rbs collection loads for code that depends on it +# +# rbs collection reads every library named here as a standard library, so the gems x depends on are left to their +# gemspecs, from which it installs their signatures. +dependencies: [] diff --git a/sig/x.rbs b/sig/x.rbs index a2aa7025..cfe4f980 100644 --- a/sig/x.rbs +++ b/sig/x.rbs @@ -1,283 +1,11 @@ module X - VERSION: Gem::Version + VERSION: String - class Authenticator - AUTHENTICATION_HEADER: String - - def header: (Net::HTTPRequest? request) -> Hash[String, String] - end - - class BearerTokenAuthenticator < Authenticator - attr_accessor bearer_token: String - def initialize: (bearer_token: String) -> void - def header: (Net::HTTPRequest? request) -> Hash[String, String] - end - - class OAuthAuthenticator < Authenticator - OAUTH_VERSION: String - OAUTH_SIGNATURE_METHOD: String - OAUTH_SIGNATURE_ALGORITHM: String - - attr_accessor api_key: String - attr_accessor api_key_secret: String - attr_accessor access_token: String - attr_accessor access_token_secret: String - def initialize: (api_key: String, api_key_secret: String, access_token: String, access_token_secret: String) -> void - def header: (Net::HTTPRequest request) -> Hash[String, String] - - private - def parse_request: (Net::HTTPRequest request) -> [String, String, Hash[String, String]] - def parse_query_params: (String query_string) -> Hash[String, String] - def uri_without_query: (URI::Generic uri) -> String - def build_oauth_header: (String method, String url, Hash[String, String] query_params) -> String - def default_oauth_params: -> Hash[String, String] - def generate_signature: (String method, String url, Hash[String, String] params) -> String - def hmac_signature: (String base_string) -> String - def signature_base_string: (String method, String url, Hash[String, String] params) -> String - def signing_key: -> String - def format_oauth_header: (Hash[String, String] params) -> String - def escape: (String value) -> String - end - - class Error < StandardError - end - - class ClientError < HTTPError - end - - class BadGateway < ClientError - end - - class BadRequest < ClientError - end - - class ConnectionException < ClientError - end - - class HTTPError < Error - JSON_CONTENT_TYPE_REGEXP: Regexp - - attr_reader response : Net::HTTPResponse - attr_reader code : String - - def initialize: (response: Net::HTTPResponse) -> void - - private - def error_message: (Net::HTTPResponse response) -> String - def message_from_json_response: (Net::HTTPResponse response) -> String - def json?: (Net::HTTPResponse response) -> bool - end - - class Forbidden < ClientError - end - - class GatewayTimeout < ClientError - end - - class Gone < ClientError - end - - class InternalServerError < ServerError - end - - class NetworkError < Error - end - - class NotAcceptable < ClientError - end - - class NotFound < ClientError - end - - class PayloadTooLarge < ClientError - end - - class ServerError < HTTPError - end - - class ServiceUnavailable < ServerError - end - - class TooManyRedirects < Error - end - - class TooManyRequests < ClientError - @rate_limits: Array[RateLimit] - - def rate_limit: -> RateLimit? - def rate_limits: -> Array[RateLimit] - def reset_at: -> Time - def reset_in: -> Integer? - end - - class Unauthorized < ClientError - end - - class UnprocessableEntity < ClientError - end - - class Connection - DEFAULT_HOST: String - DEFAULT_PORT: Integer - DEFAULT_OPEN_TIMEOUT: Integer - DEFAULT_READ_TIMEOUT: Integer - DEFAULT_WRITE_TIMEOUT: Integer - DEFAULT_DEBUG_OUTPUT: IO - NETWORK_ERRORS: Array[(singleton(Errno::ECONNREFUSED) | singleton(Errno::ECONNRESET) | singleton(Net::OpenTimeout) | singleton(Net::ReadTimeout) | singleton(OpenSSL::SSL::SSLError))] - - @proxy_url: URI::Generic | String - - extend Forwardable - - attr_accessor open_timeout : Float | Integer - attr_accessor read_timeout : Float | Integer - attr_accessor write_timeout : Float | Integer - attr_accessor debug_output : IO - - attr_reader proxy_uri: URI::Generic? - attr_reader proxy_host : String? - attr_reader proxy_port : Integer? - attr_reader proxy_user : String? - attr_reader proxy_pass : String? - - def initialize: (?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?proxy_url: URI::Generic? | String?, ?debug_output: IO) -> void - def proxy_url=: (URI::Generic | String proxy_url) -> void - def perform: (request: Net::HTTPRequest) -> Net::HTTPResponse - - private - def build_http_client: (?String host, ?Integer port) -> Net::HTTP - def configure_http_client: (Net::HTTP http_client) -> Net::HTTP - end - - class RateLimit - RATE_LIMIT_TYPE: String - APP_LIMIT_TYPE: String - USER_LIMIT_TYPE: String - TYPES: Array[String] - - attr_accessor type: String - attr_accessor response: Net::HTTPResponse - def initialize: (type: String, response: Net::HTTPResponse) -> void - def limit: -> Integer - def remaining: -> Integer - def reset_at: -> Time - def reset_in: -> Integer? - end - - class RequestBuilder - HTTP_METHODS: Hash[Symbol, (singleton(Net::HTTP::Get) | singleton(Net::HTTP::Post) | singleton(Net::HTTP::Put) | singleton(Net::HTTP::Delete))] - DEFAULT_HEADERS: Hash[String, String] - - def initialize: (?content_type: String, ?user_agent: String) -> void - def build: (http_method: Symbol, uri: URI::Generic, ?body: String?, ?headers: Hash[String, String], ?authenticator: Authenticator) -> (Net::HTTPRequest) - - private - def create_request: (http_method: Symbol, uri: URI::Generic, body: String?) -> (Net::HTTPRequest) - def add_authentication: (request: Net::HTTPRequest, authenticator: Authenticator) -> void - def add_headers: (request: Net::HTTPRequest, headers: Hash[String, String]) -> void - def escape_query_params: (URI::Generic uri) -> URI::Generic - end - - class RedirectHandler - DEFAULT_MAX_REDIRECTS: Integer - - attr_reader authenticator: Authenticator - attr_reader connection: Connection - attr_reader request_builder: RequestBuilder - attr_reader max_redirects: Integer - def initialize: (?connection: Connection, ?request_builder: RequestBuilder, ?max_redirects: Integer) -> void - def handle: (response: Net::HTTPResponse, request: Net::HTTPRequest, base_url: String, ?authenticator: Authenticator, ?redirect_count: Integer) -> Net::HTTPResponse - - private - def build_new_uri: (Net::HTTPResponse response, String base_url) -> URI::Generic - def build_request: (Net::HTTPRequest request, URI::Generic new_uri, Integer response_code, Authenticator authenticator) -> Net::HTTPRequest - def send_new_request: (URI::Generic new_uri, Net::HTTPRequest new_request) -> Net::HTTPResponse - end - - class ResponseParser - ERROR_MAP: Hash[Integer, singleton(BadGateway) | singleton(BadRequest) | singleton(ConnectionException) | singleton(Forbidden) | singleton(GatewayTimeout) | singleton(Gone) | singleton(InternalServerError) | singleton(NotAcceptable) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests) | singleton(Unauthorized) | singleton(UnprocessableEntity)] - - def parse: (response: Net::HTTPResponse, ?array_class: Class?, ?object_class: Class?) -> untyped - - private - def error: (Net::HTTPResponse response) -> HTTPError - def error_class: (Net::HTTPResponse response) -> (singleton(BadGateway) | singleton(BadRequest) | singleton(ConnectionException) | singleton(Forbidden) | singleton(GatewayTimeout) | singleton(Gone) | singleton(InternalServerError) | singleton(NotAcceptable) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests) | singleton(Unauthorized) | singleton(UnprocessableEntity)) - def json?: (Net::HTTPResponse response) -> bool - end + def self.gem_version: () -> Gem::Version class Client - DEFAULT_BASE_URL: String - DEFAULT_ARRAY_CLASS: singleton(Array) - DEFAULT_OBJECT_CLASS: singleton(Hash) - extend Forwardable - @authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator - @connection: Connection - @request_builder: RequestBuilder - @redirect_handler: RedirectHandler - @response_parser: ResponseParser - - attr_accessor base_url: String - attr_accessor default_array_class: singleton(Array) - attr_accessor default_object_class: singleton(Hash) - attr_reader api_key: String? - attr_reader api_key_secret: String? - attr_reader access_token: String? - attr_reader access_token_secret: String? - attr_reader bearer_token: String? - def initialize: (?api_key: nil, ?api_key_secret: nil, ?access_token: nil, ?access_token_secret: nil, ?bearer_token: nil, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer, ?debug_output: untyped, ?proxy_url: nil, ?default_array_class: singleton(Array), ?default_object_class: singleton(Hash), ?max_redirects: Integer) -> void - def get: (String endpoint, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped - def post: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped - def put: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped - def delete: (String endpoint, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped - def api_key=: (String api_key) -> void - def api_key_secret=: (String api_key_secret) -> void - def access_token=: (String access_token) -> void - def access_token_secret=: (String access_token_secret) -> void - def bearer_token=: (String bearer_token) -> void - - private - def initialize_oauth: (String? api_key, String? api_key_secret, String? access_token, String? access_token_secret, String? bearer_token) -> void - def initialize_default_classes: (singleton(Array) default_array_class, singleton(Hash) default_object_class) -> singleton(Hash) - def initialize_authenticator: -> (Authenticator | BearerTokenAuthenticator | OAuthAuthenticator) - def execute_request: (:delete | :get | :post | :put http_method, String endpoint, ?body: String?, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> nil - end - - module MediaUploader - MAX_RETRIES: Integer - BYTES_PER_MB: Integer - MEDIA_CATEGORIES: Array[String] - DM_GIF: String - DM_IMAGE: String - DM_VIDEO: String - SUBTITLES: String - TWEET_GIF: String - TWEET_IMAGE: String - TWEET_VIDEO: String - DEFAULT_MIME_TYPE: String - MIME_TYPES: Array[String] - GIF_MIME_TYPE: String - JPEG_MIME_TYPE: String - MP4_MIME_TYPE: String - PNG_MIME_TYPE: String - SUBRIP_MIME_TYPE: String - WEBP_MIME_TYPE: String - MIME_TYPE_MAP: Hash[String, String] - PROCESSING_INFO_STATES: Array[String] - extend MediaUploader - - def upload: (client: Client, file_path: String, media_category: String, ?media_type: String, ?boundary: String) -> untyped - def chunked_upload: (client: Client, file_path: String, media_category: String, ?media_type: String, ?boundary: String, ?chunk_size_mb: Integer) -> untyped - def await_processing: (client: Client, media: untyped) -> untyped - - private - def validate!: (file_path: String, media_category: String) -> nil - def infer_media_type: (String file_path, String media_category) -> String - def split: (String file_path, Integer chunk_size) -> Array[String] - def init: (client: Client, file_path: String, media_type: String, media_category: String) -> untyped - def append: (client: Client, file_paths: Array[String], media: untyped, media_type: String, ?boundary: String) -> Array[String] - def upload_chunk: (client: Client, query: String, upload_body: String, file_path: String, ?headers: Hash[String, String]) -> Integer? - def cleanup_file: (String file_path) -> Integer? - def finalize: (client: Client, media: untyped) -> untyped - def construct_upload_body: (file_path: String, media_type: String, ?boundary: String) -> String + include Objects::API + include Uploader::API + include Streaming::API end end diff --git a/test/sample_files/sample.jpg b/test/sample_files/sample.jpg deleted file mode 100644 index e69de29b..00000000 diff --git a/test/sample_files/sample.srt b/test/sample_files/sample.srt deleted file mode 100644 index e69de29b..00000000 diff --git a/test/sample_files/sample.webp b/test/sample_files/sample.webp deleted file mode 100644 index e69de29b..00000000 diff --git a/test/test_helper.rb b/test/test_helper.rb index ee570645..f1ee17bd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,45 +1,14 @@ -$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +# frozen_string_literal: true -unless $PROGRAM_NAME.end_with?("mutant") - require "simplecov" +require "simplecov" - SimpleCov.start do - add_filter "test" - minimum_coverage(100) - end +SimpleCov.start "strict" do + # x-core, x-uploader, x-streaming, and x-objects measure their own coverage in their own suites + skip %r{\A/?x-(core|uploader|streaming|objects)/} end require "minitest/autorun" -require "mutant/minitest/coverage" require "webmock/minitest" require "x" -TEST_BEARER_TOKEN = "TEST_BEARER_TOKEN".freeze -TEST_API_KEY = "TEST_API_KEY".freeze -TEST_API_KEY_SECRET = "TEST_API_KEY_SECRET".freeze -TEST_ACCESS_TOKEN = "TEST_ACCESS_TOKEN".freeze -TEST_ACCESS_TOKEN_SECRET = "TEST_ACCESS_TOKEN_SECRET".freeze -TEST_OAUTH_NONCE = "TEST_OAUTH_NONCE".freeze -TEST_OAUTH_TIMESTAMP = Time.utc(1983, 11, 24).to_i.to_s -TEST_MEDIA_ID = "TEST_MEDIA_ID".freeze -TEST_MEDIA_KEY = "TEST_MEDIA_KEY".freeze - -def test_oauth_credentials - { - api_key: TEST_API_KEY, - api_key_secret: TEST_API_KEY_SECRET, - access_token: TEST_ACCESS_TOKEN, - access_token_secret: TEST_ACCESS_TOKEN_SECRET - } -end - -def test_oauth_params - { - "oauth_consumer_key" => TEST_API_KEY, - "oauth_nonce" => TEST_OAUTH_NONCE, - "oauth_signature_method" => X::OAuthAuthenticator::OAUTH_SIGNATURE_METHOD, - "oauth_timestamp" => TEST_OAUTH_TIMESTAMP, - "oauth_token" => TEST_ACCESS_TOKEN, - "oauth_version" => X::OAuthAuthenticator::OAUTH_VERSION - } -end +TEST_BEARER_TOKEN = "TEST_BEARER_TOKEN" diff --git a/test/x/authenticator_test.rb b/test/x/authenticator_test.rb deleted file mode 100644 index 7d54e271..00000000 --- a/test/x/authenticator_test.rb +++ /dev/null @@ -1,16 +0,0 @@ -require_relative "../test_helper" - -module X - class AuthenticatorTest < Minitest::Test - cover Authenticator - - def setup - @authenticator = Authenticator.new - end - - def test_header - assert_kind_of Hash, @authenticator.header(nil) - assert_empty @authenticator.header(nil)["Authorization"] - end - end -end diff --git a/test/x/client_initailization_test.rb b/test/x/client_initailization_test.rb deleted file mode 100644 index a6c23d63..00000000 --- a/test/x/client_initailization_test.rb +++ /dev/null @@ -1,130 +0,0 @@ -require "ostruct" -require_relative "../test_helper" - -module X - class ClientInitializationTest < Minitest::Test - cover Client - - def setup - @client = Client.new - end - - def test_initialize_oauth_credentials - client = Client.new(**test_oauth_credentials) - - authenticator = client.instance_variable_get(:@authenticator) - - assert_instance_of OAuthAuthenticator, authenticator - assert_equal TEST_API_KEY, authenticator.api_key - assert_equal TEST_API_KEY_SECRET, authenticator.api_key_secret - assert_equal TEST_ACCESS_TOKEN, authenticator.access_token - assert_equal TEST_ACCESS_TOKEN_SECRET, authenticator.access_token_secret - end - - def test_missing_oauth_credentials - test_oauth_credentials.each_key do |missing_credential| - client = Client.new(**test_oauth_credentials.except(missing_credential)) - - assert_instance_of Authenticator, client.instance_variable_get(:@authenticator) - end - end - - def test_setting_oauth_credentials - test_oauth_credentials.each do |credential, value| - @client.public_send(:"#{credential}=", value) - - assert_equal value, @client.public_send(credential) - end - - assert_instance_of OAuthAuthenticator, @client.instance_variable_get(:@authenticator) - end - - def test_setting_oauth_credentials_reinitializes_authenticator - test_oauth_credentials.each do |credential, value| - initialize_authenticator_called = false - @client.stub :initialize_authenticator, -> { initialize_authenticator_called = true } do - @client.public_send(:"#{credential}=", value) - end - - assert_equal value, @client.public_send(credential) - assert initialize_authenticator_called, "Expected initialize_authenticator to be called" - end - end - - def test_setting_bearer_token - @client.bearer_token = "bearer_token" - - authenticator = @client.instance_variable_get(:@authenticator) - - assert_equal "bearer_token", @client.bearer_token - assert_instance_of BearerTokenAuthenticator, authenticator - end - - def test_authenticator_remains_unchanged_if_no_new_credentials - initial_authenticator = @client.instance_variable_get(:@authenticator) - - @client.api_key = nil - @client.api_key_secret = nil - @client.access_token = nil - @client.access_token_secret = nil - @client.bearer_token = nil - - new_authenticator = @client.instance_variable_get(:@authenticator) - - assert_equal initial_authenticator, new_authenticator - end - - def test_initialize_with_default_connection_options - connection = @client.instance_variable_get(:@connection) - - assert_equal Connection::DEFAULT_OPEN_TIMEOUT, connection.open_timeout - assert_equal Connection::DEFAULT_READ_TIMEOUT, connection.read_timeout - assert_equal Connection::DEFAULT_WRITE_TIMEOUT, connection.write_timeout - assert_equal Connection::DEFAULT_DEBUG_OUTPUT, connection.debug_output - assert_nil connection.proxy_url - end - - def test_initialize_connection_options - client = Client.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, proxy_url: "https://user:pass@proxy.com:42") - - connection = client.instance_variable_get(:@connection) - - assert_equal 10, connection.open_timeout - assert_equal 20, connection.read_timeout - assert_equal 30, connection.write_timeout - assert_equal $stderr, connection.debug_output - assert_equal "https://user:pass@proxy.com:42", connection.proxy_url - end - - def test_defaults - @client = Client.new - - assert_equal "https://api.twitter.com/2/", @client.base_url - assert_equal 10, @client.max_redirects - assert_equal Hash, @client.default_object_class - assert_equal Array, @client.default_array_class - end - - def test_overwrite_defaults - @client = Client.new(base_url: "https://api.twitter.com/1.1/", max_redirects: 5, default_object_class: OpenStruct, - default_array_class: Set) - - assert_equal "https://api.twitter.com/1.1/", @client.base_url - assert_equal 5, @client.max_redirects - assert_equal OpenStruct, @client.default_object_class - assert_equal Set, @client.default_array_class - end - - def test_passes_options_to_redirect_handler - client = Client.new(max_redirects: 5) - connection = client.instance_variable_get(:@connection) - request_builder = client.instance_variable_get(:@request_builder) - redirect_handler = client.instance_variable_get(:@redirect_handler) - max_redirects = redirect_handler.instance_variable_get(:@max_redirects) - - assert_equal connection, redirect_handler.connection - assert_equal request_builder, redirect_handler.request_builder - assert_equal 5, max_redirects - end - end -end diff --git a/test/x/client_method_names_test.rb b/test/x/client_method_names_test.rb new file mode 100644 index 00000000..d67f1fec --- /dev/null +++ b/test/x/client_method_names_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module X + # x-core, x-objects, x-uploader, and x-streaming are released apart, and a client is built of all four, so a method + # one of them adds within 1.x must take the name of no method of another. A method of X::Objects::API, + # X::Uploader::API, or X::Streaming::API, which X::Client includes, takes the place of a method of the same name in a + # module of x-core that X::Client includes before them, and a method X::Client defines itself takes the place of + # theirs, so a private helper of either side that the other came to name would be called by code that meant the + # other one, and would break every request. So X::Client keeps no private methods but initialize, and no name is + # shared either way. + class ClientMethodNamesTest < Minitest::Test + INCLUDED_APIS = [Objects::API, Uploader::API, Streaming::API].freeze + + def test_a_client_has_no_private_method_of_x_core_but_initialize + assert_equal %i[initialize], x_core_ancestors.flat_map { |ancestor| ancestor.private_instance_methods(false) }.sort + end + + def test_no_method_of_an_included_api_takes_the_place_of_a_method_of_x_core + INCLUDED_APIS.each do |api| + below = x_core_ancestors.drop(1) + + assert_empty methods_of(api) & own_methods_of(below), "Expected #{api} to name no method of #{below.join(", ")}" + end + end + + def test_no_method_of_the_client_takes_the_place_of_a_method_of_an_included_api + INCLUDED_APIS.each do |api| + assert_empty own_methods_of([Client]) & methods_of(api), "Expected X::Client to name no method of #{api}" + end + end + + private + + # X::Client and the modules of x-core it includes, in the order a method is looked up in them + def x_core_ancestors + Client.ancestors - Object.ancestors - INCLUDED_APIS.flat_map(&:ancestors) + end + + # The public, protected, and private methods an included API gives a client, with those of the modules it includes + def methods_of(api) = api.instance_methods + api.private_instance_methods + + # The public, protected, and private methods the modules or classes define themselves + def own_methods_of(ancestors) + ancestors.flat_map { |ancestor| ancestor.instance_methods(false) + ancestor.private_instance_methods(false) } + end + end +end diff --git a/test/x/connection_test.rb b/test/x/connection_test.rb deleted file mode 100644 index ac3f0b9b..00000000 --- a/test/x/connection_test.rb +++ /dev/null @@ -1,133 +0,0 @@ -require "net/http" -require "uri" -require_relative "../test_helper" - -module X - class ConnectionTest < Minitest::Test - cover Connection - - def setup - @connection = Connection.new - end - - def test_initialization_defaults - assert_equal Connection::DEFAULT_OPEN_TIMEOUT, @connection.open_timeout - assert_equal Connection::DEFAULT_READ_TIMEOUT, @connection.read_timeout - assert_equal Connection::DEFAULT_WRITE_TIMEOUT, @connection.write_timeout - assert_equal Connection::DEFAULT_DEBUG_OUTPUT, @connection.debug_output - assert_nil @connection.proxy_url - end - - def test_custom_initialization - connection = Connection.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, - proxy_url: "http://example.com:8080") - - assert_equal 10, connection.open_timeout - assert_equal 20, connection.read_timeout - assert_equal 30, connection.write_timeout - assert_equal $stderr, connection.debug_output - assert_equal "http://example.com:8080", connection.proxy_url - end - - def test_http_client_defaults - http_client = @connection.send(:build_http_client) - - assert_equal Connection::DEFAULT_HOST, http_client.address - assert_equal Connection::DEFAULT_PORT, http_client.port - assert_equal Connection::DEFAULT_OPEN_TIMEOUT, http_client.open_timeout - assert_equal Connection::DEFAULT_READ_TIMEOUT, http_client.read_timeout - assert_equal Connection::DEFAULT_WRITE_TIMEOUT, http_client.write_timeout - end - - def test_debug_output - http_client = @connection.send(:build_http_client) - - assert_equal Connection::DEFAULT_DEBUG_OUTPUT, http_client.instance_variable_get(:@debug_output) - end - - def test_proxy - @connection.proxy_url = "http://user:pass@example.com:8080" - - assert_equal URI("http://user:pass@example.com:8080"), @connection.proxy_uri - assert_equal "example.com", @connection.proxy_host - assert_equal "user", @connection.proxy_user - assert_equal "pass", @connection.proxy_pass - assert_equal 8080, @connection.proxy_port - end - - def test_host_port_with_proxy - connection = Connection.new(proxy_url: "https://user:pass@example.com") - http_client = connection.send(:build_http_client, "example.com", 8080) - - assert_predicate http_client, :proxy? - assert_equal "example.com", http_client.address - assert_equal 8080, http_client.port - end - - def test_client_properties - connection = Connection.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, - proxy_url: "https://proxy.com") - http_client = connection.send(:build_http_client) - - assert_predicate http_client, :proxy? - assert_equal 10, http_client.open_timeout - assert_equal 20, http_client.read_timeout - assert_equal 30, http_client.write_timeout - assert_equal $stderr, http_client.instance_variable_get(:@debug_output) - end - - def test_invalid_proxy_url - error = assert_raises(ArgumentError) { @connection.proxy_url = "ftp://ftp.twitter.com/" } - - assert_equal "Invalid proxy URL: ftp://ftp.twitter.com/", error.message - end - - def test_proxy_settings_are_respected_in_http_client - @connection.proxy_url = "http://user:pass@example.com:8080" - http_client = @connection.send(:build_http_client) - - assert_equal "example.com", http_client.proxy_address - assert_equal 8080, http_client.proxy_port - assert_equal "user", http_client.proxy_user - assert_equal "pass", http_client.proxy_pass - end - - def test_set_env_proxy - old_value = ENV.fetch("http_proxy", nil) - ENV["http_proxy"] = "https://user:pass@example.com:8080" - http_client = Connection.new.send(:build_http_client) - - assert_predicate http_client, :proxy? - assert_equal "user", http_client.proxy_user - assert_equal "pass", http_client.proxy_pass - assert_equal "example.com", http_client.proxy_address - assert_equal 8080, http_client.proxy_port - ensure - ENV["http_proxy"] = old_value - end - - def test_perform - stub_request(:get, "http://example.com:80") - request = Net::HTTP::Get.new(URI("http://example.com:80")) - @connection.perform(request:) - - assert_requested :get, "http://example.com:80" - end - - def test_network_error - stub_request(:get, "https://example.com").to_raise(Errno::ECONNREFUSED) - request = Net::HTTP::Get.new(URI("https://example.com")) - error = assert_raises(NetworkError) { @connection.perform(request:) } - - assert_equal "Network error: Connection refused - Exception from WebMock", error.message - end - - def test_no_host_or_port - stub_request(:get, "http://api.twitter.com:443/2/tweets") - request = Net::HTTP::Get.new(URI("http://api.twitter.com:443/2/tweets")) - request.stub(:uri, URI("/2/tweets")) { @connection.perform(request:) } - - assert_requested :get, "http://api.twitter.com:443/2/tweets" - end - end -end diff --git a/test/x/error_test.rb b/test/x/error_test.rb index 66fc8d76..4072c12f 100644 --- a/test/x/error_test.rb +++ b/test/x/error_test.rb @@ -1,53 +1,26 @@ +# frozen_string_literal: true + require_relative "../test_helper" module X - class ErrorsTest < Minitest::Test - cover Client - - def setup - @client = Client.new - end - - ResponseParser::ERROR_MAP.each do |status, error_class| - name = error_class.name.split("::").last - define_method :"test_initialize_#{name.downcase}_error" do - response = Net::HTTPResponse::CODE_TO_OBJ[status.to_s].new("1.1", status, error_class.name) - exception = error_class.new(response:) - - assert_equal error_class.name, exception.message - assert_equal response, exception.response - assert_equal status, exception.code - end + # x-core declares X::Error, which every error of every gem descends from, so the meta-gem, which loads them all, + # checks that each does + class MetaErrorTest < Minitest::Test + def test_error_descends_from_standard_error + assert_equal StandardError, Error.superclass end - Connection::NETWORK_ERRORS.each do |error_class| - define_method "test_#{error_class.name.split("::").last.downcase}_raises_network_error" do - stub_request(:get, "https://api.twitter.com/2/tweets").to_raise(error_class) - - assert_raises NetworkError do - @client.get("tweets") - end - end + def test_every_error_of_every_gem_descends_from_error + assert_operator HTTPError, :<, Error + assert_operator MissingResource, :<, Error + assert_operator Uploader::Error, :<, Error + assert_operator Streaming::Error, :<, Error + assert_operator UnsupportedFormat, :<, Error end - def test_unexpected_response - stub_request(:get, "https://api.twitter.com/2/tweets").to_return(status: 600) - - assert_raises Error do - @client.get("tweets") - end - end - - def test_problem_json - body = {error: "problem"}.to_json - stub_request(:get, "https://api.twitter.com/2/tweets") - .to_return(status: 400, headers: {"content-type" => "application/problem+json"}, body:) - - begin - @client.get("tweets") - rescue BadRequest => e - assert_equal "problem", e.message - end + def test_the_errors_of_a_stream_descend_from_the_error_of_x_streaming + assert_operator StreamError, :<, Streaming::Error + assert_operator RulesRejected, :<, Streaming::Error end end end diff --git a/test/x/examples_test.rb b/test/x/examples_test.rb new file mode 100644 index 00000000..b64ab7b8 --- /dev/null +++ b/test/x/examples_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "fileutils" +require "stringio" +require "tmpdir" +require_relative "../test_helper" + +module X + # Runs each example in examples/ against stubbed responses, so that an example the API has drifted from fails + class ExamplesTest < Minitest::Test + EXAMPLES_DIR = File.expand_path("../../examples", __dir__) + BASE = "https://api.x.com/2/" + V1_BASE = "https://api.x.com/1.1/" + # The files the examples read, by the path each names, beginning with the bytes their type is told by + MEDIA = { + "path/to/your/media.mp4" => "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", + "path/to/your/media.jpg" => "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00", + "path/to/your/avatar.png" => "\x89PNG\r\n\x1A\n\x00\x00\x00\x0DIHDR", + "path/to/your/banner.png" => "\x89PNG\r\n\x1A\n\x00\x00\x00\x0DIHDR" + }.freeze + # Raised by the stream when the example reconnects, which it does without end, to end it + StreamEnded = Class.new(StandardError) + + def test_every_example_has_a_smoke_test + examples = Dir.children(EXAMPLES_DIR).map { |file| File.basename(file, ".rb") } + tested = public_methods.grep(/\Atest_the_(\w+)_example_runs\z/) { $1 } + + assert_equal examples.sort, tested.sort, "Each example in examples/ needs a test_the__example_runs" + end + + def test_the_chunked_media_upload_example_runs + stub_path(:post, "media/upload/initialize", {data: {id: "7", media_key: "7_7", expires_after_secs: 86_400}}) + stub_path(:post, "media/upload/7/append", {}) + stub_path(:post, "media/upload/7/finalize", {data: {id: "7", media_key: "7_7"}}) + stub_path(:get, "media/upload", {data: {id: "7", media_key: "7_7", processing_info: {state: "succeeded"}}}) + stub_path(:post, "tweets", {data: {id: "9", text: "Posting media from @gem!"}}) + + assert_equal "9\n", run_example("chunked_media_upload") + assert_requested :post, "#{BASE}media/upload/initialize", body: {media_type: "video/mp4", media_category: "tweet_video", total_bytes: 24}.to_json + assert_requested :post, "#{BASE}media/upload/7/finalize" + assert_requested :get, "#{BASE}media/upload?command=STATUS&media_id=7" + assert_requested :post, "#{BASE}tweets", body: {text: "Posting media from @gem!", media: {media_ids: ["7"]}}.to_json + end + + def test_the_filtered_stream_example_runs + stub_stream_rules + stub_stream({data: {id: "5", text: "Hello, Ruby"}, includes: {users: [{id: "1", username: "sferik"}]}}, {data: {id: "6", text: "Hi"}}) + + assert_raises(StreamEnded) { run_example("filtered_stream") } + assert_match(/^Deleted 1 rule\(s\)$/, @output.string) + assert_match(/^@sferik: Hello, Ruby\n@: Hi$/, @output.string) + assert_requested :post, "#{BASE}tweets/search/stream/rules", body: {add: [{value: "ruby lang", tag: "ruby"}, {value: "#opensource", tag: "opensource"}]}.to_json + assert_requested :get, "#{BASE}tweets/search/stream?expansions=author_id&tweet.fields=created_at", times: 2 + end + + def test_the_followers_example_runs + stub_path(:get, "users/by/username/sferik", {data: {id: "1", username: "sferik", public_metrics: {followers_count: 2}}}) + stub_path(:get, "users/1/followers", {data: [follower("2", "alice"), follower("3", "bob")], meta: {result_count: 2}}) + stub_path(:get, "users/1/tweets", {data: [{id: "10", text: "Hello", author_id: "1"}], includes: {users: [{id: "1", username: "sferik"}]}}) + + assert_equal "alice: 5 followers\nbob: 5 followers\n2\n2\nsferik: Hello\n", run_example("followers") + assert_requested :get, path_pattern("users/1/followers"), times: 1 + end + + def test_the_pagination_example_runs + stub_path(:get, "users/by/username/sferik", {data: {id: "1", username: "sferik"}}) + stub_path(:get, "users/1/followers", {data: [{id: "2"}], meta: {next_token: "NEXT"}}) + stub_path(:get, "users/1/followers", {data: [{id: "3"}], meta: {result_count: 1}}).with(query: hash_including(pagination_token: "NEXT")) + + assert_equal "2\n2\n", run_example("pagination") + assert_requested :get, path_pattern("users/1/followers"), times: 4 + end + + def test_the_post_media_upload_example_runs + stub_path(:post, "media/upload", {data: {id: "7", media_key: "3_7"}}) + stub_path(:post, "media/metadata", {data: {id: "7", associated_metadata: {alt_text: {text: "Describe the image for people who cannot see it"}}}}) + stub_path(:post, "tweets", {data: {id: "9", text: "Posting media from @gem!"}}) + + assert_equal "9\n", run_example("post_media_upload") + assert_requested :post, "#{BASE}media/metadata", body: {id: "7", metadata: {alt_text: {text: "Describe the image for people who cannot see it"}}}.to_json + assert_requested :post, "#{BASE}tweets", body: {text: "Posting media from @gem!", media: {media_ids: ["7"]}}.to_json + end + + def test_the_profile_upload_example_runs + stub_request(:post, "#{V1_BASE}account/update_profile_image.json").to_return(body: JSON.generate({id_str: "1", screen_name: "sferik"}), headers: {"content-type" => "application/json"}) + stub_request(:post, "#{V1_BASE}account/update_profile_banner.json").to_return(status: 201) + stub_path(:get, "users/me", {data: {id: "1", username: "sferik"}}) + + assert_equal "Profile image updated for @sferik\nProfile banner updated successfully\nProfile banner updated with custom dimensions\n", run_example("profile_upload") + assert_requested :post, "#{V1_BASE}account/update_profile_banner.json", times: 2 + end + + private + + def follower(id, username) = {id:, username:, public_metrics: {followers_count: 5}} + + def path_pattern(path) = %r{\A#{Regexp.escape("#{BASE}#{path}")}(\?|\z)} + + def stub_path(method, path, body) + stub_request(method, path_pattern(path)).to_return(body: JSON.generate(body), headers: {"content-type" => "application/json"}) + end + + def stub_stream_rules + stub_path(:get, "tweets/search/stream/rules", {data: [{id: "1", value: "cats", tag: "cats"}], meta: {result_count: 1}}) + stub_path(:post, "tweets/search/stream/rules", {meta: {summary: {deleted: 1}}}).with(body: hash_including("delete")) + stub_path(:post, "tweets/search/stream/rules", {data: [{id: "2", value: "ruby lang", tag: "ruby"}, {id: "3", value: "#opensource", tag: "opensource"}], + meta: {summary: {created: 2}}}).with(body: hash_including("add")) + end + + # Stub the stream to deliver the posts and end, and to raise StreamEnded when the example reconnects, as it does + # after a stream ends for as long as it runs + def stub_stream(*posts) + stub_request(:get, path_pattern("tweets/search/stream")).to_return(body: posts.map { |post| "#{JSON.generate(post)}\r\n" }.join).then.to_raise(StreamEnded) + end + + # Run an example in an empty directory that holds the files it reads, wrapped in a module of its own, and return + # what it printed, which @output holds too, should it raise + def run_example(name) + @output = StringIO.new + Dir.mktmpdir do |dir| + write_media(dir) + printing_to(@output) { Dir.chdir(dir) { load File.join(EXAMPLES_DIR, "#{name}.rb"), true } } + end + @output.string + end + + def write_media(dir) + MEDIA.each do |path, content| + FileUtils.mkdir_p(File.join(dir, File.dirname(path))) + File.binwrite(File.join(dir, path), content) + end + end + + def printing_to(io) + stdout, $stdout = $stdout, io + yield + ensure + $stdout = stdout + end + end +end diff --git a/test/x/integration_test.rb b/test/x/integration_test.rb new file mode 100644 index 00000000..6fc867c6 --- /dev/null +++ b/test/x/integration_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "ostruct" +require_relative "../test_helper" + +module X + class IntegrationTest < Minitest::Test + BASE = "https://api.x.com/2/" + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_require_x_loads_the_uploaders + assert_respond_to Uploader::MediaUpload, :upload + assert_respond_to Uploader::Account, :update_profile_image + end + + def test_a_copy_with_other_credentials_authenticates_as_another_user + stub_current_user(TEST_BEARER_TOKEN, "9") + stub_current_user("OTHER", "12") + + assert_equal 9, @client.current_user!.id + assert_equal 12, @client.with(bearer_token: "OTHER").current_user!.id + end + + def test_client_includes_objects_api + assert_includes Client.ancestors, Objects::API + end + + def test_user_by_username + stub_json(:get, "users/by/username/sferik", {data: {id: "7505382", username: "sferik", pinned_post_id: "1"}, + includes: {posts: [{id: "1", text: "pinned"}]}}) + user = @client.find_user("sferik") + + assert_equal "sferik", user.username + assert_equal "pinned", user.pinned_post.text + assert_same @client, user.client + end + + def test_resource_class_as_object_class + stub_json(:get, "users/by/username/sferik", {data: {id: "7505382", username: "sferik", pinned_post_id: "1"}, + includes: {posts: [{id: "1", text: "pinned"}]}}) + user = @client.get("users/by/username/sferik", object_class: User) + + assert_equal "sferik", user.username + assert_equal "pinned", user.pinned_post.text + assert_same @client, user.client + end + + def test_resource_class_as_object_class_for_a_list + stub_json(:get, "users/by", {data: [{id: "7505382", username: "sferik"}, {id: "1", username: "gem"}]}) + + assert_equal %w[sferik gem], @client.get("users/by?usernames=sferik,gem", object_class: User).map(&:username) + end + + def test_objects_ignore_the_default_classes + client = Client.new(bearer_token: TEST_BEARER_TOKEN, default_object_class: OpenStruct, default_array_class: Set) + stub_json(:get, "users/by/username/sferik", {data: {id: "7505382", username: "sferik"}}) + + assert_equal "sferik", client.find_user("sferik").username + assert_equal "sferik", client.get("users/by/username/sferik", object_class: User).username + assert_kind_of OpenStruct, client.get("users/by/username/sferik") + end + + def test_a_requested_object_hydrates_to_the_full_resource + stub_json(:get, "users/by/username/sferik", {data: {id: "7505382", username: "sferik"}}) + stub_json(:get, "users/7505382", {data: {id: "7505382", public_metrics: {followers_count: 12_345}}}) + user = @client.get("users/by/username/sferik", object_class: User) + + assert_nil user.followers_count + assert_equal 12_345, user.hydrate.followers_count + assert_requested :get, %r{users/7505382\?.*user\.fields=}, times: 1 + end + + def test_a_looked_up_object_is_already_hydrated + stub_json(:get, "users/by/username/sferik", {data: {id: "7505382", username: "sferik"}}) + user = @client.find_user("sferik") + + assert_same user, user.hydrate + end + + def test_hydrate_memoizes_across_requests + stub_json(:get, "tweets/1", {data: {id: "1", author_id: "7505382"}}) + stub_json(:get, "users/7505382", {data: {id: "7505382", name: "Erik Berlin"}}) + author = @client.find_post(1).author + 2.times { author.hydrate } + + assert_equal "Erik Berlin", author.hydrate.name + assert_requested :get, %r{users/7505382}, times: 1 + end + + def test_equality_across_requests_and_entry_points + stub_json(:get, "tweets/1", {data: {id: "1", author_id: "7505382"}}) + stub_json(:get, "users/by/username/sferik", {data: {id: "7505382", username: "sferik"}}) + + assert_equal @client.find_user("sferik"), @client.get("tweets/1", object_class: Post).author + end + + def test_followers_paginate_with_maximum_page_size + stub_page("users/7505382/followers", nil, %w[1 2], "p2") + stub_page("users/7505382/followers", "p2", %w[3], nil) + followers = User.new({"id" => "7505382"}, client: @client).followers + + assert_equal [1, 2, 3], followers.map(&:id) + assert_equal [1, 2, 3], followers.map(&:id) + assert_requested :get, %r{users/7505382/followers.*max_results=1000}, times: 2 + end + + def test_hide_a_reply + stub_json(:put, "tweets/1/hidden", {data: {hidden: true}}) + + assert @client.hide_reply(1) + assert_requested :put, "#{BASE}tweets/1/hidden", body: {hidden: true}.to_json + end + + private + + def stub_current_user(bearer_token, id) + stub_request(:get, /users\/me/).with(headers: {"Authorization" => "Bearer #{bearer_token}"}) + .to_return(headers: {"content-type" => "application/json"}, body: {data: {id:}}.to_json) + end + + def stub_json(method, path, body) + stub_request(method, /\A#{Regexp.escape(BASE + path)}(\?.*)?\z/) + .to_return(body: JSON.generate(body), headers: {"content-type" => "application/json"}) + end + + def stub_page(path, token, ids, next_token) + body = {data: ids.map { |id| {id:} }, meta: {next_token:}.compact} + stub_request(:get, /\A#{Regexp.escape(BASE + path)}\?/) + .with { |request| URI.decode_www_form(request.uri.query).to_h["pagination_token"].eql?(token) } + .to_return(body: JSON.generate(body), headers: {"content-type" => "application/json"}) + end + end + + class ConcurrencyTest < Minitest::Test + def test_the_batches_of_a_lookup_and_the_chunks_of_an_upload_run_as_wide + assert_equal Uploader::MediaUpload::DEFAULT_CONCURRENCY, Objects.const_get(:BatchFinders)::DEFAULT_CONCURRENCY + end + end +end diff --git a/test/x/media_uploader_test.rb b/test/x/media_uploader_test.rb deleted file mode 100644 index 12f46595..00000000 --- a/test/x/media_uploader_test.rb +++ /dev/null @@ -1,130 +0,0 @@ -require_relative "../test_helper" -require_relative "../../lib/x/media_uploader" - -module X - class MediaUploaderTest < Minitest::Test - cover MediaUploader - - def setup - @client = Client.new - @media = {"id" => TEST_MEDIA_ID, "media_key" => TEST_MEDIA_KEY} - @data = {"data" => @media} - end - - def test_upload - file_path = "test/sample_files/sample.jpg" - stub_request(:post, "https://api.twitter.com/2/media/upload?media_category=#{MediaUploader::TWEET_IMAGE}") - .to_return(body: @media.to_json, headers: {"Content-Type" => "application/json"}) - - result = MediaUploader.upload(client: @client, file_path:, media_category: MediaUploader::TWEET_IMAGE, boundary: "AaB03x") - - assert_equal TEST_MEDIA_ID, result["id"] - end - - def test_chunked_upload - file_path = "test/sample_files/sample.mp4" - total_bytes = File.size(file_path) - chunk_size_mb = (total_bytes - 1) / MediaUploader::BYTES_PER_MB.to_f - stub_request(:post, "https://api.twitter.com/2/media/upload?command=INIT&media_category=tweet_video&media_type=video/mp4&total_bytes=#{total_bytes}") - .to_return(status: 202, headers: {"content-type" => "application/json"}, body: @data.to_json) - 2.times { |segment_index| stub_request(:post, "https://api.twitter.com/2/media/upload?command=APPEND&media_key=#{TEST_MEDIA_KEY}&segment_index=#{segment_index}").to_return(status: 204) } - stub_request(:post, "https://api.twitter.com/2/media/upload?command=FINALIZE&media_key=#{TEST_MEDIA_KEY}") - .to_return(status: 201, headers: {"content-type" => "application/json"}, body: @data.to_json) - - response = MediaUploader.chunked_upload(client: @client, file_path:, media_category: MediaUploader::TWEET_VIDEO, chunk_size_mb:) - - assert_equal TEST_MEDIA_ID, response["id"] - end - - def test_append_method - file_path = "test/sample_files/sample.mp4" - file_paths = MediaUploader.send(:split, file_path, File.size(file_path) - 1) - - file_paths.each_with_index do |_chunk_path, segment_index| - stub_request(:post, "https://api.twitter.com/2/media/upload?command=APPEND&media_key=#{TEST_MEDIA_KEY}&segment_index=#{segment_index}") - .with(headers: {"Content-Type" => "multipart/form-data, boundary=AaB03x"}).to_return(status: 204) - end - MediaUploader.send(:append, client: @client, file_paths:, media: @media, media_type: "video/mp4", boundary: "AaB03x") - - file_paths.each_with_index { |_, segment_index| assert_requested(:post, "https://api.twitter.com/2/media/upload?command=APPEND&media_key=#{TEST_MEDIA_KEY}&segment_index=#{segment_index}") } - end - - def test_await_processing - stub_request(:get, "https://api.twitter.com/2/media/upload?command=STATUS&media_key=#{TEST_MEDIA_KEY}") - .to_return(headers: {"content-type" => "application/json"}, body: '{"data":{"processing_info": {"state": "pending"}}}') - .to_return(headers: {"content-type" => "application/json"}, body: '{"data":{"processing_info": {"state": "succeeded"}}}') - result = MediaUploader.await_processing(client: @client, media: @media) - - assert_equal "succeeded", result["processing_info"]["state"] - end - - def test_await_processing_and_failed - stub_request(:get, "https://api.twitter.com/2/media/upload?command=STATUS&media_key=#{TEST_MEDIA_KEY}") - .to_return(headers: {"content-type" => "application/json"}, body: '{"data":{"processing_info": {"state": "pending"}}}') - .to_return(headers: {"content-type" => "application/json"}, body: '{"data":{"processing_info": {"state": "failed"}}}') - result = MediaUploader.await_processing(client: @client, media: @media) - - assert_equal "failed", result["processing_info"]["state"] - end - - def test_retry - file_path = "test/sample_files/sample.mp4" - stub_request(:post, "https://api.twitter.com/2/media/upload?command=INIT&media_category=tweet_video&media_type=video/mp4&total_bytes=#{File.size(file_path)}") - .to_return(status: 202, headers: {"content-type" => "application/json"}, body: @data.to_json) - stub_request(:post, "https://api.twitter.com/2/media/upload?command=APPEND&media_key=#{TEST_MEDIA_KEY}&segment_index=0") - .to_return(status: 500).to_return(status: 204) - stub_request(:post, "https://api.twitter.com/2/media/upload?command=FINALIZE&media_key=#{TEST_MEDIA_KEY}") - .to_return(status: 201, headers: {"content-type" => "application/json"}, body: @data.to_json) - - assert MediaUploader.chunked_upload(client: @client, file_path:, media_category: MediaUploader::TWEET_VIDEO) - end - - def test_validate_with_valid_params - file_path = "test/sample_files/sample.jpg" - - assert_nil MediaUploader.send(:validate!, file_path:, media_category: MediaUploader::TWEET_IMAGE) - end - - def test_validate_with_invalid_file_path - file_path = "invalid/file/path" - assert_raises(RuntimeError) do - MediaUploader.send(:validate!, file_path:, media_category: MediaUploader::TWEET_IMAGE) - end - end - - def test_validate_with_invalid_media_category - file_path = "test/sample_files/sample.jpg" - assert_raises(ArgumentError) do - MediaUploader.send(:validate!, file_path:, media_category: "invalid_category") - end - end - - def test_infer_media_type_for_gif - assert_equal MediaUploader::GIF_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.gif", "tweet_gif") - end - - def test_infer_media_type_for_jpg - assert_equal MediaUploader::JPEG_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.jpg", "tweet_image") - end - - def test_infer_media_type_for_mp4 - assert_equal MediaUploader::MP4_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.mp4", "tweet_video") - end - - def test_infer_media_type_for_png - assert_equal MediaUploader::PNG_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.png", "tweet_image") - end - - def test_infer_media_type_for_srt - assert_equal MediaUploader::SUBRIP_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.srt", "subtitles") - end - - def test_infer_media_type_for_webp - assert_equal MediaUploader::WEBP_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.webp", "tweet_image") - end - - def test_infer_media_type_with_default - assert_equal MediaUploader::DEFAULT_MIME_TYPE, MediaUploader.send(:infer_media_type, "test/sample_files/sample.dne", "tweet_image") - end - end -end diff --git a/test/x/oauth_authenticator_test.rb b/test/x/oauth_authenticator_test.rb deleted file mode 100644 index b04d0f8e..00000000 --- a/test/x/oauth_authenticator_test.rb +++ /dev/null @@ -1,119 +0,0 @@ -require_relative "../test_helper" - -module X - class OAuthAuthenticatorTest < Minitest::Test - cover OAuthAuthenticator - - def setup - @authenticator = OAuthAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, - access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) - end - - def test_initialization - assert_equal TEST_API_KEY, @authenticator.api_key - assert_equal TEST_API_KEY_SECRET, @authenticator.api_key_secret - assert_equal TEST_ACCESS_TOKEN, @authenticator.access_token - assert_equal TEST_ACCESS_TOKEN_SECRET, @authenticator.access_token_secret - end - - def test_default_oauth_nonce - request = Net::HTTP::Get.new(URI("https://example.com/")) - SecureRandom.stub :hex, TEST_OAUTH_NONCE do - authorization = @authenticator.header(request)["Authorization"] - - assert_includes authorization, "oauth_nonce=\"#{TEST_OAUTH_NONCE}\"" - end - end - - def test_default_oauth_timestamp - request = Net::HTTP::Get.new(URI("https://example.com/")) - Time.stub :now, Time.utc(1983, 11, 24) do - authorization = @authenticator.header(request)["Authorization"] - - assert_includes authorization, "oauth_timestamp=\"#{TEST_OAUTH_TIMESTAMP}\"" - end - end - - def test_header_contains_authorization_key - request = Net::HTTP::Get.new(URI("https://example.com/")) - header = @authenticator.header(request) - - assert header.key?("Authorization"), "Header does not contain \"Authorization\" key" - end - - def test_header_starts_with_oauth - request = Net::HTTP::Get.new(URI("https://example.com/")) - authorization = @authenticator.header(request)["Authorization"] - - assert authorization.start_with?("OAuth ") - end - - def test_header_contains_required_oauth_fields - request = Net::HTTP::Get.new(URI("https://example.com/")) - authorization = @authenticator.header(request)["Authorization"] - - assert_includes authorization, "oauth_consumer_key=\"#{TEST_API_KEY}\"" - assert_includes authorization, "oauth_token=\"#{TEST_ACCESS_TOKEN}\"" - end - - def test_header_contains_oauth_signature_method - request = Net::HTTP::Get.new(URI("https://example.com/")) - authorization = @authenticator.header(request)["Authorization"] - - assert_includes authorization, "oauth_signature_method=\"HMAC-SHA1\"" - end - - def test_header_contains_oauth_version - request = Net::HTTP::Get.new(URI("https://example.com/")) - authorization = @authenticator.header(request)["Authorization"] - - assert_includes authorization, "oauth_version=\"1.0\"" - end - - def test_header_in_alphabetical_order - request = Net::HTTP::Get.new(URI("https://example.com/")) - authorization = @authenticator.header(request)["Authorization"] - oauth_keys = authorization.scan(/oauth_[a-z0-9_]+/) - - assert_equal oauth_keys.sort, oauth_keys, "OAuth keys are not sorted in alphabetical order" - end - - def test_signature - request = Net::HTTP::Get.new(URI("https://example.com/?query=test")) - expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ - "oauth_signature=\"1kHVZMzcNj51v60H63%2FTZErArAk%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ - "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" - @authenticator.stub :default_oauth_params, test_oauth_params do - authorization = @authenticator.header(request)["Authorization"] - - assert_equal expected, authorization - end - end - - def test_uri_without_query - uri = URI("http://example.com/test?param=value") - - assert_equal "http://example.com/test", @authenticator.send(:uri_without_query, uri) - - uri_no_query = URI("http://example.com/test") - - assert_equal "http://example.com/test", @authenticator.send(:uri_without_query, uri_no_query) - end - - def test_parse_query_params - query_string = "param1=value1¶m2=value2" - expected = {"param1" => "value1", "param2" => "value2"} - - assert_equal expected, @authenticator.send(:parse_query_params, query_string) - end - - def test_signature_base_string_with_spaces - method = "GET" - url = "https://api.twitter.com/2/tweets/search/recent" - params = {"query" => "ruby has:media -is:retweet"} - expected = "GET&https%3A%2F%2Fapi.twitter.com%2F2%2Ftweets%2Fsearch%2Frecent&query%3Druby%2520has%253Amedia%2520-is%253Aretweet" - - assert_equal expected, @authenticator.send(:signature_base_string, method, url, params) - end - end -end diff --git a/test/x/redirect_handler_test.rb b/test/x/redirect_handler_test.rb deleted file mode 100644 index 2123cb5a..00000000 --- a/test/x/redirect_handler_test.rb +++ /dev/null @@ -1,145 +0,0 @@ -require_relative "../test_helper" - -module X - class RedirectHandlerTest < Minitest::Test - cover RedirectHandler - - def setup - @connection = Connection.new - @request_builder = RequestBuilder.new - @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder) - end - - def test_initialize_with_defaults - redirect_handler = RedirectHandler.new - - assert_instance_of Connection, redirect_handler.connection - assert_instance_of RequestBuilder, redirect_handler.request_builder - end - - def test_handle_with_no_redirects - request = Net::HTTP::Get.new("/some_path") - - response = Net::HTTPSuccess.new("1.1", "200", "OK") - - assert_equal(response, @redirect_handler.handle(response:, request:, base_url: "http://example.com")) - end - - def test_handle_with_one_redirect - authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) - request = Net::HTTP::Get.new("/") - stub_request(:get, "http://www.example.com/").with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) - - response = Net::HTTPFound.new("1.1", "302", "Found") - response["Location"] = "http://www.example.com" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com", authenticator:) - - assert_requested :get, "http://www.example.com" - end - - def test_handle_with_two_redirects - request = Net::HTTP::Delete.new("/") - stub_request(:delete, "http://example.com/2").to_return(status: 307, headers: {"Location" => "http://example.com/3"}) - stub_request(:delete, "http://example.com/3") - - response = Net::HTTPFound.new("1.1", "307", "Found") - response["Location"] = "http://example.com/2" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :delete, "http://example.com/2" - assert_requested :delete, "http://example.com/3" - end - - def test_handle_with_relative_url - request = Net::HTTP::Get.new("/some_path") - stub_request(:get, "http://example.com/some_relative_path") - - response = Net::HTTPFound.new("1.1", "302", "Found") - response["Location"] = "/some_relative_path" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :get, "http://example.com/some_relative_path" - end - - def test_handle_with_301_moved_permanently - request = Net::HTTP::Get.new("/some_path") - stub_request(:get, "http://example.com/new_path") - - response = Net::HTTPMovedPermanently.new("1.1", "301", "Moved Permanently") - response["Location"] = "http://example.com/new_path" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :get, "http://example.com/new_path" - end - - def test_handle_with_302_found - request = Net::HTTP::Get.new("/some_path") - stub_request(:get, "http://example.com/temp_path") - - response = Net::HTTPFound.new("1.1", "302", "Found") - response["Location"] = "http://example.com/temp_path" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :get, "http://example.com/temp_path" - end - - def test_handle_with_303_see_other - request = Net::HTTP::Post.new("/some_path") - stub_request(:post, "http://example.com/some_path") - stub_request(:get, "http://example.com/other_path") - - response = Net::HTTPSeeOther.new("1.1", "303", "See Other") - response["Location"] = "http://example.com/other_path" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :get, "http://example.com/other_path" - end - - def test_handle_with_307_temporary_redirect - request = Net::HTTP::Post.new("/some_path") - request.body = "request_body" - stub_request(:post, "http://example.com/temp_path") - - response = Net::HTTPTemporaryRedirect.new("1.1", "307", "Temporary Redirect") - response["Location"] = "http://example.com/temp_path" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :post, "http://example.com/temp_path", body: "request_body" - end - - def test_handle_with_308_permanent_redirect - request = Net::HTTP::Post.new("/some_path") - request.body = "request_body" - stub_request(:post, "http://example.com/new_path") - - response = Net::HTTPPermanentRedirect.new("1.1", "308", "Permanent Redirect") - response["Location"] = "http://example.com/new_path" - - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - - assert_requested :post, "http://example.com/new_path", body: "request_body" - end - - def test_handle_with_too_many_redirects - request = Net::HTTP::Get.new("/some_path") - stub_request(:get, "http://example.com/some_path").to_return(status: 302, headers: {"Location" => "http://example.com/some_path"}) - - response = Net::HTTPFound.new("1.1", "302", "Found") - response["Location"] = "http://example.com/some_path" - - e = assert_raises(TooManyRedirects) do - @redirect_handler.handle(response:, request:, base_url: "http://example.com") - end - - assert_equal "Too many redirects", e.message - assert_requested :get, "http://example.com/some_path", times: RedirectHandler::DEFAULT_MAX_REDIRECTS + 1 - end - end -end diff --git a/test/x/request_builder_test.rb b/test/x/request_builder_test.rb deleted file mode 100644 index c68dd71c..00000000 --- a/test/x/request_builder_test.rb +++ /dev/null @@ -1,79 +0,0 @@ -require "uri" -require_relative "../test_helper" - -module X - class RequestBuilderTest < Minitest::Test - cover RequestBuilder - - def setup - @authenticator = OAuthAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, - access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) - @request_builder = RequestBuilder.new - @uri = URI("http://example.com") - end - - def test_build_get_request - expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ - "oauth_signature=\"mnm1SUSsJ0X4aBwAAkwpsTf01gg%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ - "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" - @authenticator.stub :default_oauth_params, test_oauth_params do - request = @request_builder.build(http_method: :get, uri: @uri, authenticator: @authenticator) - - assert_equal "GET", request.method - assert_equal @uri, request.uri - assert_equal expected, request["Authorization"] - assert_equal "application/json; charset=utf-8", request["Content-Type"] - end - end - - def test_build_post_request - expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ - "oauth_signature=\"pcXcvPVpQINrqI3H3lCg8N1ayG0%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ - "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" - - @authenticator.stub :default_oauth_params, test_oauth_params do - request = @request_builder.build(http_method: :post, uri: @uri, body: "{}", authenticator: @authenticator) - - assert_equal "POST", request.method - assert_equal @uri, request.uri - assert_equal "{}", request.body - assert_equal expected, request["Authorization"] - end - end - - def test_custom_headers - request = @request_builder.build(http_method: :get, uri: @uri, - headers: {"User-Agent" => "Custom User Agent"}, authenticator: @authenticator) - - assert_equal "Custom User Agent", request["User-Agent"] - end - - def test_build_without_authenticator_parameter - request = @request_builder.build(http_method: :get, uri: @uri) - - assert_empty request["Authorization"] - end - - def test_unsupported_http_method - exception = assert_raises ArgumentError do - @request_builder.build(http_method: :unsupported, uri: @uri, authenticator: @authenticator) - end - - assert_equal "Unsupported HTTP method: unsupported", exception.message - end - - def test_escape_query_params - uri = "https://upload.twitter.com/1.1/media/upload.json?media_type=video/mp4" - request = @request_builder.build(http_method: :post, uri:, authenticator: @authenticator) - - assert_equal "media_type=video%2Fmp4", request.uri.query - end - - def test_escape_query_params_with_commas - uri = "https://api.twitter.com/2/tweets/search/recent?query=%23ruby&expansions=author_id&user.fields=id,name,username" - request = @request_builder.build(http_method: :post, uri:, authenticator: @authenticator) - - assert_equal "query=%23ruby&expansions=author_id&user.fields=id,name,username", request.uri.query - end - end -end diff --git a/test/x/response_parser_test.rb b/test/x/response_parser_test.rb deleted file mode 100644 index 2a975799..00000000 --- a/test/x/response_parser_test.rb +++ /dev/null @@ -1,137 +0,0 @@ -require "ostruct" -require_relative "../test_helper" - -module X - class ResponseParserTest < Minitest::Test - cover ResponseParser - - def setup - @response_parser = ResponseParser.new - @uri = URI("http://example.com") - end - - def response(uri = @uri) - Net::HTTP.get_response(uri) - end - - def test_success_response - stub_request(:get, @uri.to_s) - .to_return(body: '{"message": "success"}', headers: {"Content-Type" => "application/json"}) - - assert_equal({"message" => "success"}, @response_parser.parse(response:)) - end - - def test_non_json_success_response - stub_request(:get, @uri.to_s) - .to_return(body: "", headers: {"Content-Type" => "text/html"}) - - assert_nil @response_parser.parse(response:) - end - - def test_that_it_parses_204_no_content_response - stub_request(:get, @uri.to_s).to_return(status: 204) - - assert_nil @response_parser.parse(response:) - end - - def test_bad_request_error - stub_request(:get, @uri.to_s).to_return(status: 400) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_kind_of Net::HTTPBadRequest, exception.response - assert_equal "400", exception.code - end - - def test_unknown_error_code - stub_request(:get, @uri.to_s).to_return(status: 418) - assert_raises(Error) { @response_parser.parse(response:) } - end - - def test_too_many_requests_with_headers - stub_request(:get, @uri.to_s) - .to_return(status: 429, headers: {"x-rate-limit-remaining" => "0"}) - exception = assert_raises(TooManyRequests) { @response_parser.parse(response:) } - - assert_predicate exception.rate_limits.first.remaining, :zero? - end - - def test_error_with_title_only - stub_request(:get, @uri.to_s) - .to_return(status: [400, "Bad Request"], body: '{"title": "Some Error"}', headers: {"Content-Type" => "application/json"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_equal "Bad Request", exception.message - end - - def test_error_with_detail_only - stub_request(:get, @uri.to_s) - .to_return(status: [400, "Bad Request"], - body: '{"detail": "Something went wrong"}', headers: {"Content-Type" => "application/json"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_equal "Bad Request", exception.message - end - - def test_error_with_title_and_detail_error_message - stub_request(:get, @uri.to_s) - .to_return(status: 400, - body: '{"title": "Some Error", "detail": "Something went wrong"}', headers: {"Content-Type" => "application/json"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_equal("Some Error: Something went wrong", exception.message) - end - - def test_error_with_error_message - stub_request(:get, @uri.to_s) - .to_return(status: 400, body: '{"error": "Some Error"}', headers: {"Content-Type" => "application/json"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_equal("Some Error", exception.message) - end - - def test_error_with_errors_array_message - stub_request(:get, @uri.to_s) - .to_return(status: 400, - body: '{"errors": [{"message": "Some Error"}, {"message": "Another Error"}]}', headers: {"Content-Type" => "application/json"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_equal("Some Error, Another Error", exception.message) - end - - def test_error_with_errors_message - stub_request(:get, @uri.to_s) - .to_return(status: 400, body: '{"errors": {"message": "Some Error"}}', headers: {"Content-Type" => "application/json"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_empty exception.message - end - - def test_non_json_error_response - stub_request(:get, @uri.to_s) - .to_return(status: [400, "Bad Request"], body: "Bad Request", headers: {"Content-Type" => "text/html"}) - exception = assert_raises(BadRequest) { @response_parser.parse(response:) } - - assert_equal "Bad Request", exception.message - end - - def test_default_response_objects - stub_request(:get, @uri.to_s) - .to_return(body: '{"array": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) - hash = @response_parser.parse(response:) - - assert_kind_of Hash, hash - assert_kind_of Array, hash["array"] - assert_equal [1, 2, 2, 3], hash["array"] - end - - def test_custom_response_objects - stub_request(:get, @uri.to_s) - .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) - ostruct = @response_parser.parse(response:, object_class: OpenStruct, array_class: Set) - - assert_kind_of OpenStruct, ostruct - assert_kind_of Set, ostruct.set - assert_equal Set.new([1, 2, 3]), ostruct.set - end - end -end diff --git a/test/x/streaming_api_test.rb b/test/x/streaming_api_test.rb new file mode 100644 index 00000000..825459a8 --- /dev/null +++ b/test/x/streaming_api_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module X + class ClientStreamingAPITest < Minitest::Test + def test_client_includes_streaming_api + assert_includes Client.ancestors, Streaming::API + end + + def test_a_client_builds_a_streaming_client_of_itself + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + streaming = client.streaming(read_timeout: 25, max_reconnects: 2) + + assert_equal [StreamingClient, client, 25, 2], [streaming.class, streaming.client, streaming.read_timeout, streaming.max_reconnects] + end + end +end diff --git a/test/x/too_many_requests_test.rb b/test/x/too_many_requests_test.rb deleted file mode 100644 index 15c4f9f8..00000000 --- a/test/x/too_many_requests_test.rb +++ /dev/null @@ -1,116 +0,0 @@ -require_relative "../test_helper" - -module X - class TooManyRequestsTest < Minitest::Test - cover TooManyRequests - - def setup - response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") - - rate_limit(response) - app_limit(response) - user_limit(response) - - @exception = TooManyRequests.new(response:) - end - - def rate_limit(response) - Time.stub :now, Time.utc(1983, 11, 24) do - response["x-rate-limit-reset"] = (Time.now + 60).to_i.to_s - end - response["x-rate-limit-limit"] = "100" - response["x-rate-limit-remaining"] = "0" - end - - def app_limit(response) - Time.stub :now, Time.utc(1983, 11, 24) do - response["x-app-limit-24hour-reset"] = (Time.now + 61).to_i.to_s - end - response["x-app-limit-24hour-limit"] = "100" - response["x-app-limit-24hour-remaining"] = "0" - end - - def user_limit(response) - Time.stub :now, Time.utc(1983, 11, 24) do - response["x-user-limit-24hour-remaining"] = (Time.now + 60).to_i.to_s - end - response["x-user-limit-24hour-reset"] = "100" - response["x-user-limit-24hour-reset"] = "0" - end - - def test_initialize_with_empty_response - response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") - exception = TooManyRequests.new(response:) - - assert_equal 0, exception.rate_limits.count - assert_equal Time.at(0).utc, exception.reset_at - assert_equal 0, exception.reset_in - assert_equal "Too Many Requests", exception.message - end - - def test_rate_limit - Time.stub :now, Time.utc(1983, 11, 24) do - @exception.response["x-app-limit-24hour-reset"] = (Time.now + 61).to_i.to_s - @exception.response["x-app-limit-24hour-remaining"] = "0" - - assert_equal Time.now + 61, @exception.rate_limit.reset_at - end - end - - def test_rate_limits - Time.stub :now, Time.utc(1983, 11, 24) do - @exception.response["x-app-limit-24hour-limit"] = "200" - @exception.response["x-app-limit-24hour-remaining"] = "0" - limits = @exception.rate_limits - - assert_equal 2, limits.count - assert_equal "rate-limit", limits.first.type - assert_equal "app-limit-24hour", limits.last.type - end - end - - def test_rate_limits_exlude_non_exhausted_limits - Time.stub :now, Time.utc(1983, 11, 24) do - @exception.response["x-app-limit-24hour-limit"] = "200" - @exception.response["x-app-limit-24hour-remaining"] = "1" - limits = @exception.rate_limits - - assert_equal 1, limits.count - assert_equal "rate-limit", limits.first.type - end - end - - def test_reset_at - Time.stub :now, Time.utc(1983, 11, 24) do - @exception.response["x-app-limit-24hour-remaining"] = "0" - @exception.response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s - - assert_equal Time.at(Time.now.to_i + 200), @exception.reset_at - end - end - - def test_reset_in - Time.stub :now, Time.utc(1983, 11, 24) do - @exception.response["x-app-limit-24hour-remaining"] = "0" - @exception.response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s - - assert_equal 200, @exception.reset_in - end - end - - def test_reset_in_ceil - @exception.response["x-rate-limit-reset"] = (Time.now + 61).to_i.to_s - - assert_equal 61, @exception.reset_in - end - - def test_retry_after - Time.stub :now, Time.utc(1983, 11, 24) do - @exception.response["x-app-limit-24hour-remaining"] = "0" - @exception.response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s - - assert_equal 200, @exception.retry_after - end - end - end -end diff --git a/test/x/uploader_api_test.rb b/test/x/uploader_api_test.rb new file mode 100644 index 00000000..62b77622 --- /dev/null +++ b/test/x/uploader_api_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "stringio" +require_relative "../test_helper" + +module X + class ClientUploaderAPITest < Minitest::Test + BASE = "https://api.x.com/2/" + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_client_includes_uploader_api + assert_includes Client.ancestors, Uploader::API + end + + def test_the_upload_methods_take_the_name_of_no_other_method_of_the_client + others = (Client.ancestors - [Uploader::API]).flat_map { |ancestor| ancestor.instance_methods(false) + ancestor.private_instance_methods(false) } + + assert_empty Uploader::API.instance_methods & others + end + + def test_a_client_uploads_media_and_posts_it + stub_json(:post, "media/upload", {data: {id: "7", media_key: "3_7"}}) + stub_json(:post, "tweets", {data: {id: "9", text: "Look at this cat"}}) + media = @client.upload_media(StringIO.new("GIF89a"), media_category: "tweet_image") + + assert_equal 9, @client.create_post("Look at this cat", media_ids: [media]).id + assert_requested :post, "#{BASE}tweets", body: {text: "Look at this cat", media: {media_ids: ["7"]}}.to_json + end + + def test_a_client_looks_up_the_media_that_an_upload_became + stub_json(:post, "media/upload", {data: {id: "7", media_key: "3_7"}}) + stub_request(:get, %r{\A#{Regexp.escape(BASE)}media/3_7\?}o).to_return(body: JSON.generate({data: {media_key: "3_7", type: "photo"}}), headers: {"content-type" => "application/json"}) + uploaded = @client.upload_media(StringIO.new("GIF89a"), media_category: "tweet_image") + + assert_equal "photo", @client.find_media(uploaded.media_key).type + end + + private + + def stub_json(method, path, body) + stub_request(method, "#{BASE}#{path}").to_return(body: JSON.generate(body), headers: {"content-type" => "application/json"}) + end + end +end diff --git a/test/x/version_test.rb b/test/x/version_test.rb index 0b644831..097702f5 100644 --- a/test/x/version_test.rb +++ b/test/x/version_test.rb @@ -1,29 +1,53 @@ +# frozen_string_literal: true + require_relative "../test_helper" +require "x/uploader/version" +require "x/streaming/version" module X - class VersionTest < Minitest::Test + class MetaVersionTest < Minitest::Test def test_that_it_has_a_version_number refute_nil VERSION end - def test_segments_array - assert_kind_of Array, VERSION.segments + def test_version_string + assert_kind_of String, VERSION + end + + def test_version_frozen + assert_predicate VERSION, :frozen? + end + + def test_version_file + assert_equal File.read(File.expand_path("../../VERSION", __dir__)).strip, VERSION + end + + def test_gem_version + assert_kind_of Gem::Version, X.gem_version end - def test_major_version_integer - assert_kind_of Integer, VERSION.segments[0] + def test_gem_version_reads_version + assert_equal VERSION, X.gem_version.to_s end - def test_minor_version_integer - assert_kind_of Integer, VERSION.segments[1] + def test_lockstep_versions + assert_equal VERSION, Core::VERSION + assert_equal VERSION, Uploader::VERSION + assert_equal VERSION, Streaming::VERSION + assert_equal VERSION, Objects::VERSION end - def test_patch_version_integer - assert_kind_of Integer, VERSION.segments[2] + def test_x_core_asks_for_a_net_http_that_raises_net_open_timeout_for_a_connection_that_times_out_opening + requirement = Gem::Specification.load(File.expand_path("../../x-core/x-core.gemspec", __dir__)).dependencies.find { |dependency| dependency.name.eql?("net-http") }.requirement + + assert_equal [false, true], %w[0.9.0 0.9.1].map { |version| requirement.satisfied_by?(Gem::Version.new(version)) } end - def test_to_s - assert_kind_of String, VERSION.to_s + def test_lockstep_gem_versions + assert_equal X.gem_version, Core.gem_version + assert_equal X.gem_version, Uploader.gem_version + assert_equal X.gem_version, Streaming.gem_version + assert_equal X.gem_version, Objects.gem_version end end end diff --git a/.mutant.yml b/x-core/.mutant.yml similarity index 95% rename from .mutant.yml rename to x-core/.mutant.yml index a7469653..2be3b9af 100644 --- a/.mutant.yml +++ b/x-core/.mutant.yml @@ -13,5 +13,5 @@ mutation: operators: full timeout: 10.0 requires: -- x +- x/core usage: opensource diff --git a/x-core/.yardopts b/x-core/.yardopts new file mode 100644 index 00000000..246d9b7b --- /dev/null +++ b/x-core/.yardopts @@ -0,0 +1,8 @@ +--markup markdown +--readme README.md +--hide-api private +--embed-mixins +lib/**/*.rb +- +CHANGELOG.md +LICENSE.txt diff --git a/x-core/CHANGELOG.md b/x-core/CHANGELOG.md new file mode 100644 index 00000000..ca1600cc --- /dev/null +++ b/x-core/CHANGELOG.md @@ -0,0 +1,337 @@ +# Changelog + +All notable changes to `x-core` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +`x-core` is released in lockstep with the other gems of the [x-ruby](https://github.com/sferik/x-ruby) repository, at one version across `x-core`, `x-uploader`, `x-streaming`, `x-objects`, and `x`. This file holds the changes to the HTTP layer; [the changelog of the repository](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) holds the changes to every gem. + +## [1.0.0] - 2026-10-06 + +The first release of `x-core`, which 1.0.0 split out of the `x` gem. The entries below are the changes since `x` 0.19, the last release before the split; see [UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) for the changes that code written for 0.19 needs. + +### Added +* Split the `x` gem into five gems released in lockstep, at one version + * `x-core` (the HTTP client), `x-uploader` (uploads), `x-streaming` (streams and their rules), `x-objects` (resources). + * `x` is a meta-gem that depends on all four and mixes their object, upload, and streaming methods into `X::Client`. + * `x-core` declares `X::Error`, the base class of every error the gems raise. + * Public classes sit directly under `X`; `X::Objects::Error`, `X::Uploader::Error`, and `X::Streaming::Error` catch one gem's. + * Each gem depends on the others it needs with `>= 1.0.0, < 2`, so a later 1.x of one installs beside the others, and never an earlier one. +* Add `X::Client#get_stream`, which opens a GET request whose body its block reads as it arrives + * It sends the client's credentials, headers, timeouts, and proxy, and refreshes a rejected token, as any request does. + * It asks for a body that is not compressed (`Accept-Encoding: identity`), which Net::HTTP would hold back in blocks, unless a header of the request or client names another. + * A failed response raises its `X::HTTPError`, once `on_response` is passed it, without reaching the block. + * The block is passed an `X::StreamResponse` before its body is read, and `get_stream` returns what the block returns. + * `X::StreamResponse` reads the `status`, `headers`, `uri`, `rate_limits`, and `rate_limit` of the response. + * Its `read_body` passes a block each chunk of the body as it arrives, or returns the body whole without one. + * Its `http_response` is an escape hatch, the transport's response, whose class 1.x does not promise. + * An error `read_body` raises from the socket raises `X::NetworkError`; any other error of the block is raised as it was. + * `x-streaming` reads the streams of the API with it. +* Add `X::Client#with_retries`, `memoized`, and `memoize`, kept throughout 1.x for the gems that extend a client + * `with_retries` sends a request again after a failure, as `x-uploader` sends each chunk of an upload. + * `memoize` keeps a value under a key for the authenticator of the client, and `memoized` reads it, as `x-objects` keeps the authenticated user. +* Pass a block to `get`, `post`, `put`, or `delete` to receive the `X::Response` of each response that request gets + * It is passed what the client's `on_response` is passed, after it, failed responses and each retry among them. +* Add `X::Core.gem_version`, which returns `X::Core::VERSION` as a `Gem::Version` +* Add the `headers:` option of `X::Client.new`, headers sent with every request and stream the client makes + * They are a Hash of String or Symbol names to String values; anything else raises `ArgumentError`. + * In any `headers:`, a Symbol's underscores become hyphens: `{content_type: "text/plain"}` sends `content-type`. + * A request's or `get_stream`'s header replaces the client's of the same name, which replaces the gem's default. + * Header names are case-insensitive, and a redirect to another origin drops the ones that carry credentials. + * `X::Client#headers` reads them back frozen, each named by a String: `user_agent:` reads as `{"user-agent" => ...}`. + * Token requests send them too, with the gem's `User-Agent`, beneath the token request's own credentials; a client's `Authorization` header is never sent with one. +* Raise `ArgumentError` from `X::Client.new` for a `base_url` that is not an absolute http or https URL + * A `base_url` with a user, password, query, or fragment raises too. +* Pass a resource class, such as `X::User`, as the `object_class` of a request to build objects from the response + * A list builds an `X::Page`, which holds the response's `meta`, such as its `next_token`, and the problems it reported. + * A list answered with no `data`, or a lookup by ids that finds none, builds an empty `X::Page` rather than nil. + * An `object_class` that responds to `from_response` is passed the parsed body and `client:`, and builds the result. + * `from_response` must take the keywords it does not read with `**`; the signatures type it as `X::_ResponseBuilder`. +* Pass query parameters to `get`, `post`, `put`, `delete`, and `get_stream` as `params:` + * nil values are dropped, Arrays are joined with commas, and a `Time` is sent in UTC as ISO 8601. +* Encode a `post` or `put` body that is a Hash or an Array as JSON + * A body that is not a String, a Hash, or an Array, such as an IO or a Symbol, raises `ArgumentError` before any request. +* Send form fields as a form-encoded body with the `form:` option of `post` and `put` + * Fields are encoded as `params:` are. + * A request given both a body and `form:` raises `ArgumentError`. +* Authenticate as the app given only an `api_key` and `api_key_secret`, with `X::AppOnlyAuthenticator` + * It fetches a bearer token with the client credentials grant on the first request, and keeps it. + * A token the base URL's origin rejects with 401 is fetched again, and the request or stream is sent once more. +* Add `X::Client#with`, which copies a client with some options of `X::Client.new` changed + * For example `client.with(base_url: "https://api.x.com/1.1/")`, or `with(access_token: nil, access_token_secret: nil)`. + * A copy given no new OAuth 2.0 credentials shares the client's OAuth 2.0 authenticator, and so its refreshes. + * Such a copy given `expires_at` raises `ArgumentError`, since the expiration belongs to the shared token. + * A copy that does not share it holds no `refresh_token`, `expires_at`, `scopes`, or token hooks unless given them. + * A copy with the same app credentials and base URL shares the app-only bearer token the client fetched. +* Add the `authenticator:` option of `X::Client.new` and `X::Client#with`, to authenticate with an `X::Authenticator` + * It raises `ArgumentError` beside credentials, `expires_at`, or `scopes`, or for what is not an `X::Authenticator`. + * A custom one subclasses `X::Authenticator` and overrides `headers`, reading what `X::_AuthenticatorRequest` types. + * A client sends its token requests over its own connection, but for an authenticator given to several clients, which sends them over the first's, apart from a refresh another one's request makes; refreshes reach the `save_tokens` of every client. + * A client given an `X::OAuth2Authenticator` holds no app credentials, so its `app_only` raises `X::UnsupportedOperation`. +* Add `inspect` to `X::Client` and the authenticators, which never reveals credentials +* Keep the headers passed to `get`, `post`, `put`, and `delete` across redirects +* Pass an `X::Response` to the client's `on_response` after every request, and for each object a stream delivers + * It reads rate limits with `rate_limits` and `rate_limit`, and counts the resources returned with `resource_counts`. + * An `on_response` that is neither nil nor responds to `call` raises `ArgumentError` when the client is built. +* Add `X::Response#headers` and `X::HTTPError#headers`, a frozen Hash of the response headers with lowercase names + * Repeated fields are joined with a comma; read each cookie with `http_response.get_fields("set-cookie")`. +* Retry a request refused for a rate limit up to `max_rate_limit_retries` times, 0 by default, after the wait it asks for + * It waits no longer than `max_rate_limit_wait` seconds, 900 by default; a later reset raises at once. + * A refusal that names no wait waits a minute, doubling for each retry after; each wait adds up to 5 random seconds. + * A refusal for the project's usage cap, which `X::Problem#usage_capped?` tells apart, raises at once. +* Refresh an OAuth 2.0 access token when it expires, or when the base URL's origin rejects it with 401, and send again + * The `expires_at:` of `X::Client.new` must be a `Time`; anything else raises `ArgumentError`. + * A token refreshed less than a minute ago is not refreshed again, and a 401 from another origin refreshes nothing. + * Requests on several threads refresh once; `X::Client#expires_at` reads the expiration of the last refresh. + * A refresh that reports no lifetime forgets the expiration, rather than refresh again on every request. +* Store the tokens of each refresh with the `save_tokens:` callable of `X::Client.new`, passed an `X::OAuth2Tokens` + * Refreshes are reported one at a time, in order, leaving out one already replaced. + * Each hook runs even when another raises; then `X::TokenReportFailed` is raised, holding the `tokens` and `client`. + * A `Timeout::Error` a hook raises, even one `Timeout.timeout(5, Timeout::Error)` raises around the request, raises `X::TokenReportFailed` too. + * A `save_tokens` that is neither nil nor responds to `call` raises `ArgumentError` when the client is built. + * An authenticator reports to no hook of its own; its `refresh!` returns the frozen `X::OAuth2Tokens` of the refresh. +* Add `X::OAuth2Tokens`, the frozen `access_token`, `refresh_token`, `expires_at`, and `scopes` of a refresh or authorization + * It writes itself with Marshal, YAML, and `to_json` in a versioned format that every 1.x release reads back. + * `X::OAuth2Tokens.from_json` reads its JSON, or that JSON's Hash, back; `inspect` names neither token. + * `X::OAuth2Tokens.new` raises `ArgumentError` for an empty or non-String token, or an `expires_at` that is not a `Time`. +* Share a user's tokens among processes with a `load_tokens:` callable that returns the stored `X::OAuth2Tokens`, or nil + * `X::Client.new`, `X::Client#with`, `X::OAuth2Authorization#client`, and `X::OAuth2Authenticator.new` take it. + * A refresh reads the store under its lock first, and takes tokens another process refreshed in place of its own. + * A refresh X refuses with `invalid_request` or `invalid_grant` reads the store again, and takes the tokens there rather than raise once the other process has stored them. + * Tokens it loads are not passed to `save_tokens`. + * A `load_tokens` that does not respond to `call` raises `ArgumentError`; one returning anything else raises `TypeError`. +* Read the scopes X granted an OAuth 2.0 token with `scopes` on `X::OAuth2Tokens`, `X::OAuth2Authenticator`, and `X::Client` + * They are a frozen Array of Strings, or nil when unknown, and may be fewer than the app asked for. + * `X::Client.new`, `with`, and `X::OAuth2Authenticator.new` take `scopes:`, as in `X::Client.new(client_id:, **tokens.to_h)`. + * `scopes` that are not an Array of scope Strings, or that are given without OAuth 2.0 credentials, raise `ArgumentError`. +* Add `X::HTTPError#status`, `#body`, `#problems`, and `#problem`, to read what the API said without its response + * `status` is an Integer, as `X::Response#status` is. + * `problems` are the `X::Problem`s the body names; `problem` is the one it describes itself, or the first it names. + * `http_response` holds the response itself on `X::HTTPError` and `X::InvalidResponse`, as on `X::Response`. + * It is an escape hatch: the transport's object, a `Net::HTTPResponse` today, whose class 1.x does not promise. +* Raise an `X::ClientError` subclass of its own for the statuses 405, 408, 415, and 451, which raised `X::ClientError` itself + * They are `X::MethodNotAllowed`, `X::RequestTimeout`, `X::UnsupportedMediaType`, and `X::UnavailableForLegalReasons`. +* Raise `X::PaymentRequired`, an `X::ClientError`, for a 402, which X sends when the paying account has no credit left +* Name the request in the errors `X::HTTPError`, `X::NetworkError`, `X::InvalidResponse`, and `X::TooManyRedirects` + * Each reads its `http_method` and `uri`, and its message names it, as in `GET /2/users/1: Could not find user`. + * The message leaves out the query, which `uri` keeps, and a redirected request is named by the last request sent. +* Add `X::Authenticator#user_id`, the user an OAuth 1.0a access token acts for, or nil for credentials that name none +* Keep connections open between requests to the same host, up to 16 idle per host + * The `keep_alive_timeout:` of `X::Client` and `X::OAuth2Authorization` keeps them for 30 seconds by default. + * A connection whose request fails is closed, and a forked process opens its own. + * Copies made with `X::Client#with` that connect as the client does share its connections, as its app-only copy does. + * `X::Client#close` closes them, and those of the copies that share them; a later request opens one again. +* Add `X::Client#app_only`, a copy of a client that authenticates as a user which authenticates as the app instead + * It holds the app's bearer token: the one the client was given, or one fetched once with the app's API key and secret. + * It returns the same copy each time, and a copy made with `with` that holds the same app credentials shares its token. + * An OAuth 2.0 user client without app credentials raises `X::UnsupportedOperation`, an `X::Error` of `x-core`. + * The gems send its requests to app-only endpoints, such as streams, as the user, so X's 403 raises `X::Forbidden`. +* Refresh the tokens of a public OAuth 2.0 client, given a `client_id`, `access_token`, and `refresh_token` without a secret + * The refresh sends the client ID in its body rather than authenticate with a secret. +* Authenticate as a user with an OAuth 2.0 access token that is not refreshed, given no `refresh_token` + * `refresh_token:` is optional for `X::Client.new`, `X::Client#with`, and `X::OAuth2Authenticator.new`. + * A rejected access token raises `X::Unauthorized`, and `refresh!` raises `X::UnsupportedOperation`. +* Authorize an app to act for a user with the OAuth 2.0 authorization code flow and PKCE, with `X::OAuth2Authorization` + * It builds the URL that asks the user, with a state and code verifier to store until X redirects back. + * It exchanges the code of the redirect for `tokens`, an `X::OAuth2Tokens` that holds no app secret, or for a `client`. + * A declined authorization, a mismatched state, or an invalid redirect raises `X::AuthorizationDenied`. + * A code X refuses raises `X::AuthorizationError`; invalid options raise `ArgumentError` before the code is spent. + * `client` passes the tokens of the exchange to its `save_tokens` before it returns the client. + * It takes `headers:`, which it exchanges the code with and gives the client it builds, as a gateway may require. +* Add `X::InvalidResponse#body`, `#status`, and `#headers`, to read a successful response, or stream line, that is not JSON + * `body` falls back to the body of the response once it has been read whole, and never reads a stream. +* Add `X::TooManyRequests#exhausted_rate_limits` and `#limiting_rate_limit`, the used-up limits and the one to wait for +* Add a constant of `X::Client` for the default of each setting + * New: `DEFAULT_MAX_REDIRECTS`, `DEFAULT_MAX_RATE_LIMIT_RETRIES`, `DEFAULT_MAX_RATE_LIMIT_WAIT`, and `DEFAULT_MAX_RETRIES`. + * `DEFAULT_KEEP_ALIVE_TIMEOUT` is new too, and the `DEFAULT_*_TIMEOUT`s of `X::Connection` move to `X::Client`. +* Ship this changelog with `x-core`, which the `changelog_uri` of its gemspec names +* Ship a `.yardopts` with `x-core`, so its documentation on rubydoc.info leaves out the private API +* Send a request again when the API fails to answer it, up to `max_retries` times, 2 by default + * A `GET`, `PUT`, or `DELETE` is sent again after an `X::ServerError`, `X::RequestTimeout`, or unsent `X::NetworkError`. + * A `POST`, and any other 4xx, raises at once; `max_retries: 0` raises at once for any of these failures. A 429 and a rejected OAuth 2.0 token are handled apart, as above. + * The wait starts at up to a second and doubles to at most a minute, with jitter, or follows a longer `Retry-After`. + * A `Retry-After` of more than a minute raises at once, where a 429 waits up to `max_rate_limit_wait`. + * An error that `on_response`, a request's block, or `from_response` raises is never sent again. +* Raise `ArgumentError` from `X::Client.new` and `X::Client#with` for an invalid setting, naming it and the value given + * `max_redirects`, `max_rate_limit_retries`, and `max_retries` must be Integers of at least 0. + * `max_rate_limit_wait` must be a number of seconds of at least 0, not NaN, and `keep_alive_timeout` a finite one. + * `open_timeout`, `read_timeout`, and `write_timeout` must be finite numbers of seconds of at least 0, or nil for none. + * `default_array_class` must be a Class, and `default_object_class` a Class or respond to `from_response`. + * `X::OAuth2Authorization.new` checks its timeouts, and a request its `array_class` and `object_class`, the same way. +* Add `X::Problem`, which describes a problem the API reported of a request + * It reads `title`, `detail`, `type`, `resource_type`, `resource_id`, `parameter`, `value`, and `message`. + * It compares by value, writes itself with Marshal and YAML in a versioned format every 1.x reads, and as JSON with `to_json`. + * `about?` tells whether it names a resource, or an Integer or String identifier, comparing them as Strings. + * A format it does not read raises `X::UnsupportedFormat`, an `X::Error` that `x-core` declares for every gem. + * `X::HTTPError#problem` returns one, and `x-objects` reports them for a successful response. + +### Changed +* Hold `X::Core::VERSION` in a String rather than a `Gem::Version`; compare it with `X::Core.gem_version` +* Require Ruby 3.4 or later +* Keep frozen copies of the credentials, tokens, header values, base URL, and proxy URL a client, an authenticator, an `X::OAuth2Authorization`, or `X::OAuth2Tokens` is given, so neither the caller nor what a reader returns can change what is sent, or where +* Keep secret credentials private on a client and its authenticator, so no reader, `inspect`, or serialization reveals them + * Gone from `X::Client`: `api_key_secret`, `access_token`, `access_token_secret`, `bearer_token`, and `client_secret`. + * `refresh_token` is gone too: store tokens from the `X::OAuth2Tokens` that `save_tokens` is passed. + * Authenticators no longer read their secrets or tokens; `api_key`, `client_id`, and `expires_at` stay public. + * An authenticator's `headers` still returns the `Authorization` header it sends, which holds a bearer or OAuth 2.0 token. +* Rename `X::Authenticator#header` to `headers`, which a custom authenticator overrides + * The request it is passed answers `http_method` (a Symbol such as `:post`), `uri`, `body`, and `[]` for a header, where 0.19 passed the `Net::HTTPRequest`. +* Raise `TypeError` when a client, an authenticator, or an `X::OAuth2Authorization` is written out + * That is `Marshal.dump`, `YAML.dump`, `as_json`, and `to_json`, which ActiveSupport's `render json:` calls. + * They wrote credentials in the clear in 0.19; keep credentials in a secret store, and tokens as `X::OAuth2Tokens`. +* Keep a client's proxy URL private, since it can hold the proxy's user and password + * `X::Client` no longer reads `proxy_url`, and `inspect` leaves it out. +* Send a client's credentials to the origin of its `base_url` alone + * An endpoint or stream naming a URL of another origin is sent without `Authorization`, `Cookie`, or `Proxy-Authorization`. + * Another API version at that origin, such as `https://api.x.com/1.1/account/settings.json`, still carries them. +* Open connections with a 10-second timeout rather than 60; `read_timeout` and `write_timeout` stay at 60 seconds +* Read only the rate limits a response reports in full, with a limit, remaining count, and reset time, in base 10 + * `X::TooManyRequests#retry_after` no longer raises `KeyError` or `ArgumentError` for a missing or malformed header. +* Read the `Retry-After` header of a refused response with `X::HTTPError#retry_after`, in seconds or as an HTTP date + * It is nil for a response without one. + * `X::TooManyRequests#retry_after` reads it, then falls back on `#reset_in`, which it was an alias of. + * The retries of a client and the reconnects of a stream wait for it. +* Send requests to `api.x.com` rather than `api.twitter.com` by default +* Rename `X::ConnectionException`, the error for 409 Conflict, to `X::Conflict`, which X sends a filtered stream with no rules +* Rename `X::HTTPError#response` to `#http_response`, the name `X::Response` reads it by + * The `response` of `X::RateLimit` is now private. +* Rename `X::OAuthAuthenticator` to `X::OAuth1Authenticator` +* Move the HTTP client into `x-core`, under `lib/x/core`, and the uploaders into `x-uploader`, under `lib/x/uploader` +* Wrap every network failure in `X::NetworkError`, so a stream of `x-streaming` reconnects after it + * That is `IOError`, `SystemCallError`, Net::HTTP's open, read, and write timeouts, `Net::ProtocolError`, and `Zlib::Error`; a `Timeout::Error` that `Timeout.timeout` raises around a request is raised as it is, but one raised with its class, as by `Timeout.timeout(5, Timeout::Error)`, that lands in `save_tokens` is an `X::TokenReportFailed`, holding the tokens. + * It is also `Net::HTTPBadResponse`, `OpenSSL::SSL::SSLError`, and the `SocketError` of a host that cannot be resolved. +* Stop following redirects after exactly `max_redirects` hops, rather than one more + * `max_redirects: 0` follows none, and raises `X::TooManyRedirects` for each redirect that could be followed. +* Sign OAuth 1.0a requests with the [simple_oauth](https://github.com/laserlemon/simple_oauth) gem, which sends the same header +* Build and parse the OAuth 2.0 token refresh with simple_oauth, which sends the same request +* Rename `X::OAuth2Authenticator#refresh_token!` to `#refresh!`, which returns the frozen `X::OAuth2Tokens` of the refresh + * It returned the Hash of the token response. + * It raises `X::UnsupportedOperation` for an authenticator that holds no refresh token. +* Raise `ArgumentError` from `X::Client.new` and `X::Client#with` for credentials that do not form a complete set + * Such a client sent requests without credentials, or as the app when an access token lacked its secret. + * A credential of no complete set, such as a `client_id` beside a `bearer_token`, raises rather than be ignored. + * OAuth 2.0 credentials beside a complete set of OAuth 1.0a credentials raise. + * An empty String credential, as `ENV.fetch("X_BEARER_TOKEN", "")` reads, or one that is not a String, raises. + * `expires_at` raises beside anything but the OAuth 2.0 `client_id` and `access_token` it describes. +* Raise `ArgumentError` from an authenticator's constructor for a missing, empty, or non-String credential + * An `expires_at` that is not a `Time`, such as a String read back from JSON, raises it too. +* Raise `X::InvalidResponse` for a successful response whose body is not JSON, such as a captive portal's page + * It returned nil; a successful response without a body still returns nil. + * `X::InvalidResponse` is an `X::HTTPError` whose `problem` is nil and whose `problems` are empty. +* Tag the body of a response UTF-8 rather than binary, so it no longer raises `Encoding::CompatibilityError` + * That is the `body` of `X::Response`, `X::HTTPError`, and `X::InvalidResponse`, and each line of a stream. + * A body that is not valid UTF-8 keeps its bytes, which `valid_encoding?` tells apart. +* Raise `X::AuthorizationError`, an `X::ClientError`, when X refuses to issue or refresh a token or to exchange a code + * It replaces a bare `X::Error`, and holds the OAuth 2.0 `error_code` and the `status`, `headers`, and `body`. + * It is not an `X::Unauthorized`, so code that asks the user to authorize the app again rescues both. + * A 429, server error, redirect, or non-JSON answer from a token endpoint raises the `X::HTTPError` of that response. + * A declined authorization raises `X::AuthorizationDenied`, an `X::Error` with the `error_code` of the redirect. +* Return nil from `X::TooManyRequests#reset_at`, `#reset_in`, and `#retry_after` for a response that names no reset time + * They returned `Time.at(0)` and 0, which told a caller to retry at once. +* Make the connection of a client internal, as `X::Core::Connection`, in place of `X::Connection` + * The authenticators take no `connection:`, since the client that takes one sends its token requests over the client's own. + * `X::OAuth2Authorization` takes `base_url`, `proxy_url`, the timeouts, `debug_output`, and `headers` in place of `connection:`. +* Resolve an endpoint with a leading slash against the base URL, so `client.get("/users/me")` requests `/2/users/me` + * Pass a whole URL to reach another path of the host. +* Raise `ArgumentError` from `get`, `post`, `put`, `delete`, and `get_stream` for an invalid endpoint, before any request + * An endpoint that is not a String, such as a Symbol or a URI, raises it naming its class, rather than `NoMethodError`. + * An invalid URL, or one that is not http or https with a host, raises it naming the endpoint and what is wrong. + * It raised `URI::InvalidURIError`, or an `ArgumentError` of `Net::HTTP` that named no endpoint. +* Send each request once, turning off the retry `Net::HTTP` makes of a GET, PUT, or DELETE after a timeout or drop + * That retry sent the OAuth 1.0a nonce and signature again, and could repeat a read the API bills. + * Such a failure raises `X::NetworkError`, which `max_retries` sends again only for a request that never reached the API. +* Make the token endpoint of `X::OAuth2Authenticator` private, in place of the public `TOKEN_HOST` and `TOKEN_PATH` +* Report every rate limit a response names from `X::TooManyRequests#rate_limits`, not only the exhausted ones + * `#rate_limit` reads the 15-minute limit, as on `X::Response`, rather than the exhausted one that resets last. + * That exhausted limit, which `reset_at`, `reset_in`, and `retry_after` still wait for, is `#limiting_rate_limit`. +* Make `X::HTTPError::JSON_CONTENT_TYPE_REGEXP` and `X::OAuth2Authenticator::EXPIRATION_BUFFER` private +* Make `X::RateLimit.new` and `X::RateLimit.reported?` private +* Build the errors of `x-core` and `X::Response` with public constructors, so code that rescues one can be tested + * `X::HTTPError.new(status:, headers:, body:, http_method: nil, uri: nil)`, or with `http_response:`, and a message first. + * A status error such as `X::NotFound` needs neither, so `raise X::TooManyRequests, "slow down"` works. + * `X::NetworkError`, `X::TooManyRedirects`, and `X::AuthorizationDenied` take a message, or none. + * `X::Response.new(http_method:, uri:, status:, headers:, body:)` takes `http_response:` in their place too. + * A response beside a status, a status outside 100 to 599, or headers that are not a Hash raise `ArgumentError`. +* Name the internals of `x-core` under `X::Core`, as private constants marked `@api private`, so they can change in 1.x + * Among them are `RequestBuilder`, `RedirectHandler`, `Connection`, `ConnectionPool`, and the client's mixins. + * The client, authenticators, `X::OAuth2Authorization`, `X::Response`, `X::RateLimit`, and errors keep their names. + * The signatures `x-core` ships declare its public interface alone. +* Name the gem in the `User-Agent` of every request, token requests included, as `x-ruby/1.0.0 ruby/3.4.0 (arm64-darwin24)`, rather than `X-Client` +* Send an idempotent request again on a new connection when the kept-open connection it took had gone stale + * That is an `EOFError`, `ECONNRESET`, `ECONNABORTED`, or `EPIPE` before the status and headers of its response are read. + * A response cut off after that is never sent again, as the API answered it. +* Raise `X::NetworkError` for a body cut off before its end, or shorter than its `Content-Length`, rather than read what arrived + * Any other failure, such as a timeout, is left to the retries of the client. +* Document every error class of `x-core` alike, and draw the whole hierarchy on `X::Error` +* Request the token endpoints at the origin of the client's base URL, rather than at `api.x.com` whatever the base URL + * They keep the path the base URL serves the API at, minus a trailing API version, which was dropped. + * Under `https://gateway.example/x/2/`, app-only tokens come from `/x/oauth2/token`, refreshes from `/x/2/oauth2/token`. + * `X::OAuth2Authorization` exchanges its code at the origin of the base URL of the client it builds. +* Raise `ArgumentError` from `post` and `put` for a keyword they do not take, such as `client.post("tweets", text: "Hello")` + * The message says to pass the body as a Hash: `post("tweets", {text: "Hello"})`. +* Keep the state of `X::Client` in an internal object, so no method another gem mixes in replaces one of its helpers + +### Removed +* Remove `X::HTTPError#code`; read the Integer `#status`, or the String `error.http_response.code` +* Remove `X::HTTPError#error_message` and `#message_from_json_response`, and make `#json?` private +* Remove `X::OAuthAuthenticator::OAUTH_SIGNATURE_ALGORITHM`, `OAUTH_VERSION`, and `OAUTH_SIGNATURE_METHOD` +* Remove `X::OAuth2Authenticator::REFRESH_GRANT_TYPE` +* Remove the `base64` dependency of `x-core` +* Remove `X::RateLimit#retry_after`, an alias for `#reset_in`; read `#reset_in` +* Remove the setters of `X::RateLimit`, `X::BearerTokenAuthenticator`, `X::OAuth1Authenticator`, and `X::OAuth2Authenticator` + * Derive a client that holds another credential with `X::Client#with`. +* Remove the setters of `X::Client`; derive a client that differs with `X::Client#with` + * Gone: `api_key=`, `api_key_secret=`, `access_token=`, `access_token_secret=`, `bearer_token=`, and `client_id=`. + * Gone: `client_secret=`, `refresh_token=`, `base_url=`, `default_array_class=`, `default_object_class=`, and `max_redirects=`. + * Gone: `open_timeout=`, `read_timeout=`, `write_timeout=`, `proxy_url=`, and `debug_output=`. +* Remove the setters of `X::Connection`: `open_timeout=`, `read_timeout=`, `write_timeout=`, `proxy_url=`, and `debug_output=` +* Remove the proxy readers of `X::Connection`: `proxy_url`, `proxy_uri`, `proxy_host`, `proxy_port`, `proxy_user`, `proxy_pass` +* Remove `X::Connection::DEFAULT_HOST` and `DEFAULT_PORT`, since every request names its host +* Remove `X::OAuth1Authenticator#access_token`, a credential; `user_id` reads the user the token acts for + +### Fixed +* Keep the method and body of a `PUT` or `DELETE` that a 301 or 302 redirects, as RFC 9110 has it + * A `delete` that a 301 answered read the resource at the new location, and returned it as though deleted. + * A `POST` that a 301 or 302 redirects, and any request a 303 redirects, is still followed with a `GET`. +* Declare `json`, `net-http`, and `uri` in `sig/manifest.yaml`, so `rbs collection` loads them for dependents +* Resolve a relative redirect against the URL of the request, not the base URL, so one from `upload.x.com` stays there +* Build the message of an `X::HTTPError` from a body that is not the JSON its content type claims + * It raised `JSON::ParserError`, `KeyError`, or `TypeError` in place of the error, so a stream did not reconnect. + * An error without a `message` gives its `detail` or `title`, once: `Unauthorized`, not `Unauthorized: Unauthorized`. +* Refresh an OAuth 2.0 token over the client's own connection, so its proxy, timeouts, and debug output apply +* Drop the credentials and any `Authorization`, `Cookie`, or `Proxy-Authorization` header on a redirect to another origin + * A header named by a String, or by a Symbol such as `proxy_authorization:`, in any case, is dropped. + * A 307 or 308, and a 301 or 302 of anything but a `POST`, still sends the request body to the new host. +* Send no `Authorization` header from a client without credentials, rather than an empty one +* Send a `Content-Type` header only with a request that carries a body + * A body is sent as JSON unless the request's headers, or its `form:`, name another type. + * A redirect followed with a `GET` sends no `Content-Type`. +* Raise `X::ClientError` or `X::ServerError`, rather than `X::HTTPError`, for a 4xx or 5xx no error class names + * So a stream reconnects, and a chunk upload retries, after any server error, such as a 501. +* Link each gem's `changelog_uri` to the `main` branch rather than `master` +* Send the credentials of the authenticator on every redirected request, not only the first +* Sign a form-encoded request body with OAuth 1.0a, which the signature left out +* Sign every value of a query parameter that repeats, rather than only one of them +* Sign the normalized URL, so a request to a host with no path signs the `/` the server sees +* Form-encode the client credentials before Basic authentication on a token refresh, as RFC 6749 Section 2.3.1 requires +* Leave the user and password of a proxy out of the message of an invalid proxy URL, and out of `inspect` + * A proxy URL that cannot be parsed raises `ArgumentError` rather than `URI::InvalidURIError`. +* Decode a percent-encoded proxy user and password, which were sent to the proxy still encoded +* Connect to an `https://` proxy over TLS, rather than send it the target host and credentials in plaintext +* Raise `X::HTTPError` for a redirect that cannot be followed, rather than `KeyError` or `URI::InvalidURIError` + * That is a 300, 304, or 305, which was followed, or one whose `Location` is missing, invalid, or not HTTP or HTTPS. + * Such a redirect raised `ArgumentError` too. +* End a `base_url` without a trailing slash with one, so `https://api.x.com/2` requests `/2/users/me`, not `/users/me` +* Raise an error of `on_response`, a request's block, or an `object_class` as it was raised, not as one of the request + * Such an error is not sent again, waited out, or refreshed for, even an `X::ServerError` or an `X::Unauthorized`. + * A stream stops on it rather than reconnect; a dropped socket or a line that is not JSON still reconnects. +* Send a query parameter without a value, as in `get("users?flag")`, as `flag` rather than `flag=` + * An empty parameter, as between the `&&` of `"a=1&&b"`, is kept, where it was sent as `=`. +* Connect to a host, or a proxy, named by an IPv6 literal, such as a `base_url` of `http://[::1]:8080/` + * `x-core` depends on net-http 0.9.1 or a later 0.x, since the net-http 0.6 of Ruby 3.4 cannot, and on Ruby 4.0 the net-http before 0.9.1 raises `IO::TimeoutError` for a connection that times out opening, which is then not retried. +* Take the proxy from `https_proxy` for HTTPS and `http_proxy` for HTTP, and honor `no_proxy`, without a `proxy_url` + * `Net::HTTP` read `http_proxy` alone, whatever the scheme. +* Declare `X::BadGateway` and `X::GatewayTimeout` as `X::ServerError`s in the signatures, as the classes always were + +[1.0.0]: https://github.com/sferik/x-ruby/releases/tag/v1.0.0 diff --git a/x-core/Gemfile b/x-core/Gemfile new file mode 100644 index 00000000..cb5cfaaa --- /dev/null +++ b/x-core/Gemfile @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify the gem's dependencies in x-core.gemspec +gemspec + +gem "base64", ">= 0.2" +gem "minitest", ">= 6" +gem "minitest-mock", ">= 5.27" +gem "ostruct", ">= 0.6" +gem "rake", ">= 13.0.6" +gem "simplecov", ">= 1" +gem "webmock", ">= 3.18.1" +gem "yard", ">= 0.9" +gem "yardstick", ">= 0.9" + +# Mutant and Steep run on CRuby alone, so they are left out of the bundle of any other engine, whose job +# runs the tests alone; the mutant, steep, and docs jobs of CI all run on CRuby +platforms :mri do + gem "mutant-minitest", ">= 0.16" + gem "steep", ">= 2.0" +end diff --git a/x-core/LICENSE.txt b/x-core/LICENSE.txt new file mode 100644 index 00000000..0fdc908c --- /dev/null +++ b/x-core/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 Erik Berlin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/x-core/README.md b/x-core/README.md new file mode 100644 index 00000000..0b46781a --- /dev/null +++ b/x-core/README.md @@ -0,0 +1,51 @@ +# x-core + +The HTTP layer of the [`x` gem](https://github.com/sferik/x-ruby): a small, dependency-light client for the [X API](https://developer.x.com) that returns parsed JSON. + +It handles OAuth 1.0a, OAuth 2.0 with PKCE authorization and token refresh, and bearer tokens. It also handles redirects, proxies, timeouts, HTTP errors, and rate limits, and opens a GET whose body is read as it arrives, as a stream is. Its only dependency that is not a default gem is `simple_oauth`, which has no dependencies of its own; it also asks for net-http 0.9.1 or a later 0.x release, the default gem it sends its requests with, since the 0.6 that Ruby 3.4 ships cannot request a host named by an IPv6 literal, and on Ruby 4.0 an earlier net-http does not retry a connection that times out opening. Media uploads live in [`x-uploader`](https://github.com/sferik/x-ruby/tree/main/x-uploader), and the streams of the API, read a post at a time and reconnected as X recommends, in [`x-streaming`](https://github.com/sferik/x-ruby/tree/main/x-streaming). + +Most applications should install [`x`](https://rubygems.org/gems/x), which adds resource objects from [`x-objects`](https://github.com/sferik/x-ruby/tree/main/x-objects). Install `x-core` alone when you only want raw JSON. + +## Installation + +`x-core` requires Ruby 3.4 or later. + + bundle add x-core + +## Usage + +```ruby +require "x/core" + +client = X::Client.new(bearer_token: "INSERT YOUR BEARER TOKEN HERE") + +client.get("users/by/username/sferik") +# {"data"=>{"id"=>"7505382", "name"=>"Erik Berlin", "username"=>"sferik"}} + +# A block reads the response of that one request: its status, headers, rate limits, and the resources it +# returned. The on_response of a client receives the same summary, for every request the client makes. +client.get("users/by/username/sferik") { |response| puts response.rate_limit&.remaining } + +# A GET whose body is read as it arrives, as a stream is; x-streaming reads the streams of the API a post at a +# time with it, and reconnects them. Its block is passed an X::StreamResponse, which reads the status, headers, +# and rate limits of the response, and its body a chunk at a time +client.get_stream("tweets/sample/stream") { |response| response.read_body { |chunk| print chunk } } +``` + +`x-core` sends its requests with `Net::HTTP`, and keeps it out of what it promises: a block, a hook, an authenticator, and an error read the request and the response through objects of `x-core`'s own, `X::Response`, `X::StreamResponse`, `X::AuthenticatorRequest`, and `X::HTTPError`, which every release of 1.x keeps. The `http_response` each of `X::Response`, `X::StreamResponse`, and `X::HTTPError` reads, and the `http_response:` an `X::Response` or an `X::HTTPError` is built with, are an escape hatch for what they do not read themselves: the object is the transport's, a `Net::HTTPResponse` today, and its class is not covered by the compatibility promise of 1.x. + +See the [`x` README](https://github.com/sferik/x-ruby#readme) for more examples. + +## Development + +This gem has its own `Gemfile`, `Steepfile`, signatures, test suite, and mutation config, and does not load the other gems in this repository: + + bundle install + bundle exec rake test + bundle exec rake mutant + bundle exec rake steep + bundle exec rake yardstick + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/x-core/Rakefile b/x-core/Rakefile new file mode 100644 index 00000000..3251b6ce --- /dev/null +++ b/x-core/Rakefile @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.pattern = "test/**/*_test.rb" +end + +desc "Run mutation tests" +task :mutant do + sh "bundle exec mutant run" +end + +# Steep is in the bundle of CRuby alone, where the steep job of CI runs, so the task is defined only where it can run +begin + require "steep/rake_task" +rescue LoadError + task(:steep) { abort "steep is not in the bundle of #{RUBY_ENGINE}; type check with CRuby" } +else + Steep::RakeTask.new(:steep) +end + +require "yardstick/rake/measurement" +require "yardstick/rake/verify" + +Yardstick::Rake::Measurement.new(:yardstick_measure) do |measurement| + measurement.output = "doc/coverage.txt" +end + +Yardstick::Rake::Verify.new(:yardstick) do |verify| + verify.threshold = 100 +end + +task default: %i[test mutant steep yardstick] diff --git a/x-core/Steepfile b/x-core/Steepfile new file mode 100644 index 00000000..03042c38 --- /dev/null +++ b/x-core/Steepfile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +target :lib do + signature "sig" + check "lib" + library "forwardable" + library "json" + library "monitor" + library "net-http" + library "openssl" + library "securerandom" + library "simple_oauth" + library "time" + library "uri" + library "zlib" + configure_code_diagnostics(Steep::Diagnostic::Ruby.strict) +end diff --git a/x-core/lib/x/core.rb b/x-core/lib/x/core.rb new file mode 100644 index 00000000..0f5ab5b9 --- /dev/null +++ b/x-core/lib/x/core.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative "core/version" +require_relative "core/client" +require_relative "core/problem" +require_relative "core/oauth2_authorization" diff --git a/x-core/lib/x/core/app_only_authenticator.rb b/x-core/lib/x/core/app_only_authenticator.rb new file mode 100644 index 00000000..507715e5 --- /dev/null +++ b/x-core/lib/x/core/app_only_authenticator.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require "simple_oauth" +require_relative "authenticator" +require_relative "connection" +require_relative "credential_validator" +require_relative "setting_validator" +require_relative "errors/authorization_error" +require_relative "errors/unauthorized" +require_relative "origin" +require_relative "token_endpoint" + +module X + module Core + # Authenticates as an app with a bearer token, fetched with the API key and secret when first needed + # + # A bearer token the API rejects with 401 Unauthorized, as it does one that was invalidated, is dropped, and the + # request is sent again with one fetched in its place. + # + # @api public + class ::X::AppOnlyAuthenticator < Authenticator + # The endpoint that exchanges an API key and secret for a bearer token, at X, whose path is requested at the + # origin of the base URL of the client that takes the authenticator + TOKEN_URL = "https://api.x.com/oauth2/token" + # The message raised when the token endpoint describes no reason for the failure + DEFAULT_ERROR_MESSAGE = "Bearer token request failed" + private_constant :TOKEN_URL, :DEFAULT_ERROR_MESSAGE + + # The API key + # @api public + # @return [String] the API key + # @example Get the API key + # authenticator.api_key + attr_reader :api_key + + # Initialize a new app-only authenticator + # + # @api public + # @param api_key [String] the API key + # @param api_key_secret [String] the API key secret + # @param bearer_token [String, nil] a bearer token already fetched with these credentials + # @return [AppOnlyAuthenticator] a new authenticator + # @raise [ArgumentError] if the API key or secret is nil or empty, or the bearer token is empty + # @example Create an app-only authenticator + # X::AppOnlyAuthenticator.new(api_key: "key", api_key_secret: "secret") + def initialize(api_key:, api_key_secret:, bearer_token: nil) + CredentialValidator.validate_required!({api_key:, api_key_secret:}, {bearer_token:}) + @api_key, @api_key_secret = SettingValidator.frozen(api_key), SettingValidator.frozen(api_key_secret) + @bearer_token = SettingValidator.frozen(bearer_token) + @connection, @token_url, @token_headers = Connection.new, TOKEN_URL, {} + @mutex = Mutex.new + end + + # Generate the authentication headers, fetching the bearer token first if needed + # + # @api public + # @param _request [#http_method, #uri, #body, #[], nil] the request, which app-only authentication does not sign + # @return [Hash{String => String}] the authorization header + # @raise [AuthorizationError] if X refuses to issue the bearer token + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @example Generate the header + # authenticator.headers(request) # => {"Authorization" => "Bearer ..."} + def headers(_request) + {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"} + end + + private + + # The bearer token, fetched once with the API key and secret + # + # It is a secret, so it is private, as the bearer token of a BearerTokenAuthenticator is. Internal to x-core: + # a client that authenticates as the app fetches its token through it, and calls it with __send__. + # + # @api private + # @return [String] the bearer token + # @raise [AuthorizationError] if X refuses to issue the bearer token + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @example Get the bearer token + # bearer_token + def bearer_token + @mutex.synchronize { @bearer_token ||= fetch_bearer_token } + end + + # The connection the bearer token is fetched over + # @api private + # @return [Core::Connection] the connection + attr_reader :connection + + # Fetch the bearer token over the connection of the first client that takes it + # + # The token endpoint is requested at the scheme, host, and port of the base URL of the client, rather than of + # TOKEN_URL, so that a client pointed at another host sends the API key and secret there, as it sends its + # requests. A client that takes it after the first leaves it fetching over the connection of the first; see + # {OAuth2Authenticator}. Internal to x-core: a client fetches the token of the authenticator it builds, or is + # given, over its own connection, with its proxy, timeouts, debug output, and headers, and calls it with + # __send__, since it is private. + # + # @api private + # @param connection [Core::Connection] the connection to fetch the token over + # @param base_url [String] the base URL of the client, at whose origin the token endpoint is requested + # @param headers [Hash{String => String}] the headers of the client, which the token request is sent with + # @return [AppOnlyAuthenticator] the authenticator + def token_requests_over(connection, base_url, headers) + @mutex.synchronize do + unless @taken + @connection = connection + @token_url = TokenEndpoint.url_at(base_url, TOKEN_URL) + @token_headers = headers + end + @taken = true + end + self + end + + # The API key secret, which buys the bearer token + # @api private + # @return [String] the API key secret + # @example Buy a bearer token with the API key secret + # api_key_secret + attr_reader :api_key_secret + + # Check whether a copy of a client, given these options, holds these credentials + # + # Internal to x-core: Client shares the authenticator with a copy that holds its API key and secret and was given + # no bearer token, and calls it with __send__, since it is private. + # + # @api private + # @param options [Hash] the options the copy was given in place of the client's + # @return [Boolean] true if the options name no bearer token, and no API key or secret but the ones held + def holds?(options) + !options.key?(:bearer_token) && options.slice(:api_key, :api_key_secret) <= {api_key:, api_key_secret:} + end + + # Check whether the bearer token is fetched at the origin of a base URL + # + # A token is fetched at the origin of the client that took the authenticator first, and sent there alone, so a + # copy of the client pointed at another origin fetches one of its own, rather than send it the client's. + # Internal to x-core: Client shares the authenticator with a copy at the same origin alone, and calls it with + # __send__, since it is private. + # + # @api private + # @param base_url [String] the base URL of the copy + # @return [Boolean] true if the token endpoint is at the origin of the base URL + def fetched_for?(base_url) = Origin.same?(URI(@token_url), URI(base_url)) + + # Run a request, again with a bearer token fetched in place of one the API rejects + # + # Only a rejection by the origin the token is sent to drops it; see {Core::Origin}. A token fetched for the + # request itself is not fetched again, since the endpoint that just issued it would issue it again. Internal to + # x-core: Client runs each request through it, and calls it with __send__, since it is private. + # + # @api private + # @param origin [URI::Generic] the base URL of the client, the origin the token is sent to + # @yield runs the request + # @return [Object] what the block returns + # @raise [Unauthorized] if the API rejects the token fetched in place of the one it rejected + def retrying_rejected_token(origin) + token = @bearer_token + begin + yield + rescue Unauthorized => e + raise unless token && Origin.answered?(e, origin) + + drop_bearer_token(token) + yield + end + end + + # Drop a bearer token the API rejected, unless another request already replaced it + # @api private + # @param rejected [String] the bearer token the API rejected + # @return [void] + def drop_bearer_token(rejected) + @mutex.synchronize { @bearer_token = nil if @bearer_token.eql?(rejected) } + end + + # Exchange the API key and secret for a bearer token + # @api private + # @return [String] the bearer token + # @raise [AuthorizationError] if the token endpoint rejects the request + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + def fetch_bearer_token + TokenEndpoint.fetch(token_request, connection:, refusal: DEFAULT_ERROR_MESSAGE, headers: @token_headers).access_token + end + + # Build the client credentials request + # @api private + # @return [SimpleOAuth::OAuth2::Request] the token request + def token_request + SimpleOAuth::OAuth2::Client.new(client_id: api_key, client_secret: api_key_secret, token_endpoint: @token_url) + .client_credentials_request + end + end + end +end diff --git a/x-core/lib/x/core/authenticator.rb b/x-core/lib/x/core/authenticator.rb new file mode 100644 index 00000000..beae4890 --- /dev/null +++ b/x-core/lib/x/core/authenticator.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative "credential_holder" + +# A Ruby client for the X API +module X + module Core + # Base class for authentication + # + # Subclass it to authenticate with a scheme of your own, overriding {#headers}, as the authenticators of x-core do. + # + # @api public + class ::X::Authenticator + include CredentialHolder + + # The HTTP header name for authentication + AUTHENTICATION_HEADER = "Authorization" + + # Generate the authentication headers for a request, which authenticates as no one + # + # A client calls it for every request it sends to the origin of its base URL, and every stream it opens there, + # once the request is built, just before it is sent, and sends each header it returns, in place of any header of + # the same name. Its method, URI, headers, and body are set, so an authenticator of your own can sign any of them: + # subclass X::Authenticator and override this method, and give an instance to the authenticator: of + # X::Client.new or X::Client#with. A request to another origin, such as one a redirect leads to, is not passed to + # it, so its headers never leave the origin they were built for. It is called on the thread that sends the + # request, so one a client shares across threads must be thread-safe. + # + # The request answers four methods alone, which are all the authenticators of x-core read of it: http_method, the + # HTTP method as a Symbol, such as :post, as a response and an error name it; uri, the URI::Generic it is sent + # to, query included; body, the String it sends, or nil for none; and [], the value of a header by its name, in + # any case, or nil for one it does not send. It answers them alone whatever request the client sends, so an + # authenticator of your own reads nothing a later version of 1.x could take away. + # + # A client refreshes the token of none but its own OAuth 2.0 authenticator, so an authenticator of your own that + # holds a token that expires refreshes it here. A client given one takes it to authenticate as the app, as it + # does an X::Authenticator itself, so app_only returns the client, and a stream is opened with it. + # + # @api public + # @param _request [#http_method, #uri, #body, #[]] the request, which answers http_method, uri, body, and [] alone + # @return [Hash{String => String}] the headers that authenticate the request, empty for none + # @example Authenticate every request with a token an application keeps + # class VaultAuthenticator < X::Authenticator + # def headers(_request) = {AUTHENTICATION_HEADER => "Bearer #{Vault.read("x/bearer_token")}"} + # end + # client = X::Client.new(authenticator: VaultAuthenticator.new) + def headers(_request) + {} + end + + # The identifier of the user the credentials act for, when they name one + # + # Only an OAuth 1.0a access token names its user, so every other set of credentials answers nil, and the caller + # that wants the user of such a client asks the API for it. + # + # @api public + # @return [Integer, nil] the identifier, or nil for credentials that name no user + # @example Read the user a client acts for without a request + # client.authenticator.user_id # => nil + def user_id + end + + # Summarize the authenticator for the console without revealing credentials + # + # @api public + # @return [String] the class name + # @example Inspect an authenticator + # authenticator.inspect # => # + def inspect + "#<#{self.class}>" + end + end + end +end diff --git a/x-core/lib/x/core/authenticator_request.rb b/x-core/lib/x/core/authenticator_request.rb new file mode 100644 index 00000000..7aa99e19 --- /dev/null +++ b/x-core/lib/x/core/authenticator_request.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module X + module Core + # The request an authenticator is passed: what it may read of the request a client is about to send + # + # It answers the four methods an authenticator is guaranteed, and no more, so that an authenticator of your own + # reads nothing of the Net::HTTPRequest a client sends that a later version of 1.x, which may send another, could + # take away. + # + # Internal to x-core: RequestBuilder passes one to the authenticator of each request it builds. + # + # @api private + class AuthenticatorRequest + # Initialize the request an authenticator reads + # @api private + # @param request [Net::HTTPRequest] the request a client is about to send + # @return [AuthenticatorRequest] a new instance + # @example Pass a request to an authenticator + # authenticator.headers(X::Core::AuthenticatorRequest.new(request)) + def initialize(request) + @request = request + end + + # The HTTP method the request is sent with + # @api private + # @return [Symbol] the method, as :get, :post, :put, or :delete + # @example Read the method + # request.http_method # => :post + def http_method = @request.method.downcase.to_sym + + # The URI the request is sent to, query included + # @api private + # @return [URI::Generic] the URI + # @example Read the URI + # request.uri # => # + def uri = @request.uri + + # The body the request sends + # @api private + # @return [String, nil] the body, or nil for none + # @example Read the body + # request.body # => "{\"text\":\"Hello\"}" + def body = @request.body + + # The value of a header the request sends + # @api private + # @param name [String] the name of the header, in any case + # @return [String, nil] the value, or nil for a header the request does not send + # @example Read the content type + # request["Content-Type"] # => "application/json; charset=utf-8" + def [](name) = @request[name] + end + private_constant :AuthenticatorRequest + end +end diff --git a/x-core/lib/x/core/bearer_token_authenticator.rb b/x-core/lib/x/core/bearer_token_authenticator.rb new file mode 100644 index 00000000..60bfc920 --- /dev/null +++ b/x-core/lib/x/core/bearer_token_authenticator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "authenticator" +require_relative "credential_validator" +require_relative "setting_validator" + +module X + module Core + # Authenticator for Bearer token authentication + # @api public + class ::X::BearerTokenAuthenticator < Authenticator + # Initialize a new BearerTokenAuthenticator + # + # @api public + # @param bearer_token [String] the bearer token for authentication + # @return [BearerTokenAuthenticator] a new instance + # @raise [ArgumentError] if the bearer token is nil or empty + # @example Create a new bearer token authenticator + # authenticator = X::BearerTokenAuthenticator.new(bearer_token: "token") + def initialize(bearer_token:) + CredentialValidator.validate_required!({bearer_token:}) + @bearer_token = SettingValidator.frozen(bearer_token) + end + + # Generate the authentication headers for a request + # + # @api public + # @param _request [#http_method, #uri, #body, #[], nil] the request, which a bearer token does not sign + # @return [Hash{String => String}] the authentication header with bearer token + # @example Generate a bearer authentication header + # authenticator.headers(request) + def headers(_request) + {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"} + end + + private + + # The bearer token, which authenticates a request + # @api private + # @return [String] the bearer token + # @example Authenticate with the bearer token + # bearer_token + attr_reader :bearer_token + end + end +end diff --git a/x-core/lib/x/core/built_response.rb b/x-core/lib/x/core/built_response.rb new file mode 100644 index 00000000..c2372b0c --- /dev/null +++ b/x-core/lib/x/core/built_response.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "net/http" +require_relative "setting_validator" + +module X + module Core + # The HTTP response of an error or a summary built by hand, from its status, headers, and body + # + # Internal to x-core: HTTPError, InvalidResponse, and Response are built from the Net::HTTP response a client + # received, or, so that code that rescues one, or an on_response hook, can be tested without building one, from + # the status, headers, and body of a response, which this builds the Net::HTTP response of. + # + # @api private + module BuiltResponse + extend self + + # The message of the error raised for a status that is not one HTTP defines + INVALID_STATUS = "status must be an Integer from 100 to 599, not %s" + private_constant :INVALID_STATUS + # The message of the error raised for a response given beside what one is built of + RESPONSE_AND_STATUS = "Pass the http_response:, or the status:, headers:, and body: one is built of, not both" + private_constant :RESPONSE_AND_STATUS + # The message of the error raised for neither a response nor a status + NO_RESPONSE = "Pass the status: of the response, with its headers: and body: if it has any, or the http_response: itself" + private_constant :NO_RESPONSE + # The statuses HTTP defines + STATUSES = 100..599 + private_constant :STATUSES + + # The HTTP response given, or the one built of a status, headers, and body + # + # @api private + # @param http_response [Net::HTTPResponse, nil] the HTTP response, or nil to build one + # @param status [Integer, nil] the status of the response to build, or nil for the one given + # @param headers [Hash{String => String}, nil] the headers of the response to build, or nil for none + # @param body [String, nil] the body of the response to build, or nil for none + # @return [Net::HTTPResponse] the HTTP response + # @raise [ArgumentError] if a response is given beside a status, headers, or a body, or neither a response nor a + # status is given, or the status is not one HTTP defines, or the headers are not a Hash of names to values + # @example Build a response the API refused a request with + # X::Core::BuiltResponse.of(nil, status: 404, headers: nil, body: "{}") # => # + def of(http_response, status:, headers:, body:) + return build(status, headers || {}, body) if http_response.nil? && !status.nil? + raise ArgumentError, NO_RESPONSE if http_response.nil? + raise ArgumentError, RESPONSE_AND_STATUS unless [status, headers, body].none? + + http_response + end + + private + + # Build the HTTP response of a status, headers, and body + # + # Its body is tagged UTF-8, as the body of a response the client reads is. + # + # @api private + # @param status [Integer] the status + # @param headers [Hash{String => String}] the headers + # @param body [String, nil] the body + # @return [Net::HTTPResponse] the HTTP response + # @raise [ArgumentError] if the status is not one HTTP defines, or the headers are not a Hash of names to values + def build(status, headers, body) + response_class = response_class_of(status) + response_class.new("1.1", status.to_s, reason_of(response_class)).tap do |response| + SettingValidator.headers!(headers).each { |name, value| response[name] = value } + response.body = body.dup&.force_encoding(Encoding::UTF_8) + response.instance_variable_set(:@read, true) + end + end + + # The class Net::HTTP reads a response of a status as + # + # It is the class of the status, such as Net::HTTPNotFound, or, for a status Net::HTTP names no class of, the + # class of its kind of status, such as Net::HTTPClientError for a 4xx. + # + # @api private + # @param status [Integer] the status + # @return [Class] the class + # @raise [ArgumentError] if the status is not one HTTP defines + def response_class_of(status) + raise ArgumentError, format(INVALID_STATUS, status.inspect) unless status.instance_of?(Integer) && STATUSES.cover?(status) + + code = status.to_s + Net::HTTPResponse::CODE_TO_OBJ.fetch(code) { Net::HTTPResponse::CODE_CLASS_TO_OBJ.fetch(code[0]) } + end + + # The reason phrase of the status of a Net::HTTP response class + # + # @api private + # @param response_class [Class] the class, such as Net::HTTPNotFound + # @return [String] the reason phrase, such as "Not Found" + def reason_of(response_class) + name = response_class.name #: String + name.delete_prefix("Net::HTTP").gsub(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/, " ") + end + end + private_constant :BuiltResponse + end +end diff --git a/x-core/lib/x/core/client.rb b/x-core/lib/x/core/client.rb new file mode 100644 index 00000000..0ff2f2e6 --- /dev/null +++ b/x-core/lib/x/core/client.rb @@ -0,0 +1,690 @@ +# frozen_string_literal: true + +require_relative "client_internals" +require_relative "connection" +require_relative "credential_holder" +require_relative "rate_limit_handler" +require_relative "redirect_handler" +require_relative "retry_handler" +require_relative "setting_validator" + +module X + module Core + # A client for interacting with the X API + # + # An endpoint is resolved against the base URL, and a request carries the client's credentials to the origin of + # that base URL alone: the scheme, host, and port it names. An endpoint that names a whole URL of another origin + # is sent there without them, as a redirect that leads to one is, so that the credentials of the API never reach + # a host they were not meant for; see {Core::Origin}. + # + # The object_class of a request, of a stream, or of a client is one of two things. A class that JSON.parse builds + # each JSON object of the body into, as it does Hash, the default, OpenStruct, or a Struct, whose new takes no + # arguments and whose instances take each member with []=. Or anything that responds to from_response, which + # builds the result from the whole body instead, as the resource classes of x-objects do: it is passed the body + # parsed into Hashes and Arrays, whatever the array_class, and the client that made the request as client:, and + # what it returns is what the request returns, or, for a stream, what its block is passed for each object. Later + # versions of 1.x may pass it keyword arguments of their own, so it accepts the ones it does not read with **, as + # in def self.from_response(body, client:, **). The signatures of x-core state it as the X::_ResponseBuilder + # interface. + # + # A client keeps its credentials, settings, and connection in an object of x-core it delegates to, and has no + # private methods but initialize, so that the methods x-objects and x-uploader include into it, which may be + # named as they like, take the place of none of its own. + # + # @api public + class ::X::Client + include CredentialHolder + + # Default base URL for the X API + DEFAULT_BASE_URL = "https://api.x.com/2/" + # Default class for parsing JSON arrays + DEFAULT_ARRAY_CLASS = Array + # Default class for parsing JSON objects + DEFAULT_OBJECT_CLASS = Hash + # Default timeout for opening connections in seconds + DEFAULT_OPEN_TIMEOUT = Connection::DEFAULT_OPEN_TIMEOUT + # Default timeout for reading responses in seconds + DEFAULT_READ_TIMEOUT = Connection::DEFAULT_READ_TIMEOUT + # Default timeout for writing requests in seconds + DEFAULT_WRITE_TIMEOUT = Connection::DEFAULT_WRITE_TIMEOUT + # Default time to keep a connection open for the next request to the same host, in seconds + DEFAULT_KEEP_ALIVE_TIMEOUT = Connection::DEFAULT_KEEP_ALIVE_TIMEOUT + # Default maximum number of redirects to follow + DEFAULT_MAX_REDIRECTS = RedirectHandler::DEFAULT_MAX_REDIRECTS + # Default maximum number of times to retry a request refused for a rate limit + DEFAULT_MAX_RATE_LIMIT_RETRIES = RateLimitHandler::DEFAULT_MAX_RETRIES + # Default maximum number of seconds to wait for a rate limit to reset + DEFAULT_MAX_RATE_LIMIT_WAIT = RateLimitHandler::DEFAULT_MAX_WAIT + # Default maximum number of times to send an idempotent request again after a failure + DEFAULT_MAX_RETRIES = RetryHandler::DEFAULT_MAX_RETRIES + + # The timeout for opening connections, in seconds + # @api public + # @return [Integer, Float, nil] the timeout, or nil for none + # @example Get the open timeout + # client.open_timeout # => 10 + def open_timeout = @internals.open_timeout + + # The timeout for reading responses, in seconds + # @api public + # @return [Integer, Float, nil] the timeout, or nil for none + # @example Get the read timeout + # client.read_timeout # => 60 + def read_timeout = @internals.read_timeout + + # The timeout for writing requests, in seconds + # @api public + # @return [Integer, Float, nil] the timeout, or nil for none + # @example Get the write timeout + # client.write_timeout # => 60 + def write_timeout = @internals.write_timeout + + # The time to keep an idle connection open for the next request, in seconds + # @api public + # @return [Integer, Float] the timeout + # @example Get the keep-alive timeout + # client.keep_alive_timeout # => 30 + def keep_alive_timeout = @internals.keep_alive_timeout + + # The IO debug output is written to + # @api public + # @return [IO, #<<, nil] the IO, or anything else that takes a String with <<, or nil for none + # @example Get the debug output + # client.debug_output + def debug_output = @internals.debug_output + + # The maximum number of redirects to follow + # @api public + # @return [Integer] the maximum number of redirects + # @example Get the maximum number of redirects + # client.max_redirects # => 10 + def max_redirects = @internals.max_redirects + + # The maximum number of times to retry a request refused for a rate limit + # @api public + # @return [Integer] the maximum number of retries + # @example Get the maximum number of rate limit retries + # client.max_rate_limit_retries # => 0 + def max_rate_limit_retries = @internals.max_rate_limit_retries + + # The maximum number of seconds to wait for a rate limit to reset + # @api public + # @return [Integer, Float] the maximum wait, in seconds + # @example Get the maximum rate limit wait + # client.max_rate_limit_wait # => 900 + def max_rate_limit_wait = @internals.max_rate_limit_wait + + # The maximum number of times to send an idempotent request again after a failure + # @api public + # @return [Integer] the maximum number of retries + # @example Get the maximum number of retries + # client.max_retries # => 2 + def max_retries = @internals.max_retries + + # The authenticator for API requests + # + # It is the one the client was given, or else the one it built of its credentials. A client sends the token + # requests of an authenticator that makes them, an AppOnlyAuthenticator or an OAuth2Authenticator, over its own + # connection, with its proxy, timeouts, and debug output, whether it built the authenticator or was given it; an + # authenticator given to several clients sends them over the connection of the first, but for an OAuth 2.0 + # refresh a request of another of them makes, which goes to the token endpoint at the origin of that client's + # base URL, over its connection, with its headers. The refreshes of an OAuth2Authenticator reach the save_tokens + # of each client that authenticates with it, a refresh reads the stored tokens with the load_tokens of the + # authenticator, or else with the load_tokens of a client that authenticates with it, and the expires_at of the + # client is the authenticator's. + # + # @api public + # @return [Authenticator] the authenticator instance + # @example Check if the OAuth 2.0 token has expired + # client.authenticator.token_expired? + def authenticator = @internals.authenticator + + # A callable passed the OAuth2Tokens of each refresh, and of an authorization + # @api public + # @return [#call, nil] the callable, or nil for none + # @example Read the hook a refresh reports to + # client.save_tokens + def save_tokens = @internals.save_tokens + + # The callable a refresh reads the stored OAuth2Tokens with + # + # It returns the tokens in the storage that processes sharing the tokens of a user read, or nil for none there. + # + # @api public + # @return [#call, nil] the callable, or nil for none + # @example Read the loader a refresh reads stored tokens with + # client.load_tokens + def load_tokens = @internals.load_tokens + + # The API key for OAuth 1.0a authentication + # + # It is a frozen copy of the one the client was given, or the one the OAuth1Authenticator or AppOnlyAuthenticator + # it was given in place of credentials holds. + # + # @api public + # @return [String, nil] the API key for OAuth 1.0a authentication + # @example Get the API key + # client.api_key + def api_key = @internals.api_key_in_use + + # The OAuth 2.0 client ID + # + # It is a frozen copy of the one the client was given, or the one the OAuth2Authenticator it was given in place of + # credentials holds. + # + # @api public + # @return [String, nil] the OAuth 2.0 client ID + # @example Get the client ID + # client.client_id + def client_id = @internals.client_id_in_use + + # The time the OAuth 2.0 access token expires, as last refreshed + # + # A refresh that reports no lifetime leaves it nil, rather than the time the client was given. + # + # @api public + # @return [Time, nil] the expiration time, or nil if it is not known + # @example Get the expiration time + # client.expires_at + def expires_at = @internals.expires_at + + # The scopes X granted the OAuth 2.0 access token, as last refreshed + # + # A refresh that names no scopes keeps those the client held, as OAuth 2.0 has it. They are the ones the client + # was given, as the client of OAuth2Authorization#client is given those of the exchange of the code, until a + # refresh names others. + # + # @api public + # @return [Array, nil] the scopes, frozen, or nil if they are not known + # @example Check that the user let the app post + # client.scopes&.include?("tweet.write") + def scopes = @internals.scopes + + # The base URL for API requests + # @api public + # @return [String] the base URL for API requests, which ends with a slash + # @example Get the base URL + # client.base_url # => "https://api.x.com/2/" + def base_url = @internals.base_url + + # The default class for parsing JSON arrays + # @api public + # @return [Class] the default class for parsing JSON arrays + # @example Get the default array class + # client.default_array_class # => Array + def default_array_class = @internals.default_array_class + + # The default class for parsing JSON objects + # + # It is a class that JSON.parse builds each JSON object into, or one that responds to from_response and builds + # the result from the whole body; see {Client}. + # + # @api public + # @return [Class, #from_response] the default class for parsing JSON objects + # @example Get the default object class + # client.default_object_class # => Hash + def default_object_class = @internals.default_object_class + + # A callable passed an X::Response after each request and streamed object + # + # It is the hook of every request a client makes. A block passed to a single request receives the same + # summary, after this, for code that reads the response of that one request rather than of all of them. + # + # @api public + # @return [#call, nil] the callable, or nil for none + # @example Read the hook a client reports to + # client.on_response + def on_response = @internals.on_response + + # The headers sent with every request the client makes + # + # They are defaults: a header of the same name passed to a request, or to a stream, is sent in place of the + # client's, and each of them is sent in place of a default of the gem, such as its User-Agent. A header that + # carries credentials, such as Authorization or Cookie, is dropped by a redirect to another origin, as one + # passed to a request is. + # + # Each is named by a String, a header the client was given by a Symbol among them, so that a header is read by + # the name it is sent with, whichever the client was given: a Symbol names the header its underscores name with + # hyphens, as :user_agent names User-Agent. + # + # @api public + # @return [Hash{String => String}] the headers, frozen, each named by a String + # @example Read the headers a client sends + # client.headers # => {"User-Agent" => "my-app/1.0"} + def headers = @internals.headers + + # Initialize a new X API client + # + # @api public + # @param api_key [String, nil] the API key for OAuth 1.0a authentication + # @param api_key_secret [String, nil] the API key secret for OAuth 1.0a authentication + # @param access_token [String, nil] the access token for OAuth authentication + # @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication + # @param bearer_token [String, nil] the bearer token for authentication + # @param client_id [String, nil] the OAuth 2.0 client ID + # @param client_secret [String, nil] the OAuth 2.0 client secret + # @param refresh_token [String, nil] the OAuth 2.0 refresh token, or nil beside a client ID and access token issued + # without offline.access, which authenticate as the user until the access token expires, and cannot refresh + # @param expires_at [Time, nil] the time the OAuth 2.0 access token expires, after which a request refreshes it, + # given only beside the client_id and access_token the client authenticates with + # @param scopes [Array, nil] the scopes X granted the OAuth 2.0 access token, as OAuth2Tokens#scopes + # holds them, given only beside the client_id and access_token the client authenticates with + # @param authenticator [Authenticator, nil] an authenticator to authenticate with in place of credentials, such as + # an OAuth2Authenticator built elsewhere, or nil to build one of the credentials; see {#authenticator} + # @param base_url [String] the base URL for API requests + # @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none + # @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none + # @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none + # @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same + # host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered + # @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<, + # such as a StringIO. It is written every request and response whole, in the clear: the Authorization header, + # the client secret a token request sends, and the tokens a token response holds. Send it to a file you + # control while debugging, never to a log that is shipped elsewhere, and leave it nil in production. + # @param proxy_url [String, URI::Generic, nil] the proxy URL for requests + # @param default_array_class [Class] the default class for parsing JSON arrays + # @param default_object_class [Class, #from_response] the default class for parsing JSON objects, or one that + # responds to from_response and builds the result from the whole body; see {Client} + # @param headers [Hash{String, Symbol => String}] headers sent with every request the client makes, as defaults: a + # header of the same name passed to a request is sent in place of one of these, and each of these is sent in + # place of a default of the gem, such as its User-Agent; a Symbol names the header its underscores name with + # hyphens, as :user_agent names User-Agent + # @param max_redirects [Integer] the maximum number of redirects to follow, beyond which a redirect raises + # TooManyRedirects; 0 follows none, and raises for each redirect that could be followed + # @param max_rate_limit_retries [Integer] the maximum number of times to retry a request refused for a rate limit, + # after waiting for the limit to reset + # @param max_rate_limit_wait [Integer, Float] the maximum number of seconds to wait for a rate limit to reset; a request + # whose limit resets later raises TooManyRequests at once, as does a stream that would wait longer to reconnect, + # and a few seconds are added at random to each wait of a request, so that the requests one reset releases are + # not sent again in one burst + # @param max_retries [Integer] the maximum number of times to send a request again after the API failed to answer + # it, with a 5xx status or a 408, or after its answer never arrived, which is twice by default and is 0 for a client + # that raises at once; a retry waits up to a second before the first and up to twice as long before each after, + # but never more than a minute, a random share of each wait taken off so that the requests one failure of the + # API ended are not sent again together, or for as long as the response asks when it carries a Retry-After + # header, whichever is longer, and a response that asks for longer than a minute raises at once; a 429 is not + # among these, and waits for its rate limit to reset as max_rate_limit_wait allows, however long past a minute; only a GET, PUT, or DELETE is sent again, since + # the API may have acted on a POST whose answer never arrived, and one whose answer never arrived is sent again + # only when it never reached the API, such as for a connection refused or one that timed out opening, since the + # API bills a read it answered, such as one that timed out reading its response, whether or not the answer came + # @param on_response [#call, nil] a callable passed an X::Response after every request, failed ones included, and + # every object a stream delivers; a block passed to a single request receives the same summary, after this + # @param save_tokens [#call, nil] a callable passed the OAuth2Tokens of each refresh, to store them; the + # refreshes are reported one at a time, in the order they were made, and one already replaced is not reported; + # the client of OAuth2Authorization#client passes it the tokens of the exchange of the code as well; a callable + # that raises, as one whose storage is briefly down may, raises TokenReportFailed from the request that + # refreshed, which holds the tokens, since the refresh token they replaced is spent, and the client, with the + # error of the callable as its cause + # @param load_tokens [#call, nil] a callable that takes no arguments and returns the OAuth2Tokens in the storage + # that save_tokens writes to, or nil for none there, for processes that share the tokens of a user: X + # accepts a refresh token once, so a refresh reads the storage first, under its lock, and takes the tokens there + # in place of its own when their refresh token is another, as it is once another process has refreshed; it + # sends the request with them when their access token has not expired, and refreshes with them when it has, + # and a refresh X refuses for a refresh token another process spent reads the storage again, and takes the + # tokens there in place of raising once that process has stored them, and raises AuthorizationError if it has + # yet to, when the next request takes them; the tokens it takes came from the storage, so save_tokens is not + # passed them; see {#authenticator} + # @return [Client] a new client instance + # @raise [ArgumentError] if credentials are given that do not form a complete set, which would send requests + # without them, or authenticate as the app rather than a user + # @raise [ArgumentError] if a credential is an empty String, as an environment variable that is not set is often + # read, which would send an Authorization header that authenticates nothing + # @raise [ArgumentError] if expires_at is neither a Time nor nil, or scopes neither an Array of Strings that each + # name a scope nor nil, or either is given to a client that does not authenticate with OAuth 2.0 credentials, + # which would leave it unused + # @raise [ArgumentError] if an authenticator is given that is not an Authenticator, or beside credentials, + # expires_at, or scopes, which it would leave unused + # @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor, for any but + # keep_alive_timeout, nil, or if a maximum is not a count or a number of seconds of at least 0 + # @raise [ArgumentError] if base_url is not an absolute http or https URL with no user, password, query, or + # fragment, or headers are not a Hash that names each header with a String or a Symbol and gives it a String + # @raise [ArgumentError] if on_response, save_tokens, or load_tokens is neither nil nor responds to call + # @raise [ArgumentError] if default_array_class is not a Class, or default_object_class is neither a Class nor + # responds to from_response, which a response would be parsed with once the API had answered the request + # @example Create a client with bearer token authentication + # client = X::Client.new(bearer_token: "your_bearer_token") + # @example Create a client with OAuth 2.0 authentication that stores the tokens of each refresh + # client = X::Client.new(client_id: "id", client_secret: "secret", access_token: "token", refresh_token: "refresh", + # expires_at: Time.now + 7200, save_tokens: ->(tokens) { store.save(tokens.refresh_token) }) + # @example Share the tokens of a user among processes, which store each refresh and read the store before one + # stored = store.load(user) + # client = X::Client.new(client_id: "id", **stored.to_h, + # save_tokens: ->(tokens) { store.save(user, tokens) }, + # load_tokens: -> { store.load(user) }) + # @example Create a client with OAuth 1.0a authentication + # client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret") + # @example Create a client that authenticates with an authenticator built elsewhere + # client = X::Client.new(authenticator: X::OAuth2Authenticator.new(client_id: "id", access_token: "token", + # refresh_token: "refresh", expires_at: Time.now + 7200), save_tokens: ->(tokens) { store.save(tokens) }) + # @example Create a client that fetches an app-only bearer token with the API key and secret + # client = X::Client.new(api_key: "key", api_key_secret: "secret") + # @example Create a client that retries a rate-limited request up to three times + # client = X::Client.new(bearer_token: "your_bearer_token", max_rate_limit_retries: 3) + # @example Create a client that raises at once rather than send a lookup again the API failed to answer + # client = X::Client.new(bearer_token: "your_bearer_token", max_retries: 0) + # @example Create a client that names the application in the User-Agent of every request + # client = X::Client.new(bearer_token: "your_bearer_token", headers: {"User-Agent" => "my-app/1.0"}) + def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil, + bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil, expires_at: nil, scopes: nil, + authenticator: nil, + base_url: DEFAULT_BASE_URL, + open_timeout: DEFAULT_OPEN_TIMEOUT, + read_timeout: DEFAULT_READ_TIMEOUT, + write_timeout: DEFAULT_WRITE_TIMEOUT, + keep_alive_timeout: DEFAULT_KEEP_ALIVE_TIMEOUT, + debug_output: nil, + proxy_url: nil, + default_array_class: DEFAULT_ARRAY_CLASS, + default_object_class: DEFAULT_OBJECT_CLASS, + headers: {}, + max_redirects: DEFAULT_MAX_REDIRECTS, + max_rate_limit_retries: DEFAULT_MAX_RATE_LIMIT_RETRIES, + max_rate_limit_wait: DEFAULT_MAX_RATE_LIMIT_WAIT, + max_retries: DEFAULT_MAX_RETRIES, + on_response: nil, + save_tokens: nil, + load_tokens: nil) + @internals = ClientInternals.new(self, api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, + client_id:, client_secret:, refresh_token:, expires_at:, scopes:, authenticator:, base_url:, open_timeout:, read_timeout:, + write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:, default_array_class:, default_object_class:, + headers:, max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:, on_response:, + save_tokens:, load_tokens:) + end + + # Summarize the client for the console without revealing credentials + # + # @api public + # @return [String] the class name, base URL, and authenticator + # @example Inspect a client + # client.inspect # => #> + def inspect + "#<#{self.class} base_url=#{base_url.inspect} authenticator=#{authenticator.inspect}>" + end + + # Copy the client with some of its options changed + # + # A copy that authenticates with OAuth 2.0 shares the client's authenticator, so that a refresh by either + # client reaches the other, since X accepts a refresh token once, unless it is given a client ID, client secret, + # access token, or refresh token that the authenticator does not hold. It shares it whatever the tokens are when + # it is built, so a refresh on another thread while it is built reaches it too. A refresh then passes the + # tokens it issued to the save_tokens of each client that shares it, once for each distinct callable. The + # expiration time and the scopes belong to the access token they share, so such a copy is refused either: give + # expires_at or scopes beside the access token and refresh token they describe. + # + # A copy that does not share the OAuth 2.0 authenticator, since it is given credentials the authenticator does + # not hold, an authenticator of its own, or, as a copy of a client given the authenticator, any credential, + # holds tokens that may be another user's, so it holds none of the refresh token, expiration time, scopes, + # save_tokens, or load_tokens of the client unless it is given them. So does a copy of a client that does not + # authenticate with OAuth 2.0 that is given a credential or an authenticator. + # + # A copy of a client that was given its authenticator shares it, unless the copy is given a credential, which + # replaces it, or an authenticator of its own, which also replaces the credentials of a client that holds them. + # + # A copy that opens its connections as the client does, with the same timeouts, keep-alive timeout, debug output, + # and proxy, shares the connections the client keeps open, so that a copy made for each request, such as to send + # a header of its own, opens none of its own; {#close} on either closes them for both, and a later request of + # either opens them again. + # + # @api public + # @param options [Hash] the options to change, as accepted by initialize + # @return [Client] a new client with the same credentials and settings, apart from the options given + # @raise [ArgumentError] if the copy shares the OAuth 2.0 authenticator of the client and is given expires_at or + # scopes + # @example Derive an API v1.1 client + # v1_client = client.with(base_url: "https://api.x.com/1.1/") + # @example Derive an app-only client from the API key and secret + # app_client = client.with(access_token: nil, access_token_secret: nil) + # @example Derive a client that authenticates with another authenticator + # user_client = app_client.with(authenticator: X::OAuth2Authenticator.new(**stored_tokens)) + def with(**options) = @internals.with(self, options) # steep:ignore DifferentMethodParameterKind + + # A client that authenticates as the app, for the endpoints that refuse OAuth 1.0a + # + # A client that authenticates as a user, signing with OAuth 1.0a or with OAuth 2.0, returns a copy that + # authenticates with the app's bearer token: the one it was given, or one it fetches with its API key and secret + # the first time. It returns the same copy, with the connections it keeps open, from then on, since the + # credentials and settings of a client never change; threads that ask for the copy together get one. A copy of + # the client made with {#with} that holds the same API key and secret, and the same base URL, builds a copy of + # its own, but sends the token the client fetched, or fetches, rather than fetch one of its own from the token + # endpoint, which X limits the rate of. A client with a bearer token or an API key and secret alone already authenticates as the app, and is returned as it is, + # as is one given an authenticator that authenticates as the app, or as no one. A client given an + # OAuth1Authenticator fetches the token with the API key and secret it signs with. A client that authenticates + # with OAuth 2.0 as a user and holds neither the app's bearer token nor its API key and secret, as a client + # given an OAuth2Authenticator holds neither, raises, rather than send the user's credentials to an endpoint + # that would refuse them with 403 Forbidden. + # + # @api public + # @return [Client] a copy that authenticates with the bearer token, or the client itself + # @raise [UnsupportedOperation] if the client authenticates with OAuth 2.0 as a user and holds no credentials of + # the app + # @example Add a filtered stream rule, which takes app-only authentication + # client.app_only.post("tweets/search/stream/rules", {add: [{value: "ruby"}]}) + def app_only = @internals.app_only(self) + + # Perform a GET request to the X API + # + # @api public + # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or + # without a query string + # @param params [Hash, nil] query parameters appended to the endpoint; nil values are dropped and arrays are joined with commas + # @param headers [Hash] additional headers for the request + # @param array_class [Class] the class for parsing JSON arrays + # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to + # from_response and builds the result from the whole body; see {Client} + # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds + # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or + # https URL, before the request is sent + # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to + # from_response, before the request is sent + # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent + # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it + # @example Get a user by username + # client.get("users/by/username/sferik") + # @example Get users by identifier, requesting only some fields + # client.get("users", params: {ids: [1, 2], "user.fields": %w[id username]}) + # @example Read what a response reported of the rate limit it spent + # user = client.get("users/me") { |response| limit = response.rate_limit } + def get(endpoint, params: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, &) + @internals.execute_request(self, :get, endpoint, params:, headers:, array_class:, object_class:, &) + end + + # Perform a POST request to the X API + # + # @api public + # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or + # without a query string + # @param body [String, Hash, Array, nil] the request body; a String is sent as given, and a Hash or an Array + # is encoded as JSON + # @param params [Hash, nil] query parameters appended to the endpoint + # @param form [Hash, nil] fields to send as a form-encoded body, in place of a body; as with params, nil values + # are dropped and arrays are joined with commas + # @param headers [Hash] additional headers for the request + # @param array_class [Class] the class for parsing JSON arrays + # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to + # from_response and builds the result from the whole body; see {Client} + # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds + # @raise [ArgumentError] if both a body and form fields are given, or a keyword is given that the method takes + # none of, as the fields of a body given without the braces of a Hash are, before the request is sent + # @raise [ArgumentError] if the body is not a String, a Hash, an Array, or nil, as an IO or a Symbol is not, + # before the request is sent + # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or + # https URL, before the request is sent + # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to + # from_response, before the request is sent + # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent + # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it + # @example Create a post + # client.post("tweets", {text: "Hello, World!"}) + # @example Post a form to the v1.1 API + # v1_client.post("account/settings.json", form: {lang: "en"}) + def post(endpoint, body = nil, params: nil, form: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, **unknown, &) # steep:ignore DifferentMethodParameterKind + SettingValidator.no_unknown_keywords!(:post, endpoint, unknown) + @internals.execute_request(self, :post, endpoint, body:, params:, form:, headers:, array_class:, object_class:, &) + end + + # Perform a PUT request to the X API + # + # @api public + # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or + # without a query string + # @param body [String, Hash, Array, nil] the request body; a String is sent as given, and a Hash or an Array + # is encoded as JSON + # @param params [Hash, nil] query parameters appended to the endpoint + # @param form [Hash, nil] fields to send as a form-encoded body, in place of a body; as with params, nil values + # are dropped and arrays are joined with commas + # @param headers [Hash] additional headers for the request + # @param array_class [Class] the class for parsing JSON arrays + # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to + # from_response and builds the result from the whole body; see {Client} + # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds + # @raise [ArgumentError] if both a body and form fields are given, or a keyword is given that the method takes + # none of, as the fields of a body given without the braces of a Hash are, before the request is sent + # @raise [ArgumentError] if the body is not a String, a Hash, an Array, or nil, as an IO or a Symbol is not, + # before the request is sent + # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or + # https URL, before the request is sent + # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to + # from_response, before the request is sent + # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent + # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it + # @example Update a resource + # client.put("some/endpoint", {key: "value"}) + def put(endpoint, body = nil, params: nil, form: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, **unknown, &) # steep:ignore DifferentMethodParameterKind + SettingValidator.no_unknown_keywords!(:put, endpoint, unknown) + @internals.execute_request(self, :put, endpoint, body:, params:, form:, headers:, array_class:, object_class:, &) + end + + # Perform a DELETE request to the X API + # + # @api public + # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or + # without a query string + # @param params [Hash, nil] query parameters appended to the endpoint + # @param headers [Hash] additional headers for the request + # @param array_class [Class] the class for parsing JSON arrays + # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to + # from_response and builds the result from the whole body; see {Client} + # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds + # @raise [ArgumentError] if the endpoint is not a String, is not a valid URL, or does not resolve to an http or + # https URL, before the request is sent + # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to + # from_response, before the request is sent + # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent + # @yieldparam response [Response] the summary of each response the request got, as {#on_response} receives it + # @example Delete a post + # client.delete("tweets/1234567890") + def delete(endpoint, params: nil, headers: {}, array_class: default_array_class, object_class: default_object_class, &) + @internals.execute_request(self, :delete, endpoint, params:, headers:, array_class:, object_class:, &) + end + + # Open a GET request whose body the block reads as it arrives, as a stream's is + # + # The response is passed to the block as a {StreamResponse} before its body is read, so the block reads it, with + # its read_body, for as long as it likes; the connection is opened for the request alone, with the client's timeouts and proxy, and + # closed once the block returns. The request carries the client's credentials and headers as any other does, and + # a token the API rejects is refreshed, or fetched again, and the request sent once more, as for any other. It is + # neither retried after a failure nor redirected, and a response that is not successful raises the HTTPError of + # its status, once on_response is passed it, without reaching the block. Nothing the block reads is passed to + # on_response, since the body is the block's to read. + # + # An error the block raises reaches the caller as it was raised, but for the errors of the socket the body is read + # from, such as the IOError of a body that could not be read, or a read that timed out, which raise a NetworkError + # that names the request, as a connection that fails does, so that a stream that dropped is told apart from a + # block that failed. An error is the socket's when read_body raises it from the socket, whatever its class, so + # the IOError or Errno::ENOSPC of a file the block writes to, in the block passed to read_body or out of it, is + # the block's, and raised as it was. + # + # @api public + # @param endpoint [String] the endpoint, relative to the base URL with or without a leading slash, with or + # without a query string + # @param params [Hash, nil] query parameters appended to the endpoint + # @param headers [Hash] additional headers for the request + # @yieldparam response [StreamResponse] the successful response, whose body is not yet read + # @return [Object] what the block returns + # @raise [ArgumentError] if no block is given, or the endpoint is not a String, is not a valid URL, or does not + # resolve to an http or https URL, before the request is sent + # @raise [ArgumentError] if headers are not a Hash of header names to Strings, before the request is sent + # @raise [HTTPError] if the response is not successful + # @raise [NetworkError] if the request cannot be sent, or its body cannot be read + # @example Print the body of the sample stream as it arrives + # client.app_only.get_stream("tweets/sample/stream") { |response| response.read_body { |chunk| print chunk } } + # @example Read the connections a stream may open before its rate limit resets, then its body + # client.app_only.get_stream("tweets/sample/stream") do |response| + # logger.info("#{response.status}: #{response.rate_limit&.remaining} connections left") + # response.read_body { |chunk| print chunk } + # end + def get_stream(endpoint, params: nil, headers: {}, &block) + Kernel.raise ArgumentError, "get_stream takes a block, which reads the body of the response" if block.nil? + + @internals.execute_stream(self, endpoint, params:, headers:, &block) + end + + # Close the connections the client keeps open between requests + # + # A later request opens a connection again. A client and the copies made of it with {#with} that open their + # connections as it does share their connections, as the app-only copy of a client that signs with OAuth 1.0a + # does, so closing one closes them for all of them. + # + # @api public + # @return [void] + # @example Close the connections before a long pause + # client.close + def close = @internals.close + + # Send a request that is safe to send twice again after a failure + # + # The client sends no POST again, since the API may have acted on one whose answer never arrived, and sends no + # request again after its answer failed to arrive, since the API bills a read it answered whether or not the + # answer arrived. A request that is safe to send again anyway, such as the chunk of an upload, which names the + # segment it is appended at and which the API bills nothing for, is sent again with this: after a ServerError, a + # RequestTimeout, or a NetworkError of any kind, even a timeout, up to max_retries times, after the wait a + # response asks for, or a backoff that doubles with each retry up to a minute and is cut short at random. A + # response that asks to be left alone for longer than a minute raises at once. The block must build its request + # anew each time, so that each attempt is signed afresh, as a request of the client is. + # + # Wrap a request the client sends no more than once, such as a POST: the client sends a GET, a PUT, or a DELETE + # again itself, so one wrapped in this is sent max_retries times more for each time this sends it, nine times in + # all with the defaults, rather than three. + # + # For the gems that extend a client, such as x-uploader, which sends each chunk of an upload with it, so that a + # later x-core 1.x, which installs beside an earlier x-uploader 1.x, keeps its name and behavior throughout 1.x. + # + # @api semipublic + # @yield sends the request + # @return [Object] what the block returns + # @raise [NetworkError] if the request fails once more than the retries allow + # @raise [ServerError, RequestTimeout] if the API fails to answer once more than the retries allow, or asks for a + # wait longer than a minute + # @example Append a chunk of an upload, again after a failure + # client.with_retries { client.post("media/upload/1/append", body, headers:) } + def with_retries(&) = @internals.with_retries(&) + + # The value kept under a key for the authenticator the client holds + # + # For the gems that extend a client, such as x-objects, which keeps the identifier of the user its credentials + # act for with it, so that a later x-core 1.x, which installs beside an earlier x-objects 1.x, keeps its name and + # behavior throughout 1.x. A value is read only while the client holds the authenticator it was kept with, and a + # client keeps values though it is frozen, which a copy made with dup or clone shares, and a copy made with + # {#with} does not. + # + # @api semipublic + # @param key [Symbol] the key, which names the gem that keeps it + # @return [Object, nil] the value, or nil if none is kept for the authenticator of the client + # @example Read the identifier of the authenticated user that x-objects kept + # client.memoized(:x_objects_current_user_id) # => 7505382 + def memoized(key) = @internals.memoized(key) + + # Keep a value under a key, for the authenticator of the client + # + # For the gems that extend a client, and kept throughout 1.x, as {#memoized} is. + # + # @api semipublic + # @param key [Symbol] the key, which names the gem that keeps it + # @param value [Object] the value + # @return [Object] the value + # @example Keep the identifier of the authenticated user + # client.memoize(:x_objects_current_user_id, 7_505_382) # => 7505382 + def memoize(key, value) = @internals.memoize(key, value) + end + end +end diff --git a/x-core/lib/x/core/client_app_only.rb b/x-core/lib/x/core/client_app_only.rb new file mode 100644 index 00000000..f7c10930 --- /dev/null +++ b/x-core/lib/x/core/client_app_only.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "monitor" +require_relative "app_only_authenticator" +require_relative "errors/unsupported_operation" + +module X + module Core + # The app-only copy of a client, for the endpoints that refuse OAuth 1.0a, included into ClientInternals + # @api private + module ClientAppOnly + # The message of the error raised for a client that holds no credentials of the app to authenticate with + NO_APP_CREDENTIALS = "A client that authenticates with OAuth 2.0 as a user, and holds neither the app's bearer " \ + "token nor its API key and secret, cannot authenticate as the app. Pass the client one of them, beside the " \ + "OAuth 2.0 credentials rather than an OAuth2Authenticator, which is given alone" + private_constant :NO_APP_CREDENTIALS + + # A client that authenticates as the app, which Client#app_only returns + # + # A client that authenticates as a user returns a copy that authenticates with the app's bearer token, built + # once; any other returns itself; see {Client#app_only}. + # + # @api private + # @param client [Client] the client these are the internals of + # @return [Client] a copy that authenticates with the bearer token, or the client itself + # @raise [UnsupportedOperation] if the client authenticates with OAuth 2.0 as a user and holds no credentials of + # the app + def app_only(client) + case authenticator + when OAuth1Authenticator, OAuth2Authenticator + raise UnsupportedOperation, NO_APP_CREDENTIALS unless bearer_token || app_credentials + + app_only_copy(client) + else client + end + end + + private + + # The app-only copy of the client, built once + # + # The credentials and settings of a client never change, so the copy is built once and returned from then on. + # It is built under a lock, so that threads which ask for it together build one copy and fetch one token. + # + # @api private + # @param client [Client] the client these are the internals of + # @return [Client] the copy + def app_only_copy(client) + @app_only_monitor.synchronize { @app_only ||= build_app_only(client) } + end + + # Build a copy that holds the app's credentials and bearer token + # @api private + # @param client [Client] the client these are the internals of + # @return [Client] the copy + def build_app_only(client) + key, secret = app_credentials + with(client, {**credentials.to_h { |name, _| [name, nil] }, api_key: key, api_key_secret: secret, bearer_token: app_bearer_token}) + end + + # The app-only bearer token, the client's own or one it fetches + # @api private + # @return [String] the bearer token + def app_bearer_token = bearer_token || app_token.__send__(:bearer_token) + + # The authenticator that fetches the app-only bearer token, shared with copies + # + # It is built once, and fetches the token when it is first asked for it, and holds it from then on, so the client, + # and each copy of it that {#share_app_token} gave it to, fetch it once between them. + # + # @api private + # @return [AppOnlyAuthenticator] the authenticator + def app_token + @app_only_monitor.synchronize do + @app_token ||= begin + key, secret = app_credentials #: [String, String] + AppOnlyAuthenticator.new(api_key: key, api_key_secret: secret).__send__(:token_requests_over, @connection, base_url, headers) + end + end + end + + # Share the app-only bearer token of the client this one was copied from + # + # A copy that holds the same credentials of the app, and the same base URL, would fetch the same token, from an + # endpoint X limits the rate of, so it takes the authenticator that fetches the token of the client it was + # copied from, rather than fetch one of its own for each copy, as a copy made for each request would. The + # internals of the client copied call it on those of the copy with __send__, since it is private. + # + # @api private + # @param other [ClientInternals] the internals of the client this one was copied from + # @return [void] + def share_app_token(other) + credentials = app_credentials or return + @app_token = other.__send__(:app_token) if credentials.eql?(other.__send__(:app_credentials)) && base_url.eql?(other.base_url) + end + + # The API key and secret of the app + # + # They are the ones an OAuth1Authenticator signs with, which a client given one holds no copy of, or else the + # client's own, of which it holds both or neither, since a credential outside a complete set is refused. + # + # @api private + # @return [Array(String, String), nil] the API key and secret, or nil for a client that holds neither + def app_credentials + current = authenticator + return [current.api_key, current.__send__(:api_key_secret)] if current.is_a?(OAuth1Authenticator) + + key = api_key + secret = api_key_secret #: String + [key, secret] if key + end + end + private_constant :ClientAppOnly + end +end diff --git a/x-core/lib/x/core/client_credentials.rb b/x-core/lib/x/core/client_credentials.rb new file mode 100644 index 00000000..fc612c40 --- /dev/null +++ b/x-core/lib/x/core/client_credentials.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module X + module Core + # The authentication credentials of a client, which it reads but never changes, included into ClientInternals + # + # A client is built with the credentials it keeps for as long as it lives. {Client#with} derives a client whose + # credentials differ, rather than replacing the ones a client holds, so that a request never signs with a mix of + # old and new credentials. + # + # @api private + module ClientCredentials + # The API key for OAuth 1.0a authentication, as the client was given it + # + # It is nil for a client given an authenticator in place of credentials; see {#api_key_in_use}. + # + # @api private + # @return [String, nil] the API key for OAuth 1.0a authentication + attr_reader :api_key + + # The OAuth 2.0 client ID, as the client was given it + # + # It is nil for a client given an authenticator in place of credentials; see {#client_id_in_use}. + # + # @api private + # @return [String, nil] the OAuth 2.0 client ID + attr_reader :client_id + + # The API key of the client, or of the authenticator it was given + # + # An OAuth1Authenticator or AppOnlyAuthenticator given in place of credentials holds the API key of the client. + # {Client#api_key} returns it. + # + # @api private + # @return [String, nil] the API key, or nil if neither holds one + def api_key_in_use + case (given = @given_authenticator) + when OAuth1Authenticator, AppOnlyAuthenticator then given.api_key + else api_key + end + end + + # The OAuth 2.0 client ID of the client, or of the authenticator it was given + # + # An OAuth2Authenticator given in place of credentials holds the client ID of the client. + # {Client#client_id} returns it. + # + # @api private + # @return [String, nil] the client ID, or nil if neither holds one + def client_id_in_use + given = @given_authenticator + given.is_a?(OAuth2Authenticator) ? given.client_id : client_id + end + + private + + # The API key secret for OAuth 1.0a authentication + # + # It is private, as {Client#inspect} hides it, and the client holds it here rather than in a reader of its own, + # so that code that reflects over a client never reads a secret out of it. {Client#with} carries it to a copy + # without revealing it, and the authenticator of a client holds the credentials it signs with. + # + # @api private + # @return [String, nil] the API key secret for OAuth 1.0a authentication + attr_reader :api_key_secret + + # The access token secret for OAuth 1.0a authentication + # + # It is private for the reason {#api_key_secret} is. + # + # @api private + # @return [String, nil] the access token secret for OAuth 1.0a authentication + attr_reader :access_token_secret + + # The bearer token for authentication + # + # It is private for the reason {#api_key_secret} is. + # + # @api private + # @return [String, nil] the bearer token for authentication + attr_reader :bearer_token + + # The OAuth 2.0 client secret + # + # It is private for the reason {#api_key_secret} is. + # + # @api private + # @return [String, nil] the OAuth 2.0 client secret + attr_reader :client_secret + + # The credentials, as initialize accepts them + # @api private + # @return [Hash{Symbol => String, Time, Array, nil}] the credentials + def credentials = {api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, client_secret:, refresh_token:, expires_at:, scopes:} + + # Initialize credential instance variables + # @api private + # @return [void] + def initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, + client_id:, client_secret:, refresh_token:, expires_at:, scopes:) + CredentialValidator.validate_values!(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, client_secret:, refresh_token:, expires_at:, scopes:) + @api_key, @api_key_secret = SettingValidator.frozen(api_key), SettingValidator.frozen(api_key_secret) + @access_token, @access_token_secret = SettingValidator.frozen(access_token), SettingValidator.frozen(access_token_secret) + @bearer_token = SettingValidator.frozen(bearer_token) + @client_id, @client_secret, @refresh_token = SettingValidator.frozen(client_id), SettingValidator.frozen(client_secret), SettingValidator.frozen(refresh_token) + @expires_at, @scopes = expires_at, scopes + end + + # Refuse an authenticator beside credentials, and credentials of no complete set + # @api private + # @param authenticator [Authenticator, nil] the authenticator the client was given, or nil + # @return [void] + # @raise [ArgumentError] if the authenticator is not an Authenticator, or is given beside credentials + # @raise [ArgumentError] if the credentials do not form complete sets + def validate_credentials!(authenticator) + CredentialValidator.validate_authenticator!(authenticator, credentials) + CredentialValidator.validate!(credentials) + end + + # Take the authenticator the client was given, or build one of its credentials + # + # The authenticator built is the one of the first complete set of credentials held, and a client that holds no + # complete set sends its requests without credentials. Taking an authenticator binds it to the connection of the + # client and reports its refreshes to the client, so the client checks the authenticator, and every other + # option, before it takes it, and a client that raises leaves the authenticator as it was. + # + # @api private + # @param client [Client] the client these are the internals of + # @param given [Authenticator, nil] the authenticator the client was given, or nil to build one + # @return [void] + def initialize_authenticator(client, given) + @given_authenticator = given + @authenticator = given ? take(client, given) : built_authenticator(client) + end + + # Build the authenticator of the first complete set of credentials held + # @api private + # @param client [Client] the client these are the internals of + # @return [Authenticator] the authenticator, which sends no credentials when no set is complete + def built_authenticator(client) + oauth1_authenticator || oauth2_authenticator(client) || app_only_authenticator || bearer_authenticator || Authenticator.new + end + + # The options of a copy of the client, beside the credentials it is built with + # + # An authenticator given to the copy replaces the credentials of the client, a credential given to it replaces + # the authenticator the client was given, and a copy given neither shares that authenticator. An expiration + # time and scopes are no credentials, and are refused beside an authenticator, as they are when a client is built. + # + # @api private + # @param options [Hash{Symbol => Object}] the options the copy is given + # @return [Hash{Symbol => Object}] the options, beside the credentials or the authenticator the copy shares + def with_credentials(options) + given = @given_authenticator + return {authenticator: given, **options} if given && !options.keys.intersect?(credentials.except(:expires_at, :scopes).keys) + + options[:authenticator] ? options : {**credentials, **options} + end + + # Build an OAuth 1.0a authenticator if credentials are available + # @api private + # @return [OAuth1Authenticator, nil] the OAuth 1.0a authenticator or nil + def oauth1_authenticator + access_token = @access_token + return unless api_key && api_key_secret && access_token && access_token_secret + + OAuth1Authenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:) + end + + # Build an app-only authenticator on the client's connection, given API keys + # + # A bearer token given beside them is sent until the API rejects it, and one is fetched with them in its place. + # + # @api private + # @return [AppOnlyAuthenticator, nil] the app-only authenticator or nil + def app_only_authenticator + return unless api_key && api_key_secret + + AppOnlyAuthenticator.new(api_key:, api_key_secret:, bearer_token:).__send__(:token_requests_over, @connection, base_url, headers) + end + + # Build a bearer token authenticator if credentials are available + # @api private + # @return [BearerTokenAuthenticator, nil] the bearer token authenticator or nil + def bearer_authenticator + return unless bearer_token + + BearerTokenAuthenticator.new(bearer_token:) + end + end + private_constant :ClientCredentials + end +end diff --git a/x-core/lib/x/core/client_internals.rb b/x-core/lib/x/core/client_internals.rb new file mode 100644 index 00000000..03bb139c --- /dev/null +++ b/x-core/lib/x/core/client_internals.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +require "monitor" +require "uri" +require_relative "app_only_authenticator" +require_relative "authenticator" +require_relative "bearer_token_authenticator" +require_relative "client_app_only" +require_relative "client_credentials" +require_relative "client_memo" +require_relative "client_settings" +require_relative "client_token_refresh" +require_relative "connection" +require_relative "credential_holder" +require_relative "credential_validator" +require_relative "errors/callback_error" +require_relative "oauth1_authenticator" +require_relative "oauth2_authenticator" +require_relative "origin" +require_relative "proxy_setting" +require_relative "request_builder" +require_relative "request_encoding" +require_relative "response_parser" +require_relative "setting_validator" +require_relative "stream_body" +require_relative "stream_response" + +module X + module Core + # The credentials, settings, connection, handlers, and authenticator of a client, and the methods that send its + # requests with them + # + # X::Client is the class that x-objects and x-uploader include their methods into, and each is released apart + # from x-core, so a later version of either may name a method as it likes. A method of theirs would take the + # place of a private method of the client of the same name, and a client whose own methods called that one would + # call theirs in its place. So a client holds its state here and calls on this object, whose methods none of + # theirs can take the place of, and has no methods of its own but those of its public API and initialize. + # + # A client holds one of these for as long as it lives, and passes itself to a method that needs it, such as one + # that parses a response for it, rather than this holding the client, so that a copy of the client made with dup, + # which holds the same internals, as it held the same state before, passes itself in turn. + # + # @api private + class ClientInternals + # The headers a stream is opened with unless its own or the client's replace them, which ask for a body that is + # not compressed, so that each line reaches the stream as it arrives + STREAM_HEADERS = {"Accept-Encoding" => "identity"}.freeze + private_constant :STREAM_HEADERS + + include ClientAppOnly + include ClientCredentials + include ClientMemo + include ClientSettings + include CredentialHolder + include ClientTokenRefresh + include ProxySetting + + # Content type of a form-encoded request body + FORM_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=utf-8" + private_constant :FORM_CONTENT_TYPE + + # The authenticator for API requests + # + # {Client#authenticator} returns it. + # + # @api private + # @return [Authenticator] the authenticator instance + attr_reader :authenticator + + # The callable passed the OAuth2Tokens of each refresh + # + # {Client#save_tokens} returns it. + # + # @api private + # @return [#call, nil] the callable, or nil for none + attr_reader :save_tokens + + # The callable a refresh reads the stored OAuth2Tokens with + # + # {Client#load_tokens} returns it. + # + # @api private + # @return [#call, nil] the callable, or nil for none + attr_reader :load_tokens + + # The internals of a client + # + # A copy of a client, which {#with} builds, reads the internals of the client it copies with this. + # + # @api private + # @param client [Client] the client + # @return [ClientInternals] the internals of the client + def self.of(client) = client.instance_variable_get(:@internals) + + # Initialize the internals of a client, with the options its initialize was given + # + # The authenticator is taken last, since a client that raised must leave an authenticator it was given alone. + # + # @api private + # @param client [Client] the client these are the internals of + # @return [ClientInternals] a new instance + # @raise [ArgumentError] if an option is refused, as {Client#initialize} states + def initialize(client, api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, + client_secret:, refresh_token:, expires_at:, scopes:, authenticator:, base_url:, open_timeout:, read_timeout:, + write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:, default_array_class:, default_object_class:, + headers:, max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:, on_response:, + save_tokens:, load_tokens:) + @connection = Connection.new(open_timeout:, read_timeout:, write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:) + @proxy_url = @connection.__send__(:proxy_url) + initialize_state + initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, client_id:, client_secret:, refresh_token:, expires_at:, scopes:) + validate_credentials!(authenticator) + initialize_settings(base_url:, default_array_class:, default_object_class:, headers:, on_response:, max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:) + initialize_token_hooks(save_tokens:, load_tokens:) + initialize_authenticator(client, authenticator) + end + + # Summarize the internals of a client without revealing credentials + # + # They hold the credentials of the client, so they are summarized as {Client#inspect} summarizes the client. + # + # @api private + # @return [String] the class name, base URL, and authenticator + def inspect = "#<#{self.class} base_url=#{base_url.inspect} authenticator=#{authenticator.inspect}>" + + # Copy a client with some of its options changed + # + # It is what {Client#with} does. The internals of the copy share the authenticator and the connections of these + # as that states. + # + # @api private + # @param client [Client] the client these are the internals of + # @param options [Hash{Symbol => Object}] the options to change, as accepted by initialize + # @return [Client] a new client with the same credentials and settings, apart from the options given + def with(client, options) + client.class.new(**without_tokens_of_client({**settings, **with_credentials(options)}, options)).tap do |copy| + internals = ClientInternals.of(copy) + internals.__send__(:share_authenticator, copy, authenticator, options) + internals.__send__(:share_app_token, self) + internals.__send__(:share_connection, @connection) + end + end + + # Close the connections the client keeps open between requests + # + # It is what {Client#close} does. + # + # @api private + # @return [void] + def close = @connection.close + + # Execute an HTTP request to the X API + # + # An error a callback raised, which the request tags as a CallbackError so that no handler sends the request + # again, waits out a rate limit, or refreshes a token for it, is raised as it was, once the handlers are left, + # noted as a callback's, so that Client#with_retries does not send the request again for it either. + # + # @api private + # @param client [Client] the client these are the internals of, which a response is parsed for + # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds + def execute_request(client, http_method, endpoint, params:, headers:, array_class:, object_class:, body: nil, form: nil, &block) + SettingValidator.parsing_classes!(array_class:, object_class:) + headers = SettingValidator.headers!(headers) + uri = RequestEncoding.uri_for(base_url, endpoint, params) + headers = headers_for(form.nil? ? headers : RequestBuilder.merge_headers({"Content-Type" => FORM_CONTENT_TYPE}, headers)) + retrying(http_method) { refreshing_rejected_token(client) { perform(client, http_method, uri, body: RequestEncoding.encode_body(body, form), headers:, array_class:, object_class:, &block) } } + rescue CallbackError => e + raise CallbackError.untag(e) + end + + # Run a request, again after a server error or a rate limit, as the client allows + # + # The retries for a rate limit are counted across the attempts a server error sends again, so that + # max_rate_limit_retries bounds the retries of the request. + # + # @api private + # @param http_method [Symbol] the HTTP method of the request, which says whether it is sent again after a failure + # @yield runs the request + # @return [Object] what the block returns + def retrying(http_method, &) + @rate_limit_handler.counting do + @retry_handler.handle(idempotent: RequestBuilder.idempotent?(http_method)) { @rate_limit_handler.handle(&) } + end + end + + # Open a GET request whose body the block reads as it arrives + # + # It is what {Client#get_stream} does. An error the block raises, but for the errors of a socket, which raise a + # NetworkError, is tagged as a CallbackError, so that no rejected token is refreshed for it, and raised as it was + # once the request is left. A stream is counted as a request of its own, so that a request its block sends counts + # its rate limit retries afresh, even inside with_retries. + # + # @api private + # @param client [Client] the client these are the internals of + # @param endpoint [String] the endpoint, relative to the base URL + # @param params [Hash, nil] query parameters appended to the endpoint + # @param headers [Hash] additional headers for the request + # @yieldparam response [StreamResponse] the successful response, whose body is not yet read + # @return [Object] what the block returns + def execute_stream(client, endpoint, params:, headers:, &) + uri = RequestEncoding.uri_for(base_url, endpoint, params) + headers = headers_for(SettingValidator.headers!(headers)) + @rate_limit_handler.counting { refreshing_rejected_token(client) { perform_stream(uri, headers:, &) } } + rescue CallbackError => e + raise CallbackError.untag(e) + end + + private + + # Build what the client keeps beside its options + # + # They are the helpers that build its requests and parse its responses, and the locks of what it builds once + # and keeps: its app-only copy, and the values of its memo. + # + # @api private + # @return [void] + def initialize_state + @app_only_monitor = Monitor.new + @request_builder = RequestBuilder.new + @response_parser = ResponseParser.new + initialize_memo + end + + # Open a GET request once, and pass its response to the block if it succeeded + # + # A request to another origin than the base URL carries none of the client's credentials, as any other does. It + # asks for a body that is not compressed, unless its headers or the client's name an Accept-Encoding of their + # own, since Net::HTTP inflates a compressed body in blocks of many kilobytes, and would hold back each line of a + # stream until enough of them had arrived to fill one. + # + # @api private + # @param uri [URI::Generic] the URI of the request + # @param headers [Hash] the headers of the request, beside those of the client + # @yieldparam response [StreamResponse] the successful response + # @return [Object] what the block returns + # @raise [HTTPError] if the response is not successful + # @raise [CallbackError] if on_response or the block raises an error that is not one of a socket + def perform_stream(uri, headers:) + authenticator, headers = Origin.credentials_for(from: URI(base_url), to: uri, authenticator: self.authenticator, headers:) + request = @request_builder.build(http_method: :get, uri:, headers: RequestBuilder.merge_headers(STREAM_HEADERS, headers), authenticator:) + @connection.perform_stream(request:) do |response| + stream_failed(uri, response, request) unless response.is_a?(Net::HTTPSuccess) + reading { yield StreamResponse.__send__(:new, http_response: response, uri:) } + end + end + + # Raise the error of a stream that failed + # + # Its body is read whole, tagged UTF-8 as the body of {Connection#perform} is, before the block of the request + # ends, so that on_response and the error can read it however long after it was raised. + # + # @api private + # @param uri [URI::Generic] the URI of the request + # @param response [Net::HTTPResponse] the failed response, whose body is not yet read + # @param request [Net::HTTPRequest] the request the response answers + # @return [void] + # @raise [HTTPError] the error of the response + # @raise [CallbackError] if on_response raises + def stream_failed(uri, response, request) + response.body_encoding = Encoding::UTF_8 + response.body + CallbackError.tagging { report(:get, uri, response) } + raise @response_parser.error(response, request) + end + + # Run the block of a stream, tagging its errors but those of its socket + # + # The errors of the socket are those StreamResponse#read_body raised from it, as StreamBody notes them, which the + # connection raises as a NetworkError, and any other error is the block's own, whatever its class, which is + # raised as it was, as is the error of a socket the block raised of its own, such as the IOError of a file it + # wrote to. + # + # @api private + # @yield runs the block of the stream, passing it the response + # @return [Object] what the block returns + # @raise [CallbackError] if the block raises an error that read_body did not raise from the socket + def reading + yield + rescue => e + raise if StreamBody.socket_error?(e) + + raise CallbackError, e + end + + # Perform a request once, following redirects and parsing the response + # + # A request to another origin than the base URL carries none of the client's credentials, as a redirect to one + # carries none of them. The error on_response or the block of the request raises, as the error from_response + # raises, is tagged as a CallbackError, so that it is not taken for an error of the response. The response is + # reported, and its error named, for the request it answers, which a redirect may have sent to another URI with + # another method than the request was made with. + # + # @api private + # @param client [Client] the client these are the internals of, which a response is parsed for + # @return [Object, nil] the parsed response body, or what an object_class that responds to from_response builds + def perform(client, http_method, uri, body:, headers:, array_class:, object_class:, &) + authenticator, headers = Origin.credentials_for(from: URI(base_url), to: uri, authenticator: self.authenticator, headers:) + request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:) + response, request = @redirect_handler.follow(response: @connection.perform(request:), request:, headers:, authenticator:) + CallbackError.tagging { report(request.method, request.uri, response, &) } + @response_parser.parse(response:, array_class:, object_class:, client:, request:) + end + end + private_constant :ClientInternals + end +end diff --git a/x-core/lib/x/core/client_memo.rb b/x-core/lib/x/core/client_memo.rb new file mode 100644 index 00000000..27e48ec2 --- /dev/null +++ b/x-core/lib/x/core/client_memo.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module X + module Core + # What the gems that extend a client keep on it, for as long as it authenticates as it does, included into + # ClientInternals + # + # x-objects keeps the identifier of the user a lookup found for the credentials of a client here, so that it looks + # the user up once. A value is kept with the authenticator of the client when it was kept, and read only while the + # client authenticates with that authenticator, since what it says may be true of those credentials alone. It is + # kept under a lock, so that threads that share a client read and keep values together. + # + # The memo is held by the internals of a client, as the app-only copy of a client is, rather than by the client, + # so a frozen client keeps values as well, and a copy made with dup or clone, which holds the same internals and + # authenticator, shares them. A copy made with {Client#with} holds internals of its own, and keeps its own values. + # + # @api private + module ClientMemo + # The value kept under a key for the authenticator the client holds + # + # It is what {Client#memoized} does. + # + # @api private + # @param key [Symbol] the key, which names the gem that keeps it + # @return [Object, nil] the value, or nil if none is kept for the authenticator of the client + def memoized(key) + @memo_lock.synchronize do + owner, value = @memo[key] + value if owner.equal?(authenticator) + end + end + + # Keep a value under a key, for the authenticator of the client + # + # It is what {Client#memoize} does. + # + # @api private + # @param key [Symbol] the key, which names the gem that keeps it + # @param value [Object] the value + # @return [Object] the value + def memoize(key, value) + @memo_lock.synchronize { @memo[key] = [authenticator, value] } + value + end + + private + + # Start with nothing kept + # @api private + # @return [void] + def initialize_memo + @memo = {} + @memo_lock = Mutex.new + end + end + private_constant :ClientMemo + end +end diff --git a/x-core/lib/x/core/client_settings.rb b/x-core/lib/x/core/client_settings.rb new file mode 100644 index 00000000..27fc5bee --- /dev/null +++ b/x-core/lib/x/core/client_settings.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "forwardable" +require_relative "rate_limit_handler" +require_relative "redirect_handler" +require_relative "response" +require_relative "retry_handler" +require_relative "setting_validator" + +module X + module Core + # The settings of a client other than its credentials: its base URL, parsing classes, hook, and the settings of + # its connection and handlers, included into ClientInternals + # + # A client is built with the settings it keeps for as long as it lives. {Client#with} derives a client whose + # settings differ, rather than replacing the ones a client holds, so that a request never runs under a setting + # another thread is halfway through changing. + # + # @api private + module ClientSettings + extend Forwardable + + # The base URL for API requests + # + # {Client#base_url} returns it. + # + # @api private + # @return [String] the base URL for API requests, which ends with a slash + attr_reader :base_url + + # The default class for parsing JSON arrays + # + # {Client#default_array_class} returns it. + # + # @api private + # @return [Class] the default class for parsing JSON arrays + attr_reader :default_array_class + + # The default class for parsing JSON objects + # + # {Client#default_object_class} returns it. + # + # @api private + # @return [Class, #from_response] the default class for parsing JSON objects + attr_reader :default_object_class + + # The callable passed an X::Response after each request and streamed object + # + # {Client#on_response} returns it. + # + # @api private + # @return [#call, nil] the callable, or nil for none + attr_reader :on_response + + # The headers sent with every request the client makes + # + # {Client#headers} returns it. + # + # @api private + # @return [Hash{String => String}] the headers, frozen + attr_reader :headers + + def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :keep_alive_timeout, :debug_output + def_delegators :@redirect_handler, :max_redirects + def_delegators :@rate_limit_handler, :max_rate_limit_retries, :max_rate_limit_wait + def_delegators :@retry_handler, :max_retries + + # Send a request that is safe to send twice again after a failure + # + # It is what Client#with_retries does. + # + # @api private + # @yield sends the request + # @return [Object] what the block returns + def with_retries(&) = @rate_limit_handler.handing_down { @retry_handler.handle(idempotent: true, resend_unanswered: true, &) } + + private + + # Share the connections kept open by the client this one was copied from + # + # They are shared when this client opens its connections as that one does; see Connection#share_pool_of. + # A copy that differs only in what it sends, such as its headers, base URL, or credentials, would otherwise + # open connections of its own, with a TCP and TLS handshake for each, and keep them open, idle, until it is + # collected, so a copy made for each request would leave connections to each host behind it. + # + # The internals of the client copied call it on those of the copy with __send__, since it is private. + # + # @api private + # @param connection [Connection] the connection of the client this one was copied from + # @return [void] + def share_connection(connection) = @connection.share_pool_of(connection) + + # The settings, as initialize accepts them + # @api private + # @return [Hash{Symbol => Object}] the settings + def settings + {base_url:, open_timeout:, read_timeout:, write_timeout:, keep_alive_timeout:, debug_output:, proxy_url:, + default_array_class:, default_object_class:, headers:, max_redirects:, max_rate_limit_retries:, + max_rate_limit_wait:, max_retries:, on_response:, save_tokens:, load_tokens:} + end + + # Initialize the settings, and the handlers of redirects, rate limits, and retries + # + # An endpoint is resolved against the base URL, which drops the last segment of a path that does not end with a + # slash, so a slash is added to a base URL without one. The headers are copied and frozen, so that changing the + # Hash a client was built with never changes what it sends. + # + # @api private + # @param base_url [String] the base URL for API requests + # @param default_array_class [Class] the default class for parsing JSON arrays + # @param default_object_class [Class, #from_response] the default class for parsing JSON objects + # @param headers [Hash{String, Symbol => String}] the headers sent with every request + # @param on_response [#call, nil] the callable passed an X::Response after every request and streamed object + # @param max_redirects [Integer] the maximum number of redirects to follow + # @param max_rate_limit_retries [Integer] the maximum number of times to retry a request refused for a rate limit + # @param max_rate_limit_wait [Integer, Float] the maximum number of seconds to wait for a rate limit to reset + # @param max_retries [Integer] the maximum number of times to send an idempotent request again after a failure + # @return [void] + # @raise [ArgumentError] if on_response is neither nil nor responds to call + # @raise [ArgumentError] if default_array_class is not a Class, or default_object_class is neither a Class nor + # responds to from_response + def initialize_settings(base_url:, default_array_class:, default_object_class:, headers:, on_response:, + max_redirects:, max_rate_limit_retries:, max_rate_limit_wait:, max_retries:) + base_url = SettingValidator.base_url!(base_url) + @base_url = (base_url.end_with?("/") ? base_url : "#{base_url}/").freeze + @default_array_class = SettingValidator.array_class!(:default_array_class, default_array_class) + @default_object_class = SettingValidator.object_class!(:default_object_class, default_object_class) + @headers = SettingValidator.headers!(headers).freeze + @on_response = SettingValidator.callable!(:on_response, on_response) + @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder, max_redirects:) + @rate_limit_handler = RateLimitHandler.new(max_rate_limit_retries:, max_rate_limit_wait:) + @retry_handler = RetryHandler.new(max_retries:) + end + + # The headers of a request, the client's under the request's own + # + # A header of the request is sent in place of one of the client whose name differs from it in case alone. + # + # @api private + # @param request_headers [Hash{String => String}] the headers passed to the request + # @return [Hash{String => String}] the headers to send + def headers_for(request_headers) = RequestBuilder.merge_headers(headers, request_headers) + + # Pass a response to on_response and to the block of the request + # + # Both receive the one summary, the client's hook first, so that a hook which counts every request and a block + # which reads the response of one see the same object. Neither is built a summary when there is nothing to + # pass it to. + # + # @api private + # @param http_method [Symbol, String] the HTTP method of the request, in any case + # @param uri [URI::Generic] the URI of the request + # @param response [Net::HTTPResponse] the HTTP response + # @yieldparam response [Response] the summary of the response + # @return [void] + def report(http_method, uri, response, &block) + return unless on_response || block + + summary = Response.new(http_response: response, http_method:, uri:) + on_response&.call(summary) + block&.call(summary) + end + end + private_constant :ClientSettings + end +end diff --git a/x-core/lib/x/core/client_token_refresh.rb b/x-core/lib/x/core/client_token_refresh.rb new file mode 100644 index 00000000..f2692a42 --- /dev/null +++ b/x-core/lib/x/core/client_token_refresh.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require_relative "app_only_authenticator" +require_relative "oauth2_authenticator" +require_relative "setting_validator" + +module X + module Core + # The OAuth 2.0 authenticator of a client, which its copies share, and the tokens it last refreshed, included into + # ClientInternals + # @api private + module ClientTokenRefresh + # The message of the error raised for a copy given an expiration time or scopes for the access token it shares + SHARED_EXPIRATION = "A copy that shares the access token of the client shares its expiration time and scopes, " \ + "so it cannot be given %s. Pass it beside the access token and refresh token it belongs to" + private_constant :SHARED_EXPIRATION + # The options that belong to the tokens of the client, which a copy that does not keep the client's + # authenticator holds only when it is given them + TOKEN_OPTIONS = %i[refresh_token expires_at scopes save_tokens load_tokens].freeze + # The options that are credentials, any of which a copy that authenticates as the client is not given + CREDENTIAL_OPTIONS = %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret refresh_token].freeze + private_constant :TOKEN_OPTIONS, :CREDENTIAL_OPTIONS + + # The time the OAuth 2.0 access token expires, as last refreshed + # + # {Client#expires_at} returns it. + # + # @api private + # @return [Time, nil] the expiration time, or nil if it is not known + def expires_at + current = oauth2_authenticator_in_use + current ? current.expires_at : @expires_at + end + + # The scopes X granted the OAuth 2.0 access token, as last refreshed + # + # {Client#scopes} returns it. + # + # @api private + # @return [Array, nil] the scopes, or nil if they are not known + def scopes + current = oauth2_authenticator_in_use + current ? current.scopes : @scopes + end + + private + + # Initialize the callables that store and load the tokens of a refresh + # @api private + # @param save_tokens [#call, nil] the callable passed the OAuth2Tokens of each refresh + # @param load_tokens [#call, nil] the callable that returns the OAuth2Tokens in the store + # @return [void] + # @raise [ArgumentError] if save_tokens or load_tokens is neither nil nor responds to call + def initialize_token_hooks(save_tokens:, load_tokens:) + @save_tokens = SettingValidator.callable!(:save_tokens, save_tokens) + @load_tokens = SettingValidator.callable!(:load_tokens, load_tokens) + end + + # Share the authenticator of the client this one was copied from + # + # A copy that holds the credentials of the client shares its OAuth 2.0 or app-only authenticator, so that the + # copy refreshes the tokens of the client, or sends the bearer token it fetched, rather than hold tokens of its + # own. A copy given an authenticator of its own keeps it. The internals of the client copied call it on those of + # the copy with __send__, since it is private. + # + # @api private + # @param copy [Client] the copy these are the internals of + # @param other [Authenticator] the authenticator of the client this one was copied from + # @param options [Hash] the options the copy was given in place of the client's + # @return [void] + def share_authenticator(copy, other, options) + return if options[:authenticator] + + case other + when AppOnlyAuthenticator then share_app_only(other, options) + when OAuth2Authenticator then share_oauth2(copy, other, options) + end + end + + # Share the OAuth 2.0 authenticator of the client this one was copied from + # + # A refresh by either client then reaches both. X accepts a refresh token once, so a copy that refreshed with + # an authenticator of its own would leave the original client with a refresh token that no longer works. + # + # Whether the two share it is decided by the options the copy was given, not by the tokens it was built with: + # a refresh on another thread may replace them while it is built, and a copy that held on to the ones replaced + # could never refresh again. The expiration time and scopes are facts about the access token the two hold, so a + # copy that shares it is refused either, rather than set it for the client it was copied from. + # + # @api private + # @param copy [Client] the copy these are the internals of + # @param other [OAuth2Authenticator] the authenticator of the client this one was copied from + # @param options [Hash] the options the copy was given in place of the client's + # @return [void] + # @raise [ArgumentError] if the copy shares the authenticator and was given an expiration time or scopes + def share_oauth2(copy, other, options) + return unless oauth2_authenticator_in_use && other.__send__(:holds?, options) + + given = options.keys & %i[expires_at scopes] + raise ArgumentError, format(SHARED_EXPIRATION, given.join(" or ")) unless given.empty? + + @authenticator = join(copy, other) + end + + # The options of a copy, without the tokens of a client it does not share + # + # A copy that authenticates otherwise than the client authenticates with tokens that may be another user's. It + # holds the refresh token, expiration time, scopes, save_tokens, and load_tokens of the client only when it is + # given them: a refresh of the copy would otherwise spend the refresh token the client holds, which X accepts + # once, read the store of the client with its load_tokens and take the client's tokens in place of its own, or + # pass its tokens to the save_tokens of the client, which would store them in place of the client's. + # + # @api private + # @param copied [Hash{Symbol => Object}] the options the copy is built with + # @param options [Hash{Symbol => Object}] the options the copy was given + # @return [Hash{Symbol => Object}] the options, without those of the tokens of the client the copy was not given + def without_tokens_of_client(copied, options) + keeps_authenticator?(copied, options) ? copied : copied.except(*(TOKEN_OPTIONS - options.keys)) + end + + # Check whether a copy keeps the authenticator of the client + # + # A copy of a client given its authenticator keeps it unless given a credential or an authenticator, which + # replaces it. A copy of a client built from credentials keeps its OAuth 2.0 authenticator unless given an + # authenticator, or a client ID, client secret, access token, or refresh token the authenticator does not hold, + # and keeps any other authenticator unless given an authenticator or any credential. + # + # @api private + # @param copied [Hash{Symbol => Object}] the options the copy is built with + # @param options [Hash{Symbol => Object}] the options the copy was given + # @return [Boolean] whether the copy authenticates as the client does + def keeps_authenticator?(copied, options) + return copied[:authenticator].equal?(@authenticator) if @given_authenticator + return false if options[:authenticator] + + current = oauth2_authenticator_in_use + current ? current.__send__(:holds?, options) : !options.keys.intersect?(CREDENTIAL_OPTIONS) + end + + # Share the app-only authenticator of the client this one was copied from + # + # It is shared by a copy that authenticates as the app with the API key and secret the authenticator holds, at + # the origin it fetches the bearer token at, and was given no bearer token of its own, so the bearer token the + # client fetched, or fetches, is fetched once, and is sent to no other origin than the one that issued it. + # + # @api private + # @param other [AppOnlyAuthenticator] the authenticator of the client this one was copied from + # @param options [Hash] the options the copy was given in place of the client's + # @return [void] + def share_app_only(other, options) + return unless AppOnlyAuthenticator === @authenticator && other.__send__(:holds?, options) + + @authenticator = other if other.__send__(:fetched_for?, base_url) + end + + # Take an authenticator the client was given, as it would one it built + # + # The token requests of an authenticator that makes them are sent over the connection of the first client + # given it, and the refreshes of an OAuth 2.0 one reach the save_tokens of each client that shares it. + # + # @api private + # @param client [Client] the client these are the internals of + # @param authenticator [Authenticator] the authenticator + # @return [Authenticator] the authenticator + def take(client, authenticator) + case authenticator + when OAuth2Authenticator then join(client, authenticator.__send__(:token_requests_over, @connection, base_url, headers)) + when AppOnlyAuthenticator then authenticator.__send__(:token_requests_over, @connection, base_url, headers) + else authenticator + end + end + + # Join the clients whose save_tokens an OAuth 2.0 authenticator reports to + # @api private + # @param client [Client] the client these are the internals of + # @param authenticator [OAuth2Authenticator] the authenticator + # @return [OAuth2Authenticator] the authenticator + def join(client, authenticator) + clients = authenticator.__send__(:clients) + clients[client] = true + authenticator.__send__(:report_refreshes_to, -> { clients.keys.filter_map(&:save_tokens).uniq }) + authenticator + end + + # The OAuth 2.0 authenticator, if the client authenticates with one + # @api private + # @return [OAuth2Authenticator, nil] the authenticator or nil + def oauth2_authenticator_in_use + current = @authenticator + current if current.is_a?(OAuth2Authenticator) + end + + # The OAuth 2.0 authenticator of the client's credentials, if they form a set + # + # A copy of a client shares the authenticator of the client it was copied from, rather than the one this + # builds, when the two hold the same credentials; see share_authenticator. A client ID and access token without + # a refresh token, as an authorization without offline.access issues them, build an authenticator that acts + # for the user and cannot refresh. + # + # @api private + # @param client [Client] the client these are the internals of + # @return [OAuth2Authenticator, nil] the OAuth 2.0 authenticator or nil + def oauth2_authenticator(client) + client_id = @client_id + access_token = @access_token + return unless client_id && access_token + + new_oauth2_authenticator(client, client_id:, access_token:, refresh_token: @refresh_token) + end + + # The OAuth 2.0 authenticator of the client's credentials, as last refreshed + # + # It is the one the client built of them, or shares with the client it was copied from. A client given its + # authenticator holds no credentials, so the tokens of that authenticator are none of its credentials, which a + # copy would be built with beside it. + # + # @api private + # @return [OAuth2Authenticator, nil] the authenticator, or nil for a client that holds no OAuth 2.0 credentials + def oauth2_credentials_in_use = (oauth2_authenticator_in_use unless @given_authenticator) + + # The access token for OAuth authentication, as last refreshed + # + # It is private, as {ClientCredentials#api_key_secret} is, and as the access token of the authenticator is. A + # hook given to save_tokens is passed the OAuth2Tokens of the refresh it reports. + # + # @api private + # @return [String, nil] the access token for OAuth authentication + def access_token + current = oauth2_credentials_in_use + current ? current.__send__(:access_token) : @access_token + end + + # The OAuth 2.0 refresh token, as last refreshed + # + # It is private for the reason {#access_token} is. + # + # @api private + # @return [String, nil] the OAuth 2.0 refresh token + def refresh_token + current = oauth2_credentials_in_use + current ? current.__send__(:refresh_token) : @refresh_token + end + + # Build an OAuth 2.0 authenticator whose refreshes reach the clients that share it + # + # It sends its token requests over the client's connection. A public client has no client secret, and refreshes + # its tokens with its client ID alone. + # + # @api private + # @param client [Client] the client these are the internals of + # @param client_id [String] the OAuth 2.0 client ID + # @param access_token [String] the OAuth 2.0 access token + # @param refresh_token [String, nil] the OAuth 2.0 refresh token, or nil for an access token that is not refreshed + # @return [OAuth2Authenticator] the OAuth 2.0 authenticator + def new_oauth2_authenticator(client, client_id:, access_token:, refresh_token:) + authenticator = OAuth2Authenticator.new(client_id:, client_secret: @client_secret, access_token:, refresh_token:, expires_at: @expires_at, scopes: @scopes) + join(client, authenticator.__send__(:token_requests_over, @connection, base_url, headers)) + end + + # Run a request, again if a refresh replaces an OAuth 2.0 token the API rejects + # + # Only a rejection by the origin of the base URL, which the token is sent to, refreshes it; see {Origin}. An + # app-only bearer token the API rejects is fetched again the same way; see {AppOnlyAuthenticator}. A stream + # that Client#get_stream opens runs through it too. + # + # @api private + # @param client [Client] the client these are the internals of, which a refresh that fails to report is raised + # with + # @yield runs the request + # @return [Object] what the block returns + def refreshing_rejected_token(client, &) + case (current = @authenticator) + when OAuth2Authenticator then current.__send__(:retrying_rejected_token, URI(base_url), @connection, client, &) + when AppOnlyAuthenticator then current.__send__(:retrying_rejected_token, URI(base_url), &) + else yield + end + end + end + private_constant :ClientTokenRefresh + end +end diff --git a/x-core/lib/x/core/connection.rb b/x-core/lib/x/core/connection.rb new file mode 100644 index 00000000..5b9967ec --- /dev/null +++ b/x-core/lib/x/core/connection.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require "net/http" +require "openssl" +require "uri" +require "zlib" +require_relative "connection_pool" +require_relative "connection_proxy" +require_relative "connection_request" +require_relative "proxy_setting" +require_relative "request_context" +require_relative "setting_validator" +require_relative "errors/network_error" +require_relative "errors/callback_error" + +module X + module Core + # Manages HTTP connections to the X API + # + # Internal to x-core: Client, and the authenticators and authorization that fetch tokens send + # their requests with it, so that it can change within 1.x. Configure it through the settings of Client, such as + # proxy_url and the timeouts. + # + # Requests keep their connections open for the next request to the same host, which saves opening a TCP and + # TLS connection each time. A stream opens a connection of its own, which it holds for as long as it reads. + # + # A connection keeps the settings it was built with for as long as it lives, as {Client} does, so a request never + # opens a connection under a setting another thread is halfway through changing. Build another connection to + # reach the API differently. + # + # @api private + class Connection + include ConnectionProxy + include ConnectionRequest + include ProxySetting + + # Default timeout for opening connections in seconds; opening a connection is a TCP handshake and a TLS one, + # which a reachable host finishes in well under a second, so a host that takes longer is one a request waits + # on rather than reaches, and it is given less time than reading a response, which an endpoint may be slow to + # send + DEFAULT_OPEN_TIMEOUT = 10 # seconds + # Default timeout for reading responses in seconds + DEFAULT_READ_TIMEOUT = 60 # seconds + # Default timeout for writing requests in seconds + DEFAULT_WRITE_TIMEOUT = 60 # seconds + # Default time to keep a connection open for the next request to the same host, in seconds; X holds an idle + # connection open for more than five minutes, so a connection closed within this time was closed by a proxy + DEFAULT_KEEP_ALIVE_TIMEOUT = 30 # seconds + # The settings a connection is opened under, which a connection that shares the pool of another holds the same of + SETTINGS = %i[open_timeout read_timeout write_timeout keep_alive_timeout debug_output proxy_url].freeze + private_constant :SETTINGS + # The timeout for opening connections in seconds + # @api private + # @return [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none + # @example Get the open timeout + # connection.open_timeout # => 10 + attr_reader :open_timeout + + # The timeout for reading responses in seconds + # @api private + # @return [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none + # @example Get the read timeout + # connection.read_timeout # => 60 + attr_reader :read_timeout + + # The timeout for writing requests in seconds + # @api private + # @return [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none + # @example Get the write timeout + # connection.write_timeout # => 60 + attr_reader :write_timeout + + # The IO object for debug output + # @api private + # @return [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<, such as + # a Logger, or nil for none + # @example Get the debug output + # connection.debug_output + attr_reader :debug_output + + # The time to keep a connection open for the next request, in seconds + # @api private + # @return [Integer, Float] the time in seconds + # @example Get the keep-alive timeout + # connection.keep_alive_timeout # => 30 + attr_reader :keep_alive_timeout + + # Initialize a new connection + # + # @api private + # @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none + # @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none + # @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none + # @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same + # host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered + # @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<, + # such as a StringIO or a Logger + # @param proxy_url [String, URI::Generic, nil] the proxy URL for requests + # @return [Connection] a new connection instance + # @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor, for any but + # keep_alive_timeout, nil + # @example Create a connection with default settings + # connection = X::Core::Connection.new + # @example Create a connection with custom timeouts + # connection = X::Core::Connection.new(open_timeout: 30, read_timeout: 30) + def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, + write_timeout: DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, proxy_url: nil) + @open_timeout = SettingValidator.timeout!(:open_timeout, open_timeout) + @read_timeout = SettingValidator.timeout!(:read_timeout, read_timeout) + @write_timeout = SettingValidator.timeout!(:write_timeout, write_timeout) + @keep_alive_timeout = SettingValidator.finite_seconds!(:keep_alive_timeout, keep_alive_timeout) + @debug_output = debug_output + @pool = ConnectionPool.new + initialize_proxy(proxy_url) + end + + # Summarize the connection for the console without revealing proxy credentials + # + # @api private + # @return [String] the class name, proxy URL, and timeouts + # @example Inspect a connection + # connection.inspect # => # + def inspect + "#<#{self.class} proxy_url=#{redacted_proxy_url.inspect} open_timeout=#{open_timeout} " \ + "read_timeout=#{read_timeout} write_timeout=#{write_timeout}>" + end + + # Perform an HTTP request + # + # Internal to x-core: Client, its redirects, and token requests send their requests with it, so that it can change + # within 1.x, as the Net::HTTP requests it takes may. + # + # The body of the response is tagged UTF-8, the encoding of the JSON the API sends, rather than the binary that + # Net::HTTP reads it as, so that it can be searched and joined with other Strings. A body that is not valid + # UTF-8, such as the page of a proxy in another encoding, keeps its bytes, and valid_encoding? tells it apart. + # + # @api private + # @param request [Net::HTTPRequest] the HTTP request to perform + # @return [Net::HTTPResponse] the HTTP response, whose body is tagged UTF-8 + # @raise [NetworkError] if a network error occurs, or Net::HTTP cannot read the response, as one whose header + # holds a bare CR, for which it raises ArgumentError + # @example Perform a request + # response = connection.perform(request: request) + def perform(request:) + uri = request.uri + hostname, port = host_and_port(uri) + use_ssl = uri.scheme.eql?("https") + send_request(request, [use_ssl, hostname, port], -> { open_http_client(uri, use_ssl) }) + rescue *NETWORK_ERRORS, ArgumentError => e + raise NetworkError.new("Network error: #{e}", **RequestContext.of(request)) + end + + # Perform a streaming HTTP request + # + # Internal to x-core: Client#get_stream opens its requests with it. + # + # The connection is opened for this request and closed once the block returns, rather than taken from the + # connections kept open and given back, since a stream holds its connection for as long as it reads. Once the + # block returns, the connection is closed with what is left of the body unread, rather than read to its end, as + # Net::HTTP would otherwise read it, which a stream never reaches. + # + # An error the block raises, which the client tags as a CallbackError unless it is one of a socket, is raised + # tagged, rather than reported as a network error: the block of a stream runs inside the request that reads it, + # and the errors a socket raises are the ones a stream reconnects after. + # + # The body is not tagged UTF-8 here, as the body of {#perform} is: Net::HTTP tags a body it reads whole, and + # raises for one it passes to a block a chunk at a time, as a stream is read. + # + # @api private + # @param request [Net::HTTPRequest] the HTTP request to perform + # @yield [Net::HTTPResponse] the HTTP response for streaming + # @return [Object] what the block returns + # @raise [NetworkError] if a network error occurs, or Net::HTTP cannot read the response, as one whose header + # holds a bare CR, for which it raises ArgumentError + # @raise [CallbackError] if the block raises an error that is not one of a socket + # @example Perform a streaming request + # connection.perform_stream(request: request) { |response| response.read_body { |chunk| } } + def perform_stream(request:) + http_client = build_http_client(request.uri) + http_client.use_ssl = request.uri.scheme.eql?("https") + catch { |done| http_client.request(request) { |response| throw done, yield(response) } } + rescue *NETWORK_ERRORS, ArgumentError => e + raise NetworkError.new("Network error: #{e}", **RequestContext.of(request)) + end + + # Check whether an error is one a request raises as a NetworkError + # + # @api private + # @param error [Exception] the error + # @return [Boolean] true if the error is one of a socket + # @example Check an error a stream raised + # X::Core::Connection.network_error?(IOError.new) # => true + def self.network_error?(error) = NETWORK_ERRORS.any? { |network_error| error.is_a?(network_error) } + + # Close the connections kept open between requests + # + # A later request opens a connection again. Nothing else closes them: the sockets of a connection that is + # dropped rather than closed are shut as the garbage collector reclaims them, at a time the process does not + # choose and without the shutdown this performs. + # + # @api private + # @return [void] + # @example Close the connections before a long pause + # connection.close + def close + @pool.clear + end + + # Take connections from the pool of another connection that opens them alike + # + # Internal to x-core: Client#with gives a copy the connections of the client it was copied from with it. The + # two then take their connections from one pool, and close them together, so a copy made for each request opens + # none of its own. A connection whose timeouts, keep-alive timeout, debug output, or proxy differ keeps a pool + # of its own, since a connection is opened under those. + # + # @api private + # @param other [Connection] the connection whose pool to share + # @return [void] + # @example Share the connections of another connection + # connection.share_pool_of(other) + def share_pool_of(other) + @pool = other.__send__(:pool) if SETTINGS.all? { |name| __send__(name) == other.__send__(name) } + end + + private + + # The connections kept open between requests + # @api private + # @return [ConnectionPool] the pool + attr_reader :pool + + # The host and port to connect to for a URI + # + # The URI of a request is an HTTP or HTTPS URL, since Net::HTTP builds a request from no other, so it names both. + # + # @api private + # @param uri [URI::Generic] the URI of the request + # @return [Array(String, Integer)] the host and the port to connect to + def host_and_port(uri) + hostname = uri.hostname #: String + port = uri.port #: Integer + [hostname, port] + end + + # Open an HTTP client for requests read whole, which tags each body UTF-8 + # + # @api private + # @param uri [URI::Generic] the URI of the request + # @param use_ssl [Boolean] whether to connect over TLS + # @return [Net::HTTP] the HTTP client + def open_http_client(uri, use_ssl) + build_http_client(uri).tap do |http_client| + http_client.use_ssl = use_ssl + http_client.response_body_encoding = Encoding::UTF_8 + end + end + + # Build an HTTP client for the host of a URI + # + # The client connects to an HTTPS proxy over TLS. A client that reaches its host directly is given no proxy to + # resolve, rather than the :ENV of Net::HTTP, which reads http_proxy for a request of any scheme: the proxy of a + # request is resolved from the scheme of its own URI, by ConnectionProxy. + # + # @api private + # @param uri [URI::Generic] the URI of the request + # @return [Net::HTTP] the HTTP client + def build_http_client(uri) + host, port = host_and_port(uri) + proxy = proxy_for(uri) + http_client = if proxy + Net::HTTP.new(host, port, proxy.hostname, proxy.port, decode(proxy.user), decode(proxy.password), nil, proxy.instance_of?(URI::HTTPS)) + else + Net::HTTP.new(host, port, nil) + end + configure_http_client(http_client) + end + + # Configure a new HTTP client with the timeouts and debug output of the connection + # + # The settings of a connection never change, so they are applied as each client is opened, rather than before + # each request one makes. + # + # Net::HTTP sends a GET, PUT, or DELETE request again by itself after a timeout or a dropped connection, with the + # same OAuth 1.0a nonce and signature, which the API may bill twice, so its retries are turned off: a request + # that fails raises NetworkError, and the caller decides whether to send it again. + # + # Net::HTTP keeps a connection for two seconds by default, which reuses it within a burst of requests alone, so + # it keeps one for keep_alive_timeout instead. + # + # Net::HTTP returns a body that ends before the length its Content-Length gives as though it were whole, which + # would be parsed as JSON cut short, so ignore_eof is turned off: a connection that closes before the body is + # read raises EOFError, which a request raises as a NetworkError. + # + # @api private + # @param http_client [Net::HTTP] the HTTP client to configure + # @return [Net::HTTP] the configured HTTP client + def configure_http_client(http_client) + http_client.tap do |c| + c.open_timeout = open_timeout + c.read_timeout = read_timeout + c.write_timeout = write_timeout + c.max_retries = 0 + c.ignore_eof = false + c.keep_alive_timeout = keep_alive_timeout + c.set_debug_output(debug_output) + end + end + end + private_constant :Connection + end +end diff --git a/x-core/lib/x/core/connection_pool.rb b/x-core/lib/x/core/connection_pool.rb new file mode 100644 index 00000000..5482e0d1 --- /dev/null +++ b/x-core/lib/x/core/connection_pool.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "net/http" + +module X + module Core + # The open HTTP connections of a Connection, kept to be used again by the next request to the same host + # + # A connection is used by one request at a time: a request takes an idle connection, or opens one, and gives + # it back once it has read the response. A request that fails closes its connection instead, and so does one + # that finishes after clear. A forked process opens + # connections of its own rather than share its parent's. + # + # @api private + class ConnectionPool + # Most idle connections kept open to each host, as many as the chunks x-uploader sends at once at most, so that + # each sender of an upload keeps its connection from one chunk to the next + MAX_IDLE = 16 + + # Initialize an empty pool + # + # @api private + # @return [ConnectionPool] a new pool + def initialize + @lock = Mutex.new + end + + # Run a block with an open connection, keeping it for later if the block returns + # + # The block is told whether the connection was one the pool had kept open, since a connection the peer closed + # while it was idle fails the request that takes it, where one opened for the request did not go stale. + # + # @api private + # @param key [Array] the scheme, host, and port the connection is to + # @param open [Proc] builds a connection to the host, when none is idle + # @param fresh [Boolean] whether to open a connection even when one is idle + # @yield [Net::HTTP, Boolean] the started connection, and whether it came from the pool + # @return [Object] what the block returns + def with(key, open, fresh: false) + pool, http_client, pooled = checkout(key, open, fresh) + kept = nil + begin + result = yield http_client, pooled + kept = checkin(key, http_client, pool) + result + ensure + close(http_client) unless kept + end + end + + # Close every idle connection, and each one in use once its request finishes + # + # A forked process leaves the connections its parent opened alone, since closing one would end the parent's + # TLS session over the socket they share. + # + # @api private + # @return [void] + def clear + idle = @lock.synchronize do + forget_after_fork + @idle.values.flatten.tap { @idle = {} } + end + idle.each { |http_client| close(http_client) } + end + + private + + # Take an idle connection to a host, or open one + # @api private + # @param key [Array] the scheme, host, and port + # @param open [Proc] builds a connection to the host + # @param fresh [Boolean] whether to open a connection even when one is idle + # @return [Array(Hash, Net::HTTP, bool)] the idle connections the connection returns to, the started + # connection, and whether it was idle rather than opened for this request + def checkout(key, open, fresh) + pool, idle = @lock.synchronize do + forget_after_fork + [@idle, (@idle[key]&.pop unless fresh)] + end + http_client = idle || open.call + http_client.start unless http_client.started? + [pool, http_client, !idle.nil?] + end + + # Keep a connection for later, unless the pool was cleared or is full + # @api private + # @param key [Array] the scheme, host, and port + # @param http_client [Net::HTTP] the connection + # @param pool [Hash] the idle connections when the connection was taken, which clear replaces + # @return [Array, nil] the idle connections to the host, or nil if the connection was not kept + def checkin(key, http_client, pool) + @lock.synchronize do + idle = @idle[key] ||= [] + idle.push(http_client) if pool.equal?(@idle) && idle.size < MAX_IDLE + end + end + + # Drop the connections a parent process opened, which a fork must not share + # + # The first call in a process, which is the first in a new pool, starts the pool with no connections. + # + # @api private + # @return [void] + def forget_after_fork + return if @pid.eql?(Process.pid) + + @idle = {} + @pid = Process.pid + end + + # Close a connection, unless it is closed already + # @api private + # @param http_client [Net::HTTP] the connection + # @return [void] + def close(http_client) + http_client.finish + rescue IOError + nil + end + end + private_constant :ConnectionPool + end +end diff --git a/x-core/lib/x/core/connection_proxy.rb b/x-core/lib/x/core/connection_proxy.rb new file mode 100644 index 00000000..61bdb330 --- /dev/null +++ b/x-core/lib/x/core/connection_proxy.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "uri" + +module X + module Core + # The proxy of a connection, parsed from its URL, included into Connection + # + # A proxy URL can hold the user and password of the proxy, so a connection reveals neither its URL nor anything + # parsed from it, and its inspect leaves out the user and password; see {ProxySetting}. + # + # A connection given no proxy URL takes the proxy the environment names for each request, and sends a request to + # a host that no_proxy names without a proxy. Set no_proxy to reach every host directly from a process whose + # environment names a proxy. + # + # Most HTTP clients take the proxy the environment names, and Net::HTTP reads http_proxy alone, whatever the scheme of the request, which would leave the HTTPS requests + # these gems make unproxied for anyone who sets https_proxy, and proxied through whatever http_proxy names for + # anyone who sets that instead. So the proxy of a request is resolved here, from the scheme of its own URI, and + # passed to Net::HTTP, which is given no proxy of its own to resolve. + # + # @api private + module ConnectionProxy + private + + # The parsed proxy URI + # @api private + # @return [URI::Generic, nil] the parsed proxy URI, or nil to take the proxy the environment names + attr_reader :proxy_uri + + # Read the proxy URL a connection is built with + # + # A connection keeps it for as long as it lives, as it keeps every setting. The message of an invalid URL + # leaves out its user and password. + # + # @api private + # @param proxy_url [String, URI::Generic, nil] the proxy URL, or nil to take the proxy from the environment + # @return [void] + # @raise [ArgumentError] if the proxy URL is invalid + def initialize_proxy(proxy_url) + @proxy_uri = proxy_url&.then { |url| parse_proxy_url(url) } + @proxy_url = @proxy_uri&.then { |uri| String(uri) } + end + + # The proxy of a request, the one given or the one the environment names + # + # The proxy the connection was given stands for every request. Otherwise the URI of the request is asked for + # the proxy of its own scheme. + # + # @api private + # @param uri [URI::Generic] the URI of the request + # @return [URI::Generic, nil] the proxy of the request, or nil to reach the host directly + def proxy_for(uri) = proxy_uri || uri.find_proxy + + # A percent-encoded component of a proxy URL, decoded + # + # @api private + # @param component [String, nil] the component, or nil for none + # @return [String, nil] the decoded component, or nil for none + def decode(component) = component&.then { |value| URI.decode_uri_component(value) } + + # The proxy URL without its user and password + # @api private + # @return [String, nil] the proxy URL, without its user and password + def redacted_proxy_url = proxy_url&.then { |url| redact(url) } + + # Parse a proxy URL, which must be an HTTP or HTTPS URL that names a host + # + # A URL that names no host, such as http:proxy:8080, is refused, since Net::HTTP would take its missing host for + # no proxy at all and send each request around the proxy. The URL is parsed anew, so that a URI the caller + # changes later does not change the proxy. + # + # @api private + # @param proxy_url [String, URI::Generic] the proxy URL + # @return [URI::HTTP] the parsed proxy URL + # @raise [ArgumentError] if the proxy URL is invalid + def parse_proxy_url(proxy_url) + proxy_uri = URI(String(proxy_url)) + raise ArgumentError, "Invalid proxy URL: #{redact(proxy_url)}" unless proxy_uri.is_a?(URI::HTTP) && !proxy_uri.host.to_s.empty? + + proxy_uri + rescue URI::InvalidURIError + raise ArgumentError, "Invalid proxy URL: #{redact(proxy_url)}", cause: nil + end + + # A proxy URL without its user and password + # + # A password written into a URL unescaped can hold any character, an @ or a / among them, so everything + # between the scheme and the last @ is left out, and everything before the last @ of a URL without a scheme. + # + # @api private + # @param proxy_url [String, URI::Generic] the proxy URL + # @return [String] the URL, without the user and password + def redact(proxy_url) = String(proxy_url).sub(%r{([^/]*//)?.*@}m, "\\1") + end + private_constant :ConnectionProxy + end +end diff --git a/x-core/lib/x/core/connection_request.rb b/x-core/lib/x/core/connection_request.rb new file mode 100644 index 00000000..e84cb2d9 --- /dev/null +++ b/x-core/lib/x/core/connection_request.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "net/http" +require "openssl" +require "zlib" +require_relative "request_builder" + +module X + module Core + # How a connection sends one request, and what it counts as a failure of the network, included into Connection + # @api private + module ConnectionRequest + # Network errors that should be wrapped in NetworkError + # + # IOError covers EOFError, and a read from a socket closed under it. SystemCallError covers every error the + # operating system reports for a socket, such as a refused, reset, or aborted connection, a network that is down + # or has no route, or a write refused with EPIPE. The open, read, and write timeouts are those of Net::HTTP, and + # no other Timeout::Error, such as one Timeout.timeout raises around a request, which is raised as it is, so that + # a rescue of it still catches it, and no handler sends the request again for it. A connection cut off + # mid-response can leave Net::HTTP a status line it cannot parse, or a compressed body that Zlib cannot inflate, + # and a proxy that refuses to open a tunnel, such as with 407 Proxy Authentication Required, raises a + # Net::ProtocolError. A response whose Content-Length or Content-Range Net::HTTP cannot read raises a + # Net::HTTPHeaderSyntaxError. + NETWORK_ERRORS = [ + IOError, + Net::HTTPBadResponse, + Net::HTTPHeaderSyntaxError, + Net::ProtocolError, + OpenSSL::SSL::SSLError, + SocketError, + SystemCallError, + Net::OpenTimeout, + Net::ReadTimeout, + Net::WriteTimeout, + Zlib::Error + ].freeze + + # Errors that say a connection kept open had been closed by the time a request was sent on it + # + # EOFError is read from a socket the peer closed, and a write to one it closed or reset is refused with EPIPE, + # ECONNRESET, or ECONNABORTED. A timeout is not among them: a request that timed out waiting for its response + # may have reached the API, which may have acted on it. Nor is any of them once the response has begun: a body + # cut off as it is read is one the API sent. + STALE_CONNECTION_ERRORS = [EOFError, Errno::ECONNABORTED, Errno::ECONNRESET, Errno::EPIPE].freeze + private_constant :NETWORK_ERRORS, :STALE_CONNECTION_ERRORS + + private + + # Send a request, once more on a new connection when a kept one had gone stale + # + # X, or a proxy between, can close a connection that is being kept open for the next request, and the request + # that takes it then fails as it is written or its response is first read, as one of STALE_CONNECTION_ERRORS. + # That request never reached the API, so an idempotent one is sent again, on a connection opened for it rather + # than taken from the pool, where another connection may have gone stale too. A request that failed any other + # way, such as by timing out, or on a connection opened for it, is not sent again: nothing about it says the API + # did not act on it. Nor is one whose response had begun, which Net::HTTP says by passing it to the block of + # the request once its status and headers are read: a connection that drops as the body is read was not stale, + # and the API answered the request, so it raises as a read that timed out does. Net::HTTP would send a request + # again of its own, after a timeout as well, with the OAuth 1.0a nonce and signature of the attempt that failed, + # which is why its retries are turned off and the request the caller built is sent again here. + # + # @api private + # @param request [Net::HTTPRequest] the HTTP request to send + # @param key [Array] the scheme, host, and port to connect to + # @param open [Proc] builds a connection to the host + # @return [Net::HTTPResponse] the HTTP response + # @raise [StandardError] whatever the request raised, once it may not be sent again + def send_request(request, key, open) + stale = false + begin + @pool.with(key, open) do |http_client, from_pool| + stale = from_pool + http_client.request(request) { stale = false } + end + rescue *STALE_CONNECTION_ERRORS + raise unless stale && idempotent?(request) + + @pool.with(key, open, fresh: true) { |http_client, _| http_client.request(request) } + end + end + + # Check whether sending a request again has the same effect as sending it once + # @api private + # @param request [Net::HTTPRequest] the HTTP request + # @return [Boolean] true for a GET, PUT, or DELETE + def idempotent?(request) = RequestBuilder.idempotent?(request.method.downcase.to_sym) + end + private_constant :ConnectionRequest + end +end diff --git a/x-core/lib/x/core/credential_holder.rb b/x-core/lib/x/core/credential_holder.rb new file mode 100644 index 00000000..175e79c3 --- /dev/null +++ b/x-core/lib/x/core/credential_holder.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module X + module Core + # Refuses Marshal, YAML, and JSON for what holds credentials, included into a client and its internals, an + # authenticator, and an authorization + # + # Marshal and YAML would write the credentials such an object holds, in the clear, wherever what they write is + # kept, such as a cache, where a client in a Hash that is cached would carry them without a word, or the arguments + # of a job a queue writes as YAML. Where Marshal would not raise TypeError, for a lock the object holds, it would + # write them silently, and YAML writes every instance variable, a lock or not, so each refuses alike, with the + # TypeError Marshal raises for what it cannot write, such as a Proc, so that code that rescues it around + # Marshal.dump, as a cache or a deep copy does, rescues this too. JSON is refused alike, since ActiveSupport's + # Object#as_json reads every instance variable as YAML does, so that rendering a client, or a Hash that holds one, + # as JSON would write its credentials into a response or a log. + # + # Internal to x-core: the methods it gives these classes, marshal_dump, encode_with, as_json, and to_json, are + # public API, but the module is only how they are shared, and which classes include it can change within 1.x. + # They call raise and format on Kernel, so that a module included into X::Client after it, as x-objects and + # x-uploader are, cannot change what they raise, whatever the module defines. + # + # @api private + module CredentialHolder + # The message of the error raised for Marshal, YAML, or JSON, which names the class refused and what refused it + REFUSAL_MESSAGE = "%s holds credentials, which %s would write in the clear wherever it is kept; keep the " \ + "credentials in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them" + private_constant :REFUSAL_MESSAGE + + # Refuse to be written with Marshal, which would write the credentials + # + # @api public + # @return [void] + # @raise [TypeError] always + # @example Store the tokens a refresh issued, rather than the client + # X::Client.new(**credentials, save_tokens: ->(tokens) { store.save(**tokens.to_h) }) + def marshal_dump = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "Marshal")) + + # Refuse to be written as YAML, which would write the credentials + # + # YAML reads no marshal_dump, and writes every instance variable of an object that does not say how it is + # written, credentials and all, so it is refused as Marshal is. + # + # @api public + # @param _coder [Psych::Coder] the coder YAML would write the object with + # @return [void] + # @raise [TypeError] always + # @example Enqueue the identifier of a user, rather than a client, for a job its queue writes as YAML + # PostJob.perform_later(user_id: client.authenticator.user_id) + def encode_with(_coder) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "YAML")) + + # Refuse to be read as JSON, which would write the credentials + # + # ActiveSupport's Object#as_json reads every instance variable of an object that does not say how it is read, + # credentials and all, so it is refused as YAML is. + # + # @api public + # @return [void] + # @raise [TypeError] always + # @example Render the user a client acts for, rather than the client + # render json: {user_id: client.authenticator.user_id} + def as_json(*) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "JSON")) + + # Refuse to be written as JSON, which would write the credentials + # + # It raises as {#as_json} does, for the reason that says, so that JSON.generate refuses a client within what it + # writes as well. + # + # @api public + # @param _state [JSON::State, nil] the state JSON would write the object with + # @return [void] + # @raise [TypeError] always + # @example Log the user a client acts for, rather than the client + # logger.info(JSON.generate(user_id: client.authenticator.user_id)) + def to_json(_state = nil) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "JSON")) + end + private_constant :CredentialHolder + end +end diff --git a/x-core/lib/x/core/credential_validator.rb b/x-core/lib/x/core/credential_validator.rb new file mode 100644 index 00000000..ad129443 --- /dev/null +++ b/x-core/lib/x/core/credential_validator.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require_relative "authenticator" + +module X + module Core + # Checks that the credentials of a new client form complete sets + # + # A client authenticates with the first complete set of credentials it has, and ignores the rest. Credentials that + # form no set would send requests without credentials, an access token without the rest of its set would + # authenticate as the app, or with a bearer token, rather than as the user it belongs to, and any other credential + # of a set that is not complete, such as a client ID beside a bearer token, is a mistake that a client would + # otherwise hide. So every credential must belong to a complete set. A client may hold several, such as the + # bearer token of an app beside its API key and secret, but not OAuth 2.0 credentials beside OAuth 1.0a ones, which + # share the access token, and which it authenticates with first, so that the OAuth 2.0 credentials would go unused. + # A client never changes the credentials it was built with, so this runs once, when the client is built. + # + # @api private + module CredentialValidator + extend self + + # The message of the error raised for credentials that do not form a complete set + INCOMPLETE_CREDENTIALS = "The credentials given do not form a complete set. Pass api_key, api_key_secret, " \ + "access_token, and access_token_secret for OAuth 1.0a; client_id and access_token, with the refresh_token " \ + "that refreshes it and the client_secret of a confidential client, for OAuth 2.0; bearer_token for the app's " \ + "bearer token; or api_key and api_key_secret to authenticate as the app. Leave out any credential of a set " \ + "that is not complete" + private_constant :INCOMPLETE_CREDENTIALS + + # The credentials of each set: OAuth 1.0a, OAuth 2.0 for a confidential and for a public client, with a refresh + # token and without one, as an authorization without offline.access issues them, a bearer token, and the app's + # API key and secret + CREDENTIAL_SETS = [ + %i[api_key api_key_secret access_token access_token_secret], + %i[client_id client_secret access_token refresh_token], + %i[client_id access_token refresh_token], + %i[client_id client_secret access_token], + %i[client_id access_token], + %i[bearer_token], + %i[api_key api_key_secret] + ].freeze + + # The credentials a client authenticates with before OAuth 2.0 credentials, when it holds both + OAUTH1_CREDENTIALS = CREDENTIAL_SETS.first + # The credentials of the least set of OAuth 2.0, which an expiration time is the expiration time of the token of + OAUTH2_CREDENTIALS = CREDENTIAL_SETS.fetch(4) + # The credentials of OAuth 2.0 that no other set holds, which a client that authenticates with OAuth 1.0a ignores + OAUTH2_ONLY_CREDENTIALS = %i[client_id client_secret refresh_token].freeze + private_constant :OAUTH1_CREDENTIALS, :OAUTH2_CREDENTIALS, :OAUTH2_ONLY_CREDENTIALS + + # The message of the error raised for OAuth 2.0 credentials the client would leave unused + UNUSED_OAUTH2_CREDENTIALS = "%s are OAuth 2.0 credentials, which a client given OAuth 1.0a credentials would " \ + "leave unused, since it authenticates with those, and the access_token they share is the OAuth 1.0a one. Pass " \ + "the credentials of one or the other" + private_constant :UNUSED_OAUTH2_CREDENTIALS + + # The message of the error raised for an expiration time the client would leave unused + UNUSED_EXPIRES_AT = "expires_at is the time an OAuth 2.0 access token expires, so it is given beside the " \ + "client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \ + "bearer_token, an api_key and api_key_secret, or none, which would leave it unused. Leave it out" + private_constant :UNUSED_EXPIRES_AT + + # The message of the error raised for an expiration time that is not a Time + INVALID_EXPIRES_AT = "expires_at must be a Time, such as Time.at(seconds) for a time stored as seconds since the " \ + "epoch, or nil if it is not known" + private_constant :INVALID_EXPIRES_AT + + # The message of the error raised for scopes the client would leave unused + UNUSED_SCOPES = "scopes are the scopes X granted an OAuth 2.0 access token, so they are given beside the " \ + "client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \ + "bearer_token, an api_key and api_key_secret, or none, which would leave them unused. Leave them out" + private_constant :UNUSED_SCOPES + + # The message of the error raised for scopes that are not an Array of scopes + INVALID_SCOPES = "scopes must be an Array of Strings that each name a scope, such as %w[tweet.read users.read], " \ + "or nil if they are not known" + private_constant :INVALID_SCOPES + + # A scope, which OAuth 2.0 names with printable characters other than a space, a quote, and a backslash + SCOPE = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/ + private_constant :SCOPE + + # The message of the error raised for a credential that is an empty String + EMPTY_CREDENTIAL = "%s is empty. Pass the credential, or leave it out, since an empty one authenticates nothing" + private_constant :EMPTY_CREDENTIAL + + # The message of the error raised for a credential that is not a String + NOT_A_STRING = "%s must be a String, not a %s" + private_constant :NOT_A_STRING + + # The message raised for a credential an authenticator requires that is nil or empty + MISSING_CREDENTIAL = "%s is nil or empty. Pass the credential, which the authenticator cannot authenticate without" + private_constant :MISSING_CREDENTIAL + + # The message of the error raised for an authenticator that is not one + NOT_AN_AUTHENTICATOR = "authenticator must be an X::Authenticator, such as an X::OAuth2Authenticator, or nil, " \ + "not a %s" + private_constant :NOT_AN_AUTHENTICATOR + + # The message of the error raised for an authenticator given beside credentials + AUTHENTICATOR_AND_CREDENTIALS = "An authenticator holds the credentials it authenticates with, so it cannot be " \ + "given beside %s. Pass the authenticator, or the credentials, and leave out the other" + private_constant :AUTHENTICATOR_AND_CREDENTIALS + + # Raise for a credential not a String or empty, or an expiration time not a Time + # + # An environment variable that is not set is often read as an empty String, as ENV.fetch("X_BEARER_TOKEN", "") + # reads it, which would send an Authorization header that authenticates nothing, for the API to refuse. A + # credential that is not a String, such as the Integer of a client ID read from YAML, would be signed with as the + # String it converts to, if it converts to one at all, so it is refused rather than sent as something else. + # + # @api private + # @param credentials [Hash{Symbol => String, Time, nil}] the credentials, as Client#initialize accepts them + # @return [void] + # @raise [ArgumentError] if a credential is neither a String nor nil, is an empty String, or one of whitespace + # alone, if the expiration time is neither a Time nor nil, or if the scopes are neither an Array of scopes nor + # nil + # @example Check the credentials of a client + # X::Core::CredentialValidator.validate_values!(bearer_token: "", expires_at: nil) + def validate_values!(credentials) + credentials.except(:expires_at, :scopes).each do |name, value| + next if value.nil? + raise ArgumentError, format(NOT_A_STRING, name, value.class) unless String === value + raise ArgumentError, format(EMPTY_CREDENTIAL, name) unless value.match?(/\S/) + end + validate_expires_at!(credentials[:expires_at]) + validate_scopes!(credentials[:scopes]) + end + + # Raise for a credential an authenticator requires that is nil or empty + # + # An authenticator is given the credentials it authenticates with, so each is required, and one given as nil, + # as ENV[] reads a variable that is not set, or as an empty String, would authenticate nothing. They, and those it + # takes beside them, are checked as {#validate_values!} checks those of a client. + # + # @api private + # @param required [Hash{Symbol => String, nil}] the credentials the authenticator requires, by the names it + # takes them by + # @param others [Hash{Symbol => String, Time, nil}] the credentials and expiration time it takes beside them + # @return [void] + # @raise [ArgumentError] if a credential required is nil, an empty String, or one of whitespace alone + # @raise [ArgumentError] if a credential is neither a String nor nil, another credential is empty, or the + # expiration time is neither a Time nor nil + # @example Check the credential of a bearer token authenticator + # X::Core::CredentialValidator.validate_required!({bearer_token: ENV["X_BEARER_TOKEN"]}) + def validate_required!(required, others = {}) + required.each { |name, value| raise ArgumentError, format(MISSING_CREDENTIAL, name) unless value.to_s.match?(/\S/) } + validate_values!(required.merge(others)) + end + + # Raise for an expiration time that is not a Time + # + # A token refresh compares the expiration time with the current time before every request, which fails for a time + # stored as a number or a String. + # + # @api private + # @param expires_at [Object] the expiration time + # @return [void] + # @raise [ArgumentError] if the expiration time is neither a Time nor nil + # @example Check an expiration time + # X::Core::CredentialValidator.validate_expires_at!(Time.now + 7200) + def validate_expires_at!(expires_at) + raise ArgumentError, INVALID_EXPIRES_AT unless expires_at.nil? || expires_at.is_a?(Time) + end + + # Raise for scopes that are not an Array of scopes + # + # @api private + # @param scopes [Object] the scopes + # @return [void] + # @raise [ArgumentError] if the scopes are neither an Array of Strings that each name a scope nor nil + # @example Check scopes + # X::Core::CredentialValidator.validate_scopes!(%w[tweet.read users.read]) + def validate_scopes!(scopes) + raise ArgumentError, INVALID_SCOPES unless scopes.nil? || scopes?(scopes) + end + + # Check whether scopes are an Array of Strings that each name a scope + # + # @api private + # @param scopes [Object] the scopes + # @return [Boolean] true if the scopes are an Array of Strings that each name a scope + # @example Check scopes + # X::Core::CredentialValidator.scopes?(%w[tweet.read users.read]) # => true + def scopes?(scopes) = scopes.is_a?(Array) && scopes.all? { |scope| scope.is_a?(String) && SCOPE.match?(scope) } + + # The scopes as the tokens, an authenticator, or a client holds them + # + # They are frozen, the Array and each String, apart from those given, so that the caller that gave them can + # change neither what the client holds nor what save_tokens is passed. + # + # @api private + # @param scopes [Array, nil] the scopes, which validate_scopes! accepted + # @return [Array, nil] the scopes, frozen, or nil for none + # @example Hold scopes + # X::Core::CredentialValidator.frozen_scopes(%w[tweet.read]) # => ["tweet.read"] + def frozen_scopes(scopes) = scopes&.map { |scope| -scope }.freeze + + # Raise for an authenticator that is not one, or that is given beside credentials + # + # A client given an authenticator authenticates with it alone, so a credential given beside it, the expiration + # time of an OAuth 2.0 access token included, which an OAuth2Authenticator is built with, would go unused. The + # error names the class of something that is not an authenticator, rather than inspect it, since a Hash of + # credentials passed in its place would show them. + # + # @api private + # @param authenticator [Object] the authenticator, or nil for none + # @param credentials [Hash{Symbol => String, Time, nil}] the credentials, as Client#initialize accepts them + # @return [void] + # @raise [ArgumentError] if the authenticator is neither an Authenticator nor nil, or is given beside a + # credential + # @example Check the authenticator of a client + # X::Core::CredentialValidator.validate_authenticator!(authenticator, bearer_token: nil) + def validate_authenticator!(authenticator, credentials) + return if authenticator.nil? + raise ArgumentError, format(NOT_AN_AUTHENTICATOR, authenticator.class) unless authenticator.is_a?(Authenticator) + + given = credentials.compact.keys + raise ArgumentError, format(AUTHENTICATOR_AND_CREDENTIALS, given.join(", ")) unless given.empty? + end + + # Raise for incomplete credentials, or ones that leave others unused + # + # A client given OAuth 1.0a credentials authenticates with them, so OAuth 2.0 credentials given beside them, which + # would share their access token, raise. An expiration time and scopes are those of an OAuth 2.0 access token, + # which a client reads only when it authenticates with OAuth 2.0 credentials, so either given to a client that + # authenticates otherwise raises, as it does beside an authenticator. + # + # @api private + # @param credentials [Hash{Symbol => String, Time, Array, nil}] the credentials, as Client#initialize + # accepts them + # @return [void] + # @raise [ArgumentError] if a credential belongs to no complete set, OAuth 2.0 credentials are given beside + # OAuth 1.0a ones, or an expiration time or scopes are given to a client that does not authenticate with OAuth + # 2.0 credentials + # @example Check the credentials of a client + # X::Core::CredentialValidator.validate!(api_key: "key") + def validate!(credentials) + raise ArgumentError, INCOMPLETE_CREDENTIALS if incomplete?(credentials) + + unused = unused_oauth2_credentials(credentials) + raise ArgumentError, format(UNUSED_OAUTH2_CREDENTIALS, unused.join(", ")) unless unused.empty? + raise ArgumentError, UNUSED_EXPIRES_AT if unused?(credentials, :expires_at) + raise ArgumentError, UNUSED_SCOPES if unused?(credentials, :scopes) + end + + private + + # Check whether a credential was given that belongs to no complete set + # + # The expiration time and scopes are no credentials, so they belong to no set, and unused? checks them. + # + # @api private + # @param credentials [Hash{Symbol => String, Time, Array, nil}] the credentials + # @return [Boolean] true if the credentials do not form complete sets + def incomplete?(credentials) + given = credentials.except(:expires_at, :scopes).compact.keys + complete = CREDENTIAL_SETS.select { |set| (set - given).empty? } + (given - complete.flatten).any? + end + + # The OAuth 2.0 credentials given beside OAuth 1.0a ones, which would go unused + # + # @api private + # @param credentials [Hash{Symbol => String, Time, Array, nil}] the credentials + # @return [Array] the names of the OAuth 2.0 credentials given, or none when OAuth 1.0a ones are not + def unused_oauth2_credentials(credentials) + given = credentials.compact.keys + (OAUTH1_CREDENTIALS - given).empty? ? OAUTH2_ONLY_CREDENTIALS & given : [] + end + + # Check whether expires_at or scopes were given that the client would leave unused + # + # @api private + # @param credentials [Hash{Symbol => String, Time, Array, nil}] the credentials + # @param name [Symbol] the name of what was given of the access token, expires_at or scopes + # @return [Boolean] true if it was given, and the client holds no OAuth 2.0 credentials, which validate! refuses + # beside OAuth 1.0a ones before it checks this + def unused?(credentials, name) + given = credentials.compact.keys + given.include?(name) && !(OAUTH2_CREDENTIALS - given).empty? + end + end + private_constant :CredentialValidator + end +end diff --git a/x-core/lib/x/core/errors/authorization_denied.rb b/x-core/lib/x/core/errors/authorization_denied.rb new file mode 100644 index 00000000..f21bb529 --- /dev/null +++ b/x-core/lib/x/core/errors/authorization_denied.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "error" + +module X + # Error raised when the redirect back from X reports that the app was not authorized + # + # X redirects the user back to the app with an error, rather than a code, when the user declines to authorize the + # app, or when X cannot ask them, and the redirect is refused when its state does not match the one the app sent, + # or when it is not a valid URL. The redirect is a request of the user's browser, not a response of X, so the error + # holds no response, and descends from Error directly; a refusal of X to exchange the code raises + # AuthorizationError, a ClientError that holds the response of X. + # + # @api public + class AuthorizationDenied < Error + # The OAuth 2.0 error code the redirect reported, such as access_denied + # @api public + # @return [String, nil] the error code, or nil for a redirect that reported none, as one whose state does not + # match reports none + # @example Tell a user who declined from a failure + # rescue X::AuthorizationDenied => e + # redirect_to root_path if e.error_code.eql?("access_denied") + attr_reader :error_code + + # Build the error of a redirect that simple_oauth refused + # + # Internal to x-core: it takes an error of simple_oauth, whose type may change within 1.x, and is private, so + # OAuth2Authorization calls it with __send__. + # + # @api private + # @param error [SimpleOAuth::OAuth2::Error] the failure + # @param default_message [String] the message when the redirect describes no reason + # @return [AuthorizationDenied] a new instance + # @example Raise the error of a redirect that reported a user who declined + # raise X::AuthorizationDenied.__send__(:from, error, "Authorization failed") + def self.from(error, default_message) = new(error.description || error.code || default_message, error_code: error.code) + private_class_method :from + + # Initialize a new AuthorizationDenied + # + # @api public + # @param message [String, nil] the reason the app was not authorized, or nil for the name of the class, as an + # exception raised with no message is named + # @param error_code [String, nil] the OAuth 2.0 error code, or nil for none + # @return [AuthorizationDenied] a new instance + # @example Create an error + # X::AuthorizationDenied.new("The user denied the request", error_code: "access_denied") + # @example Raise the error with no message, as a test stub may + # raise X::AuthorizationDenied + def initialize(message = nil, error_code: nil) + super(message) + @error_code = error_code + end + end +end diff --git a/x-core/lib/x/core/errors/authorization_error.rb b/x-core/lib/x/core/errors/authorization_error.rb new file mode 100644 index 00000000..6e31e5d6 --- /dev/null +++ b/x-core/lib/x/core/errors/authorization_error.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "json" +require_relative "client_error" + +module X + # Error raised when X refuses to issue a token + # + # X refuses a token when it declines an authorization code, a refresh token, such as one that was revoked or already + # used, or an app's API key and secret. It answers with a response of 4xx, which the error holds, as any other + # ClientError holds the response that raised it, and the error code of the response tells the refusals apart. A + # token endpoint that fails to answer, with 429 Too Many Requests, a server error, a redirect, or a page that is not + # JSON, such as that of a proxy, firewall, or captive portal, refuses nothing, so it raises the HTTPError, such as + # the TooManyRequests or ServerError a client retries, or the InvalidResponse, a response of the API raises, rather + # than this error. + # + # It is a ClientError, so code that rescues the failures of a response, as rescue X::HTTPError does around the + # requests of a client, catches the refresh that X refuses in the middle of one. A client that refreshes an OAuth 2.0 + # token the API rejected raises it when X refuses the refresh, with the Unauthorized that rejected the token as its + # cause, so rescue both to ask the user to authorize the app again: Unauthorized for credentials the API rejects, + # and this error for a refresh token X no longer accepts. The redirect back from X that reports a user who declined, + # or a state that does not match, carries no response of X, so it raises AuthorizationDenied instead. + # + # @api public + class AuthorizationError < ClientError + # The OAuth 2.0 error code X reported, such as invalid_grant or invalid_request + # + # It is read from the JSON of the body, whatever the content type of the response says, as OAuth 2.0 reads it. + # + # @api public + # @return [String, nil] the error code, or nil if X reported none + # @example Forget the tokens of a user whose refresh token X no longer accepts + # rescue X::AuthorizationError => e + # store.forget(user) if e.error_code.eql?("invalid_request") + def error_code + String.try_convert(Hash.try_convert(JSON.parse(body.to_s))&.[]("error")) + rescue JSON::ParserError + nil + end + end +end diff --git a/x-core/lib/x/core/errors/bad_gateway.rb b/x-core/lib/x/core/errors/bad_gateway.rb new file mode 100644 index 00000000..b42eccdf --- /dev/null +++ b/x-core/lib/x/core/errors/bad_gateway.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "server_error" + +module X + # Raised for a 502 Bad Gateway response, which the API sends when it cannot reach the service behind it + # @api public + class BadGateway < ServerError; end +end diff --git a/x-core/lib/x/core/errors/bad_request.rb b/x-core/lib/x/core/errors/bad_request.rb new file mode 100644 index 00000000..bc6530ea --- /dev/null +++ b/x-core/lib/x/core/errors/bad_request.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 400 Bad Request response, which the API sends for a request it cannot read, such as one whose + # parameters or body it does not take + # @api public + class BadRequest < ClientError; end +end diff --git a/x-core/lib/x/core/errors/callback_error.rb b/x-core/lib/x/core/errors/callback_error.rb new file mode 100644 index 00000000..8241a5fd --- /dev/null +++ b/x-core/lib/x/core/errors/callback_error.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module X + module Core + # Stands in for an error a callback raised, so that what runs it does not take it for an error of its own + # + # A request runs the client's on_response hook, the block it was passed, and whatever an object_class builds its + # objects with, inside the handlers that send it again, wait out a rate limit, and refresh a rejected token, where + # an X::ServerError, an X::TooManyRequests, or an X::Unauthorized a callback raised, such as one of a request it + # made itself, would otherwise send the request again, reading what the API billed again, or spend a refresh + # token. The block of Client#get_stream runs inside the request that reads its body, where an X::Unauthorized it + # raised would otherwise refresh a token, and the stream be opened again. + # + # Internal to x-core: Client#perform, ResponseParser, and the stream Client#get_stream opens tag the errors of a + # callback with it, and Client raises the error it holds in its place, noted as a callback's, so that + # Client#with_retries, which runs outside the request, does not send it again for that error either. + # + # @api private + class CallbackError < StandardError + # The errors of callbacks raised in place of the CallbackError that tagged them, each held as its own key for as + # long as anything else holds it + UNTAGGED = ObjectSpace::WeakMap.new + private_constant :UNTAGGED + + # The error the callback raised + # @api private + # @return [StandardError] the error the callback raised + # @example Raise the error a callback raised + # raise error.error + attr_reader :error + + # Run a callback, tagging the error it raises + # + # An error already tagged, by a callback that runs within another, is raised as it is. + # + # @api private + # @yield [] runs the callback + # @return [Object] what the callback returned + # @raise [CallbackError] if the callback raised + # @example Run the hook of a response + # X::Core::CallbackError.tagging { on_response.call(summary) } + def self.tagging + yield + rescue CallbackError + raise + rescue => e + raise new(e) + end + + # The error a CallbackError holds, noted as a callback's, to raise in its place + # + # @api private + # @param tagged [CallbackError] the CallbackError that tagged the error + # @return [StandardError] the error the callback raised + # @example Raise the error a callback raised once the request is left + # raise X::Core::CallbackError.untag(e) + def self.untag(tagged) + UNTAGGED[tagged.error] = tagged.error + end + + # Check whether an error is one a callback raised, which a request raised untagged + # + # A request raises it in place of the CallbackError that tagged it, once its handlers are left. + # + # @api private + # @param error [StandardError] the error a request raised + # @return [Boolean] true if a callback raised it + # @example Check whether an error is a callback's + # X::Core::CallbackError.untagged?(error) # => false + def self.untagged?(error) = UNTAGGED[error].equal?(error) + + # Initialize a new CallbackError + # + # It takes the message of the error it stands in for, which is read only if it escapes what runs the callback. + # + # @api private + # @param error [StandardError] the error the callback raised + # @return [CallbackError] a new instance + # @example Tag the error a callback raised + # raise X::Core::CallbackError, error + def initialize(error) + super + @error = error + end + end + private_constant :CallbackError + end +end diff --git a/x-core/lib/x/core/errors/client_error.rb b/x-core/lib/x/core/errors/client_error.rb new file mode 100644 index 00000000..46cba6e5 --- /dev/null +++ b/x-core/lib/x/core/errors/client_error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "http_error" + +module X + # The base class of the errors of a 4xx response, which the API refused + # + # The request is most often the reason it was refused, so sending it again unchanged is refused again. Some are + # refused for a state that can change: TooManyRequests and RequestTimeout pass once the rate limit resets or the API + # reads the request in time, PaymentRequired once credit is added, and Conflict once a filtered stream has rules. + # + # @api public + # @example Tell a request the API refused from a failure of the API + # rescue X::ClientError => e + # logger.warn("#{e.status}: #{e.message}") + class ClientError < HTTPError; end +end diff --git a/x-core/lib/x/core/errors/conflict.rb b/x-core/lib/x/core/errors/conflict.rb new file mode 100644 index 00000000..dc000dfd --- /dev/null +++ b/x-core/lib/x/core/errors/conflict.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 409 Conflict response, which X sends a filtered stream that has no rules + # @api public + class Conflict < ClientError; end +end diff --git a/x-core/lib/x/core/errors/error.rb b/x-core/lib/x/core/errors/error.rb new file mode 100644 index 00000000..f8ea549e --- /dev/null +++ b/x-core/lib/x/core/errors/error.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module X + # The base class of every error the X gems raise + # + # Rescuing it catches every way a request can fail: an HTTPError for a response that failed, a NetworkError for + # a request that never got a response, and the errors the gems raise for what they will not send or cannot read. + # + # X::Error + # ├── X::HTTPError a response that failed, which the error holds, and itself a redirect not followed + # │ ├── X::ClientError 4xx: the request was refused, and mostly is again + # │ │ ├── X::BadRequest 400 + # │ │ ├── X::Unauthorized 401 + # │ │ ├── X::PaymentRequired 402 + # │ │ ├── X::Forbidden 403 + # │ │ ├── X::NotFound 404 + # │ │ ├── X::MethodNotAllowed 405 + # │ │ ├── X::NotAcceptable 406 + # │ │ ├── X::RequestTimeout 408 + # │ │ ├── X::Conflict 409 + # │ │ ├── X::Gone 410 + # │ │ ├── X::PayloadTooLarge 413 + # │ │ ├── X::UnsupportedMediaType 415 + # │ │ ├── X::UnprocessableEntity 422 + # │ │ ├── X::TooManyRequests 429 + # │ │ ├── X::UnavailableForLegalReasons 451 + # │ │ └── X::AuthorizationError X refused to issue a token, as its token endpoint answered + # │ ├── X::ServerError 5xx: the API failed, and the same request may pass later + # │ │ ├── X::InternalServerError 500 + # │ │ ├── X::BadGateway 502 + # │ │ ├── X::ServiceUnavailable 503 + # │ │ └── X::GatewayTimeout 504 + # │ └── X::InvalidResponse 2xx: a response that succeeded, whose body is not the JSON it claims + # ├── X::NetworkError the request never reached the API, or its response never arrived + # ├── X::AuthorizationDenied the redirect back from X says the app was not authorized + # ├── X::TokenReportFailed save_tokens raised for the tokens of an exchange of a code or a refresh + # ├── X::TooManyRedirects a response redirected more times than max_redirects allows + # ├── X::UnsupportedOperation the API, or the credentials of the client, offer no way to do what was asked + # ├── X::UnsupportedFormat Marshal, YAML, or JSON read a state written in a format this release does not read + # ├── X::Objects::Error the failures of the object layer, from x-objects + # │ ├── X::MissingResource a resource that was asked for does not exist + # │ ├── X::UnreadableResponse a response that succeeded says what the API does not document + # │ │ └── X::InvalidAttribute a response holds a value that is not what the API documents it to be + # │ └── X::MissingClient a resource that holds no client was asked to make a request + # ├── X::Streaming::Error the failures of a stream, from x-streaming + # │ ├── X::StreamError a line of a stream held errors and no data + # │ └── X::RulesRejected the API left rules of the filtered stream unchanged, and no block took them + # └── X::Uploader::Error the failures of an upload, from x-uploader + # ├── X::InvalidMedia the media does not exist, cannot be read, is empty, or is too large + # │ └── X::InvalidMediaType the media is of a type the API does not take + # ├── X::ChunkedUploadFailed a chunk or the finalize of an initialized upload failed + # ├── X::AltTextFailed the media was uploaded, but its alt text could not be added + # ├── X::MediaProcessingCheckFailed the media was uploaded, but a check of its processing failed + # ├── X::MediaProcessingFailed X could not process the media that was uploaded + # ├── X::MediaProcessingTimeout the media was still processing when the wait ran out + # └── X::MissingMediaData a response of an upload describes no media + # + # @api public + # @example Rescue every failure of a request + # begin + # client.get("users/me") + # rescue X::Error => e + # logger.error(e.message) + # end + class Error < StandardError; end +end diff --git a/x-core/lib/x/core/errors/forbidden.rb b/x-core/lib/x/core/errors/forbidden.rb new file mode 100644 index 00000000..f56158f0 --- /dev/null +++ b/x-core/lib/x/core/errors/forbidden.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 403 Forbidden response, which the API sends for a request whose credentials it accepts but whose + # access level, or whose account, may not do what it asks + # @api public + class Forbidden < ClientError; end +end diff --git a/x-core/lib/x/core/errors/gateway_timeout.rb b/x-core/lib/x/core/errors/gateway_timeout.rb new file mode 100644 index 00000000..e250895b --- /dev/null +++ b/x-core/lib/x/core/errors/gateway_timeout.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "server_error" + +module X + # Raised for a 504 Gateway Timeout response, which the API sends when the service behind it took too long to answer + # @api public + class GatewayTimeout < ServerError; end +end diff --git a/x-core/lib/x/core/errors/gone.rb b/x-core/lib/x/core/errors/gone.rb new file mode 100644 index 00000000..2c08b5a2 --- /dev/null +++ b/x-core/lib/x/core/errors/gone.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 410 Gone response, which the API sends for an endpoint it has retired + # @api public + class Gone < ClientError; end +end diff --git a/x-core/lib/x/core/errors/http_error.rb b/x-core/lib/x/core/errors/http_error.rb new file mode 100644 index 00000000..44225d7b --- /dev/null +++ b/x-core/lib/x/core/errors/http_error.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require "json" +require "time" +require_relative "error" +require_relative "../built_response" +require_relative "../problem" +require_relative "../request_context" +require_relative "../response_headers" + +module X + module Core + # Base class for HTTP errors from the X API + # + # The message is what the API said went wrong, read from the body of the response, behind the method and path of + # the request it answered. {#body} holds that body as it arrived, {#headers} the headers it came with, and + # {#problem} the problem it describes the failure with as a whole, and {#problems} each problem it names, for code + # that acts on the reason rather than logging it. {#http_method} and {#uri} are the request the API refused. + # + # A 4xx raises a ClientError, a 5xx a ServerError, and a body that is not JSON an InvalidResponse, each a subclass + # of this. It is raised itself for a redirect the client does not follow: a 300 Multiple Choices, 304 Not Modified, + # or 305 Use Proxy, and a redirect whose Location is missing, is not a valid URL, or is not an HTTP or HTTPS URL. + # + # @api public + class ::X::HTTPError < Error + include RequestContext + include ResponseHeaders + + # @!method http_method + # The HTTP method the request was sent with + # @api public + # @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one + # @example Tell a read that failed from a write + # writes_failed += 1 unless error.http_method.eql?(:get) + # @!method uri + # The URI the request was sent to + # @api public + # @return [URI::Generic, nil] the URI, or nil for an error built without one + # @example Count the failures of each endpoint + # failures[error.uri.path] += 1 + # @!method headers + # The headers of the response + # + # The names are lowercase, and a field the API sent more than once is joined with a comma. + # @api public + # @return [Hash{String => String}] the headers, frozen + # @example Read how long the API took to answer + # error.headers["x-response-time"] + + # Regular expression to match JSON content types + JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json} + private_constant :JSON_CONTENT_TYPE_REGEXP + # The keys of a body that describes the failure itself, rather than naming the errors of the request + PROBLEM_KEYS = %w[title detail type error].freeze + private_constant :PROBLEM_KEYS + # The header that says how long to wait before sending the request again + RETRY_AFTER_HEADER = "retry-after" + private_constant :RETRY_AFTER_HEADER + # The value of a Retry-After header that counts seconds, rather than naming the time to wait until + RETRY_AFTER_SECONDS = /\A\d+\z/ + private_constant :RETRY_AFTER_SECONDS + # The message of the error raised for an HTTPError itself, which has no status, given neither a response nor one + ANY_STATUS = "X::HTTPError is raised for a response of any status, so it is built with the status: of one, or the " \ + "http_response: itself; raise the error of the status, such as X::NotFound, to build one without either" + private_constant :ANY_STATUS + + # The response itself, as the client received it + # + # It is an escape hatch, for what the error does not read: the status is {#status}, the headers are + # {#headers}, and the body is {#body}. It is the response of the transport the client sent the request with, a + # Net::HTTPResponse today, or the one built of the status, headers, and body the error was given. Its class is + # not covered by the compatibility promise of 1.x: a later release of 1.x may send its requests with another + # library, whose response this then returns. + # + # @api public + # @return [Net::HTTPResponse] the response of the transport + # @example Read the reason phrase of the status line + # error.http_response.message # => "Too Many Requests" + attr_reader :http_response + + # The problems the API named in the body of the response + # + # They are the errors the body names, such as each parameter of the request the API refused, or else the + # problem the body describes itself, which is {#problem}. + # + # @api public + # @return [Array] the problems, frozen, empty for a response that describes none in JSON + # @example Name each parameter the API refused + # error.problems.flat_map { |problem| problem.to_h.fetch("parameters", {}).keys } # => ["ids", "user.fields"] + attr_reader :problems + + # The problem the API described the failure with as a whole + # + # It is the problem the body of the response describes itself, by its title, detail, type, and status, whether + # or not the body names errors of its own, which are {#problems}, so its type reads the kind of failure alike + # for a request the API refused a parameter of and for one it refused to authorize; its attributes are the whole + # body. A body that describes no problem of its own, but names errors, as the responses of v1.1 do, is described + # by the first of them. + # + # @api public + # @return [Problem, nil] the problem, or nil for a response that describes none in JSON + # @example Tell a request the API found invalid from one it refused to authorize + # error.problem&.type # => "https://api.twitter.com/2/problems/invalid-request" + # @example Act on the reason rather than the status + # wait_for_the_next_month if error.problem&.usage_capped? + attr_reader :problem + + # Initialize a new HTTPError + # + # Public, so that code that rescues an HTTPError, or a subclass such as NotFound, can be tested with one built from + # the status, headers, and body of a response, or from a Net::HTTP response, as x-core builds each from the + # response it parses. The error names the request, when given its method and URI, as x-core names the request the + # response answers. + # + # It can be raised as any other exception is, as in raise X::NotFound, or raise X::NotFound, "gone", for a test + # double that stands in for a client: an error of a status, such as NotFound, given neither a response nor a + # status, is built with the status x-core raises it for, a ClientError or ServerError with the first of its kind, + # 400 or 500, and an InvalidResponse with 200. An HTTPError itself, which is raised for a status of any kind, must + # be given one. A message given is the message of the error, in place of the one read from the body. + # + # @api public + # @param message [String, nil] the message, or nil for the one the body describes the failure with + # @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as + # {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of + # the status, headers, and body, which every release of 1.x takes + # @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given, or nil for the + # status of the class + # @param headers [Hash{String => String}, nil] the headers of the response, when it is not given + # @param body [String, nil] the body of the response, when it is not given + # @param http_method [Symbol, String, nil] the method of the request the response answers, in any case + # @param uri [URI::Generic, nil] the URI of the request the response answers + # @return [HTTPError] a new instance + # @raise [ArgumentError] if the HTTP response is given beside a status, headers, or a body, or neither it nor a + # status is given to an HTTPError itself, or the status is not from 100 to 599, or the headers are not a Hash of + # names to values + # @example Create the error of a user that does not exist + # error = X::NotFound.new(status: 404, headers: {"content-type" => "application/json"}, + # body: %({"title":"Not Found Error","detail":"Could not find user."})) + # @example Raise the error of a rate limit from a test double + # raise X::TooManyRequests, "Too Many Requests" + # @example Create an HTTP error from a response + # error = X::HTTPError.new(http_response: response, http_method: :get, uri: URI("https://api.x.com/2/users/me")) + def initialize(message = nil, http_response: nil, status: nil, headers: nil, body: nil, http_method: nil, uri: nil) + @http_response = built_response(http_response, status:, headers:, body:) + name_request(http_method, uri) + parsed = parsed_body + errors = Problem.all_from(parsed) + described = (Problem.new(parsed) if describes_problem?(parsed)) + @problems = errors.empty? ? [described].compact.freeze : errors + @problem = described || errors.first + super(message_naming_request(message || message_from(parsed) || @http_response.message)) + end + + # The status an error of this class is built with by default + # + # It is the status an error given neither a response nor a status is built with: the status x-core raises the class, or the nearest class it descends from, for, or the first status of + # the kind of a ClientError or ServerError, 400 or 500. + # + # @api private + # @return [Integer, nil] the status, or nil for an HTTPError itself, which is raised for a status of any kind + # @example Get the status of a NotFound + # X::NotFound.__send__(:default_status) # => 404 + def self.default_status + parser = Core.const_get(:ResponseParser) + ancestors.each do |ancestor| + status = parser::ERROR_MAP.key(ancestor) || parser::STATUS_CLASS_ERRORS.key(ancestor)&.*(100) + return status if status + end + nil + end + private_class_method :default_status + + # The HTTP status code, as an Integer like X::Response#status + # + # @api public + # @return [Integer] the HTTP status code + # @example Handle a status the errors do not name + # retry if error.status.eql?(408) + def status = Integer(http_response.code) + + # The body of the response, as it arrived + # + # A server can send any body with an error, so it is the JSON the API describes a failure with, or whatever + # else was sent in its place, such as the page of a proxy. It is tagged UTF-8, the encoding of the JSON the API + # sends, and a body that is not valid UTF-8 keeps its bytes, so valid_encoding? tells it apart. + # + # @api public + # @return [String, nil] the body, tagged UTF-8, or nil for a response without one + # @example Log what the API sent + # logger.error(error.body) + def body = http_response.body + + # The seconds the response asks a request to wait before it is sent again + # + # The API sends a Retry-After header with a request it refused for a rate limit, and with some of the responses + # of a failure of its own, such as a 503 that names the time its endpoint is expected back. The header counts + # the seconds from when the response was sent, or names the time to wait until. A client waits it out before it + # sends an idempotent request refused for a failure of the API's own again, up to a minute, and before it sends + # any request refused for a rate limit again, up to max_rate_limit_wait; see {Client#initialize}. + # + # @api public + # @return [Integer, nil] the seconds, never negative, or nil for a response that does not say + # @example Wait as long as the API asks before sending a request again + # sleep(error.retry_after || 1) + def retry_after + value = http_response[RETRY_AFTER_HEADER] + return if value.nil? + + value.match?(RETRY_AFTER_SECONDS) ? Integer(value, 10) : seconds_until(value) + end + + private + + # The HTTP response given, or the one built of the status, headers, and body + # + # A response given neither a response nor a status is built with the status of the class, and an HTTPError itself, + # which has none, raises. + # + # @api private + # @param http_response [Net::HTTPResponse, nil] the HTTP response, or nil to build one + # @param status [Integer, nil] the status of the response to build, or nil for the status of the class + # @param headers [Hash{String => String}, nil] the headers of the response to build, or nil for none + # @param body [String, nil] the body of the response to build, or nil for none + # @return [Net::HTTPResponse] the HTTP response + # @raise [ArgumentError] if the error is given neither a response nor a status, and its class has no status, or as + # {BuiltResponse.of} raises + def built_response(http_response, status:, headers:, body:) + status ||= self.class.__send__(:default_status) if http_response.nil? + raise ArgumentError, ANY_STATUS if http_response.nil? && status.nil? + + BuiltResponse.of(http_response, status:, headers:, body:) + end + + # The seconds until the time an HTTP date names + # + # They count from the Date the response was sent at, on the clock of the API that named the time, so the wait + # holds however far the local clock is from the API's; a response that names no Date counts from now. + # + # @api private + # @param value [String] the value of the header + # @return [Integer, nil] the seconds, never negative, or nil if the value names no time + def seconds_until(value) + [(Time.httpdate(value) - sent_at).ceil, 0].max + rescue ArgumentError + nil + end + + # The time the response was sent at, by its Date header + # + # A response that names no Date, or one that cannot be read, was sent now. + # + # @api private + # @return [Time] the time + def sent_at + date = http_response["date"] + date.nil? ? Time.now : Time.httpdate(date) + rescue ArgumentError + Time.now + end + + # The body of the response, parsed as a JSON object + # + # A server can send any body with an error, so a body that is not JSON, or that is not the JSON its content + # type claims, parses to an empty object rather than raise while the error is built. + # + # @api private + # @return [Hash{String => Object}] the parsed body, empty for a body that is not a JSON object + def parsed_body + return {} unless json? + + Hash.try_convert(JSON.parse(body.to_s)) || {} + rescue JSON::ParserError + {} + end + + # Check whether a body describes a problem itself, rather than only naming errors + # + # @api private + # @param body [Hash{String => Object}] the parsed body + # @return [Boolean] true if the body holds a title, detail, type, or error of its own + def describes_problem?(body) = PROBLEM_KEYS.any? { |key| body.key?(key) } + + # The message a body describes the failure with + # + # A body that holds no message leaves the error with the status message of the response. + # + # @api private + # @param body [Hash{String => Object}] the parsed body + # @return [String, nil] the message, or nil if the body holds none + def message_from(body) + message_from_errors(body["errors"]) || message_from_problem(body) || String.try_convert(body["error"]) + end + + # Join the messages of an errors array + # + # Each error gives its message, or else its detail or title. + # + # @api private + # @param errors [Object] the errors of the body + # @return [String, nil] the joined messages, or nil if there are none + def message_from_errors(errors) + messages = Array(errors).filter_map do |error| + Hash.try_convert(error)&.values_at("message", "detail", "title")&.grep(String)&.first + end + messages.join(", ") unless messages.empty? + end + + # The title and detail of a problem, joined + # + # A detail that says no more than the title, as the Unauthorized of a 401 does, is left out, rather than + # repeated behind it. + # + # @api private + # @param body [Hash{String => untyped}] the body + # @return [String, nil] the title and detail, the title alone if the detail is the same, or nil unless the body + # has both + def message_from_problem(body) + title = body["title"] + detail = body["detail"] + return unless title && detail + + title.eql?(detail) ? title : "#{title}: #{detail}" + end + + # Check whether the response carries JSON + # @api private + # @return [Boolean] true if the response is JSON + def json? + JSON_CONTENT_TYPE_REGEXP === http_response["content-type"] + end + end + end +end diff --git a/x-core/lib/x/core/errors/internal_server_error.rb b/x-core/lib/x/core/errors/internal_server_error.rb new file mode 100644 index 00000000..1b26b4b5 --- /dev/null +++ b/x-core/lib/x/core/errors/internal_server_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "server_error" + +module X + # Raised for a 500 Internal Server Error response, which the API sends when a request it accepted failed + # @api public + class InternalServerError < ServerError; end +end diff --git a/x-core/lib/x/core/errors/invalid_response.rb b/x-core/lib/x/core/errors/invalid_response.rb new file mode 100644 index 00000000..d49c3a2d --- /dev/null +++ b/x-core/lib/x/core/errors/invalid_response.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative "http_error" + +module X + module Core + # Error raised for a successful response whose body is not JSON, such as the page of a proxy or captive portal + # + # It is an X::HTTPError, so that code that rescues the failures of a response reads it as it reads any other: + # the status is {#status}, the headers are {#headers}, the body is {#body}, and {#http_method} and {#uri} are the + # request it answered. A body that is not JSON describes no problem, so {#problem} is nil and {#problems} empty. + # + # @api public + class ::X::InvalidResponse < ::X::HTTPError + # The body that is not JSON: the whole body of a response, or the line of a stream + # + # The body of a stream can be read only as it arrives, so an error raised for a line of a stream holds that line. + # It is tagged UTF-8, as {Response#body} is, and keeps the bytes of a body that is not valid UTF-8. + # + # An error given no body holds the body of its response, as {HTTPError#body} does, once that response has been + # read whole. The body of a response that has not been read, as that of a stream still arriving, is never read + # for it, since reading it would wait for the rest of the stream, or take the lines the stream has yet to read. + # + # @api public + # @return [String, nil] the body, or the line of a stream, tagged UTF-8, or else the body of the response once it + # has been read whole, or nil for an error of a response that has not been, or that has none + # @example Read the body that could not be parsed + # error.body + def body = @body || body_read + + # Initialize a new InvalidResponse + # + # Public, so that code that rescues an InvalidResponse can be tested with one built from the status, headers, + # and body of a response, or from a Net::HTTP response, as ResponseParser and the stream of x-streaming build one. The error + # names the request, when given its method and URI, as x-core names the request the response answers. + # + # It can be raised as any other exception is, as in raise X::InvalidResponse, and is built with a status of 200 + # when it is given neither a response nor a status, as HTTPError states. + # + # @api public + # @param message [String, nil] the message, or nil for the one that names the status and content type + # @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as + # {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of + # the status, headers, and body, which every release of 1.x takes + # @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given, or nil for 200 + # @param headers [Hash{String => String}, nil] the headers of the response, when it is not given + # @param body [String, nil] the body that is not JSON, which is the body of a response built of the status + # @param http_method [Symbol, String, nil] the method of the request the response answers, in any case + # @param uri [URI::Generic, nil] the URI of the request the response answers + # @return [InvalidResponse] a new instance + # @raise [ArgumentError] if the HTTP response is given beside a status or headers, or the status is not from 100 + # to 599, or the headers are not a Hash of names to values + # @example Create the error of a page a proxy answered with + # error = X::InvalidResponse.new(status: 200, headers: {"content-type" => "text/html"}, body: "") + # @example Create an error for a line of a stream + # error = X::InvalidResponse.new(http_response: response, body: line, http_method: :get, uri: stream_uri) + def initialize(message = nil, http_response: nil, status: nil, headers: nil, body: nil, http_method: nil, uri: nil) + @body = body.dup&.force_encoding(Encoding::UTF_8) + super(message, http_response:, status:, headers:, body: (body if http_response.nil?), http_method:, uri:) + end + + # The status an InvalidResponse is built with by default + # + # It is the status one given neither a response nor a status is built with. A body that is not JSON is raised for a response that succeeded, so it is 200 OK. + # + # @api private + # @return [Integer] the status, 200 + # @example Get the status of an InvalidResponse + # X::InvalidResponse.__send__(:default_status) # => 200 + def self.default_status = 200 + private_class_method :default_status + + private + + # The body of the response, if it has been read whole + # + # A response read whole holds its body as a String, where one read in chunks, as a stream is, holds what it read + # them with, and one not yet read reads its body when asked for it, which this never asks it. + # + # @api private + # @return [String, nil] the body, or nil for a response not yet read, read in chunks, or without one + def body_read + String.try_convert(http_response.body) if http_response.instance_variable_get(:@read) + end + + # The message of the error, which says the body is not JSON + # + # It names what the response says the body is instead, since a body that is not JSON holds no message. + # + # @api private + # @param _body [Hash{String => Object}] the parsed body, which is empty + # @return [String] the message + def message_from(_body) = "The body of the #{http_response.code} response is not JSON (#{http_response["content-type"] || "no content type"})" + end + end +end diff --git a/x-core/lib/x/core/errors/method_not_allowed.rb b/x-core/lib/x/core/errors/method_not_allowed.rb new file mode 100644 index 00000000..4a530e5c --- /dev/null +++ b/x-core/lib/x/core/errors/method_not_allowed.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 405 Method Not Allowed response, which the API sends for a method the endpoint does not take, such + # as a POST to an endpoint that only reads + # @api public + class MethodNotAllowed < ClientError; end +end diff --git a/x-core/lib/x/core/errors/network_error.rb b/x-core/lib/x/core/errors/network_error.rb new file mode 100644 index 00000000..024dbb8f --- /dev/null +++ b/x-core/lib/x/core/errors/network_error.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative "error" +require_relative "../request_context" + +module X + module Core + # Raised when a request never reached the API, or its response never arrived + # + # It stands for every error a socket raises: a refused, reset, or dropped connection, a host that cannot be + # resolved, a TLS handshake that failed, a proxy that refused to open a tunnel, and the open, read, and write + # timeouts of the client. A client sends an idempotent request again only after one that never reached the API, + # such as a refused connection or a timeout opening it, and leaves the rest, and any POST, to the caller, since + # whether the API acted on it is unknown; see Client#initialize. + # + # The message names the request that failed, and {#http_method} and {#uri} read it, since nothing of a request + # that got no response is left to read it from. + # + # @api public + # @example Tell a request that failed on the network from one the API refused + # rescue X::NetworkError => e + # logger.warn("#{e.message}, giving up") + class ::X::NetworkError < Error + include RequestContext + + # @!method http_method + # The HTTP method the request that failed was sent with + # @api public + # @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one + # @example Tell a read that failed from a write + # writes_failed += 1 unless error.http_method.eql?(:get) + # @!method uri + # The URI the request that failed was sent to + # @api public + # @return [URI::Generic, nil] the URI, or nil for an error built without one + # @example Count the failures of each host + # failures[error.uri.host] += 1 + + # Initialize a new NetworkError + # + # Public, so that code that rescues a NetworkError can be tested with one built by hand, as Connection builds one + # for the errors a socket raises, or raised with no message, as any exception is. The error names the request, + # when given its method and URI, as x-core names the request that failed. + # + # @api public + # @param message [String, nil] what went wrong on the network, or nil for the name of the class, as an exception + # raised with no message is named, which names no request + # @param http_method [Symbol, String, nil] the method of the request that failed, in any case + # @param uri [URI::Generic, nil] the URI of the request that failed + # @return [NetworkError] a new instance + # @example Create a network error + # error = X::NetworkError.new("Network error: Connection refused", http_method: :get, uri: URI("https://api.x.com/2/users/me")) + def initialize(message = nil, http_method: nil, uri: nil) + name_request(http_method, uri) + super(message && message_naming_request(message)) + end + end + end +end diff --git a/x-core/lib/x/core/errors/not_acceptable.rb b/x-core/lib/x/core/errors/not_acceptable.rb new file mode 100644 index 00000000..b7254e88 --- /dev/null +++ b/x-core/lib/x/core/errors/not_acceptable.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 406 Not Acceptable response, which the API sends for a request that asks for a format the endpoint + # does not serve + # @api public + class NotAcceptable < ClientError; end +end diff --git a/x-core/lib/x/core/errors/not_found.rb b/x-core/lib/x/core/errors/not_found.rb new file mode 100644 index 00000000..bd95ffa3 --- /dev/null +++ b/x-core/lib/x/core/errors/not_found.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 404 Not Found response, which the API sends for an endpoint it does not serve + # + # A lookup of a resource that does not exist is answered with 200 OK and no data, so this is not what a missing + # user or post raises; see X::MissingResource. + # + # @api public + class NotFound < ClientError; end +end diff --git a/x-core/lib/x/core/errors/payload_too_large.rb b/x-core/lib/x/core/errors/payload_too_large.rb new file mode 100644 index 00000000..6e2a91ed --- /dev/null +++ b/x-core/lib/x/core/errors/payload_too_large.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 413 Payload Too Large response, which the API sends for a body larger than the endpoint takes, such + # as a media chunk above the size it accepts + # @api public + class PayloadTooLarge < ClientError; end +end diff --git a/x-core/lib/x/core/errors/payment_required.rb b/x-core/lib/x/core/errors/payment_required.rb new file mode 100644 index 00000000..09be4723 --- /dev/null +++ b/x-core/lib/x/core/errors/payment_required.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 402 Payment Required response, which the API sends for a request the account that pays for the app + # has no credit left to pay for, and which is refused again until credit is added + # @api public + class PaymentRequired < ClientError; end +end diff --git a/x-core/lib/x/core/errors/request_timeout.rb b/x-core/lib/x/core/errors/request_timeout.rb new file mode 100644 index 00000000..8a4e759d --- /dev/null +++ b/x-core/lib/x/core/errors/request_timeout.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 408 Request Timeout response, which the API sends when it gave up waiting for the rest of a request + # @api public + class RequestTimeout < ClientError; end +end diff --git a/x-core/lib/x/core/errors/server_error.rb b/x-core/lib/x/core/errors/server_error.rb new file mode 100644 index 00000000..31f55c8c --- /dev/null +++ b/x-core/lib/x/core/errors/server_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "http_error" + +module X + # The base class of the errors of a 5xx response, which the API failed to answer + # + # The request is not the reason it failed, so the same request may pass later. A client given a max_retries above + # zero sends an idempotent request again after one, waiting a little longer before each attempt. + # + # @api public + # @example Retry a request the API failed to answer + # client = X::Client.new(bearer_token: token, max_retries: 2) + class ServerError < HTTPError; end +end diff --git a/x-core/lib/x/core/errors/service_unavailable.rb b/x-core/lib/x/core/errors/service_unavailable.rb new file mode 100644 index 00000000..ae2655fe --- /dev/null +++ b/x-core/lib/x/core/errors/service_unavailable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "server_error" + +module X + # Raised for a 503 Service Unavailable response, which the API sends when it is overloaded or down for maintenance + # @api public + class ServiceUnavailable < ServerError; end +end diff --git a/x-core/lib/x/core/errors/token_report_failed.rb b/x-core/lib/x/core/errors/token_report_failed.rb new file mode 100644 index 00000000..4fb15e2f --- /dev/null +++ b/x-core/lib/x/core/errors/token_report_failed.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative "error" + +module X + # Error raised when save_tokens raised for the tokens of an exchange of a code or of a refresh + # + # An authorization code works once, and so does a refresh token, so the tokens X issues for either are held in + # memory alone until save_tokens stores them: the first refresh token of X::OAuth2Authorization#client by the + # client it builds, and the one a refresh issued by the authenticator that refreshed, since the one it replaced is + # spent. They are not lost to a failure to store them, such as a database that is briefly down: the error holds + # them, so they can be stored again, and the client, which the exchange builds, or whose request refreshed. The + # error save_tokens raised is the cause, whose message the message ends with. + # + # @api public + class TokenReportFailed < Error + # The client the authorization built, or whose request refreshed the tokens + # @api public + # @return [Client, nil] the client, or nil for a refresh made by an authenticator alone, as its refresh! makes one + # @example Act for the user once the tokens are stored + # rescue X::TokenReportFailed => e + # store(e.tokens.refresh_token) + # e.client.get("users/me") + attr_reader :client + + # The tokens of the exchange or the refresh, which save_tokens raised for + # @api public + # @return [OAuth2Tokens, nil] the tokens, or nil if none were given + # @example Store the tokens again + # rescue X::TokenReportFailed => e + # store(e.tokens.refresh_token) + attr_reader :tokens + + # Initialize the error with the client and the tokens it failed to report + # + # @api public + # @param message [String, nil] the message, or nil for one that says the tokens of an exchange were not stored + # @param client [Client, nil] the client the authorization built, or whose request refreshed the tokens + # @param tokens [OAuth2Tokens, nil] the tokens of the exchange or the refresh + # @return [TokenReportFailed] a new error + # @example Raise the error for tokens save_tokens raised for + # raise X::TokenReportFailed.new(client:, tokens:) + def initialize(message = nil, client: nil, tokens: nil) + @client = client + @tokens = tokens + super(message || "The code was exchanged for tokens, but save_tokens raised for them") + end + + # The message, ending with why save_tokens raised + # + # It ends with the message of the error save_tokens raised, which is the cause, if there is one. + # + # @api public + # @return [String] the message + # @example Read why the tokens were not stored + # error.message # => "The code was exchanged for tokens, but save_tokens raised for them: connection refused" + def to_s = [super, cause&.message].compact.join(": ") + end +end diff --git a/x-core/lib/x/core/errors/too_many_redirects.rb b/x-core/lib/x/core/errors/too_many_redirects.rb new file mode 100644 index 00000000..5c3106d1 --- /dev/null +++ b/x-core/lib/x/core/errors/too_many_redirects.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "error" +require_relative "../request_context" + +module X + module Core + # Raised for a response that redirected more times than the client's max_redirects allows + # + # The message names the request whose redirect was one too many, and {#http_method} and {#uri} read it: the + # request that redirect answered, which is the one a client sent last, rather than the one it was first asked for. + # + # @api public + # @example Tell an endpoint that redirects in a loop from the others + # rescue X::TooManyRedirects => e + # logger.warn("#{e.uri} keeps redirecting") + class ::X::TooManyRedirects < Error + include RequestContext + + # @!method http_method + # The HTTP method the request whose redirect was one too many was sent with + # @api public + # @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one + # @example Tell a read that redirected in a loop from a write + # writes_failed += 1 unless error.http_method.eql?(:get) + # @!method uri + # The URI the request whose redirect was one too many was sent to + # @api public + # @return [URI::Generic, nil] the URI, or nil for an error built without one + # @example Get the path that keeps redirecting + # error.uri.path # => "/2/users/me" + + # Initialize a new TooManyRedirects + # + # Public, so that code that rescues a TooManyRedirects can be tested with one built by hand, as RedirectHandler + # builds one, or raised with no message, as any exception is. The error names the request, when given its method + # and URI, as x-core names the request whose redirect was one too many. + # + # @api public + # @param message [String, nil] what went wrong, or nil for the name of the class, as an exception raised with no + # message is named, which names no request + # @param http_method [Symbol, String, nil] the method of the request whose redirect was one too many, in any case + # @param uri [URI::Generic, nil] the URI of the request whose redirect was one too many + # @return [TooManyRedirects] a new instance + # @example Create an error + # error = X::TooManyRedirects.new("Too many redirects", http_method: :get, uri: URI("https://api.x.com/2/users/me")) + def initialize(message = nil, http_method: nil, uri: nil) + name_request(http_method, uri) + super(message && message_naming_request(message)) + end + end + end +end diff --git a/x-core/lib/x/core/errors/too_many_requests.rb b/x-core/lib/x/core/errors/too_many_requests.rb new file mode 100644 index 00000000..f01827bb --- /dev/null +++ b/x-core/lib/x/core/errors/too_many_requests.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "client_error" +require_relative "../rate_limit" + +module X + # Error raised when rate limit is exceeded (HTTP 429) + # @api public + class TooManyRequests < ClientError + # The rate limits the response reports in its headers, as X::Response reports them + # + # They are read once and frozen, since the other readers of the error, and the wait before a request is sent + # again, read them too, so that a caller changing them changes none of those. + # + # @api public + # @return [Array] the 15-minute limit, and the 24-hour app and user limits when reported, frozen + # @example Print how many requests remain in each window + # error.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" } + def rate_limits + @rate_limits ||= RateLimit.__send__(:all_from, http_response).freeze + end + + # The 15-minute rate limit of the endpoint, which nearly every response reports + # + # @api public + # @return [RateLimit, nil] the rate limit, or nil if the response reports none + # @example Read how many of the 15-minute requests are left + # error.rate_limit&.remaining # => 3 + def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) } + + # The rate limits with no requests left, one of which refused the request + # + # @api public + # @return [Array] the reported limits that are exhausted + # @example Name the windows that are used up + # error.exhausted_rate_limits.map(&:type) # => ["app-limit-24hour"] + def exhausted_rate_limits = rate_limits.select(&:exhausted?) + + # The exhausted rate limit that resets last, which a request waits for + # + # @api public + # @return [RateLimit, nil] the limit, or nil if the response reports none as exhausted + # @example Name the window that refused the request + # error.limiting_rate_limit&.type # => "app-limit-24hour" + def limiting_rate_limit = exhausted_rate_limits.max_by(&:reset_at) + + # Get the time when the rate limit resets + # + # @api public + # @return [Time, nil] the reset time, or nil if the response does not say when the limit resets + # @example Get the reset time + # error.reset_at + def reset_at + limiting_rate_limit&.reset_at + end + + # Get the seconds until the rate limit resets + # + # @api public + # @return [Integer, nil] the seconds until reset, or nil if the response does not say when the limit resets + # @example Get the time until reset + # error.reset_in + def reset_in + limiting_rate_limit&.reset_in + end + + # The seconds to wait before retrying, as the response asks + # + # A Retry-After header counts the seconds from when the response was sent, so it says the same thing however + # far this machine's clock is from the API's, where reset_in is off by the difference between the two. The + # header is preferred for that reason, and the limit that refused the request answers for a response that + # carries none. X recommends waiting a minute, doubling the wait for each retry after, when it says neither. + # + # @api public + # @return [Integer, nil] the seconds to wait before retrying, or nil if the response does not say + # @example Wait before retrying + # sleep(error.retry_after || 60) + def retry_after = super || reset_in + end +end diff --git a/x-core/lib/x/core/errors/unauthorized.rb b/x-core/lib/x/core/errors/unauthorized.rb new file mode 100644 index 00000000..7fe67f23 --- /dev/null +++ b/x-core/lib/x/core/errors/unauthorized.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 401 Unauthorized response, which the API sends for credentials it does not accept, such as a token + # that has expired or been revoked, or a request that carries none + # + # A client that authenticates with OAuth 2.0 refreshes a token the API rejects and sends the request again, so it + # raises this error for a token that is rejected once refreshed, and an AuthorizationError, whose cause is this + # error, for a refresh X refuses. + # + # @api public + class Unauthorized < ClientError; end +end diff --git a/x-core/lib/x/core/errors/unavailable_for_legal_reasons.rb b/x-core/lib/x/core/errors/unavailable_for_legal_reasons.rb new file mode 100644 index 00000000..5eb3fd37 --- /dev/null +++ b/x-core/lib/x/core/errors/unavailable_for_legal_reasons.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 451 Unavailable For Legal Reasons response, which the API sends for a resource withheld in the + # country the request came from + # @api public + class UnavailableForLegalReasons < ClientError; end +end diff --git a/x-core/lib/x/core/errors/unprocessable_entity.rb b/x-core/lib/x/core/errors/unprocessable_entity.rb new file mode 100644 index 00000000..6610441b --- /dev/null +++ b/x-core/lib/x/core/errors/unprocessable_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 422 Unprocessable Entity response, which the API sends for a request it can read but will not act + # on, such as a stream rule it rejects + # @api public + class UnprocessableEntity < ClientError; end +end diff --git a/x-core/lib/x/core/errors/unsupported_format.rb b/x-core/lib/x/core/errors/unsupported_format.rb new file mode 100644 index 00000000..5cf0c179 --- /dev/null +++ b/x-core/lib/x/core/errors/unsupported_format.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "error" + +module X + # Raised when Marshal, YAML, or JSON reads what was written in a format this release does not read + # + # What the X gems write with Marshal or YAML, such as a resource, a page, or a problem, and the JSON of the tokens + # of OAuth 2.0, is plain data led by the number of + # its format, which every release of 1.x writes, each adding only what an earlier one ignores, so that a cache + # written by one release of 1.x is read by every other, and one written in another format, as a later major + # version may write, fails where it is read, rather than read as something it is not. Such a cache is written again. + # + # @api public + # @example Read a cached user again when it was written in another format + # begin + # Marshal.load(cached) + # rescue X::UnsupportedFormat + # Rails.cache.delete("user") + # end + class UnsupportedFormat < Error; end +end diff --git a/x-core/lib/x/core/errors/unsupported_media_type.rb b/x-core/lib/x/core/errors/unsupported_media_type.rb new file mode 100644 index 00000000..fc4e759b --- /dev/null +++ b/x-core/lib/x/core/errors/unsupported_media_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "client_error" + +module X + # Raised for a 415 Unsupported Media Type response, which the API sends for a body in a format the endpoint does + # not take, such as a form where it takes JSON + # @api public + class UnsupportedMediaType < ClientError; end +end diff --git a/x-core/lib/x/core/errors/unsupported_operation.rb b/x-core/lib/x/core/errors/unsupported_operation.rb new file mode 100644 index 00000000..37020e02 --- /dev/null +++ b/x-core/lib/x/core/errors/unsupported_operation.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "error" + +module X + # Raised when there is no way to do what was asked with what the client holds + # + # The API may offer none, as it offers no lookup of lists in a batch, or the credentials a client holds may not + # reach it: a client that authenticates with OAuth 2.0 as a user, and holds no credentials of the app, has no + # app-only client for Client#app_only to return, and an OAuth 2.0 authenticator that holds no refresh token, as an + # authorization without the offline.access scope issues none, cannot refresh its access token. Either is raised + # before any request. A request the API refuses the credentials of a client for raises the error of its response + # instead, as a stream, or the count of the full archive, of a client with no app-only client raises the + # X::Forbidden of the 403 the API answers it with. + # + # @api public + class UnsupportedOperation < Error; end +end diff --git a/x-core/lib/x/core/oauth1_authenticator.rb b/x-core/lib/x/core/oauth1_authenticator.rb new file mode 100644 index 00000000..d6a07829 --- /dev/null +++ b/x-core/lib/x/core/oauth1_authenticator.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "simple_oauth" +require "uri" +require_relative "authenticator" +require_relative "credential_validator" +require_relative "setting_validator" + +module X + module Core + # Authenticator for OAuth 1.0a authentication + # @api public + class ::X::OAuth1Authenticator < Authenticator + # The media type whose body OAuth 1.0a signs as request parameters + FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" + private_constant :FORM_CONTENT_TYPE + + # The API key (consumer key) + # @api public + # @return [String] the API key (consumer key) + # @example Get the API key + # authenticator.api_key + attr_reader :api_key + + # Initialize a new OAuth1Authenticator + # + # @api public + # @param api_key [String] the API key (consumer key) + # @param api_key_secret [String] the API key secret (consumer secret) + # @param access_token [String] the access token + # @param access_token_secret [String] the access token secret + # @return [OAuth1Authenticator] a new instance + # @raise [ArgumentError] if a credential is nil or empty + # @example Create an OAuth authenticator + # authenticator = X::OAuth1Authenticator.new( + # api_key: "key", + # api_key_secret: "secret", + # access_token: "token", + # access_token_secret: "token_secret" + # ) + def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) + CredentialValidator.validate_required!({api_key:, api_key_secret:, access_token:, access_token_secret:}) + @api_key, @api_key_secret = SettingValidator.frozen(api_key), SettingValidator.frozen(api_key_secret) + @access_token, @access_token_secret = SettingValidator.frozen(access_token), SettingValidator.frozen(access_token_secret) + end + + # The identifier of the user the access token acts for + # + # An OAuth 1.0a access token begins with the identifier of the user who authorized it, so a client that signs + # with one knows the user it acts for without asking the API. + # + # @api public + # @return [Integer, nil] the identifier, or nil for a token that begins with none + # @example Read the user a client acts for without a request + # client.authenticator.user_id # => 7505382 + def user_id + prefix = access_token[/\A(\d+)-/, 1] + Integer(prefix, 10) if prefix + end + + # Generate the OAuth authentication headers for a request + # + # The signature covers the HTTP method, the URL, its query parameters, and a + # form-encoded body. Bodies of any other media type, such as the JSON and multipart + # bodies the X API takes, are not signed. + # + # @api public + # @param request [#http_method, #uri, #body, #[]] the request, whose method, uri, body, and Content-Type the signature reads + # @return [Hash{String => String}] the authentication header with OAuth signature + # @example Generate an OAuth authentication header + # authenticator.headers(request) + def headers(request) + oauth_header = SimpleOAuth::Header.new(request.http_method, request.uri, form_params(request), credentials) + {AUTHENTICATION_HEADER => oauth_header.to_s} + end + + private + + # The API key secret (consumer secret), which signs a request + # @api private + # @return [String] the API key secret (consumer secret) + # @example Sign with the API key secret + # api_key_secret + attr_reader :api_key_secret + + # The access token, which signs each request and names the user it acts for + # + # It is a credential, so it is private, as the access token of a client is: the user it acts for is read with + # {#user_id}. + # + # @api private + # @return [String] the access token + # @example Sign with the access token + # access_token + attr_reader :access_token + + # The access token secret, which signs a request + # @api private + # @return [String] the access token secret + # @example Sign with the access token secret + # access_token_secret + attr_reader :access_token_secret + + # The credentials, under the names simple_oauth gives them + # @api private + # @return [Hash{Symbol => String}] the credentials for signing + def credentials + {consumer_key: api_key, consumer_secret: api_key_secret, token: access_token, + token_secret: access_token_secret} + end + + # The parameters a form-encoded body contributes to the signature + # + # A parameter that repeats is signed once per value, as RFC 5849 Section 3.4.1.3.2 requires, + # which the key-value pairs preserve. + # + # @api private + # @param request [#http_method, #uri, #body, #[]] the request + # @return [Array] the body parameters, empty unless the body is form-encoded + def form_params(request) + URI.decode_www_form(form_body(request)) + end + + # The body whose parameters take part in the signature + # + # @api private + # @param request [#http_method, #uri, #body, #[]] the request + # @return [String] the body, or an empty String when it is not form-encoded + def form_body(request) + form_encoded?(request) ? request.body.to_s : "" + end + + # Check whether a request carries a form-encoded body + # @api private + # @param request [#http_method, #uri, #body, #[]] the request + # @return [Boolean] true if the body is form-encoded + def form_encoded?(request) + request["Content-Type"].to_s.split(";").first.to_s.strip.downcase.eql?(FORM_CONTENT_TYPE) + end + end + end +end diff --git a/x-core/lib/x/core/oauth2_authenticator.rb b/x-core/lib/x/core/oauth2_authenticator.rb new file mode 100644 index 00000000..d8d95e5f --- /dev/null +++ b/x-core/lib/x/core/oauth2_authenticator.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require "simple_oauth" +require_relative "authenticator" +require_relative "connection" +require_relative "credential_validator" +require_relative "setting_validator" +require_relative "errors/unsupported_operation" +require_relative "oauth2_refresh" + +module X + module Core + # Handles OAuth 2.0 authentication, refreshing the access token when it expires + # + # X issues a new refresh token with each access token and accepts a refresh token once, so an authenticator + # refreshes under a lock, and the authenticator of a client passes the tokens each refresh issued, as OAuth2Tokens, + # to the save_tokens of that client and of each copy of it that shares the authenticator, so that they can be + # stored. + # + # Processes that share the tokens of a user, storing each refresh with save_tokens, read the store with + # load_tokens, which a refresh calls under its lock before it refreshes: X accepts a refresh token once, and a + # process that refreshed with the one another had already spent would be refused. The tokens a refresh takes from + # the store are not passed to save_tokens, since they came from it. + # + # X issues no refresh token for an authorization without the offline.access scope, so an authenticator built + # without one authenticates as the user until its access token expires, and refreshes nothing: a request sent + # with an access token that expired is sent as it is, for the API to reject with Unauthorized, and one the API + # rejects is not sent again. + # + # @api public + class ::X::OAuth2Authenticator < Authenticator + include OAuth2Refresh + + # The endpoint that refreshes an access token, at X, whose path is requested at the origin of the base URL of + # the client that takes the authenticator + TOKEN_URL = "https://api.x.com/2/oauth2/token" + # Buffer time in seconds to account for clock skew and network latency + EXPIRATION_BUFFER = 30 + private_constant :TOKEN_URL, :EXPIRATION_BUFFER + # The message raised for a refresh of an authenticator that holds no refresh token + NO_REFRESH_TOKEN = "The authenticator holds no refresh token, which X issues only for an authorization with the " \ + "offline.access scope, so its access token cannot be refreshed. Ask the user to authorize the app again, with " \ + "offline.access to refresh the token that authorization issues" + private_constant :NO_REFRESH_TOKEN + + # The OAuth 2.0 client ID + # @api public + # @return [String] the client ID + # @example Get the client ID + # authenticator.client_id + attr_reader :client_id + # The expiration time of the access token + # @api public + # @return [Time, nil] the expiration time + # @example Get the expiration time + # authenticator.expires_at + attr_reader :expires_at + # The scopes X granted the access token, as last refreshed + # + # A refresh that names no scopes keeps those the authenticator held, as OAuth 2.0 has it. + # + # @api public + # @return [Array, nil] the scopes, frozen, or nil if they are not known + # @example Check that the user let the app post + # authenticator.scopes&.include?("tweet.write") + attr_reader :scopes + + # Initialize a new OAuth 2.0 authenticator + # + # @api public + # @param client_id [String] the OAuth 2.0 client ID + # @param client_secret [String, nil] the OAuth 2.0 client secret, or nil for a public client, which sends its + # client ID in the body of a refresh instead of authenticating with a secret + # @param access_token [String] the OAuth 2.0 access token + # @param refresh_token [String, nil] the OAuth 2.0 refresh token, or nil for an access token issued without the + # offline.access scope, which the authenticator cannot refresh + # @param expires_at [Time, nil] the expiration time of the access token + # @param scopes [Array, nil] the scopes X granted the access token, or nil if they are not known + # @param load_tokens [#call, nil] a callable that takes no arguments and returns the OAuth2Tokens in the storage + # the tokens of the user are shared through, or nil for none there, which a refresh reads first, as the + # load_tokens of X::Client#initialize describes; nil reads the load_tokens of a client that authenticates with + # the authenticator instead + # @return [OAuth2Authenticator] a new authenticator instance + # @raise [ArgumentError] if the client ID or access token is nil or empty, the refresh token or client secret is + # empty, the expiration time is neither a Time nor nil, the scopes are neither an Array of Strings that each name + # a scope nor nil, or load_tokens is neither nil nor responds to call + # @example Create an authenticator + # authenticator = X::OAuth2Authenticator.new( + # client_id: "id", + # client_secret: "secret", + # access_token: "token", + # refresh_token: "refresh" + # ) + # @example Share the tokens of a user among processes, storing each refresh and reading the store before one + # authenticator = X::OAuth2Authenticator.new(client_id: "id", **store.load(user).to_h, + # load_tokens: -> { store.load(user) }) + # client = X::Client.new(authenticator:, save_tokens: ->(tokens) { store.save(user, tokens) }) + def initialize(client_id:, access_token:, refresh_token: nil, client_secret: nil, expires_at: nil, scopes: nil, load_tokens: nil) + CredentialValidator.validate_required!({client_id:, access_token:}, {refresh_token:, client_secret:, expires_at:, scopes:}) + initialize_refresh(load_tokens) + @client_id, @client_secret = SettingValidator.frozen(client_id), SettingValidator.frozen(client_secret) + @access_token, @refresh_token = SettingValidator.frozen(access_token), SettingValidator.frozen(refresh_token) + @expires_at, @scopes = expires_at, CredentialValidator.frozen_scopes(scopes) + @connection, @token_url, @token_headers = Connection.new, TOKEN_URL, {} + @clients = ObjectSpace::WeakMap.new + end + + # Generate the authentication header, refreshing an expired token first + # + # An authenticator that holds no refresh token sends an access token that expired as it is, for the API to reject. + # + # @api public + # @param _request [#http_method, #uri, #body, #[], nil] the request, which a bearer token does not sign + # @return [Hash{String => String}] the authentication header + # @raise [AuthorizationError] if the token has expired and X refuses to refresh it + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh, with the tokens + # @example Get the header + # authenticator.headers(request) + def headers(_request) + refresh_expired_token(connection) + {AUTHENTICATION_HEADER => "Bearer #{access_token}"} + end + + # Summarize the authenticator for the console without revealing credentials + # + # @api public + # @return [String] the class name, client ID, and expiration time + # @example Inspect an authenticator + # authenticator.inspect # => # + def inspect = "#<#{self.class} client_id=#{client_id.inspect} expires_at=#{expires_at.inspect}>" + + # Check if the access token has expired or will expire soon + # + # @api public + # @return [Boolean] true if the token has expired or will expire within the buffer period + # @example Check expiration + # authenticator.token_expired? + def token_expired? + return false if expires_at.nil? + + Time.now >= expires_at - EXPIRATION_BUFFER + end + + # Refresh the access token using the refresh token + # + # The authenticator holds the new tokens once it returns, and the authenticator of a client has passed them to the + # save_tokens of the clients that share it. The tokens it returns are those of this refresh, frozen, the + # same object save_tokens is passed, so they are a set that belongs together, whatever refreshes follow on + # other threads. + # + # A refresh reads the tokens in storage first, with load_tokens, and refreshes with the refresh token there when it + # is another. When X refuses the refresh for a refresh token another process spent, and the storage holds + # another, the tokens there are returned in place of an error, and are not passed to save_tokens. + # + # A save_tokens that raises, as one whose storage is briefly down may, raises TokenReportFailed once each + # has been passed the tokens, which holds them, since the refresh token they replaced is spent and the + # authenticator holds them alone, with the error save_tokens raised as its cause. + # + # @api public + # @return [OAuth2Tokens] the tokens the refresh issued, or those it took from storage in place of a refusal + # @raise [UnsupportedOperation] if the authenticator holds no refresh token, before any request + # @raise [AuthorizationError] if X refuses to refresh the token + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @raise [TokenReportFailed] if save_tokens raises for the tokens of the refresh, with the tokens + # @example Refresh the tokens and store them + # store.save(**authenticator.refresh!.to_h) + def refresh! + raise UnsupportedOperation, NO_REFRESH_TOKEN unless refresh_token + + tokens = @mutex.synchronize do + adopt_stored_tokens + refresh(connection, nil) + end + report_refresh(tokens, nil) + tokens + end + + private + + # The OAuth 2.0 access token, as last refreshed + # + # It is a secret, so it is private, as the access token of a client is, since a client hands out its + # authenticator. The tokens of a refresh are passed to save_tokens, and returned by {#refresh!}. Internal to + # x-core: a client reads it with __send__. + # + # @api private + # @return [String] the access token + attr_reader :access_token + + # The OAuth 2.0 refresh token, as last refreshed + # + # It is private for the reason {#access_token} is. + # + # @api private + # @return [String, nil] the refresh token, or nil for an authenticator that cannot refresh + attr_reader :refresh_token + + # The OAuth 2.0 client secret, which authenticates a refresh + # @api private + # @return [String, nil] the client secret, or nil for a public client + # @example Refresh with the client secret + # client_secret + attr_reader :client_secret + + # The connection for making token requests + # + # refresh! sends its request over it, as does a request signed with the authenticator alone, with the headers of + # the client that took the authenticator first. A client refreshes over its own connection, with its own headers, + # instead, so that the copies of a client that share its authenticator, but were given another proxy, other + # timeouts, other debug output, or other headers, refresh with those. + # + # @api private + # @return [Core::Connection] the connection + attr_reader :connection + + # The clients that authenticate with the authenticator, held weakly + # + # Each refresh reaches the save_tokens of each of them. Internal to x-core: a client joins the clients of the + # authenticator it builds, shares with the client it was copied from, or is given, and reads them with __send__, + # since they are private. + # + # @api private + # @return [ObjectSpace::WeakMap] the clients, each held as a key + attr_reader :clients + + # Send the token requests over the connection of the first client that takes it + # + # The token endpoint is requested at the scheme, host, and port of the base URL of the client, rather than of + # TOKEN_URL, so that a client pointed at another host sends the client credentials and refresh token there, as it + # sends its requests. A refresh a request of a later client makes goes to the origin of that client instead. + # + # The authenticator is shared by the copies of the client that takes it, and may be given to other clients + # besides, so a client that takes it after the first leaves it sending them over the connection of the first, + # as a copy of a client leaves the authenticator of that client. Internal to x-core: a client sends the token + # requests of the authenticator it builds, or is given, over its own connection, with its proxy, timeouts, debug + # output, and headers, and calls it with __send__, since it is private. + # + # @api private + # @param connection [Core::Connection] the connection to send the token requests over + # @param base_url [String] the base URL of the client, at whose origin the token endpoint is requested + # @param headers [Hash{String => String}] the headers of the client, which the token requests are sent with + # @return [OAuth2Authenticator] the authenticator + def token_requests_over(connection, base_url, headers) + @mutex.synchronize do + unless @taken + @connection = connection + @token_url = TokenEndpoint.url_at(base_url, TOKEN_URL) + @token_headers = headers + end + @taken = true + end + self + end + + # Check whether the authenticator holds the credentials among some options + # + # The options are those of a client, and the credentials among them its OAuth 2.0 ones. + # + # Internal to x-core: a copy of a client shares the authenticator unless it was given a credential this does not + # hold, and calls it with __send__, since it is private. + # + # @api private + # @param options [Hash{Symbol => Object}] the options of a client, of which the others are ignored + # @return [Boolean] true if each client ID, client secret, access token, or refresh token among them is the one + # this holds + def holds?(options) + options.slice(:client_id, :client_secret, :access_token, :refresh_token) <= {client_id:, client_secret:, access_token:, refresh_token:} + end + + # Pass each refresh to the callables another reads + # + # Internal to x-core: Client passes the refreshes of the authenticator it builds to the save_tokens of each + # client that shares it, and calls it with __send__, since it is private. + # + # @api private + # @param hooks [#call] a callable that returns the callables to pass each refresh, read at each refresh + # @return [#call] the callable + def report_refreshes_to(hooks) = @reporter.to(hooks) + + # The client for the token endpoint + # @api private + # @param token_url [String] the URL of the token endpoint + # @return [SimpleOAuth::OAuth2::Client] the OAuth 2.0 client + def oauth2_client(token_url) = SimpleOAuth::OAuth2::Client.new(client_id:, client_secret:, token_endpoint: token_url) + + # The URL of the token endpoint a refresh is sent to, as token_headers tells + # @api private + # @param client [Client, nil] the client whose request refreshes, or nil for none + # @return [String] the URL + def token_url(client) = client ? TokenEndpoint.url_at(client.base_url, TOKEN_URL) : @token_url + end + end +end diff --git a/x-core/lib/x/core/oauth2_authorization.rb b/x-core/lib/x/core/oauth2_authorization.rb new file mode 100644 index 00000000..77c90fb1 --- /dev/null +++ b/x-core/lib/x/core/oauth2_authorization.rb @@ -0,0 +1,400 @@ +# frozen_string_literal: true + +require "securerandom" +require "simple_oauth" +require "uri" +require_relative "client" +require_relative "connection" +require_relative "credential_holder" +require_relative "credential_validator" +require_relative "errors/authorization_denied" +require_relative "errors/authorization_error" +require_relative "errors/token_report_failed" +require_relative "oauth2_authenticator" +require_relative "oauth2_tokens" +require_relative "setting_validator" +require_relative "token_endpoint" + +module X + module Core + # Authorizes an app to act for a user with the OAuth 2.0 authorization code flow and PKCE + # + # The flow takes two requests to the app: one that sends the user to X to authorize it, and one that X redirects + # the user back to. The state and code verifier of the first must reach the second, so store them, as in the + # session, and build the authorization again with them when X redirects back. + # + # @api public + class ::X::OAuth2Authorization + include CredentialHolder + + # The page that asks a user to authorize an app + AUTHORIZATION_URL = "https://x.com/i/oauth2/authorize" + # The endpoint that exchanges an authorization code for tokens, at X, whose path is requested at the origin of + # the base URL of the authorization, or of the client it builds + TOKEN_URL = "https://api.x.com/2/oauth2/token" + private_constant :TOKEN_URL + # The scopes that read posts and users, and keep a refresh token to act for the user after the access token expires + DEFAULT_SCOPES = %w[tweet.read users.read offline.access].freeze + # The number of random bytes in a generated state + STATE_BYTES = 32 + private_constant :STATE_BYTES + # The message raised when X describes no reason for a failed authorization + DEFAULT_ERROR_MESSAGE = "Authorization failed" + private_constant :DEFAULT_ERROR_MESSAGE + # The message raised for a redirect back from X that is not a valid URL + INVALID_CALLBACK_MESSAGE = "The redirect back from X is not a valid URL" + private_constant :INVALID_CALLBACK_MESSAGE + # The options of Client#initialize that the exchange of the code gives the client of #client + CREDENTIALS = %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret + refresh_token expires_at scopes authenticator].freeze + private_constant :CREDENTIALS + # The message raised for credentials given to #client, which the exchange of the code gives the client + CREDENTIALS_GIVEN_MESSAGE = "The client of an authorization authenticates with the tokens X exchanges the code " \ + "for, so it cannot be given %s" + private_constant :CREDENTIALS_GIVEN_MESSAGE + # The message raised for a redirect URI that is not a String of more than whitespace + INVALID_REDIRECT_URI = "redirect_uri must not be nil or empty; pass the URL X redirects the user back to, as " \ + "registered for the app" + private_constant :INVALID_REDIRECT_URI + # The message raised for scopes that are not an Array of Strings + INVALID_SCOPES = "scopes must be an Array of Strings that each name a scope, such as %%w[tweet.read users.read offline.access], not %s" + private_constant :INVALID_SCOPES + # The message raised for a state that is nil or empty + MISSING_STATE = "state must not be nil or empty; pass the state stored when the user was sent to X" + private_constant :MISSING_STATE + + # The OAuth 2.0 client ID of the app + # @api public + # @return [String] the client ID + # @example Get the client ID + # authorization.client_id + attr_reader :client_id + + # The URL X redirects the user back to, as registered for the app + # @api public + # @return [String] the redirect URI + # @example Get the redirect URI + # authorization.redirect_uri + attr_reader :redirect_uri + + # The scopes the app asks the user for + # + # They are a frozen copy of the ones given, so that changing the Array the authorization was given never + # changes what it asks for, or the scopes of the tokens it builds when X names none. + # + # @api public + # @return [Array] the scopes, frozen + # @example Get the scopes + # authorization.scopes # => ["tweet.read", "users.read", "offline.access"] + attr_reader :scopes + + # The value that ties the redirect back from X to this authorization + # @api public + # @return [String] the state + # @example Store the state in the session + # session[:state] = authorization.state + attr_reader :state + + # The PKCE code verifier, to store until X redirects back + # @api public + # @return [String] the code verifier + # @example Store the code verifier in the session + # session[:code_verifier] = authorization.code_verifier + attr_reader :code_verifier + + # Initialize an authorization + # + # A new state and code verifier are generated unless they are given. The authorization code is exchanged for + # tokens at the origin of the base URL given, with the proxy, timeouts, keep-alive timeout, debug output, and + # headers given, which a client built with {#client} is given too. + # + # @api public + # @param client_id [String] the OAuth 2.0 client ID of the app + # @param redirect_uri [String] the URL X redirects the user back to, as registered for the app + # @param client_secret [String, nil] the client secret of a confidential app, or nil for a public client + # @param scopes [Array] the scopes to ask the user for; offline.access keeps a refresh token + # @param state [String] the state, as stored when the user was sent to X + # @param code_verifier [String] the PKCE code verifier, as stored when the user was sent to X + # @param base_url [String] the base URL of the client the authorization builds, at whose origin the code is + # exchanged, as the client refreshes its tokens, so that an authorization pointed at another host, such as a test + # server, exchanges the code there + # @param proxy_url [String, URI::Generic, nil] the proxy URL for the token request + # @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none + # @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none + # @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none + # @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same + # host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered + # @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<, + # such as a StringIO. It is written every request and response whole, in the clear: the Authorization header, + # the client secret a token request sends, and the tokens a token response holds. Send it to a file you + # control while debugging, never to a log that is shipped elsewhere, and leave it nil in production. + # @param headers [Hash{String, Symbol => String}] the headers the code is exchanged with, beside the User-Agent of + # the gem, which one of them of that name replaces, as the headers of a client are sent, such as one a gateway + # the base URL names requires; an Authorization header is not sent, since the exchange carries its own + # credentials + # @return [OAuth2Authorization] a new authorization + # @raise [ArgumentError] if the client ID or redirect URI is nil or empty, or the client secret is empty, which + # would send the user to X with a URL it refuses + # @raise [ArgumentError] if the scopes are not an Array of Strings that each name a scope, as a String that holds + # a space, which names two, does not + # @raise [ArgumentError] if the state is nil or empty, which would accept the redirect of any authorization + # @raise [ArgumentError] if the code verifier is not 43 to 128 unreserved characters + # @raise [ArgumentError] if the base URL is not an absolute http or https URL with no user, password, query, or + # fragment + # @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor nil, or the + # keep-alive timeout is not a finite number of seconds of at least 0 + # @raise [ArgumentError] if the headers are not a Hash that names each header with a String or a Symbol and gives + # it a String + # @example Start an authorization + # authorization = X::OAuth2Authorization.new(client_id: "id", redirect_uri: "https://example.com/callback") + def initialize(client_id:, redirect_uri:, client_secret: nil, scopes: DEFAULT_SCOPES, state: SecureRandom.urlsafe_base64(STATE_BYTES), + code_verifier: SimpleOAuth::OAuth2::PKCE.generate.verifier, base_url: Client::DEFAULT_BASE_URL, proxy_url: nil, + open_timeout: Client::DEFAULT_OPEN_TIMEOUT, read_timeout: Client::DEFAULT_READ_TIMEOUT, + write_timeout: Client::DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: Client::DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, headers: {}) + validate!(client_id:, redirect_uri:, client_secret:, scopes:, state:) + @client_id, @client_secret, @redirect_uri = SettingValidator.frozen(client_id), SettingValidator.frozen(client_secret), SettingValidator.frozen(redirect_uri) + @scopes = CredentialValidator.frozen_scopes(scopes) + @state = state + @pkce = SimpleOAuth::OAuth2::PKCE.new(verifier: code_verifier) + @code_verifier = code_verifier + @settings = {base_url: SettingValidator.base_url!(base_url), proxy_url: proxy_url&.then { |url| -String(url) }, open_timeout:, read_timeout:, write_timeout:, keep_alive_timeout:, debug_output:, headers: SettingValidator.headers!(headers)} + @connection = Connection.new(**@settings.except(:base_url, :headers)) + end + + # Summarize the authorization for the console without revealing its secrets + # + # @api public + # @return [String] the class name, client ID, redirect URI, and scopes + # @example Inspect an authorization + # authorization.inspect # => # + def inspect + "#<#{self.class} client_id=#{client_id.inspect} redirect_uri=#{redirect_uri.inspect} scopes=#{scopes}>" + end + + # The page on X that asks the user to authorize the app, to redirect the user to + # + # @api public + # @return [String] the authorization URL, with the state and PKCE code challenge + # @example Send the user to X + # redirect_to authorization.url + def url + oauth2_client(base_url).authorization_url(redirect_uri:, pkce: @pkce, state:, scope: scopes) + end + + # Exchange the code of the redirect back from X for the tokens of the user + # + # An authorization code works once, so call this, or {#client}, once for each redirect. + # + # The tokens are the OAuth2Tokens a client passes save_tokens and reads from load_tokens, which are stored for + # each user, so they leave out the client ID, and the client secret of a confidential client, which are the + # app's and kept once, apart from them; pass them beside the tokens to a client built of them, which refreshes + # the access token with them. + # + # @api public + # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters + # @return [OAuth2Tokens] the tokens: the access token, the refresh token, and the expiration time; an + # authorization without offline.access issues no refresh token, so its tokens hold none, and a client built of + # them acts for the user until the access token expires, and cannot authenticate as the app + # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a + # valid URL + # @raise [AuthorizationError] if X refuses the code, with the response that refused it + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @example Store the tokens of the user + # store.save(authorization.tokens(request.url)) + # @example Build the client of a confidential app from the tokens it stored + # X::Client.new(client_id: ENV.fetch("X_CLIENT_ID"), client_secret: ENV.fetch("X_CLIENT_SECRET"), **store.load.to_h) + def tokens(callback) = tokens_from(exchange(callback, base_url, @settings.fetch(:headers))) + + # Exchange the code of the redirect back from X for a client + # + # The options are checked before the code is exchanged, since X accepts it once, so an option the client refuses, + # such as a misspelled keyword, raises before the code is spent rather than after, with the tokens it was + # exchanged for lost. + # + # The save_tokens of the client is passed the OAuth2Tokens of the exchange before the client is returned, as + # it is passed those of each refresh after, so that a callable that stores them stores every refresh token X + # issues, the first among them; a refresh token held by the client alone would be lost with it, and the user would + # have to authorize the app again. Without offline.access, which issues no refresh token, it is passed tokens whose + # refresh token is nil, so it stores the access token that acts for the user until it expires. A + # callable that raises, as one whose storage is briefly down may, raises TokenReportFailed, which holds the client + # and the tokens, so neither is lost with the code. + # + # The code is exchanged at the origin of the base URL of the client, the base_url of the options or else that of + # the authorization, as the client refreshes its tokens there, and through the proxy, with the timeouts, + # keep-alive timeout, debug output, and headers of the client, so a client given a proxy reaches X through it + # from the first request of its tokens. + # + # @api public + # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters + # @param options [Hash] other options of Client#initialize, such as save_tokens, which it is built with + # beside the base URL, proxy, timeouts, keep-alive timeout, debug output, and headers of the authorization, and + # in place of them + # @return [Client] a client with the user's credentials + # @raise [ArgumentError] if an option is one Client#initialize refuses, or a credential or an authenticator, + # which the client is given by the exchange of the code + # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a + # valid URL + # @raise [AuthorizationError] if X refuses the code, with the response that refused it + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @raise [TokenReportFailed] if save_tokens raises for the tokens of the exchange, with the client and tokens + # @example Act for the user who authorized the app, storing the refresh token of the exchange and of each refresh + # client = authorization.client(request.url, save_tokens: ->(tokens) { store.save(tokens.refresh_token) }) + def client(callback, **options) # steep:ignore DifferentMethodParameterKind + given = options.keys & CREDENTIALS + raise ArgumentError, format(CREDENTIALS_GIVEN_MESSAGE, given.join(", ")) unless given.empty? + + checked = Client.new(**@settings, **options) # refuses an option before the code, which X accepts once, is spent + tokens = tokens_from(exchange(callback, checked.base_url, checked.headers, connection_for(options))) + client_of(tokens, options).tap { |client| report_exchange(client, tokens) } + end + + private + + # Raise for arguments an authorization cannot be started or finished with + # + # They are read only once the URL is built, or the code exchanged, where a client ID or redirect URI that is nil + # or empty would build a URL X refuses, rather than raise where the authorization was given them. + # + # @api private + # @param client_id [Object] the OAuth 2.0 client ID of the app + # @param redirect_uri [Object] the URL X redirects the user back to + # @param client_secret [Object] the client secret of a confidential app, or nil for a public client + # @param scopes [Object] the scopes to ask the user for + # @param state [Object] the state + # @return [void] + # @raise [ArgumentError] if an argument is not one the authorization takes + def validate!(client_id:, redirect_uri:, client_secret:, scopes:, state:) + CredentialValidator.validate_required!({client_id:}, {client_secret:}) + raise ArgumentError, INVALID_REDIRECT_URI unless String.try_convert(redirect_uri)&.match?(/\S/) + raise ArgumentError, format(INVALID_SCOPES, scopes.inspect) unless CredentialValidator.scopes?(scopes) + raise ArgumentError, MISSING_STATE if state.to_s.empty? + end + + # The OAuth 2.0 client secret of a confidential app + # + # It is private, as the secrets of a client and its authenticators are, and inspect leaves it out. The credentials + # of an authorization hold it, since a client is built from them. + # + # @api private + # @return [String, nil] the client secret, or nil for a public client + attr_reader :client_secret + + # The base URL of the client the authorization builds + # + # The code is exchanged at its origin, unless the client is given another. + # + # @api private + # @return [String] the base URL + def base_url = @settings.fetch(:base_url) + + # The connection that exchanges the authorization code for tokens + # @api private + # @return [Core::Connection] the connection + attr_reader :connection + + # The client for the authorization page and token endpoint + # + # The token endpoint is the one at the origin of the base URL of the client the code is exchanged for. + # + # @api private + # @param base_url [String] the base URL of the client the code is exchanged for + # @return [SimpleOAuth::OAuth2::Client] the OAuth 2.0 client + def oauth2_client(base_url) + SimpleOAuth::OAuth2::Client.new(client_id:, client_secret:, authorization_endpoint: AUTHORIZATION_URL, + token_endpoint: TokenEndpoint.url_at(base_url, TOKEN_URL)) + end + + # The connection that exchanges the code for a client, built with its settings + # + # It has the proxy, timeouts, keep-alive timeout, and debug output of the options of the client, and else those of + # the authorization, as the client does. + # + # @api private + # @param options [Hash] the options of Client#initialize the client is built with + # @return [Core::Connection] the connection + def connection_for(options) = Connection.new(**@settings.merge(options.slice(*@settings.keys)).except(:base_url, :headers)) + + # Exchange the code of the redirect back from X for a token + # + # The connection is closed once the code is exchanged, whether it is the authorization's own or one built for the + # exchange of a client, rather than left open for requests the authorization never sends, since a code is + # exchanged once; an authorization that exchanges another code, of another redirect, opens it again. + # + # @api private + # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters + # @param base_url [String] the base URL of the client the code is exchanged for, at whose origin it is exchanged + # @param headers [Hash{String => String}] the headers of the client the code is exchanged for, which the token + # request is sent with + # @param over [Core::Connection] the connection to send the token request over + # @return [SimpleOAuth::OAuth2::Token] the token + # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a + # valid URL + # @raise [AuthorizationError] if X refuses the code, with the response that refused it + def exchange(callback, base_url, headers, over = connection) + token_request = oauth2_client(base_url).authorization_code_request(code: code_of(callback), redirect_uri:, code_verifier:) + TokenEndpoint.fetch(token_request, connection: over, refusal: DEFAULT_ERROR_MESSAGE, headers:) + ensure + over.close + end + + # The authorization code of the redirect back from X + # + # The redirect is its URL, whose query is read, or else its query string or query parameters. + # + # @api private + # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters + # @return [String] the code + # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a + # valid URL + def code_of(callback) + query = String.try_convert(callback)&.then { |url| URI(url).query } || callback + SimpleOAuth::OAuth2::AuthorizationResponse.parse(query, state:).code + rescue URI::InvalidURIError + raise AuthorizationDenied.new(INVALID_CALLBACK_MESSAGE), cause: nil + rescue SimpleOAuth::OAuth2::Error => e + raise AuthorizationDenied.__send__(:from, e, DEFAULT_ERROR_MESSAGE), cause: nil + end + + # The client of the tokens of the exchange, built with the options given + # @api private + # @param tokens [OAuth2Tokens] the tokens of the exchange + # @param options [Hash] other options of Client#initialize, in place of those of the authorization + # @return [Client] the client + def client_of(tokens, options) = Client.new(client_id:, client_secret:, **tokens.to_h, **@settings, **options) + + # Pass the tokens of the exchange to the save_tokens of the client + # @api private + # @param client [Client] the client + # @param tokens [OAuth2Tokens] the tokens of the exchange + # @return [void] + # @raise [TokenReportFailed] if save_tokens raises, with the client and tokens + def report_exchange(client, tokens) + hook = client.save_tokens or return + + begin + hook.call(tokens) + rescue + raise TokenReportFailed.new(client:, tokens:) + end + end + + # The tokens of a user from the token X returned + # + # A client is built of them as OAuth 2.0 credentials whether or not X issued a refresh token, since the access + # token acts for the user either way: one given as a bearer token would be taken for the app's, and sent to the + # endpoints that take app-only authentication, which refuse it. A token issued without offline.access has no + # refresh token, so the tokens hold nil in its place. A token that names no scopes was granted those the app + # asked for, as OAuth 2.0 has it, so the tokens hold those. + # + # @api private + # @param token [SimpleOAuth::OAuth2::Token] the token + # @return [OAuth2Tokens] the tokens + def tokens_from(token) + OAuth2Tokens.new(access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token.expires_at, + scopes: TokenEndpoint.scopes_of(token) || scopes) + end + end + end +end diff --git a/x-core/lib/x/core/oauth2_refresh.rb b/x-core/lib/x/core/oauth2_refresh.rb new file mode 100644 index 00000000..49c0e78f --- /dev/null +++ b/x-core/lib/x/core/oauth2_refresh.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require "simple_oauth" +require_relative "errors/authorization_error" +require_relative "errors/unauthorized" +require_relative "oauth2_tokens" +require_relative "origin" +require_relative "refresh_reporter" +require_relative "setting_validator" +require_relative "token_endpoint" + +module X + module Core + # How an OAuth2Authenticator refreshes its tokens, before a request and after a rejection, included into it + # + # Processes that share the tokens of a user read the store they share with load_tokens before a refresh, and take + # the tokens there in place of their own when those hold another refresh token, which another process issued + # by a refresh of its own. The tokens taken are copied, and never recorded as the latest a refresh issued, so the + # RefreshReporter passes them to no save_tokens: they came from the store. + # + # Internal to x-core: the methods are private, and a client calls retrying_rejected_token with __send__. + # + # @api private + module OAuth2Refresh + # The message raised when the token endpoint describes no reason for the failure + DEFAULT_ERROR_MESSAGE = "Token refresh failed" + private_constant :DEFAULT_ERROR_MESSAGE + # Seconds after a refresh in which a rejection of the access token it issued refreshes nothing + FRESH_TOKEN_SECONDS = 60 + private_constant :FRESH_TOKEN_SECONDS + # The message of the error raised for load_tokens that returns what is not tokens + NOT_TOKENS = "load_tokens must return an X::OAuth2Tokens, or nil for none in the store, not a %s. Build the " \ + "tokens from what the store holds with X::OAuth2Tokens.new" + private_constant :NOT_TOKENS + # The error codes of a refresh X refuses for a refresh token it no longer accepts, as one another process spent + REFUSED_REFRESH_TOKEN = %w[invalid_request invalid_grant].freeze + private_constant :REFUSED_REFRESH_TOKEN + + private + + # Initialize the lock, the reporter, and the loader of the refreshes + # @api private + # @param load_tokens [#call, nil] the callable that returns the OAuth2Tokens in the store, or nil for none + # @return [void] + # @raise [ArgumentError] if load_tokens is neither nil nor responds to call + def initialize_refresh(load_tokens) + @load_tokens = SettingValidator.callable!(:load_tokens, load_tokens) + @mutex = Mutex.new + @reporter = RefreshReporter.new + @spent_refresh_tokens = Set.new + end + + # Refresh the access token if it has expired, over a connection + # + # An authenticator that holds no refresh token refreshes nothing, and sends the token it holds. + # + # @api private + # @param connection [Core::Connection] the connection to send the refresh over + # @param client [Client, nil] the client whose request refreshes, whose headers it is sent with, or nil for none + # @return [void] + # @raise [AuthorizationError] if X refuses to refresh the token + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @raise [TokenReportFailed] if save_tokens raises for the tokens of the refresh + def refresh_expired_token(connection, client = nil) + tokens = @mutex.synchronize { renew(connection, client) if refresh_token && token_expired? } + report_refresh(tokens, client) if tokens + end + + # Refresh a rejected access token, unless it was already replaced or just issued + # + # Requests that were sent with the same token, and rejected together, refresh it once between them. A token + # issued by a refresh less than FRESH_TOKEN_SECONDS ago has not expired, so the API rejects it for another + # reason, which a refresh would not change: it is not refreshed, and the rejection is raised, rather than spend + # a refresh token on each request an endpoint that always rejects the token answers. An authenticator that holds no + # refresh token refreshes nothing, so the rejection is raised. + # + # @api private + # @param rejected_token [String] the access token the API rejected + # @param connection [Core::Connection] the connection to send the refresh over + # @param client [Client, nil] the client whose request the API rejected, whose headers the refresh is sent with, + # or nil for none + # @return [Boolean] true if the access token is no longer the one rejected + # @raise [AuthorizationError] if X refuses to refresh the token + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + # @raise [TokenReportFailed] if save_tokens raises for the tokens of the refresh + def refresh_rejected_token!(rejected_token, connection, client = nil) + tokens, replaced = @mutex.synchronize do + [(renew(connection, client) if refresh_token && access_token.eql?(rejected_token) && !fresh?), !access_token.eql?(rejected_token)] + end + report_refresh(tokens, client) if tokens + replaced + end + + # Run a request, again if the API rejects a token that a refresh replaces + # + # X rejects an expired access token with 401 Unauthorized, which an authenticator that does not know when + # its token expires learns only from the rejection. A 401 from another origin than the one the token is sent to + # answers a request that carried no token, whether it named that origin or was redirected there, so it refreshes + # nothing: X accepts a refresh token once, and a refresh would replace the tokens for a rejection of no token. + # + # A token that has expired is refreshed before the request, and one the API rejects after it, over the + # connection given, which is the one of the client that sends the request, with the headers of that client: the + # copies of a client share its authenticator, and a copy given another proxy, other timeouts, other debug output, + # or other headers refreshes the tokens it shares with them, as it sends its requests with them. + # + # Internal to x-core: Client runs each request it sends with an OAuth 2.0 authenticator through it, and calls it + # with __send__, since it is private. + # + # @api private + # @param origin [URI::Generic] a URI of the origin the token is sent to, such as the base URL of a client + # @param connection [Core::Connection] the connection to send a refresh over + # @param client [Client, nil] the client that sends the request, which TokenReportFailed holds, or nil for none + # @yield runs the request + # @return [Object] what the block returns + # @raise [Unauthorized] if the request is rejected again, or by another origin, or a refresh does not replace + # the access token + # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh, with the tokens and client + def retrying_rejected_token(origin, connection, client = nil) + refresh_expired_token(connection, client) + token = access_token + begin + yield + rescue Unauthorized => e + raise unless Origin.answered?(e, origin) && refresh_rejected_token!(token, connection, client) + + yield + end + end + + # Take the tokens in the store, or else refresh, holding the lock + # + # Tokens taken from the store whose access token has not expired are sent as they are, and the others refreshed + # with the refresh token there. + # + # @api private + # @param connection [Core::Connection] the connection to send the refresh over + # @param client [Client, nil] the client whose request refreshes, or nil for none + # @return [OAuth2Tokens, nil] the tokens the refresh issued, or those it took from the store in place of a + # refusal, or nil for tokens taken from the store that need no refresh + # @raise [AuthorizationError] if X refuses to refresh the token + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + def renew(connection, client) + refresh(connection, client) unless adopt_stored_tokens && !token_expired? + end + + # Refresh the access token, holding the lock + # + # It is called for an authenticator that holds a refresh token alone, which a refresh never takes away. A + # refresh X refuses for a refresh token it no longer accepts reads the store again, and takes the tokens there, + # if they hold another refresh token, in place of raising: another process spent the refresh token first. + # + # @api private + # @param connection [Core::Connection] the connection to send the refresh over + # @param client [Client, nil] the client whose request refreshes, or nil for none + # @return [OAuth2Tokens] the tokens the refresh issued, once the authenticator holds them, or those it took from + # the store in place of a refusal + # @raise [AuthorizationError] if X refuses to refresh the token, and the store holds no other + # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer, + # as a server error, a redirect, or the page of a proxy says + def refresh(connection, client) + held = refresh_token #: String + request = oauth2_client(token_url(client)).refresh_token_request(refresh_token: held) + update_tokens(TokenEndpoint.fetch(request, connection:, refusal: DEFAULT_ERROR_MESSAGE, headers: token_headers(client))) + @spent_refresh_tokens << held + issued = refresh_token #: String + @reporter.issued(OAuth2Tokens.new(access_token:, refresh_token: issued, expires_at:, scopes:)) + rescue AuthorizationError => e + adopt_in_place_of(e) or raise + end + + # The headers a refresh is sent with + # + # A refresh a client's request makes is sent to the token endpoint at the origin of that client's base URL, + # with its headers, over its connection, and one made for no client, as refresh! and a request signed with the + # authenticator alone make, to the endpoint of the client that took the authenticator first, with its headers, + # over its connection. + # + # @api private + # @param client [Client, nil] the client whose request refreshes, or nil for none + # @return [Hash{String => String}] the headers + def token_headers(client) = client ? client.headers : @token_headers + + # Take the stored tokens in place of a refused refresh + # @api private + # @param error [AuthorizationError] the refusal + # @return [OAuth2Tokens, nil] a copy of the tokens taken, or nil if X refused anything but the refresh token, or + # the store holds no other + def adopt_in_place_of(error) = (adopt_stored_tokens if REFUSED_REFRESH_TOKEN.include?(error.error_code)) + + # Take the stored tokens, if they hold another refresh token + # + # Tokens that hold no refresh token, as an authorization without offline.access stores them, are not taken, since + # they would take away the refresh token of an authenticator that refreshes, which a refresh never does. Tokens + # that hold the refresh token the authenticator holds, or one any of its refreshes spent, are its own, as the + # store holds them until save_tokens has stored the tokens of a later refresh, which it is passed once the lock + # is released, so they are not taken: refreshes on several threads while save_tokens is slow leave the store + # holding tokens older than the last refresh spent, whose refresh token X no longer accepts. The age of the access token taken is unknown, so it is not fresh. + # + # @api private + # @return [OAuth2Tokens, nil] a copy of the tokens taken, or nil if the store holds none, or none of another's + def adopt_stored_tokens + stored = stored_tokens or return + stored_refresh_token = stored.refresh_token + return if stored_refresh_token.nil? || stored_refresh_token.eql?(refresh_token) || @spent_refresh_tokens.include?(stored_refresh_token) + + @refreshed_at = nil + @access_token = stored.access_token + @refresh_token = stored.refresh_token + @expires_at = stored.expires_at + @scopes = stored.scopes + OAuth2Tokens.new(**stored.to_h) + end + + # The tokens in the store the tokens of the user are shared through + # + # They are read with the load_tokens of the authenticator, or else with the load_tokens of a client that + # authenticates with it. The error names the class of what load_tokens returned, rather than inspect it, since a + # Hash of the tokens would show them. + # + # @api private + # @return [OAuth2Tokens, nil] the tokens, or nil for none in the store, or no load_tokens to read it with + # @raise [TypeError] if load_tokens returns neither OAuth2Tokens nor nil + def stored_tokens + load_tokens = @load_tokens || clients.keys.filter_map(&:load_tokens).first + stored = load_tokens&.call + raise TypeError, format(NOT_TOKENS, stored.class) unless stored.nil? || stored.is_a?(OAuth2Tokens) + + stored + end + + # Pass the tokens of a refresh to its callables, once the lock is released + # + # A callable can make a request of its own, such as looking up the user whose tokens it stores, which asks this + # authenticator for a header and so takes the lock again. The refreshes are reported in the order they were + # made, and one already replaced is not reported; see {Core::RefreshReporter}. + # + # @api private + # @param tokens [OAuth2Tokens] the tokens the refresh issued + # @param client [Client, nil] the client whose request refreshed, which TokenReportFailed holds, or nil for none + # @return [void] + # @raise [TokenReportFailed] if save_tokens raises for the tokens, with the tokens and client + def report_refresh(tokens, client) = @reporter.report(tokens, client) + + # Update tokens from the response + # @api private + # @param token [SimpleOAuth::OAuth2::Token] the token the endpoint returned + # @return [void] + def update_tokens(token) + @refreshed_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @access_token = SettingValidator.frozen(token.access_token) + @refresh_token = SettingValidator.frozen(token.refresh_token) if token.refresh_token + @expires_at = token.expires_at + @scopes = TokenEndpoint.scopes_of(token) || scopes + end + + # Check whether a refresh issued the access token within FRESH_TOKEN_SECONDS + # + # A token the authenticator was given, rather than refreshed, may be of any age, so it is not fresh. + # + # @api private + # @return [Boolean] true if a refresh issued the token less than FRESH_TOKEN_SECONDS ago + def fresh? + refreshed_at = @refreshed_at + !refreshed_at.nil? && Process.clock_gettime(Process::CLOCK_MONOTONIC) - refreshed_at < FRESH_TOKEN_SECONDS + end + end + private_constant :OAuth2Refresh + end +end diff --git a/x-core/lib/x/core/oauth2_tokens.rb b/x-core/lib/x/core/oauth2_tokens.rb new file mode 100644 index 00000000..9eae09b1 --- /dev/null +++ b/x-core/lib/x/core/oauth2_tokens.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require "json" +require "time" +require_relative "credential_validator" +require_relative "setting_validator" +require_relative "errors/unsupported_format" + +module X + module Core + # The OAuth 2.0 tokens one refresh issued, or the exchange of an authorization code, which save_tokens is + # passed to store + # + # It is frozen, and taken while the refresh holds its lock, so it holds the tokens of the refresh it reports, + # whatever refreshes follow on other threads, where the authenticator holds the tokens of the latest one. + # + # @api public + class ::X::OAuth2Tokens + # The number of the format of the state Marshal writes, which every release of 1.x writes + # + # A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a + # Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later. + MARSHAL_FORMAT = 1 + private_constant :MARSHAL_FORMAT + + # The message of the error raised for a token that is not a String + NOT_A_STRING = "%s must be a String, not a %s" + private_constant :NOT_A_STRING + # The message of the error raised for a token that is neither a String nor nil + NOT_A_STRING_OR_NIL = "%s must be a String or nil, not a %s" + private_constant :NOT_A_STRING_OR_NIL + # The message of the error raised for JSON that is not an object + NOT_AN_OBJECT = "the JSON of X::OAuth2Tokens must be an object, not a %s" + private_constant :NOT_AN_OBJECT + # The message of the error raised for an expiration time in JSON that is neither a String nor nil + NOT_A_TIME_STRING = "the expires_at of the JSON of X::OAuth2Tokens must be an ISO 8601 String or nil, not a %s" + private_constant :NOT_A_TIME_STRING + # The digits of a second the JSON of an expiration time holds, as many as a Time holds, so it reads back equal + FRACTION_DIGITS = 9 + private_constant :FRACTION_DIGITS + + # Read tokens back from the JSON that to_json wrote, or the Hash that as_json gave + # + # A store that holds JSON, such as Redis or a JSON column, holds the tokens as as_json gives them: the number of + # their format, each token, and the expiration time as an ISO 8601 String, which this reads back as a Time. The + # Hash may be keyed by Symbol, as JSON.parse(json, symbolize_names: true) and many caches give it back. + # + # @api public + # @param json [String, Hash{String, Symbol => Object}] the JSON to_json wrote, or the Hash as_json gave + # @return [OAuth2Tokens] the frozen tokens + # @raise [JSON::ParserError] if the String is not JSON + # @raise [ArgumentError] if the JSON is not an object, its expiration time is not an ISO 8601 String or nil, or + # its tokens or scopes are not those the constructor takes + # @raise [UnsupportedFormat] if the JSON is of a format this release does not read + # @example Read tokens stored as JSON + # X::OAuth2Tokens.from_json(redis.get("tokens")) + def self.from_json(json) + state = json_state(json) + new(access_token: state["access_token"], refresh_token: state["refresh_token"], + expires_at: json_time(state["expires_at"]), scopes: state["scopes"]) + end + + # The Hash of the JSON of tokens, of the format this release reads + # + # @api private + # @param json [String, Hash{String, Symbol => Object}] the JSON to_json wrote, or the Hash as_json gave + # @return [Hash{String => Object}] the Hash, keyed by String + # @raise [ArgumentError] if the JSON is not an object + # @raise [UnsupportedFormat] if the JSON is of a format this release does not read + def self.json_state(json) + state = json.is_a?(String) ? JSON.parse(json) : json + raise ArgumentError, format(NOT_AN_OBJECT, state.class) unless state.is_a?(Hash) + + state = state.transform_keys(&:to_s) + format = state["format"] + raise UnsupportedFormat, "#{self} reads format #{MARSHAL_FORMAT} of JSON, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format) + + state + end + + # The expiration time the JSON of tokens holds as an ISO 8601 String + # + # @api private + # @param expires_at [String, nil] the expiration time as JSON holds it + # @return [Time, nil] the expiration time, or nil for tokens that hold none + # @raise [ArgumentError] if the expiration time is neither an ISO 8601 String nor nil + def self.json_time(expires_at) + return if expires_at.nil? + raise ArgumentError, format(NOT_A_TIME_STRING, expires_at.class) unless expires_at.is_a?(String) + + Time.iso8601(expires_at) + end + private_class_method :json_state, :json_time + + # The OAuth 2.0 access token the refresh issued + # @api public + # @return [String] the access token + # @example Get the access token + # tokens.access_token + attr_reader :access_token + + # The OAuth 2.0 refresh token the refresh issued + # + # It is the refresh token the refresh was sent with when X issued none. An authorization without the + # offline.access scope issues none, so the tokens of its exchange hold nil, and the access token acts for the + # user until it expires, with nothing to refresh it. + # + # @api public + # @return [String, nil] the refresh token, or nil for tokens issued without one + # @example Get the refresh token + # tokens.refresh_token + attr_reader :refresh_token + + # The time the access token expires, or nil when the refresh reported no lifetime + # @api public + # @return [Time, nil] the expiration time + # @example Get the expiration time + # tokens.expires_at + attr_reader :expires_at + + # The scopes X granted the access token + # + # They are the scopes the user authorized, which may be fewer than the app asked for, since a user can leave + # some out on the consent screen, so an app that needs a scope checks for it here rather than learn of it from + # the 403 Forbidden of a request. A refresh that names no scopes keeps those it refreshed, as OAuth 2.0 has it. + # + # @api public + # @return [Array, nil] the scopes, frozen, or nil when X named none and none were known + # @example Check that the user let the app post + # tokens.scopes.include?("tweet.write") + attr_reader :scopes + + # Initialize the tokens of a refresh + # + # @api public + # @param access_token [String] the access token + # @param refresh_token [String, nil] the refresh token, or nil for tokens issued without one + # @param expires_at [Time, nil] the expiration time of the access token + # @param scopes [Array, nil] the scopes X granted the access token, or nil when they are not known + # @return [OAuth2Tokens] the frozen tokens + # @raise [ArgumentError] if the access token is not a String, the refresh token neither a String nor nil, or a + # token is empty, or if the expiration time is neither a Time nor nil, as it is a String for tokens read back + # from JSON other than with from_json, or the scopes are neither an Array of Strings that each name a scope nor + # nil + # @example Build the tokens of a refresh + # X::OAuth2Tokens.new(access_token: "token", refresh_token: "refresh", expires_at: Time.now + 7200, + # scopes: %w[tweet.read users.read offline.access]) + def initialize(access_token:, refresh_token: nil, expires_at: nil, scopes: nil) + raise ArgumentError, format(NOT_A_STRING, :access_token, access_token.class) if access_token.nil? + raise ArgumentError, format(NOT_A_STRING_OR_NIL, :refresh_token, refresh_token.class) unless refresh_token.nil? || refresh_token.is_a?(String) + + CredentialValidator.validate_required!({access_token:}, {refresh_token:, expires_at:, scopes:}) + @access_token = SettingValidator.frozen(access_token) + @refresh_token = SettingValidator.frozen(refresh_token) + @expires_at = expires_at + @scopes = CredentialValidator.frozen_scopes(scopes) + freeze + end + + # The tokens as a Hash, to store, and to build a client of + # + # Its keys are keywords X::Client.new and X::OAuth2Authenticator.new take, so that the tokens build a client, as + # in X::Client.new(client_id:, **tokens.to_h), and a later release of 1.x adds a key to it only as both take it. + # + # @api public + # @return [Hash{Symbol => String, Time, Array, nil}] the access token, refresh token, expiration time, + # and scopes + # @example Store the tokens + # store.save(**tokens.to_h) + def to_h = {access_token:, refresh_token:, expires_at:, scopes:} + + # Check whether other tokens are the same tokens + # + # @api public + # @param other [Object] the other tokens + # @return [Boolean] true if the other tokens are of the same class, with the same values + # @example Compare tokens + # tokens == other + def ==(other) = other.instance_of?(self.class) && to_h.eql?(other.to_h) + alias_method :eql?, :== + + # The hash of the tokens, for use as a Hash key + # + # @api public + # @return [Integer] the hash + # @example Get the hash + # tokens.hash + def hash = [self.class, to_h].hash + + # The tokens as JSON writes them, led by the number of their format + # + # JSON holds no Time, so the expiration time is written as an ISO 8601 String in UTC, to the nanosecond, which + # from_json reads back as a Time equal to it. It holds the tokens themselves, since tokens are written as JSON to + # be stored, so what JSON wrote is kept as secret as the tokens are. + # + # @api public + # @return [Hash{String => Integer, String, Array, nil}] the number of the format, the access token, refresh + # token, expiration time, and scopes + # @example Store the tokens of a refresh in Redis + # X::Client.new(**credentials, save_tokens: ->(tokens) { redis.set("tokens", tokens.to_json) }) + def as_json(*) + {"format" => MARSHAL_FORMAT, "access_token" => access_token, "refresh_token" => refresh_token, + "expires_at" => expires_at&.getutc&.iso8601(FRACTION_DIGITS), "scopes" => scopes} + end + + # The tokens as JSON, which from_json reads back + # + # @api public + # @param state [JSON::State, nil] the state a JSON encoder passes, which the Hash of as_json is given + # @return [String] the tokens as a JSON object + # @example Store the tokens of a refresh in Redis + # X::Client.new(**credentials, save_tokens: ->(tokens) { redis.set("tokens", tokens.to_json) }) + def to_json(state = nil) = as_json.to_json(state) + + # Summarize the tokens for the console without revealing them + # + # @api public + # @return [String] the class name and expiration time + # @example Inspect tokens + # tokens.inspect # => # + def inspect = "#<#{self.class} expires_at=#{expires_at.inspect}>" + + # The state Marshal writes + # + # What is written is plain data, led by the number of its format, so that tokens written by one release of 1.x are + # read by a later one: the tokens, their expiration time, and their scopes, as to_h gives them. It holds the tokens themselves, + # since tokens are marshalled to be stored, so what Marshal wrote is kept as secret as the tokens are. + # + # @api public + # @return [Array(Integer, Hash{Symbol => String, Time, Array, nil})] the number of the format, then the + # tokens as a Hash + # @example Store the tokens of a refresh + # X::Client.new(**credentials, save_tokens: ->(tokens) { File.binwrite("tokens", Marshal.dump(tokens)) }) + def marshal_dump = [MARSHAL_FORMAT, to_h] + + # Restore tokens Marshal read, built as the constructor builds them, frozen + # + # @api public + # @param state [Array] the state Marshal wrote + # @return [void] + # @raise [UnsupportedFormat] if the state is of a format this release does not read + # @example Read stored tokens + # Marshal.load(File.binread("tokens")).expires_at + def marshal_load(state) + format, tokens = state #: [Integer, {access_token: String, refresh_token: String?, expires_at: Time?, scopes: Array[String]?}] + raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format) + + initialize(**tokens.slice(:access_token, :refresh_token, :expires_at, :scopes)) # steep:ignore InsufficientKeywordArguments + end + + # Write the state Marshal writes as YAML + # + # YAML would write the instance variables of the tokens, and read them back into tokens that are not frozen, so + # they say how they are written: the number of their format, then each of them, under the name to_h gives it. + # + # @api public + # @param coder [Psych::Coder] the coder YAML writes the tokens with + # @return [void] + # @example Write tokens as YAML + # YAML.dump(tokens) + def encode_with(coder) + coder["format"] = MARSHAL_FORMAT + to_h.each { |key, value| coder[key.to_s] = value } + end + + # Restore tokens YAML read, frozen, as Marshal restores them + # + # @api public + # @param coder [Psych::Coder] the coder YAML read the tokens with + # @return [void] + # @raise [UnsupportedFormat] if the state is of a format this release does not read + # @example Read tokens written as YAML + # YAML.unsafe_load(File.read("tokens.yml")).expires_at + def init_with(coder) = marshal_load([coder["format"], coder.map.transform_keys(&:to_sym)]) + end + end +end diff --git a/x-core/lib/x/core/origin.rb b/x-core/lib/x/core/origin.rb new file mode 100644 index 00000000..cca5dd71 --- /dev/null +++ b/x-core/lib/x/core/origin.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "uri" +require_relative "authenticator" + +module X + module Core + # The origin a request is sent to, and the credentials it carries there + # + # Credentials are for the origin they were given for: the scheme, host, and port of the base URL of a client. + # A request to any other origin carries none of them, whether it was a redirect followed there or an endpoint + # that named a whole URL, since the credentials of the API are no business of another host. What is dropped is + # the authenticator, which signs the Authorization header of a request, and any Authorization, Cookie, or + # Proxy-Authorization header of the client or the request itself. + # + # Internal to x-core: Client and RedirectHandler decide with it what a request carries. + # + # @api private + module Origin + extend self + + # The headers that carry credentials, which a request to another origin drops + CREDENTIAL_HEADERS = [Authenticator::AUTHENTICATION_HEADER, "Cookie", "Proxy-Authorization"].freeze + + # The authenticator and headers a request carries to an origin + # + # The credentials that would leave the origin they were given for are dropped. + # + # @api private + # @param from [URI::Generic] the URI the credentials were given for, such as the base URL of a client + # @param to [URI::Generic] the URI the request is sent to + # @param authenticator [Authenticator] the authenticator of the request + # @param headers [Hash{String => String}] the headers of the request + # @return [Array(Authenticator, Hash{String, Symbol => String})] the authenticator that signs the request, + # and the headers it is sent with + # @example Keep the credentials of a request to the API + # X::Core::Origin.credentials_for(from: URI(client.base_url), to: uri, authenticator:, headers:) + def credentials_for(from:, to:, authenticator:, headers:) + return [authenticator, headers] if same?(from, to) + + [Authenticator.new, without_credentials(headers)] + end + + # Check whether two URIs share a scheme, host, and port + # + # @api private + # @param uri [URI::Generic] the one URI + # @param other [URI::Generic] the other URI + # @return [Boolean] true if both have the same origin + # @example Check whether an endpoint names the host of the base URL + # X::Core::Origin.same?(URI(client.base_url), uri) + def same?(uri, other) = of(uri).eql?(of(other)) + + # Check whether the response an error was raised for came from an origin + # + # A request answered by the origin its credentials were given for carried them, and one answered by another, + # after a redirect there, carried none, so only a rejection by that origin rejected them. + # + # @api private + # @param error [HTTPError] the error + # @param origin [URI::Generic] a URI of the origin + # @return [Boolean] true if the response came from the origin, and false for one that names no URI + # @example Check whether a rejection came from the base URL of a client + # X::Core::Origin.answered?(error, URI(client.base_url)) + def answered?(error, origin) + uri = error.http_response.uri + !uri.nil? && same?(origin, uri) + end + + private + + # The scheme, host, and port of a URI, in lowercase + # @api private + # @param uri [URI::Generic] the URI + # @return [Array(String, String, Integer)] the origin + def of(uri) + normalized = uri.normalize + [normalized.scheme, normalized.host, normalized.port] + end + + # Headers without the ones that carry credentials + # + # Authorization, Cookie, and Proxy-Authorization are dropped, whatever their case, whether a String or a Symbol + # names them. + # + # @api private + # @param headers [Hash{String => String}] the headers + # @return [Hash{String => String}] the headers that carry no credentials + def without_credentials(headers) + headers.reject { |name, _| CREDENTIAL_HEADERS.any? { |header| name.casecmp?(header) } } + end + end + private_constant :Origin + end +end diff --git a/x-core/lib/x/core/problem.rb b/x-core/lib/x/core/problem.rb new file mode 100644 index 00000000..61ab58d3 --- /dev/null +++ b/x-core/lib/x/core/problem.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +require "json" +require_relative "errors/unsupported_format" + +module X + # A problem the API described, in a response that failed or in one that otherwise succeeded + # + # The API describes what went wrong the same way whether it refused the request, which raises an {HTTPError} + # whose {HTTPError#problem} is one of these, or answered it with the resources it could and named the rest as + # errors, which the object layer reads as the problems of a resource or a page. Code that acts on the reason + # rather than logging it reads the same object either way. + # + # @api public + class Problem + # The number of the format of the state Marshal writes, which every release of 1.x writes + # + # A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a + # Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later. + MARSHAL_FORMAT = 1 + # The name YAML writes each part of the state under, in the order Marshal writes them + YAML_KEYS = %w[format attrs].freeze + private_constant :MARSHAL_FORMAT, :YAML_KEYS + + # The raw attributes of the problem + # @api public + # @return [Hash{String => Object}] the attributes + # @example Get the raw attributes + # problem.attrs # => {"title" => "Not Found Error", "detail" => "Could not find tweet with pinned_tweet_id: [1].", ...} + attr_reader :attrs + + # @!method to_h + # Alias for attrs, returns the raw attributes + # @api public + # @return [Hash{String => Object}] the attributes + # @example Convert a problem to a hash + # problem.to_h + alias_method :to_h, :attrs + + # The problems a response body reports + # + # @api public + # @param body [Hash, nil] the parsed response body + # @return [Array] the problems, empty if there are none + # @example Read the problems of a response + # X::Problem.all_from(client.get("users/me")) + def self.all_from(body) + entries = Array(body.to_h["errors"]) #: Array[untyped] + entries.filter_map { |attrs| Hash.try_convert(attrs)&.then { |hash| new(hash) } }.freeze + end + + # Initialize a problem from the attributes the API reported + # + # @api public + # @param attrs [Hash{String => Object}] the attributes + # @return [Problem] a new problem + # @example Build a problem + # X::Problem.new({"title" => "Not Found Error"}) + def initialize(attrs) + @attrs = deep_freeze(attrs) + freeze + end + + # The short, general description of the problem + # + # @api public + # @return [String, nil] the title + # @example Get the title + # problem.title # => "Not Found Error" + def title = attrs["title"] + + # The description of this occurrence of the problem + # + # @api public + # @return [String, nil] the detail + # @example Get the detail + # problem.detail # => "Could not find tweet with pinned_tweet_id: [1]." + def detail = attrs["detail"] + + # The URI that identifies the kind of problem + # + # @api public + # @return [String, nil] the type + # @example Get the type + # problem.type # => "https://api.x.com/2/problems/resource-not-found" + def type = attrs["type"] + + # The kind of resource the problem concerns + # + # @api public + # @return [String, nil] the resource type, such as tweet or user + # @example Get the resource type + # problem.resource_type # => "tweet" + def resource_type = attrs["resource_type"] + + # The identifier of the resource the problem concerns + # + # It is the String the API gave, whatever the kind of resource, since the API names a user by a username as + # often as by an identifier, and a space, a place, or media by an identifier that is not a number; {#about?} + # compares it with a resource, or the identifier of one, as a String. + # + # @api public + # @return [String, nil] the resource identifier + # @example Get the resource identifier + # problem.resource_id # => "1" + def resource_id = attrs["resource_id"] + + # The request parameter the problem concerns + # + # @api public + # @return [String, nil] the parameter, such as ids or pinned_tweet_id + # @example Get the parameter + # problem.parameter # => "pinned_tweet_id" + def parameter = attrs["parameter"] + + # The value of the parameter the problem concerns + # + # It is the value the API gave, as the request sent it, so an identifier is a String, as resource_id is. + # + # @api public + # @return [Object, nil] the value + # @example Get the value + # problem.value # => "1" + def value = attrs["value"] + + # The message the API gave for a request it refused + # + # The errors of a request the API refused carry a message where the problems of a response that succeeded carry + # a detail, so a problem read from a failed request reads as one of either. + # + # @api public + # @return [String, nil] the message + # @example Get the message + # problem.message # => "Could not authenticate you" + def message = attrs["message"] + + # Check whether the problem is a resource that was not found + # + # @api public + # @return [Boolean] true for a resource-not-found problem + # @example Skip the posts that no longer exist + # problems.reject(&:not_found?) + def not_found? = type.to_s.end_with?("/resource-not-found") + + # Check whether the problem is an operational-disconnect of a stream + # + # X sends one before it closes a stream for its own reasons. A stream reconnects after a line that holds such problems alone, as it does after a connection that dropped. + # + # @api public + # @return [Boolean] true for an operational-disconnect problem + # @example Tell a disconnect apart from another error of a stream + # error.problems.all?(&:disconnect?) + def disconnect? = type.to_s.end_with?("/operational-disconnect") + + # Check whether the problem is the usage cap of the project, reached for the month + # + # X refuses every request of a project that has used the posts its plan allows for the month with a 429 of + # this problem, until the month ends, so a client neither waits for it nor sends the request again, and a stream + # does not reconnect after it, however its rate limits are set. + # + # @api public + # @return [Boolean] true for a usage-capped problem + # @example Tell the usage cap apart from a rate limit + # wait_for_the_next_month if error.problem&.usage_capped? + def usage_capped? = type.to_s.end_with?("/usage-capped") + + # Check whether the problem is about a resource + # + # The resource_id of a problem is the String the API gave, where the resources of x-objects hold an Integer + # identifier, so the identifier of the resource, or the identifier given, is compared with it as a String. A + # username names the user a problem names by it. A problem that names no resource is about none. + # + # @api public + # @param resource [#id, Integer, String] the resource, or its identifier + # @return [Boolean] true if the resource_id of the problem names the resource + # @example Check whether a problem is about a user + # problem.about?(user) # => true + # @example Check whether a problem is about a post by its identifier + # error.problems.any? { |problem| problem.about?(1_234_567_890) } + def about?(resource) + id = case resource + when Integer, String then resource + else resource.id + end + resource_id.eql?(id.to_s) + end + + # Check whether another problem is the same problem + # + # @api public + # @param other [Object] the other problem + # @return [Boolean] true if the other problem is a Problem of the same attributes + # @example Check whether a response reported a problem before + # seen.include?(problem) + def ==(other) = other.instance_of?(self.class) && attrs.eql?(other.attrs) + alias_method :eql?, :== + + # The hash of the problem, which equal problems share + # + # @api public + # @return [Integer] the hash + # @example Count the distinct problems + # problems.uniq.size + def hash = [self.class, attrs].hash + + # The attributes, as a JSON encoder and ActiveSupport read them + # + # ActiveSupport's Object#as_json would otherwise read the instance variables, which is the same Hash under + # another name. + # + # @api public + # @return [Hash{String => Object}] the attributes + # @example Serialize a problem + # problem.as_json # => {"title" => "Not Found Error"} + def as_json(*) = attrs + + # The attributes as JSON + # + # @api public + # @param state [JSON::State, nil] the state a JSON encoder passes, which the attributes are given + # @return [String] the attributes as a JSON object + # @example Serialize a problem + # problem.to_json # => "{\"title\":\"Not Found Error\"}" + def to_json(state = nil) = as_json.to_json(state) + + # Summarize the problem for the console + # + # A problem the API described in a response that succeeded carries a detail, and one it named among the errors + # of a request it refused carries a message in its place, so the summary reads whichever of the two it holds. + # + # @api public + # @return [String] the class name, title, and detail, or message + # @example Inspect a problem of a response that succeeded + # problem.inspect # => # + # @example Inspect a problem of a request the API refused + # problem.inspect # => # + def inspect = "#<#{self.class} #{[title, detail || message].compact.join(": ")}>" + + # The state Marshal writes + # + # What is written is plain data, led by the number of its format, so that a problem written by one release of 1.x + # is read by a later one: its attributes, as the API described it. + # + # @api public + # @return [Array(Integer, Hash{String => Object})] the number of the format, then the attributes + # @example Cache the problems of a response + # Rails.cache.write("problems", X::Problem.all_from(body)) + def marshal_dump = [MARSHAL_FORMAT, attrs] + + # Restore a problem Marshal read, frozen as the problem that was written was + # + # @api public + # @param state [Array] the state Marshal wrote + # @return [void] + # @raise [UnsupportedFormat] if the state is of a format this release does not read + # @example Read cached problems + # Marshal.load(Marshal.dump(problem)).title + def marshal_load(state) + format, attrs = state + raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format) + + initialize(attrs) + end + + # Write the state Marshal writes as YAML + # + # YAML would write the instance variables of the problem, and read them back into one that is not frozen, so it says + # how it is written: each part of the state Marshal writes, under its name. + # + # @api public + # @param coder [Psych::Coder] the coder YAML writes the problem with + # @return [void] + # @example Write a problem as YAML + # YAML.dump(problem) + def encode_with(coder) = YAML_KEYS.zip(marshal_dump) { |key, value| coder[key] = value } + + # Restore a problem YAML read, frozen, as Marshal restores one + # + # @api public + # @param coder [Psych::Coder] the coder YAML read the problem with + # @return [void] + # @raise [UnsupportedFormat] if the state is of a format this release does not read + # @example Read a problem written as YAML + # YAML.unsafe_load(YAML.dump(problem)).title + def init_with(coder) = marshal_load(coder.map.values_at(*YAML_KEYS)) + + private + + # Copy the attributes with String keys, and freeze them and what they hold + # @api private + # @param value [Object] the value + # @return [Object] the frozen copy + def deep_freeze(value) + case value + when Hash then value.transform_keys(&:to_s).transform_values { |element| deep_freeze(element) }.freeze + when Array then value.map { |element| deep_freeze(element) }.freeze + when String then value.dup.freeze + else value + end + end + end +end diff --git a/x-core/lib/x/core/proxy_setting.rb b/x-core/lib/x/core/proxy_setting.rb new file mode 100644 index 00000000..7818cc42 --- /dev/null +++ b/x-core/lib/x/core/proxy_setting.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module X + module Core + # The proxy URL a connection, or the internals of a client, was built with, included into each + # + # A proxy URL can hold the user and password of the proxy, so neither reveals it to a caller, as a client reveals + # none of its secrets. The reader is private, and a copy of a client, which opens its connections with the proxy + # of the client it copies, calls it on the internals of that client with __send__. + # + # @api private + module ProxySetting + private + + # The proxy URL, as it was given + # @api private + # @return [String, URI::Generic, nil] the proxy URL, or nil to take the proxy the environment names + def proxy_url = @proxy_url + end + private_constant :ProxySetting + end +end diff --git a/x-core/lib/x/core/rate_limit.rb b/x-core/lib/x/core/rate_limit.rb new file mode 100644 index 00000000..a5d542a7 --- /dev/null +++ b/x-core/lib/x/core/rate_limit.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module X + # Represents rate limit information from an API response + # @api public + class RateLimit + # Rate limit type identifier + RATE_LIMIT_TYPE = "rate-limit" + # App limit type identifier + APP_LIMIT_TYPE = "app-limit-24hour" + # User limit type identifier + USER_LIMIT_TYPE = "user-limit-24hour" + # All supported rate limit types + TYPES = [RATE_LIMIT_TYPE, APP_LIMIT_TYPE, USER_LIMIT_TYPE].freeze + # The fields of a rate limit, each of which a response reports in a header of its own + FIELDS = %w[limit remaining reset].freeze + private_constant :FIELDS + # The value of a header of a rate limit, which counts requests, or the seconds since the epoch, in base 10 + COUNT = /\A\d+\z/ + private_constant :COUNT + + # The type of rate limit + # @api public + # @return [String] the type of rate limit + # @example Get the rate limit type + # rate_limit.type # => "rate-limit" + attr_reader :type + + # Every rate limit a response reports in full, in the order of TYPES + # + # Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that + # response may, and is private, so X::Response#rate_limits and X::TooManyRequests#rate_limits, which read the + # same limits, call it with __send__. + # + # @api private + # @param http_response [Net::HTTPResponse] the HTTP response + # @return [Array] the 15-minute limit, and the 24-hour app and user limits, when reported + # @example Read every limit a response reports + # X::RateLimit.__send__(:all_from, http_response) + def self.all_from(http_response) = TYPES.filter_map { |type| new(type:, http_response:) if reported?(type, http_response) } + + # Check whether a response has the limit, remaining, and reset of a rate limit + # + # Each is a count in base 10, and a limit whose header holds anything else, such as a proxy that mangled it, is + # not reported, rather than read as another number or raise once it is read, which would raise from a refusal + # in place of the TooManyRequests the refusal is. + # + # Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that + # response may. + # + # @api private + # @param type [String] the type of rate limit + # @param http_response [Net::HTTPResponse] the HTTP response + # @return [Boolean] true if the response has every header of the rate limit, each a count in base 10 + # @example Check for the 15-minute rate limit + # X::RateLimit.__send__(:reported?, "rate-limit", response) + def self.reported?(type, http_response) = FIELDS.all? { |field| http_response["x-#{type}-#{field}"].to_s.match?(COUNT) } + private_class_method :all_from, :reported?, :new + + # Initialize a new RateLimit + # + # Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that + # response may, and new is private, so that only all_from builds one, as X::Response#rate_limits and + # X::TooManyRequests#rate_limits read them. + # + # @api private + # @param type [String] the type of rate limit + # @param http_response [Net::HTTPResponse] the HTTP response containing rate limit headers + # @return [RateLimit] a new instance + # @example Create a rate limit instance + # rate_limit = X::RateLimit.__send__(:new, type: "rate-limit", http_response: response) + def initialize(type:, http_response:) + @type = type + @http_response = http_response + end + + # Get the rate limit maximum + # + # @api public + # @return [Integer] the maximum number of requests allowed + # @example Get the rate limit + # rate_limit.limit + def limit = field("limit") + + # Get the remaining requests + # + # @api public + # @return [Integer] the number of requests remaining + # @example Get the remaining requests + # rate_limit.remaining + def remaining = field("remaining") + + # Check whether the limit has no requests left + # + # @api public + # @return [Boolean] true if no requests remain in the window + # @example Wait for a limit that is used up + # sleep rate_limit.reset_in if rate_limit.exhausted? + def exhausted? = remaining.zero? + + # Get the time when the rate limit resets + # + # @api public + # @return [Time] the time when the rate limit resets + # @example Get the reset time + # rate_limit.reset_at + def reset_at = Time.at(field("reset")) + + # Get the seconds until the rate limit resets + # + # @api public + # @return [Integer] the seconds until the rate limit resets + # @example Get the reset time in seconds + # rate_limit.reset_in + def reset_in + [(reset_at - Time.now).ceil, 0].max + end + + private + + # The response the limit was read from, as the client received it + # + # Internal to x-core: the limit reads its headers from it, so that a limit promises nothing of Net::HTTP. + # {#limit}, {#remaining}, and {#reset_at} are the headers it reports, and X::Response#http_response and + # X::HTTPError#http_response are the response itself. + # + # @api private + # @return [Net::HTTPResponse] the HTTP response the rate limit headers came with + attr_reader :http_response + + # Read a field of the rate limit from its header, in base 10 + # @api private + # @param name [String] the name of the field: limit, remaining, or reset + # @return [Integer] the value of the field + def field(name) = Integer(http_response.fetch("x-#{type}-#{name}"), 10) + end +end diff --git a/x-core/lib/x/core/rate_limit_handler.rb b/x-core/lib/x/core/rate_limit_handler.rb new file mode 100644 index 00000000..5d33fbf6 --- /dev/null +++ b/x-core/lib/x/core/rate_limit_handler.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require_relative "errors/too_many_requests" +require_relative "setting_validator" + +module X + module Core + # Retries requests the API refuses for a rate limit, waiting until the limit resets + # + # Internal to x-core: Client retries with it, and takes max_rate_limit_retries and max_rate_limit_wait. + # + # @api private + class RateLimitHandler + # Default maximum number of retries, which retries nothing + DEFAULT_MAX_RETRIES = 0 + # Default maximum number of seconds to wait for a rate limit to reset, the length of a 15-minute window + DEFAULT_MAX_WAIT = 900 + # Seconds to wait before the first retry of a request refused without a Retry-After header or a reset time, + # doubled for each retry after + UNREPORTED_RESET_WAIT = 60 + # The most seconds added at random to a wait, which keep apart the requests one reset releases + RESET_JITTER = 5 + # The fiber-local key of the retries counted across the attempts of the request being sent + RETRIES = :x_core_rate_limit_retries + # The fiber-local key of the retries Client#with_retries hands down to the request it sends again + HANDED = :x_core_rate_limit_retries_handed + + # The maximum number of times to retry a request refused for a rate limit + # @api private + # @return [Integer] the maximum number of retries + # @example Get or set the maximum retries + # handler.max_rate_limit_retries = 3 + attr_reader :max_rate_limit_retries + + # The maximum number of seconds to wait for a rate limit to reset before retrying + # @api private + # @return [Integer, Float] the maximum wait in seconds + # @example Get or set the maximum wait + # handler.max_rate_limit_wait = 60 + attr_reader :max_rate_limit_wait + + # Initialize a new rate limit handler + # + # @api private + # @param max_rate_limit_retries [Integer] the maximum number of times to retry a request refused for a rate limit + # @param max_rate_limit_wait [Integer, Float] the maximum number of seconds to wait for a rate limit to reset + # @return [RateLimitHandler] a new instance + # @raise [ArgumentError] if the maximum number of retries is not an Integer of at least 0, or the maximum wait + # is not a number of seconds of at least 0 + # @example Create a rate limit handler + # handler = X::Core::RateLimitHandler.new(max_rate_limit_retries: 3) + def initialize(max_rate_limit_retries: DEFAULT_MAX_RETRIES, max_rate_limit_wait: DEFAULT_MAX_WAIT) + @max_rate_limit_retries = SettingValidator.count!(:max_rate_limit_retries, max_rate_limit_retries) + @max_rate_limit_wait = SettingValidator.seconds!(:max_rate_limit_wait, max_rate_limit_wait) + end + + # Run a request, running it again after a rate limit resets + # + # A request is retried while retries remain and the wait the response asks for is within the maximum wait; + # otherwise the error is raised, as it is for the usage cap of the project, which lasts until the month ends. A response that asks for neither a wait nor a reset time waits a minute + # before the first retry, doubling the wait for each retry after, as X recommends. The block must build its + # request anew each time, so that each attempt is signed afresh. + # + # Every request of an app shares the app's limits, and so the time they reset, so a few seconds are added at + # random to each wait, to keep the requests one reset releases from being sent again in one burst. + # + # @api private + # @yield runs the request + # @return [Object] what the block returns + # @raise [TooManyRequests] if the request is refused once more than the retries allow, or for too long a wait + # @example Retry a request + # handler.handle { client.get("users/me") } + def handle + retries = 0 + begin + yield + rescue TooManyRequests => e + retries = counted(retries) + sleep wait_before_retry(e, retries) + retry + end + end + + # Count the retries of each attempt of a request as retries of one request + # + # A request that another handler sends again, after a server error, is rate limited anew on each attempt, so its + # retries are counted across the attempts, rather than from nothing on each, so that max_rate_limit_retries + # bounds the retries of the request. The count is the request's own: a request a callback of it sends, such as + # on_response or save_tokens, counts afresh, and leaves the count of the request as it was. A request that + # Client#with_retries sends again counts on from the retries its earlier attempts took. + # + # @api private + # @yield runs the attempts of the request + # @return [Object] what the block returns + # @example Count the rate limits of a request across its attempts + # handler.counting { retry_handler.handle(idempotent: true) { handler.handle { request } } } + def counting + previous = Thread.current[RETRIES] + handed = Thread.current[HANDED] #: Integer? + Thread.current[HANDED] = nil + Thread.current[RETRIES] = handed || 0 + begin + yield + ensure + Thread.current[HANDED] = Thread.current.fetch(RETRIES) if handed + Thread.current[RETRIES] = previous + end + end + + # Hand the retries of a request down across the attempts with_retries sends + # + # It is what Client#with_retries counts with, so that the request it wraps counts on from the retries its earlier + # attempts took. + # + # @api private + # @yield runs the attempts of the request + # @return [Object] what the block returns + # @example Count the rate limits of a request across the attempts with_retries sends + # handler.handing_down { retry_handler.handle(idempotent: true) { client.post(...) } } + def handing_down + previous = Thread.current[HANDED] + begin + Thread.current[HANDED] = 0 + yield + ensure + Thread.current[HANDED] = previous + end + end + + private + + # The number of a retry, counted across the attempts of a request that are counted + # @api private + # @param retries [Integer] the retries counted by the attempt alone + # @return [Integer] the number of the retry + def counted(retries) + shared = Thread.current[RETRIES] #: Integer? + return retries + 1 unless shared + + Thread.current[RETRIES] = shared + 1 + end + + # The seconds to wait before a retry, raising the error if it may not retry + # + # The random share is added to the wait rather than taken off it, since a request sent before the limit + # resets is refused again, and so it is not counted against the maximum wait, which is the longest reset a + # request waits for. + # + # @api private + # @param error [TooManyRequests] the error the request raised + # @param retries [Integer] the number of the retry, counting from one + # @return [Float] the seconds the response asks the request to wait, and a random share of RESET_JITTER + # @raise [TooManyRequests] the error being rescued, if no retries remain, the wait is too long, or the project + # has reached its usage cap + def wait_before_retry(error, retries) + raise if retries > max_rate_limit_retries || error.problem&.usage_capped? + + wait = error.retry_after || UNREPORTED_RESET_WAIT << (retries - 1) + raise if wait > max_rate_limit_wait + + wait + (rand * RESET_JITTER) + end + end + private_constant :RateLimitHandler + end +end diff --git a/x-core/lib/x/core/redirect_handler.rb b/x-core/lib/x/core/redirect_handler.rb new file mode 100644 index 00000000..2058b019 --- /dev/null +++ b/x-core/lib/x/core/redirect_handler.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require_relative "authenticator" +require_relative "connection" +require_relative "errors/too_many_redirects" +require_relative "origin" +require_relative "request_context" +require_relative "request_builder" +require_relative "setting_validator" + +module X + module Core + # Handles HTTP redirects for API requests + # + # Internal to x-core: Client follows redirects with it, and max_redirects is set on the client. + # + # @api private + class RedirectHandler + # Default maximum number of redirects to follow + DEFAULT_MAX_REDIRECTS = 10 + # The redirects that keep the method and the body of the request, whatever the method + METHOD_PRESERVING_CODES = [307, 308].freeze + # The redirects that keep the method and the body of a request other than a POST, which is followed with a GET + MOVED_CODES = [301, 302].freeze + # The redirects that are followed: those that move the request, and 303 See Other, which is followed with a GET + FOLLOWED_CODES = [*MOVED_CODES, 303, *METHOD_PRESERVING_CODES].freeze + private_constant :METHOD_PRESERVING_CODES, :MOVED_CODES, :FOLLOWED_CODES + + # The maximum number of redirects to follow + # @api private + # @return [Integer] the maximum number of redirects to follow + # @example Get or set the maximum redirects + # handler.max_redirects = 5 + attr_reader :max_redirects + + # The connection for making requests + # @api private + # @return [Connection] the connection for making requests + # @example Get the connection + # handler.connection + attr_reader :connection + + # The request builder for creating requests + # @api private + # @return [RequestBuilder] the request builder for creating requests + # @example Get the request builder + # handler.request_builder + attr_reader :request_builder + + # Initialize a new RedirectHandler + # + # @api private + # @param connection [Connection] the connection for making requests + # @param request_builder [RequestBuilder] the request builder for creating requests + # @param max_redirects [Integer] the maximum number of redirects to follow + # @return [RedirectHandler] a new instance + # @raise [ArgumentError] if the maximum number of redirects is not an Integer of at least 0 + # @example Create a redirect handler + # handler = X::Core::RedirectHandler.new(connection: conn, request_builder: builder) + def initialize(connection: Connection.new, request_builder: RequestBuilder.new, + max_redirects: DEFAULT_MAX_REDIRECTS) + @connection = connection + @request_builder = request_builder + @max_redirects = SettingValidator.count!(:max_redirects, max_redirects) + end + + # Handle redirects for an HTTP response + # + # A redirect to another scheme, host, or port drops the credentials, the authenticator's and any Authorization, + # Cookie, or Proxy-Authorization header among the headers, as Origin decides; see {Origin}. A 307 or 308 keeps + # the method and the body of the request, so a request whose body holds something private replays it to the + # host it is redirected to, whatever its origin. A 301 or 302 keeps them too, as RFC 9110 Section 15.4 has it, + # but for a POST, which it lets a client follow with a GET, so a PUT or a DELETE is not answered by a GET that + # reads as its success. A 303 See Other is followed with a GET, which sends no body, so it sends no Content-Type + # either, such as the form type of a request given form:. + # + # A redirect that cannot be followed, a 300 Multiple Choices, 304 Not Modified, or 305 Use Proxy whatever its + # Location, or one whose location is missing, is not a valid URL, or is not an HTTP or HTTPS URL, is returned as + # it is, so that the client raises an HTTPError for it, however many redirects were followed before it. A + # redirect that can be followed once max_redirects have been raises TooManyRedirects, which names the request + # that redirect answered, so a max_redirects of 0 follows none, and raises for every one that could be. + # + # @api private + # @param response [Net::HTTPResponse] the HTTP response to handle + # @param request [Net::HTTPRequest] the request the response answers, built from a URI + # @param headers [Hash] additional headers to send with redirected requests + # @param authenticator [Authenticator] the authenticator for requests + # @param redirect_count [Integer] the current redirect count + # @return [Net::HTTPResponse] the final HTTP response after following redirects + # @raise [TooManyRedirects] if the maximum number of redirects is exceeded + # @example Handle a response + # response = handler.handle(response: resp, request: req) + def handle(response:, request:, headers: {}, authenticator: Authenticator.new, redirect_count: 0) + follow(response:, request:, headers:, authenticator:, redirect_count:).first + end + + # Follow redirects, and return the final response with the request it answers + # + # Redirects are followed as handle follows them. The final response answers the last request that was sent, which a redirect may have sent to another URI + # with another method, so the response is reported, and its error named, for that request rather than the + # first. + # + # @api private + # @param response [Net::HTTPResponse] the HTTP response to handle + # @param request [Net::HTTPRequest] the request the response answers, built from a URI + # @param headers [Hash] additional headers to send with redirected requests + # @param authenticator [Authenticator] the authenticator for requests + # @param redirect_count [Integer] the current redirect count + # @return [Array(Net::HTTPResponse, Net::HTTPRequest)] the final HTTP response and the request it answers + # @raise [TooManyRedirects] if the maximum number of redirects is exceeded + # @example Follow a response + # response, request = handler.follow(response: resp, request: req) + def follow(response:, request:, headers: {}, authenticator: Authenticator.new, redirect_count: 0) + return [response, request] unless followed?(response) + + uri = request.uri #: URI::Generic + new_uri = build_new_uri(response, uri) + return [response, request] if new_uri.nil? + check_redirect_count(request, redirect_count) + + preserve = preserves_method?(request, Integer(response.code)) + authenticator, headers = Origin.credentials_for(from: uri, to: new_uri, authenticator:, headers: headers_for(preserve, headers)) + new_request = build_request(request, new_uri, preserve, headers, authenticator) + follow(response: connection.perform(request: new_request), request: new_request, headers:, authenticator:, + redirect_count: redirect_count + 1) + end + + private + + # Whether a response is a redirect that is followed, given a location that can be + # @api private + # @param response [Net::HTTPResponse] the response + # @return [Boolean] whether the status of the response is one that is followed + def followed?(response) = FOLLOWED_CODES.include?(Integer(response.code)) + + # Raise for a redirect that would be one more than max_redirects allows + # @api private + # @param request [Net::HTTPRequest] the request the redirect answered, which the error names + # @param redirect_count [Integer] the redirects followed so far + # @return [void] + # @raise [TooManyRedirects] if max_redirects have been followed + def check_redirect_count(request, redirect_count) + raise TooManyRedirects.new("Too many redirects", **RequestContext.of(request)) if redirect_count >= max_redirects + end + + # Build a new URI from the redirect response + # + # A relative location is relative to the request that was redirected, as RFC 9110 Section 10.2.2 requires, + # which need not share the base URL: a request can name a URL of its own. An HTTP or HTTPS URL that names no + # host, such as https:///users or http:, is not one a request can be sent to, as RFC 9110 Section 4.2.1 says. + # + # @api private + # @param response [Net::HTTPResponse] the redirect response + # @param uri [URI::Generic] the URI of the request that was redirected + # @return [URI::HTTP, nil] the new URI, or nil if the location is missing, invalid, or not an HTTP or HTTPS URL + # of a host + def build_new_uri(response, uri) + location = response["location"] or return + new_uri = URI.join(uri, location) + new_uri if new_uri.is_a?(URI::HTTP) && !new_uri.host.to_s.empty? + rescue URI::InvalidURIError + nil + end + + # Whether a redirect keeps the method and the body of the request it answers + # @api private + # @param request [Net::HTTPRequest] the request that was redirected + # @param response_code [Integer] the status code of the redirect + # @return [Boolean] true for a 307 or 308, and for a 301 or 302 of any method but POST + def preserves_method?(request, response_code) + METHOD_PRESERVING_CODES.include?(response_code) || + (MOVED_CODES.include?(response_code) && !request.method.eql?("POST")) + end + + # The headers of a redirected request, without a Content-Type a GET does not send + # @api private + # @param preserve [Boolean] whether the redirected request keeps the method and the body + # @param headers [Hash] the headers of the request that was redirected + # @return [Hash] the headers to send with the redirected request + def headers_for(preserve, headers) + return headers if preserve + + headers.reject { |name, _| name.casecmp?("Content-Type") } + end + + # Build a new request for the redirect + # @api private + # @param request [Net::HTTPRequest] the original request + # @param uri [URI] the new URI + # @param preserve [Boolean] whether the new request keeps the method and the body, or is a GET + # @param headers [Hash] additional headers for the request + # @param authenticator [Authenticator] the authenticator + # @return [Net::HTTPRequest] the new request + def build_request(request, uri, preserve, headers, authenticator) + http_method = :get + if preserve + http_method = request.method.downcase.to_sym + body = request.body + end + + request_builder.build(http_method:, uri:, body:, headers:, authenticator:) + end + end + private_constant :RedirectHandler + end +end diff --git a/x-core/lib/x/core/refresh_reporter.rb b/x-core/lib/x/core/refresh_reporter.rb new file mode 100644 index 00000000..9adf1913 --- /dev/null +++ b/x-core/lib/x/core/refresh_reporter.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "monitor" +require_relative "errors/token_report_failed" + +module X + module Core + # Reports the refreshes of an OAuth 2.0 authenticator to the callables that store their tokens + # + # The refreshes are reported one at a time, in the order they were made, and a refresh another has replaced by + # the time it is reported is not reported at all: a callable that stores the tokens of a refresh as another + # stores the tokens that replaced them would otherwise overwrite them with a refresh token X no longer accepts. + # A callable that raises keeps none of the others from being passed the tokens, and once every one has been, + # TokenReportFailed is raised, which holds the tokens, since the refresh token they replaced is spent, with the + # first error as its cause. A Timeout::Error is a failure of a callable too, even one Timeout.timeout raises with + # its class around the request, since the error that holds the tokens is the only place left to find them; one it + # raises without a class is not a StandardError until it leaves the block, so it is raised as it is. + # + # The lock it reports under is reentrant, so a callable can make a request of its own, such as looking up the + # user whose tokens it stores, which may refresh and report again on the same thread. + # + # @api private + class RefreshReporter + # The message of the error raised when a callable raised for the tokens of a refresh + REPORT_FAILED = "The tokens were refreshed, but save_tokens raised for them" + # The fiber-local key of a callable that a gem extending a client sets, which the callables are run inside + # + # x-streaming sets it while a stream runs, and x-uploader in each thread of a chunked upload, so that a stop of + # the stream, or of the upload, waits for save_tokens to store the tokens of a refresh, rather than cutting it + # short and leaving the store with a refresh token X no longer accepts. The callable is passed a block, which it + # runs and whose result it returns. + GUARD = :x_core_refresh_report_guard + + # Initialize a reporter with no refresh to report + # @api private + # @return [RefreshReporter] a new reporter + def initialize + @monitor = Monitor.new + end + + # Record the tokens of a refresh as the latest, while the refresh holds its lock + # @api private + # @param tokens [OAuth2Tokens] the tokens the refresh issued + # @return [OAuth2Tokens] the tokens + def issued(tokens) = (@latest = tokens) + + # Pass each refresh to the callables another reads + # @api private + # @param hooks [#call] a callable that returns the callables to pass each refresh, read at each refresh + # @return [#call] the callable + def to(hooks) = (@hooks = hooks) + + # Pass the tokens of a refresh to the callables, unless a later one replaced them + # @api private + # @param tokens [OAuth2Tokens] the tokens the refresh issued + # @param client [Client, nil] the client whose request refreshed, which the error holds, or nil for none + # @return [void] + # @raise [TokenReportFailed] if a callable raises, with the tokens and client, once every one has been passed them + def report(tokens, client) + @monitor.synchronize { guarded { pass(tokens, client) } if tokens.equal?(@latest) } + end + + private + + # Run a block inside the guard of the fiber, if a gem extending the client set one + # @api private + # @yield passes the tokens to the callables + # @return [void] + def guarded(&) = (guard = Thread.current[GUARD]) ? guard.call(&) : yield + + # Pass tokens to each callable, raising for the first error once all have run + # @api private + # @param tokens [OAuth2Tokens] the tokens the refresh issued + # @param client [Client, nil] the client whose request refreshed, or nil for none + # @return [void] + # @raise [TokenReportFailed] if a callable raises, with the tokens and client, and the first error as its cause + def pass(tokens, client) + hooks = @hooks + return unless hooks + + errors = hooks.call.filter_map do |hook| + hook.call(tokens) + nil + rescue => e + e + end + raise TokenReportFailed.new(REPORT_FAILED, client:, tokens:), cause: errors.first unless errors.empty? + end + end + private_constant :RefreshReporter + end +end diff --git a/x-core/lib/x/core/request_builder.rb b/x-core/lib/x/core/request_builder.rb new file mode 100644 index 00000000..d5563f5e --- /dev/null +++ b/x-core/lib/x/core/request_builder.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require_relative "authenticator" +require_relative "authenticator_request" +require_relative "version" + +module X + module Core + # Builds HTTP requests for the X API + # + # Internal to x-core: Client builds its requests with it. + # + # @api private + class RequestBuilder + # Default headers for API requests + DEFAULT_HEADERS = { + "User-Agent" => "x-ruby/#{Core::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} (#{RUBY_PLATFORM})" + }.freeze + # The headers of a request that carries a body, which one without a body does not send, since it has no body + # for a content type to describe + BODY_HEADERS = {"Content-Type" => "application/json; charset=utf-8"}.freeze + # Mapping of HTTP method symbols to Net::HTTP classes + HTTP_METHODS = { + get: Net::HTTP::Get, + post: Net::HTTP::Post, + put: Net::HTTP::Put, + delete: Net::HTTP::Delete + }.freeze + # The HTTP methods a request may be sent again with, since sending one again asks the API for what sending it + # once did, where a second POST would post a second time + IDEMPOTENT_METHODS = %i[get put delete].freeze + + # Check whether sending a request again has the same effect as sending it once + # + # @api private + # @param http_method [Symbol] the HTTP method (:get, :post, :put, :delete) + # @return [Boolean] true for a GET, PUT, or DELETE + # @example Check whether a post may be sent again + # X::Core::RequestBuilder.idempotent?(:post) # => false + def self.idempotent?(http_method) = IDEMPOTENT_METHODS.include?(http_method) + + # Merge headers over others, as HTTP names them, without regard to case + # + # A header of overrides is sent in place of one of headers whose name differs from it in case alone, as + # "user-agent" is sent in place of "User-Agent", where Hash#merge would keep both and send whichever came last. + # + # @api private + # @param headers [Hash{String => String}] the headers overridden + # @param overrides [Hash{String => String}] the headers sent in place of those of the same name + # @return [Hash{String => String}] the headers of both, those of overrides in place of the others + # @example Replace the User-Agent of a client with one named in lowercase + # X::Core::RequestBuilder.merge_headers({"User-Agent" => "a"}, {"user-agent" => "b"}) # => {"user-agent" => "b"} + def self.merge_headers(headers, overrides) + headers.reject { |name, _| overrides.any? { |override, _| override.casecmp?(name) } }.merge(overrides) + end + + # Build an HTTP request + # + # @api private + # @param http_method [Symbol] the HTTP method (:get, :post, :put, :delete) + # @param uri [URI] the request URI + # @param body [String, nil] the request body + # @param headers [Hash] additional headers for the request + # @param authenticator [Authenticator] the authenticator for the request + # @return [Net::HTTPRequest] the built HTTP request + # @raise [ArgumentError] if the HTTP method is not supported + # @example Build a GET request + # builder.build(http_method: :get, uri: URI("https://api.x.com/2/users/me")) + def build(http_method:, uri:, body: nil, headers: {}, authenticator: Authenticator.new) + request = create_request(http_method:, uri:, body:) + add_headers(request:, headers:, body:) + add_authentication(request:, authenticator:) + request + end + + private + + # Create an HTTP request + # @api private + # @param http_method [Symbol] the HTTP method + # @param uri [URI] the request URI + # @param body [String, nil] the request body + # @return [Net::HTTPRequest] the created request + def create_request(http_method:, uri:, body:) + http_method_class = HTTP_METHODS[http_method] + + raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class + + escaped_uri = escape_query_params(uri) + request = http_method_class.new(escaped_uri) + request.body = body + request + end + + # Add authentication to a request + # @api private + # @param request [Net::HTTPRequest] the request + # @param authenticator [Authenticator] the authenticator + # @return [void] + def add_authentication(request:, authenticator:) + authenticator.headers(AuthenticatorRequest.new(request)).each do |key, value| + request[key] = value + end + end + + # Add headers to a request + # + # A request that carries a body is given the JSON content type the API takes, which a header of the caller, such + # as the form content type of a request given form fields, replaces. A request without a body, such as a GET or a + # DELETE, is given no content type at all, rather than one for a body it does not send. + # + # @api private + # @param request [Net::HTTPRequest] the request + # @param headers [Hash] additional headers + # @param body [String, nil] the request body, which decides whether a content type is sent + # @return [void] + def add_headers(request:, headers:, body:) + defaults = body.nil? ? DEFAULT_HEADERS : DEFAULT_HEADERS.merge(BODY_HEADERS) + RequestBuilder.merge_headers(defaults, headers).each do |key, value| + request[key] = value + end + end + + # Escape query parameters in a URI + # + # A parameter without a value, as the flag of "?flag" is, is sent without one, rather than with the empty value + # of "?flag=", which an endpoint may read otherwise. + # + # @api private + # @param uri [URI] the URI + # @return [URI] the URI with escaped query parameters + def escape_query_params(uri) + URI(uri).tap do |u| + u.query = URI.encode_www_form(query_pairs(u.query)).gsub("%2C", ",") if u.query + end + end + + # Decode the name and value of each parameter of a query + # + # A parameter without a value is given nil for one. + # @api private + # @param query [String] the query + # @return [Array] the name and value of each parameter + def query_pairs(query) + query.split("&").map do |pair| + name, value = pair.split("=", 2) + [URI.decode_www_form_component(name.to_s), value&.then { |escaped| URI.decode_www_form_component(escaped) }] + end + end + end + private_constant :RequestBuilder + end +end diff --git a/x-core/lib/x/core/request_context.rb b/x-core/lib/x/core/request_context.rb new file mode 100644 index 00000000..bbeffb37 --- /dev/null +++ b/x-core/lib/x/core/request_context.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module X + module Core + # The request an error names: the method it was sent with and the URI it was sent to + # + # An error says what went wrong, and code that makes many requests needs to know which request it went wrong + # for. The errors a request raises are built with it, so each of them reads the method and URI of that request, + # and names it in its message, as "GET /2/users/1: Could not find user" does. + # + # A request that names another host is not followed there with the credentials of the API, so the URI is the + # one the request was sent to, whole, rather than a path read against a base URL. + # + # Internal to x-core: the errors of a request include it, and the parsers and the connection build those + # errors with the request that raised them. + # + # @api private + module RequestContext + # The HTTP method the request was sent with + # + # @api public + # @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one, + # such as one a test built from a response alone + # @example Tell a read that failed from a write + # writes_failed += 1 unless error.http_method.eql?(:get) + attr_reader :http_method + + # The URI the request was sent to + # + # @api public + # @return [URI::Generic, nil] the URI, or nil for an error built without one + # @example Count the failures of each endpoint + # failures[error.uri.path] += 1 + attr_reader :uri + + # The method and URI of a request, as the keywords of an error that names it + # + # The errors take the method and URI of a request rather than the Net::HTTP request itself, so that code that + # builds one, as a test does, depends on neither Net::HTTP nor the way x-core sends a request. + # + # @api private + # @param request [Net::HTTPRequest, nil] the request that failed, or nil for none + # @return [Hash{Symbol => Object}, nil] the http_method and uri of the request, or nil, which splats no keywords, + # for none + # @example The keywords of a request + # RequestContext.of(request) # => {http_method: "GET", uri: #} + def self.of(request) = request && {http_method: request.method, uri: request.uri} + + private + + # Read the method, URI, and line of the request an error names + # + # The line is the method and the path the request was sent for, which Net::HTTP reads off the URI, so that a + # URI naming a host and nothing else is named as the request for its root that it was sent as. + # + # @api private + # @param http_method [Symbol, String, nil] the method the request was sent with, in any case, or nil for none + # @param uri [URI::Generic, nil] the URI the request was sent to, or nil for none + # @return [void] + def name_request(http_method, uri) + @http_method = http_method&.downcase&.to_sym + @uri = uri + return unless http_method && uri + + path = uri.path #: String + @request_line = "#{http_method.upcase} #{path.empty? ? "/" : path}" + end + + # The message of an error, behind the request it names + # + # The query is left out, since the object layer asks for every field of a resource, which makes a query + # longer than the rest of the message. + # + # @api private + # @param message [String] what went wrong + # @return [String] the message, behind the method and path of the request an error names, or as it is for an + # error that names none + def message_naming_request(message) + line = @request_line + line.nil? ? message : "#{line}: #{message}" + end + end + private_constant :RequestContext + end +end diff --git a/x-core/lib/x/core/request_encoding.rb b/x-core/lib/x/core/request_encoding.rb new file mode 100644 index 00000000..69c86a99 --- /dev/null +++ b/x-core/lib/x/core/request_encoding.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "json" +require "uri" + +module X + module Core + # Encodes the query strings and bodies of requests + # + # Internal to x-core: a client resolves the endpoint of each request, and of each stream it opens, and encodes + # the body of a request, with it. + # + # @api private + module RequestEncoding + extend self + + # The slashes that begin an endpoint, which would resolve against the host of the base URL rather than its path + LEADING_SLASHES = %r{\A/+} + private_constant :LEADING_SLASHES + + # The message of the error raised for a request given both a body and form fields + BODY_AND_FORM = "Pass a body or form fields, not both, since a request sends one body" + private_constant :BODY_AND_FORM + + # The message of the error raised for an endpoint that does not resolve to a URL a request can be sent to + INVALID_ENDPOINT = "Invalid endpoint %s: %s" + private_constant :INVALID_ENDPOINT + + # The message of the error raised for an endpoint that is not a String + ENDPOINT_NOT_A_STRING = "endpoint must be a String, such as \"users/me\", not a %s" + private_constant :ENDPOINT_NOT_A_STRING + + # The message of the error raised for a body that is not a String, a Hash, or an Array + BODY_NOT_ENCODABLE = "body must be a String, a Hash, or an Array, not a %s; read an IO, or call to_h, to give what it holds" + private_constant :BODY_NOT_ENCODABLE + + # Resolve an endpoint and its query parameters against a base URL + # + # An endpoint that is not a String, such as a Symbol or a URI, raises ArgumentError naming its class, rather than + # the NoMethodError of a String method it does not have. An endpoint that is not a valid URL reference, such as one that holds a space or a malformed percent escape, + # or that resolves to anything but an http or https URL with a host, such as "foo:bar", raises ArgumentError + # naming it, before any request is built, rather than the URI::InvalidURIError or the ArgumentError of Net::HTTP + # it would raise as the request was built. + # + # @api private + # @param base_url [String] the base URL the endpoint is relative to + # @param endpoint [String] the endpoint, with or without a leading slash or a query string + # @param params [Hash, nil] the query parameters + # @return [URI::HTTP] the URL of the request + # @raise [ArgumentError] if the endpoint is not a String, or does not resolve to an http or https URL with a host + def uri_for(base_url, endpoint, params) + uri = URI.join(base_url, endpoint_with(endpoint, params)) + return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? + + raise ArgumentError, format(INVALID_ENDPOINT, endpoint.inspect, "it does not name an http or https URL") + rescue URI::InvalidURIError + raise ArgumentError, format(INVALID_ENDPOINT, endpoint.inspect, "it is not a valid URL; escape what a URL may not hold, such as a space") + end + + # Encode a form as form fields, and a Hash or an Array body as JSON + # + # A String body is given back as it is. + # + # Form fields are encoded as query parameters are, so a field of nil is dropped, an Array is joined with commas, + # and a Time is given in UTC in the ISO 8601 form the API takes. A Hash or an Array is encoded as JSON, since + # Net::HTTP sends nothing but a String. A body of any other class, such as an IO or a Symbol, raises + # ArgumentError naming its class, before any request is built, rather than be sent as the JSON of its to_s, + # which is the name of the object rather than what it holds. + # + # @api private + # @param body [String, Hash, Array, nil] the request body + # @param form [Hash, nil] the form fields + # @return [String, nil] the encoded body + # @raise [ArgumentError] if both a body and form fields are given, which would send one and drop the other + # @raise [ArgumentError] if the body is not a String, a Hash, an Array, or nil + def encode_body(body, form) + raise ArgumentError, BODY_AND_FORM if body && form + return encode_fields(form) unless form.nil? + + case body + when nil, String then body + when Hash, Array then JSON.generate(body) + else raise ArgumentError, format(BODY_NOT_ENCODABLE, body.class) + end + end + + private + + # Append query parameters to an endpoint, relative to the base URL + # + # An endpoint resolves against the base URL, which would drop the path of the base URL, such as the /2/ of the + # API version, for an endpoint that begins with a slash, so leading slashes are removed. + # + # @api private + # @param endpoint [String] the endpoint, with or without a leading slash or a query string + # @param params [Hash, nil] the query parameters + # @return [String] the endpoint, without leading slashes, with the parameters in its query string + # @raise [ArgumentError] if the endpoint is not a String + def endpoint_with(endpoint, params) + raise ArgumentError, format(ENDPOINT_NOT_A_STRING, endpoint.class) unless endpoint.is_a?(String) + + endpoint = endpoint.sub(LEADING_SLASHES, "") + query = encode_fields(params.to_h) + return endpoint if query.empty? + + "#{endpoint}#{endpoint.include?("?") ? "&" : "?"}#{query}" + end + + # Encode query parameters or form fields + # + # A field of nil is dropped, and the others are encoded with query_value. + # + # @api private + # @param fields [Hash] the parameters or fields + # @return [String] the encoded fields + def encode_fields(fields) = URI.encode_www_form(fields.compact.transform_values { |value| query_value(value) }) + + # Encode a query parameter or form field value + # + # An Array is joined with commas, and a Time is given in UTC in the ISO 8601 form the API takes. + # + # @api private + # @param value [Object] the value + # @return [Object] the encoded value + def query_value(value) + case value + when Array then value.join(",") + when Time then value.getutc.iso8601 + else value + end + end + end + private_constant :RequestEncoding + end +end diff --git a/x-core/lib/x/core/response.rb b/x-core/lib/x/core/response.rb new file mode 100644 index 00000000..4259ad8a --- /dev/null +++ b/x-core/lib/x/core/response.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require_relative "built_response" +require_relative "rate_limit" +require_relative "response_headers" + +module X + module Core + # A summary of one API response, or one object of a stream, which a client passes to its on_response hook + # @api public + class ::X::Response + include ResponseHeaders + + # @!method headers + # The headers of the response + # + # The names are lowercase, and a field the API sent more than once is joined with a comma. + # @api public + # @return [Hash{String => String}] the headers, frozen + # @example Read how long the API took to answer + # response.headers["x-response-time"] + + # The HTTP method of the request + # @api public + # @return [Symbol] the HTTP method + # @example Get the HTTP method + # response.http_method # => :get + attr_reader :http_method + + # The URI of the request + # @api public + # @return [URI::Generic] the request URI + # @example Get the path of the request + # response.uri.path # => "/2/users/me" + attr_reader :uri + + # The response itself, as the client received it + # + # It is an escape hatch, for what a summary does not read: the status is {#status}, the headers are + # {#headers}, and the body is {#body}. It is the response of the transport the client sent the request with, a + # Net::HTTPResponse today, or the one built of the status, headers, and body the summary was given. Its class is + # not covered by the compatibility promise of 1.x: a later release of 1.x may send its requests with another + # library, whose response this then returns. + # + # @api public + # @return [Net::HTTPResponse] the response of the transport + # @example Read the reason phrase of the status line + # response.http_response.message # => "OK" + attr_reader :http_response + + # Summarize a response + # + # Public, so that an on_response hook can be tested with a summary built from the status, headers, and body of + # a response, or from a Net::HTTP response, as the client builds one for each response it reads. + # + # @api public + # @param http_method [Symbol, String] the HTTP method of the request, in any case, which is read as a lowercase + # Symbol, as an error reads it + # @param uri [URI::Generic] the URI of the request + # @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as + # {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of + # the status, headers, and body, which every release of 1.x takes + # @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given + # @param headers [Hash{String => String}, nil] the headers of the response, when it is not given + # @param body [String, nil] the part of the body summarized, such as one object of a stream, or nil for all of + # it, which is the body of a response built of the status + # @return [Response] a new summary + # @raise [ArgumentError] if the HTTP response is given beside a status or headers, or neither it nor a status is + # given, or the status is not from 100 to 599, or the headers are not a Hash of names to values + # @example Summarize a response + # X::Response.new(http_method: :get, uri: URI("https://api.x.com/2/users/me"), status: 200, + # headers: {"x-rate-limit-remaining" => "74"}, body: %({"data":{"id":"1"}})) + # @example Summarize a Net::HTTP response + # X::Response.new(http_response:, http_method: :get, uri: URI("https://api.x.com/2/users/me")) + def initialize(http_method:, uri:, http_response: nil, status: nil, headers: nil, body: nil) + @http_method = http_method.downcase.to_sym + @uri = uri + @http_response = BuiltResponse.of(http_response, status:, headers:, body: (body if http_response.nil?)) + @body = body.dup&.force_encoding(Encoding::UTF_8) + end + + # The body summarized: one streamed object, or else the whole body + # + # It is tagged UTF-8, the encoding of the JSON the API sends. A body that is not valid UTF-8 keeps its bytes, so + # valid_encoding? tells it apart, and scrub replaces what is not UTF-8. + # + # @api public + # @return [String, nil] the body, tagged UTF-8 + # @example Log the body + # logger.debug(response.body) + def body = @body || http_response.body + + # The HTTP status code + # + # @api public + # @return [Integer] the status code + # @example Get the status code + # response.status # => 200 + def status = Integer(http_response.code) + + # Check whether the request succeeded + # + # @api public + # @return [Boolean] true for a 2xx status + # @example Count the failed requests + # failures += 1 unless response.success? + def success? = http_response.is_a?(Net::HTTPSuccess) + + # The rate limits the response reports in its headers + # + # @api public + # @return [Array] the 15-minute limit, and the 24-hour app and user limits when reported + # @example Print how many requests remain in each window + # response.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" } + def rate_limits = RateLimit.__send__(:all_from, http_response) + + # The 15-minute rate limit of the endpoint, which nearly every response reports + # + # @api public + # @return [RateLimit, nil] the rate limit, or nil if the response reports none + # @example Slow down near the limit + # sleep response.rate_limit.reset_in if response.rate_limit&.remaining&.zero? + def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) } + + # The number of resources the body holds, as data and as each kind of include + # + # The API bills reads by the resource, but once a UTC day for each, so these count what the response returned, + # not what X billed for it. The body is parsed once, however many times a summary is asked what it holds. A + # body whose includes is not an object holds no includes to count. + # + # @api public + # @return [Hash{String => Integer}] the count of data and of each include, such as users and posts + # @example Count the users a lookup returned + # response.resource_counts # => {"data" => 1, "posts" => 1} + def resource_counts + body = parsed_body + data = body["data"] + counts = {"data" => Array.try_convert(data)&.size || [data].compact.size} + Hash.try_convert(body["includes"])&.each { |key, resources| counts[key] = Array(resources).size } + counts + end + + # The number of resources the body holds, in data and includes together + # + # @api public + # @return [Integer] the resource count + # @example Total the resources a client has read + # total += response.resource_count + def resource_count = resource_counts.values.sum + + private + + # The body parsed as a JSON object, read once and kept + # + # A hook that reads a summary reads a body the client has parsed already, so parsing it again for each count + # would parse every response of a client twice. + # + # @api private + # @return [Hash{String => Object}] the parsed body + def parsed_body + @parsed_body ||= parse_body + end + + # The body parsed as a JSON object, or an empty one for a body that holds none + # @api private + # @return [Hash{String => Object}] the parsed body + def parse_body + Hash.try_convert(JSON.parse(body.to_s)) || {} + rescue JSON::ParserError + {} + end + end + end +end diff --git a/x-core/lib/x/core/response_headers.rb b/x-core/lib/x/core/response_headers.rb new file mode 100644 index 00000000..c6b2ede8 --- /dev/null +++ b/x-core/lib/x/core/response_headers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module X + module Core + # The headers of a response, read from the response it holds, included into Response, HTTPError, and InvalidResponse + # + # Internal to x-core: it gives a summary of a response, and the error of a failed one, the same headers. + # + # @api private + module ResponseHeaders + # The headers of the response + # + # The names are lowercase, whatever case the API sent them in, since HTTP field names mean the same in any + # case, so a header is read here by the name it is written with here. A field the API sent more than once + # is joined with a comma, as HTTP joins the lines of a repeated field. + # + # Set-Cookie is joined the same way, though HTTP does not join it, since a cookie may hold a comma, as its + # Expires attribute does, so the cookies of a response that sets more than one cannot be read apart from its + # headers: read each of them from the http_response, with get_fields. + # + # @api public + # @return [Hash{String => String}] the headers, frozen + # @example Read how long the API took to answer + # response.headers["x-response-time"] + # @example Read each cookie a response sets + # response.http_response.get_fields("set-cookie") + def headers = http_response.to_hash.transform_values { |values| values.join(", ") }.freeze + end + private_constant :ResponseHeaders + end +end diff --git a/x-core/lib/x/core/response_parser.rb b/x-core/lib/x/core/response_parser.rb new file mode 100644 index 00000000..3a0766ba --- /dev/null +++ b/x-core/lib/x/core/response_parser.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require_relative "errors/bad_gateway" +require_relative "errors/bad_request" +require_relative "errors/callback_error" +require_relative "errors/conflict" +require_relative "errors/http_error" +require_relative "errors/invalid_response" +require_relative "errors/forbidden" +require_relative "errors/gateway_timeout" +require_relative "errors/gone" +require_relative "errors/internal_server_error" +require_relative "errors/method_not_allowed" +require_relative "errors/not_acceptable" +require_relative "errors/not_found" +require_relative "errors/payload_too_large" +require_relative "errors/payment_required" +require_relative "errors/request_timeout" +require_relative "errors/service_unavailable" +require_relative "errors/too_many_requests" +require_relative "errors/unauthorized" +require_relative "errors/unavailable_for_legal_reasons" +require_relative "errors/unprocessable_entity" +require_relative "errors/unsupported_media_type" +require_relative "request_context" + +module X + module Core + # Parses HTTP responses from the X API + # + # Internal to x-core: Client parses responses with it. + # + # @api private + class ResponseParser + # Mapping of HTTP status codes to error classes + ERROR_MAP = { + 400 => BadRequest, + 401 => Unauthorized, + 402 => PaymentRequired, + 403 => Forbidden, + 404 => NotFound, + 405 => MethodNotAllowed, + 406 => NotAcceptable, + 408 => RequestTimeout, + 409 => Conflict, + 410 => Gone, + 413 => PayloadTooLarge, + 415 => UnsupportedMediaType, + 422 => UnprocessableEntity, + 429 => TooManyRequests, + 451 => UnavailableForLegalReasons, + 500 => InternalServerError, + 502 => BadGateway, + 503 => ServiceUnavailable, + 504 => GatewayTimeout + }.freeze + # Error classes for the statuses ERROR_MAP does not name, keyed by the class of status: 4xx or 5xx + STATUS_CLASS_ERRORS = {4 => ClientError, 5 => ServerError}.freeze + + # Parse an HTTP response + # + # A successful response without a body, such as 204 No Content, parses to nil. One whose body is not JSON + # raises, rather than parse to nil as though the response had no body. + # + # @api private + # @param response [Net::HTTPResponse] the HTTP response to parse + # @param array_class [Class, nil] the class for parsing JSON arrays + # @param object_class [Class, nil] the class for parsing JSON objects, or a class that builds objects from + # the whole body (see {#decode}) + # @param client [Client, nil] the client that made the request + # @param request [Net::HTTPRequest, nil] the request the response answers, which its error names + # @return [Object, nil] the parsed response body + # @raise [HTTPError] if the response is not successful + # @raise [InvalidResponse] if the body of a successful response is not JSON + # @example Parse a response + # parser.parse(response: response, request: request) + def parse(response:, array_class: nil, object_class: nil, client: nil, request: nil) + raise error(response, request) unless response.is_a?(Net::HTTPSuccess) + + body = response.body.to_s + return if blank?(body) + + begin + decode(body, array_class:, object_class:, client:) + rescue JSON::ParserError + raise InvalidResponse.new(http_response: response, **RequestContext.of(request)) + end + end + + # Decode a JSON document into the classes a request asked for + # + # JSON gives every object in a document the same object_class, at every depth. A class that + # models a whole response instead responds to from_response, which receives the document + # parsed into Hashes and Arrays along with the client, and whatever it returns is the result; + # see {Client} for the protocol, which later versions of 1.x may pass other keywords to. An error from_response + # raises is tagged as a CallbackError, so that the handlers of a request do not take it for the request's own. + # + # @api private + # @param json [String] the JSON document + # @param array_class [Class, nil] the class for parsing JSON arrays + # @param object_class [Class, #from_response, nil] the class for parsing JSON objects, or one that + # builds objects from the whole document + # @param client [Client, nil] the client that made the request, passed to from_response + # @return [Object] the decoded document + # @raise [JSON::ParserError] if the document is not valid JSON + # @raise [CallbackError] if from_response raises + # @example Decode a document into the default classes + # parser.decode('{"data": {"id": "1"}}') # => {"data" => {"id" => "1"}} + def decode(json, array_class: nil, object_class: nil, client: nil) + return JSON.parse(json, array_class:, object_class:) unless object_class.respond_to?(:from_response) + + document = JSON.parse(json) + CallbackError.tagging { object_class.from_response(document, client:) } + end + + # Create the error of a response that is not successful + # + # Internal to x-core: TokenEndpoint raises it for a token request the endpoint failed to answer. + # + # @api private + # @param response [Net::HTTPResponse] the HTTP response + # @param request [Net::HTTPRequest, nil] the request the response answers, which the error names + # @return [HTTPError] the error + # @example Raise the error of a response + # raise parser.error(response, request) + def error(response, request) + error_class(response).new(http_response: response, **RequestContext.of(request)) + end + + private + + # Check whether a body holds nothing but whitespace + # + # A body that is not valid UTF-8 holds something other than whitespace, and a regular expression raises for it, + # so it is not matched against one: it is not JSON, and raises InvalidResponse, which holds it. + # + # @api private + # @param body [String] the body of the response, tagged UTF-8 + # @return [Boolean] true for a body that is empty or holds whitespace alone + def blank?(body) = body.valid_encoding? && !body.match?(/\S/) + + # Get the error class for a response, falling back on its class of status + # @api private + # @param response [Net::HTTPResponse] the HTTP response + # @return [Class] the error class + def error_class(response) + status = Integer(response.code) + ERROR_MAP.fetch(status) { STATUS_CLASS_ERRORS.fetch(status / 100, HTTPError) } + end + end + private_constant :ResponseParser + end +end diff --git a/x-core/lib/x/core/retry_handler.rb b/x-core/lib/x/core/retry_handler.rb new file mode 100644 index 00000000..278dc32d --- /dev/null +++ b/x-core/lib/x/core/retry_handler.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "net/http" +require "socket" +require_relative "errors/callback_error" +require_relative "errors/http_error" +require_relative "errors/network_error" +require_relative "errors/request_timeout" +require_relative "errors/server_error" +require_relative "setting_validator" + +module X + module Core + # Sends a request again after the API failed to answer it, or after its answer never arrived + # + # Internal to x-core: Client retries with it, and takes max_retries, and Client#with_retries sends a request + # again with it that a client sends no more than once, as it does any POST. + # + # @api private + class RetryHandler + # Default maximum number of retries, which sends an idempotent request twice more before it raises + DEFAULT_MAX_RETRIES = 2 + # Seconds to wait before the first retry, doubled for each retry after, up to MAX_RETRY_AFTER + INITIAL_WAIT = 1 + # The longest wait a response may ask for that a request waits out before it is sent again, and the longest the + # backoff grows to; a response that asks to be left alone for longer raises at once, rather than hold a caller for + # minutes on end + MAX_RETRY_AFTER = 60 + # The failures a retry may follow, none of which the request itself is the reason for: a 408 says the API gave + # up waiting for the request, not that it refused it + RETRIABLE_ERRORS = [NetworkError, ServerError, RequestTimeout].freeze + # The errors of a socket, the cause of a NetworkError, that fail a request before any of it is written: a host + # that cannot be resolved or reached, a connection refused, and a connection or TLS handshake that timed out + UNSENT_ERRORS = [Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, SocketError].freeze + + # The maximum number of times to send an idempotent request again after a failure + # @api private + # @return [Integer] the maximum number of retries + # @example Read the maximum retries + # handler.max_retries # => 2 + attr_reader :max_retries + + # Initialize a new retry handler + # + # @api private + # @param max_retries [Integer] the maximum number of times to send an idempotent request again + # @return [RetryHandler] a new instance + # @raise [ArgumentError] if the maximum number of retries is not an Integer of at least 0 + # @example Create a handler that sends a failed request twice more + # handler = X::Core::RetryHandler.new(max_retries: 2) + def initialize(max_retries: DEFAULT_MAX_RETRIES) + @max_retries = SettingValidator.count!(:max_retries, max_retries) + end + + # Run a request, running it again after a failure of the API or of the network + # + # A request is sent again while retries remain, after waiting up to a second before the first retry and up to + # twice as long before each retry after, but never more than MAX_RETRY_AFTER, or for as long as the response asks + # when it carries a Retry-After header, whichever is longer. A response that asks to be left alone for longer than MAX_RETRY_AFTER raises + # at once, since waiting it out would hold the caller for minutes. Only an idempotent request is retried: the + # API may have acted on a POST whose answer never arrived, so sending that again could post twice. The block + # must build its request anew each time, so that each attempt is signed afresh. + # + # A TooManyRequests is not retried here: a request sent again before its rate limit resets is refused again, so + # RateLimitHandler waits for the reset instead, for as long as max_rate_limit_wait allows, well past + # MAX_RETRY_AFTER. + # + # A NetworkError is retried only when the request never left: a request that timed out reading its response, + # or whose connection dropped once it was written, may have been answered, and the API bills a read it answered + # whether or not the answer arrived, so sending it again could bill it again. resend_unanswered retries those + # too, for a request the API bills nothing for, such as the chunk of an upload. + # + # An error a callback of the request raised, such as on_response, is not the API's failure, and is raised as it + # is, so that a request Client#with_retries wraps is not sent again for it, though the API answered it. + # + # @api private + # @param idempotent [Boolean] whether sending the request again has the same effect as sending it once + # @param resend_unanswered [Boolean] whether to send the request again after a NetworkError that may have come + # after the API received it + # @yield runs the request + # @return [Object] what the block returns + # @raise [NetworkError] if the request fails once more than the retries allow + # @raise [ServerError, RequestTimeout] if the API fails to answer once more than the retries allow, or asks for a + # wait longer than MAX_RETRY_AFTER + # @example Retry a lookup + # handler.handle(idempotent: true) { client.get("users/me") } + def handle(idempotent:, resend_unanswered: false) + retries = 0 + begin + yield + rescue *RETRIABLE_ERRORS => e + retries += 1 + requested = retry_after(e) + raise unless idempotent && retries <= max_retries && requested.to_i <= MAX_RETRY_AFTER && resendable?(e, resend_unanswered) + + sleep([requested, backoff(retries)].compact.max) + retry + end + end + + private + + # Whether a failure leaves a request safe to send again + # + # A ServerError or a RequestTimeout is an answer, which says the API failed to act on the request. A NetworkError + # says the API never received it only when its cause is among UNSENT_ERRORS; any other may have come after the + # API answered. None of them says so when a callback raised it, rather than the API. + # + # @api private + # @param error [Error] the error the request raised + # @param resend_unanswered [Boolean] whether a request the API may have answered is sent again + # @return [Boolean] true if the request may be sent again + def resendable?(error, resend_unanswered) + return false if CallbackError.untagged?(error) + + resend_unanswered || error.is_a?(HTTPError) || UNSENT_ERRORS.any? { |unsent| error.cause.is_a?(unsent) } + end + + # The seconds a response asks a request to wait before it is sent again + # + # A request that never got a response, which raised a NetworkError, asks for no wait, and neither does a + # response that carries no Retry-After header. + # + # @api private + # @param error [Error] the error the request raised + # @return [Integer, nil] the seconds the response asks for, or nil if it asks for none + def retry_after(error) + error.retry_after if error.is_a?(HTTPError) + end + + # The seconds to wait before a retry + # + # The wait doubles with each retry until it reaches MAX_RETRY_AFTER, and a random share of up to half of it is + # taken off. The share is what keeps the requests apart: a failure of the API fails every request in flight at + # once, and requests that waited the same time would be sent again together, to fail together once more. The + # cap keeps a client given many retries from sleeping for longer than a response may ask it to, as the waits + # of a doubling without end would: the tenth retry would otherwise wait over eight minutes. + # + # @api private + # @param retries [Integer] the number of the retry, counting from one + # @return [Float] the seconds to wait + def backoff(retries) + wait = [INITIAL_WAIT << (retries - 1), MAX_RETRY_AFTER].min + wait - (rand * wait / 2) + end + end + private_constant :RetryHandler + end +end diff --git a/x-core/lib/x/core/setting_validator.rb b/x-core/lib/x/core/setting_validator.rb new file mode 100644 index 00000000..d291dd6a --- /dev/null +++ b/x-core/lib/x/core/setting_validator.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +require "uri" + +module X + module Core + # Checks the settings of the handlers of a client when the client is built + # + # A setting is compared with a count of attempts or a number of seconds only once a request has failed, so a + # setting that is not a number, such as a String read from an environment variable, would raise from that + # comparison, in place of the failure it was compared for. So each is checked when the handler is built, which + # is when the client that holds it is. + # + # A timeout is handed to Net::HTTP, which reads it only once a request waits, so a timeout that is not a number + # raises from inside Net::HTTP as the first request is sent, and Float::INFINITY raises there too, or ends the + # thread that Timeout keeps for every timeout of the process, since no deadline can be set that far off. So each + # is checked when the connection is built, which is when the client that holds it is. + # + # The base URL and the headers of a client are read only once a request is built, so a base URL that is no URL, + # or headers that are not a Hash of names to values, raised from the first request, rather than where the client + # was given them, so each is checked when the client is built. + # + # The classes a response is parsed into are read only once a response has arrived, so a class that cannot parse + # one, such as the String "Hash", raised once the API had answered, and billed, the request. So the default + # classes of a client are checked when the client is built, and the classes of a request before it is sent. + # + # Internal to x-core: the handlers of redirects, rate limits, and retries, a connection, and a client check their + # settings with it. + # + # @api private + module SettingValidator + extend self + + # The message of the error raised for a count that is not an Integer of at least 0 + INVALID_COUNT = "%s must be an Integer of at least 0, not %s" + # The message of the error raised for seconds that are not a number of at least 0 + INVALID_SECONDS = "%s must be a number of seconds of at least 0, not %s" + # The message of the error raised for seconds that are not a finite number of at least 0 + INVALID_FINITE_SECONDS = "%s must be a finite number of seconds of at least 0, not %s" + # The message of the error raised for a timeout that is neither a finite number of seconds of at least 0 nor nil + INVALID_TIMEOUT = "%s must be a finite number of seconds of at least 0, or nil for no timeout, not %s" + # The message of the error raised for a base URL that is not an absolute HTTP or HTTPS URL + INVALID_BASE_URL = "base_url must be an absolute http or https URL with no query or fragment, such as \"https://api.x.com/2/\", not %s" + # The message of the error raised for a base URL that holds a user or a password, which it leaves out, since + # either may be a secret + BASE_URL_WITH_USERINFO = "base_url must hold no user or password, which no request sends" + # The scheme and authority of a URL whose authority holds a user or a password, before the at sign of the host + USERINFO = %r{\A[^:/?#]+://[^/?#]*@} + # The message of the error raised for headers that are not a Hash + INVALID_HEADERS = "headers must be a Hash of header names to values, not a %s" + # The message of the error raised for a header whose name or value is not what a header takes + INVALID_HEADER = "headers must name each header with a String or a Symbol and give it a String, " \ + "not %s with a %s" + # The message of the error raised for a callable that does not respond to call + INVALID_CALLABLE = "%s must respond to call, as a Proc or a lambda does, or be nil, not a %s" + # The message of the error raised for a class to parse JSON arrays into that is not a Class + INVALID_ARRAY_CLASS = "%s must be a Class that JSON.parse builds each array into, such as Array, not %s" + # The message of the error raised for a class to parse JSON objects into that is not a Class, nor builds a result + INVALID_OBJECT_CLASS = "%s must be a Class that JSON.parse builds each object into, such as Hash, or respond to " \ + "from_response, as the resource classes of x-objects do, not %s" + # The message of the error raised for keywords a request takes none of, as the fields of a body given without + # the braces of a Hash are read + UNKNOWN_KEYWORDS = "unknown keyword%s: %s; pass a body as a Hash in braces, as %s(%s, %s)" + private_constant :INVALID_COUNT, :INVALID_SECONDS, :INVALID_FINITE_SECONDS, :INVALID_TIMEOUT, + :INVALID_BASE_URL, :INVALID_HEADERS, :INVALID_HEADER, :INVALID_CALLABLE, :INVALID_ARRAY_CLASS, :INVALID_OBJECT_CLASS, + :UNKNOWN_KEYWORDS + + # Check that a count is an Integer of at least 0 + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the value of the setting + # @return [Integer] the value + # @raise [ArgumentError] if the value is not an Integer of at least 0 + # @example Check the retries of a client + # X::Core::SettingValidator.count!(:max_retries, 2) # => 2 + def count!(name, value) + return value if count?(value) + + raise ArgumentError, format(INVALID_COUNT, name, value.inspect) + end + + # Check that seconds are a real number of at least 0 + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the value of the setting + # @return [Integer, Float] the value + # @raise [ArgumentError] if the value is not a real number of at least 0 + # @example Check the longest wait for a rate limit + # X::Core::SettingValidator.seconds!(:max_rate_limit_wait, 900) # => 900 + def seconds!(name, value) + return value if seconds?(value) + + raise ArgumentError, format(INVALID_SECONDS, name, value.inspect) + end + + # Check that seconds are a finite real number of at least 0 + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the value of the setting + # @return [Integer, Float] the value + # @raise [ArgumentError] if the value is not a finite real number of at least 0 + # @example Check the time a connection is kept open + # X::Core::SettingValidator.finite_seconds!(:keep_alive_timeout, 30) # => 30 + def finite_seconds!(name, value) + return value if finite_seconds?(value) + + raise ArgumentError, format(INVALID_FINITE_SECONDS, name, value.inspect) + end + + # Check that a timeout is finite seconds of at least 0, or nil for no timeout + # + # Net::HTTP waits for as long as it takes when a timeout is nil. + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the value of the setting + # @return [Integer, Float, nil] the value + # @raise [ArgumentError] if the value is neither a finite real number of at least 0 nor nil + # @example Check the timeout for reading a response + # X::Core::SettingValidator.timeout!(:read_timeout, 60) # => 60 + def timeout!(name, value) + return value if value.nil? || finite_seconds?(value) + + raise ArgumentError, format(INVALID_TIMEOUT, name, value.inspect) + end + + # Check that a base URL is an absolute HTTP or HTTPS URL, with a host + # + # An endpoint is appended to the path of the base URL, so a query or a fragment, which would come before the + # endpoint rather than after it, is refused. So is a user or a password, which Net::HTTP sends with no request, + # and which the client would reveal in its inspect and in the URI of every error; the error that refuses one + # leaves the URL out, since the password is a secret. + # + # @api private + # @param value [Object] the base URL + # @return [String] the base URL + # @raise [ArgumentError] if the base URL is not a String that is an absolute http or https URL with a host, and + # no user, password, query, or fragment + # @example Check the base URL of the v1.1 API + # X::Core::SettingValidator.base_url!("https://api.x.com/1.1/") # => "https://api.x.com/1.1/" + def base_url!(value) + raise ArgumentError, BASE_URL_WITH_USERINFO if value.is_a?(String) && value.match?(USERINFO) + return -value if value.is_a?(String) && http_url?(value) + + raise ArgumentError, format(INVALID_BASE_URL, value.inspect) + end + + # A frozen copy of a String a caller gave, such as a credential, or nil + # + # A credential, token, header, or URL is copied as it is taken, so that the caller that gave it can change + # neither what is sent nor where. + # + # @api private + # @param value [String, nil] the String, or nil + # @return [String, nil] a frozen copy of the String, or nil + # @example Hold a credential + # X::Core::SettingValidator.frozen(+"token") # => "token" + def frozen(value) = value && -value + + # Check that headers are a Hash of header names to String values + # + # The headers are returned with each named by a String. Each header must be named with a String or a Symbol. A Symbol names the header its underscores name with + # hyphens, as :content_type names Content-Type, so that it replaces the header of that name, and is dropped where + # that header is, rather than being sent beside it as a header no one sends. The error names the class of what is + # not a Hash, and the name of a header whose name or value is not what a header takes with the class of its + # value, rather than inspect either, since headers carry credentials, such as an Authorization header. + # + # @api private + # @param value [Object] the headers + # @return [Hash{String => String}] the headers, each named by a String + # @raise [ArgumentError] if the headers are not a Hash, or name a header with anything but a String or a + # Symbol, or give one anything but a String + # @example Check headers that name the application + # X::Core::SettingValidator.headers!("User-Agent" => "MyApp/1.0") # => {"User-Agent" => "MyApp/1.0"} + # @example Name a header with a Symbol + # X::Core::SettingValidator.headers!(content_type: "text/plain") # => {"content-type" => "text/plain"} + def headers!(value) + raise ArgumentError, format(INVALID_HEADERS, value.class) unless value.is_a?(Hash) + + invalid = value.find { |name, header| !header_name?(name) || !header.is_a?(String) } + invalid ? invalid_header!(*invalid) : value.to_h { |name, header| [header_name(name), -header] } + end + + # Check that a callable responds to call, or is nil + # + # A callable is called only once what it is called for happens, such as a refresh, so one that does not respond + # to call would raise NoMethodError from inside a request, long after the client was given it. The error names + # its class rather than inspect it, since a callable can close over credentials. + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the callable, or nil for none + # @return [#call, nil] the value + # @raise [ArgumentError] if the value is neither nil nor responds to call + # @example Check the loader of the tokens a client shares + # X::Core::SettingValidator.callable!(:load_tokens, -> { store.load }) # => # + def callable!(name, value) + return value if value.nil? || value.respond_to?(:call) + + raise ArgumentError, format(INVALID_CALLABLE, name, value.class) + end + + # Check that the class to parse JSON arrays into is a Class + # + # JSON.parse builds each array of a body with the new of the class, and appends each element with <<. + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the class + # @return [Class] the value + # @raise [ArgumentError] if the value is not a Class + # @example Check the class a client parses arrays into + # X::Core::SettingValidator.array_class!(:default_array_class, Array) # => Array + def array_class!(name, value) + return value if value.instance_of?(Class) + + raise ArgumentError, format(INVALID_ARRAY_CLASS, name, value.inspect) + end + + # Check that the class to parse JSON objects into is a Class, or builds a result + # + # JSON.parse builds each object of a body with the new of a class, and sets each member with []=, unless it + # responds to from_response, which builds the result from the whole body instead; see {Client}. + # + # @api private + # @param name [Symbol] the name of the setting, which the error names + # @param value [Object] the class, or what builds the result of a request + # @return [Class, #from_response] the value + # @raise [ArgumentError] if the value is neither a Class nor responds to from_response + # @example Check the class a client parses objects into + # X::Core::SettingValidator.object_class!(:default_object_class, Hash) # => Hash + def object_class!(name, value) + return value if value.instance_of?(Class) || value.respond_to?(:from_response) + + raise ArgumentError, format(INVALID_OBJECT_CLASS, name, value.inspect) + end + + # Refuse the keywords a request takes none of, saying how to pass them as its body + # + # A body is a positional argument, so its fields given without the braces of a Hash, as in + # post("tweets", text: "Hello"), are read as keywords, which Ruby would refuse as unknown without saying that + # the body is passed in braces. + # + # @api private + # @param http_method [Symbol] the method of the request, such as :post + # @param endpoint [String] the endpoint of the request + # @param keywords [Hash{Symbol, String => Object}] the keywords the request takes none of + # @return [void] + # @raise [ArgumentError] if there are any such keywords + # @example Refuse the fields of a body given without braces + # X::Core::SettingValidator.no_unknown_keywords!(:post, "tweets", {text: "Hello"}) + # # raises ArgumentError: unknown keyword: :text; pass a body as a Hash in braces, as post("tweets", {text: "Hello"}) + def no_unknown_keywords!(http_method, endpoint, keywords) + return if keywords.empty? + + names = keywords.keys.map(&:inspect).join(", ") + raise ArgumentError, format(UNKNOWN_KEYWORDS, ("s" if keywords.size > 1), names, http_method, endpoint.inspect, keywords) + end + + # Check the classes a request parses its response into, before the request is sent + # + # @api private + # @param array_class [Object] the class to parse JSON arrays into + # @param object_class [Object] the class to parse JSON objects into, or what builds the result of the request + # @return [void] + # @raise [ArgumentError] if the array class is not a Class, or the object class is neither a Class nor responds + # to from_response + # @example Check the classes of a request + # X::Core::SettingValidator.parsing_classes!(array_class: Array, object_class: X::User) + def parsing_classes!(array_class:, object_class:) + array_class!(:array_class, array_class) + object_class!(:object_class, object_class) + end + + private + + # Raise for a header whose name or value is not what a header takes + # @api private + # @param name [Object] the name of the header + # @param header [Object] the value of the header + # @return [void] + # @raise [ArgumentError] always + def invalid_header!(name, header) + raise ArgumentError, format(INVALID_HEADER, name: header_name?(name) ? name.inspect : "a #{name.class}", value: header.class) + end + + # The String a header is named by + # + # A String names itself, and a Symbol the header its underscores name with hyphens. + # + # @api private + # @param name [String, Symbol] the name of the header + # @return [String] the name of the header + def header_name(name) = name.instance_of?(Symbol) ? name.name.tr("_", "-") : name + + # Check whether a value names a header, as a String or a Symbol does + # @api private + # @param value [Object] the value + # @return [Boolean] true if the value is a String or a Symbol + def header_name?(value) = value.is_a?(String) || value.instance_of?(Symbol) + + # Check whether a String is an absolute HTTP or HTTPS URL fit to be a base URL + # @api private + # @param value [String] the URL + # @return [Boolean] true if the URL is an absolute http or https URL with a host, and no query or fragment + def http_url?(value) + uri = URI(value) + uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.query.nil? && uri.fragment.nil? + rescue URI::InvalidURIError + false + end + + # Check whether a value is an Integer of at least 0 + # @api private + # @param value [Object] the value + # @return [Boolean] true if the value is an Integer of at least 0 + def count?(value) = value.instance_of?(Integer) && !value.negative? + + # Check whether a value is a real number of at least 0 + # + # Float::NAN is neither negative nor at least 0, and a wait compared with it is never longer, so it is compared + # with 0 rather than asked whether it is negative, which would let it pass as a limit that never applies. + # + # @api private + # @param value [Object] the value + # @return [Boolean] true if the value is a real number of at least 0 + def seconds?(value) = value.is_a?(Numeric) && value.real? && value >= 0 + + # Check whether a value is a finite real number of at least 0 + # @api private + # @param value [Object] the value + # @return [Boolean] true if the value is a finite real number of at least 0 + def finite_seconds?(value) = seconds?(value) && value.finite? + end + private_constant :SettingValidator + end +end diff --git a/x-core/lib/x/core/stream_body.rb b/x-core/lib/x/core/stream_body.rb new file mode 100644 index 00000000..f4cd76ae --- /dev/null +++ b/x-core/lib/x/core/stream_body.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module X + module Core + # Tells the errors of the socket the body of a stream is read from apart from those of the code that reads it + # + # The block of Client#get_stream reads the body of the response, and an error of a socket raised while it is read, + # such as the IOError of a body that dropped, or a read that timed out, is a NetworkError, which a stream + # reconnects after, while the block's own errors are raised as they were. The class of an error does not tell + # which it is, since code of the block's own, such as a write to a full disk, raises the errors a socket does. So + # the StreamResponse the block is passed reads its body through this, which notes the errors read_body raises + # from the socket, and not those of the block read_body passes each chunk to. + # + # Internal to x-core: StreamResponse#read_body reads the body of a stream with it, and the stream + # Client#get_stream opens raises an error as a NetworkError only when socket_error? says it is one. + # + # @api private + module StreamBody + # The errors read_body raised from the socket, each held as its own key for as long as anything else holds it + SOCKET_ERRORS = ObjectSpace::WeakMap.new + private_constant :SOCKET_ERRORS + + # Check whether an error is one read_body raised from the socket + # + # @api private + # @param error [Exception] the error + # @return [Boolean] true if read_body raised it from the socket + # @example Check an error a stream raised + # X::Core::StreamBody.socket_error?(error) # => false + def self.socket_error?(error) = SOCKET_ERRORS[error].equal?(error) + + # Read the body, noting the errors of its socket + # + # They are the network errors reading it raises that the sink of its chunks did not raise. + # + # @api private + # @param failed [Array] the errors the sink of the chunks raised + # @yield reads the body + # @return [Object] what the block returns + # @raise [StandardError] whatever the block raises + def self.noting(failed) + yield + rescue => e + SOCKET_ERRORS[e] = e if Connection.network_error?(e) && !failed.include?(e) + raise + end + + # The block read_body passes each chunk to + # + # It notes the error the sink of the chunks raises, which is not the socket's. + # + # @api private + # @param sink [Proc] the block each chunk is passed to + # @param failed [Array] the errors of the sink, which the error it raises is added to + # @return [Proc] the block + def self.passing(sink, failed) + lambda do |chunk| + sink.call(chunk) + rescue => e + failed << e + raise + end + end + end + private_constant :StreamBody + end +end diff --git a/x-core/lib/x/core/stream_response.rb b/x-core/lib/x/core/stream_response.rb new file mode 100644 index 00000000..c658381a --- /dev/null +++ b/x-core/lib/x/core/stream_response.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "rate_limit" +require_relative "response_headers" +require_relative "stream_body" + +module X + module Core + # The response of a request whose body is read as it arrives, which Client#get_stream passes to its block + # + # It is passed to the block before its body is read, so it reads the status and headers the API answered with, + # and the block reads the body with {#read_body}, for as long as it likes. It is x-core's own, rather than the + # response of the library the client sends its requests with, so that a block written for one release of 1.x + # reads the response of every other, whatever sends the request. + # + # @api public + class ::X::StreamResponse + include ResponseHeaders + + # @!method headers + # The headers of the response + # + # The names are lowercase, and a field the API sent more than once is joined with a comma. + # @api public + # @return [Hash{String => String}] the headers, frozen + # @example Read the content type of a stream + # response.headers["content-type"] + + # The URI of the request + # @api public + # @return [URI::Generic] the request URI + # @example Get the path of the request + # response.uri.path # => "/2/tweets/sample/stream" + attr_reader :uri + + # The response itself, as the client received it, with its body not yet read + # + # It is an escape hatch, for what this does not read: the status is {#status}, the headers are {#headers}, and + # the body is read with {#read_body}. It is the response of the transport the client sends its requests with, a + # Net::HTTPResponse today, and its class is not covered by the compatibility promise of 1.x: a later release of + # 1.x may send its requests with another library, whose response this then returns. A body read from it, rather + # than with {#read_body}, raises the errors of its socket as they are, which Client#get_stream raises as the + # block's own. + # + # @api public + # @return [Net::HTTPResponse] the response of the transport + # @example Read the reason phrase of the status line + # response.http_response.message # => "OK" + attr_reader :http_response + + # Initialize the response of a stream + # + # Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that + # response may, and is private, so the stream Client#get_stream opens builds it with __send__. + # + # @api private + # @param http_response [Net::HTTPResponse] the successful response, whose body is not yet read + # @param uri [URI::Generic] the URI of the request + # @return [StreamResponse] a new instance + # @example Build the response of a stream + # X::StreamResponse.__send__(:new, http_response:, uri: URI("https://api.x.com/2/tweets/sample/stream")) + def initialize(http_response:, uri:) + @http_response = http_response + @uri = uri + end + private_class_method :new + + # The HTTP status code + # + # @api public + # @return [Integer] the status code + # @example Get the status code + # response.status # => 200 + def status = Integer(http_response.code) + + # The rate limits the response reports in its headers + # + # @api public + # @return [Array] the 15-minute limit, and the 24-hour app and user limits when reported + # @example Print how many connections remain in each window + # response.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" } + def rate_limits = RateLimit.__send__(:all_from, http_response) + + # The 15-minute rate limit of the endpoint + # + # For a stream, it counts the connections that may be opened in the window. + # + # @api public + # @return [RateLimit, nil] the rate limit, or nil if the response reports none + # @example Read how many times a stream may connect again before the limit resets + # response.rate_limit&.remaining # => 49 + def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) } + + # Read the body, a chunk at a time as it arrives, or whole + # + # Given a block, each chunk of the body is passed to it as it arrives, until the body ends, which the body of a + # stream does only when its connection closes. A chunk is whatever arrived, in binary, so it may end within a + # line, or within a character. Without a block, the body is read to its end, and returned whole. + # + # An error of the socket the body is read from, such as a connection that dropped, or a read that timed out, + # is raised from Client#get_stream as a NetworkError, and an error the block raises is raised as it was, + # whatever its class. + # + # @api public + # @yieldparam chunk [String] each chunk of the body, as it arrives + # @return [String, nil] the body, read whole, or nil once a block was passed each chunk of it + # @raise [StandardError] whatever the socket or the block raises + # @example Print the body of a stream as it arrives + # response.read_body { |chunk| print chunk } + # @example Read a body that ends whole + # body = response.read_body + def read_body(&block) + failed = [] #: Array[Exception] + body = StreamBody.noting(failed) { block ? http_response.read_body(&StreamBody.passing(block, failed)) : http_response.read_body } + body unless block + end + end + end +end diff --git a/x-core/lib/x/core/token_endpoint.rb b/x-core/lib/x/core/token_endpoint.rb new file mode 100644 index 00000000..8bbc2ee8 --- /dev/null +++ b/x-core/lib/x/core/token_endpoint.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "simple_oauth" +require "uri" +require_relative "credential_validator" +require_relative "errors/authorization_error" +require_relative "errors/invalid_response" +require_relative "oauth2_tokens" +require_relative "authenticator" +require_relative "request_builder" +require_relative "request_context" +require_relative "response_parser" + +module X + module Core + # Sends the token requests that simple_oauth builds over a client's connection + # + # Internal to x-core: the app-only and OAuth 2.0 authenticators, and OAuth2Authorization, fetch tokens with it, + # so that the proxy, timeouts, debug output, and headers of a client apply to token requests too. + # + # @api private + module TokenEndpoint + extend self + + # The responses that refuse a token request whatever their body, as RFC 6749 refuses one + REFUSALS = [Net::HTTPBadRequest, Net::HTTPUnauthorized].freeze + private_constant :REFUSALS + + # The classes of the responses that answer a token request, with a token or a refusal, when their body is JSON + ANSWERS = [Net::HTTPSuccess, Net::HTTPClientError].freeze + private_constant :ANSWERS + + # The version of the API that ends the path of a base URL, such as the /2/ of https://api.x.com/2/ + API_VERSION = %r{/\d+(?:\.\d+)*/?\z} + private_constant :API_VERSION + + # The message of the error raised for a token a successful response holds that cannot be read as one + UNREADABLE_TOKEN = "The token endpoint answered with a token that cannot be read" + private_constant :UNREADABLE_TOKEN + + # The URL of a token endpoint at a base URL, under the path it serves the API at + # + # A token endpoint of X is requested at the scheme, host, and port of the base URL of the client that sends its + # requests, so that a client pointed at another host, such as a test server or a recording proxy, sends its + # credentials there, as it sends its requests, rather than to X. The path is the one X serves the endpoint at, + # under the path the base URL serves the API at, which is its path without the version of the API that ends it, + # so that a gateway that serves the API under a path of its own, as https://gateway.example/x/2/ serves it under + # /x, serves the token endpoints under that path too, as X serves them beside the API. + # + # @api private + # @param base_url [String] the base URL of the client + # @param token_url [String] the URL X serves the endpoint at + # @return [String] the URL of the endpoint at the origin of the base URL + # @example The token endpoint of a client pointed at a test server + # X::Core::TokenEndpoint.url_at("http://localhost:3000/2/", X::AppOnlyAuthenticator::TOKEN_URL) + # # => "http://localhost:3000/oauth2/token" + # @example The token endpoint of a client pointed at a gateway that serves the API under a path + # X::Core::TokenEndpoint.url_at("https://gateway.example/x/2/", X::OAuth2Authenticator::TOKEN_URL) + # # => "https://gateway.example/x/2/oauth2/token" + def url_at(base_url, token_url) + base_path, path = URI(base_url).path, URI(token_url).path #: [String, String] + mount = base_path.sub(API_VERSION, "").chomp("/") + String(URI.join(base_url, "#{mount}#{path}")) + end + + # The scopes a token names + # + # A token response names the scopes it granted as one String, each scope apart from the next by a space. + # + # @api private + # @param token [SimpleOAuth::OAuth2::Token] the token the endpoint returned + # @return [Array, nil] the scopes, frozen, or nil if the token names none + # @example Read the scopes of a token + # X::Core::TokenEndpoint.scopes_of(token) # => ["tweet.read", "users.read", "offline.access"] + def scopes_of(token) + scopes = String.try_convert(token.scope).to_s.split + CredentialValidator.frozen_scopes(scopes) unless scopes.empty? + end + + # Send a token request and read the token the endpoint returns + # + # The endpoint answers a token request as OAuth 2.0 does: with the token in a successful response of JSON, or + # with a refusal of the request, which raises AuthorizationError, in a response of 400 Bad Request or 401 + # Unauthorized, or of another status of 4xx whose body is JSON, as X refuses the API key and secret of an app + # with a 403 Forbidden. Any other response says that the endpoint failed to answer rather than that it refused the credentials, as a + # redirect does, or the page of a proxy, firewall, or captive portal, and so does a response of 429 Too Many + # Requests, or of a server error. It raises the error a response of the API with that status raises: the + # HTTPError of a status that is not successful, which a client waits out or retries as it does that response, + # and InvalidResponse for a successful one whose body is not JSON, or holds no token, rather than + # AuthorizationError, which tells a caller to ask the user to authorize the app again, which a failure of the + # network is no reason to. + # + # @api private + # @param token_request [SimpleOAuth::OAuth2::Request] the token request + # @param connection [Connection] the connection to send it over + # @param refusal [String] the message of a refusal that describes no reason + # @param headers [Hash{String => String}] the headers of the client that sends it; see {#post} + # @return [SimpleOAuth::OAuth2::Token] the token + # @raise [TooManyRequests] if the endpoint limits the rate of the request + # @raise [HTTPError] if the endpoint fails to answer, as a server error, a redirect, or a refusal whose body is + # not JSON says + # @raise [InvalidResponse] if the endpoint answers successfully with a body that is not JSON, or holds no token + # @raise [AuthorizationError] if the endpoint refuses the request, with the response that refused it + # @example Refresh a token + # X::Core::TokenEndpoint.fetch(oauth2_client.refresh_token_request(refresh_token:), connection:, + # refusal: "Token refresh failed", headers: client.headers) + def fetch(token_request, connection:, refusal:, headers:) + request = post(token_request, headers) + response = connection.perform(request:) + raise failure(response, request) unless answer?(response) + + token_of(response, request, refusal) + end + + private + + # Check whether the endpoint answered as OAuth 2.0 does, with a token or a refusal + # + # A refusal is a response of a status REFUSALS names, whatever its body, or of another status of 4xx, other than + # 429 Too Many Requests, whose body is JSON. + # + # @api private + # @param response [Net::HTTPResponse] the response of the endpoint + # @return [Boolean] true for a successful response of JSON, or a refusal + def answer?(response) + return true if REFUSALS.include?(response.class) + + ANSWERS.any? { |answer| response.is_a?(answer) } && !response.instance_of?(Net::HTTPTooManyRequests) && + json_object?(response.body) + end + + # Check whether a body is a JSON object + # + # A response of no content, such as a 204 a gateway answers with, has no body, which is not one. + # + # @api private + # @param body [String, nil] the body of the response + # @return [Boolean] true if the body parses to a Hash + def json_object?(body) + JSON.parse(body.to_s).instance_of?(Hash) + rescue JSON::ParserError + false + end + + # The token a response of the endpoint holds + # + # The error raised in place of it is raised with the cause of the failure simple_oauth reports, rather than the + # failure, so that its cause is the error of x-core it was raised in rescue of, such as the Unauthorized that led + # a client to refresh, and nil for none. + # + # @api private + # @param response [Net::HTTPResponse] the response of the endpoint, a token or a refusal + # @param request [Net::HTTP::Post] the token request, which the error names + # @param refusal [String] the message of a refusal that describes no reason + # @return [SimpleOAuth::OAuth2::Token] the token + # @raise [AuthorizationError] if the response refuses the request + # @raise [InvalidResponse] if the response is successful, but holds no token, or one that cannot be read + def token_of(response, request, refusal) + SimpleOAuth::OAuth2::Token.from_response(status: response.code, body: response.body).tap { |token| readable!(token) } + rescue SimpleOAuth::OAuth2::Error => e + raise refused(e, response, request, refusal), cause: e.cause + rescue ArgumentError + raise InvalidResponse.new(UNREADABLE_TOKEN, http_response: response, **RequestContext.of(request)) + end + + # Check that a token can be read as the tokens a client holds + # + # It is checked before a client takes any of it. A refresh token that is not a String, an access token that is + # blank or holds a line break, or scopes that are not scope tokens, is not a token X documents, and taking it + # would leave the client holding what it cannot send, or report to save_tokens. + # + # @api private + # @param token [SimpleOAuth::OAuth2::Token] the token + # @return [void] + # @raise [ArgumentError] if the token cannot be read so + def readable!(token) + raise ArgumentError if token.access_token.match?(/[\r\n]/) + + OAuth2Tokens.new(access_token: token.access_token, refresh_token: token.refresh_token, scopes: scopes_of(token)) + end + + # Create the error of a response that holds no token + # + # Its message is the reason X described, or else the error code it reported, or else the message given. + # + # @api private + # @param error [SimpleOAuth::OAuth2::Error] the failure simple_oauth reports of the response + # @param response [Net::HTTPResponse] the response of the endpoint + # @param request [Net::HTTP::Post] the token request, which the error names + # @param refusal [String] the message of a refusal that describes no reason + # @return [AuthorizationError, InvalidResponse] the error of a refusal, or InvalidResponse for a successful + # response that holds no token + def refused(error, response, request, refusal) + message = error.description || error.code || refusal + return InvalidResponse.new(message, http_response: response, **RequestContext.of(request)) if response.is_a?(Net::HTTPSuccess) + + AuthorizationError.new(message, http_response: response, **RequestContext.of(request)) + end + + # Create the error of a response in which the endpoint failed to answer + # @api private + # @param response [Net::HTTPResponse] the response of the endpoint + # @param request [Net::HTTP::Post] the token request, which the error names + # @return [HTTPError, InvalidResponse] the error of the status of the response, or InvalidResponse for a + # successful one + def failure(response, request) + return ResponseParser.new.error(response, request) unless response.is_a?(Net::HTTPSuccess) + + InvalidResponse.new(http_response: response, **RequestContext.of(request)) + end + + # Build the POST that sends a token request + # + # It is sent with the headers a request of the API is sent with: the User-Agent of the gem, and the headers of + # the client, which replace it, so that a gateway the base URL names, which may require a header of its own, is + # sent it with the token requests of the client too. The headers of the token request itself replace both, as + # its credentials and its form content type, and the client's Authorization header is never sent, since a token + # request carries its own credentials, or none, as the refresh of a public client does. + # + # @api private + # @param token_request [SimpleOAuth::OAuth2::Request] the token request + # @param headers [Hash{String => String}] the headers of the client that sends it + # @return [Net::HTTP::Post] the request + def post(token_request, headers) + request = Net::HTTP::Post.new(URI(token_request.url)) + client_headers = headers.reject { |name, _| name.casecmp?(Authenticator::AUTHENTICATION_HEADER) } + [RequestBuilder::DEFAULT_HEADERS, client_headers, token_request.headers].each do |sent| + sent.each { |name, value| request[name] = value } + end + request.body = token_request.body + request + end + end + private_constant :TokenEndpoint + end +end diff --git a/x-core/lib/x/core/version.rb b/x-core/lib/x/core/version.rb new file mode 100644 index 00000000..451ddab1 --- /dev/null +++ b/x-core/lib/x/core/version.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rubygems/version" + +module X + # The HTTP layer of the X gem + # @api public + module Core + # The current version of the x-core gem + # @api public + VERSION = "1.0.0" + + # The version as a Gem::Version, which compares one release with another + # + # VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it, + # or send it wherever a String belongs, such as the User-Agent of a request. This builds the Gem::Version that + # compares it with another version, which a String compares by character rather than by segment. + # + # @api public + # @return [Gem::Version] the version + # @example Take a path that a later release opened + # X::Core.gem_version >= Gem::Version.new("1.1") + def self.gem_version = Gem::Version.new(VERSION) + end +end diff --git a/x-core/sig/internal/x-core.rbs b/x-core/sig/internal/x-core.rbs new file mode 100644 index 00000000..c0d90895 --- /dev/null +++ b/x-core/sig/internal/x-core.rbs @@ -0,0 +1,877 @@ +# The internals of x-core: the modules, classes, constants, and methods the code keeps private, which Steep type +# checks it against, but which the gem does not ship, since the gemspec packages sig/*.rbs alone. Code that depends +# on x-core is checked against sig/x-core.rbs, which declares its public interface, so that it cannot refer to what +# the code hides, and these can change within 1.x. +module X + module Core + module TokenEndpoint + extend TokenEndpoint + + REFUSALS: Array[singleton(Net::HTTPBadRequest) | singleton(Net::HTTPUnauthorized)] + ANSWERS: Array[singleton(Net::HTTPSuccess) | singleton(Net::HTTPClientError)] + API_VERSION: Regexp + UNREADABLE_TOKEN: String + + def fetch: (SimpleOAuth::OAuth2::Request token_request, connection: Connection, refusal: String, headers: Hash[String, String]) -> SimpleOAuth::OAuth2::Token + def url_at: (String base_url, String token_url) -> String + def scopes_of: (SimpleOAuth::OAuth2::Token token) -> Array[String]? + + private + def answer?: (Net::HTTPResponse response) -> bool + def readable!: (SimpleOAuth::OAuth2::Token token) -> void + def json_object?: (String body) -> bool + def token_of: (Net::HTTPResponse response, Net::HTTP::Post request, String refusal) -> SimpleOAuth::OAuth2::Token + def refused: (SimpleOAuth::OAuth2::Error error, Net::HTTPResponse response, Net::HTTP::Post request, String refusal) -> (AuthorizationError | InvalidResponse) + def failure: (Net::HTTPResponse response, Net::HTTP::Post request) -> HTTPError + def post: (SimpleOAuth::OAuth2::Request token_request, Hash[String, String] headers) -> Net::HTTP::Post + end + + class CallbackError < StandardError + UNTAGGED: ObjectSpace::WeakMap[StandardError, StandardError] + + attr_reader error: StandardError + + def self.tagging: [T] () { () -> T } -> T + def self.untag: (CallbackError tagged) -> StandardError + def self.untagged?: (StandardError error) -> bool + def initialize: (StandardError error) -> void + end + + module CredentialHolder + REFUSAL_MESSAGE: String + + def marshal_dump: () -> bot + def encode_with: (untyped _coder) -> bot + def as_json: (*untyped) -> bot + def to_json: (?JSON::State? _state) -> bot + end + + module ProxySetting + @proxy_url: (URI::Generic | String)? + + private + def proxy_url: () -> (URI::Generic | String)? + end + + module ConnectionProxy : ProxySetting + @proxy_url: (URI::Generic | String)? + @proxy_uri: URI::Generic? + + private + attr_reader proxy_uri: URI::Generic? + def initialize_proxy: ((URI::Generic | String)? proxy_url) -> void + def proxy_for: (URI::Generic uri) -> URI::Generic? + def decode: (String? component) -> String? + def redacted_proxy_url: () -> String? + def parse_proxy_url: (URI::Generic | String proxy_url) -> URI::Generic + def redact: (URI::Generic | String proxy_url) -> String + end + + class ConnectionPool + MAX_IDLE: Integer + + @lock: Thread::Mutex + @idle: Hash[[bool, String, Integer], Array[Net::HTTP]] + @pid: Integer? + + def initialize: () -> void + def with: [T] ([bool, String, Integer] key, ^() -> Net::HTTP open, ?fresh: bool) { (Net::HTTP, bool) -> T } -> T + def clear: () -> void + + private + def checkout: ([bool, String, Integer] key, ^() -> Net::HTTP open, bool fresh) -> [Hash[[bool, String, Integer], Array[Net::HTTP]], Net::HTTP, bool] + def checkin: ([bool, String, Integer] key, Net::HTTP http_client, Hash[[bool, String, Integer], Array[Net::HTTP]] pool) -> Array[Net::HTTP]? + def forget_after_fork: () -> void + def close: (Net::HTTP http_client) -> void + end + + module RequestContext + @http_method: Symbol? + @uri: URI::Generic? + @request_line: String? + + attr_reader http_method: Symbol? + attr_reader uri: URI::Generic? + + def self.of: (Net::HTTPRequest? request) -> { http_method: String, uri: URI::Generic }? + + private + def name_request: ((Symbol | String)? http_method, URI::Generic? uri) -> void + def message_naming_request: (String message) -> String + end + + class AuthenticatorRequest + include _AuthenticatorRequest + + @request: Net::HTTPRequest + + def initialize: (Net::HTTPRequest request) -> void + end + + class RequestBuilder + HTTP_METHODS: Hash[Symbol, (singleton(Net::HTTP::Get) | singleton(Net::HTTP::Post) | singleton(Net::HTTP::Put) | singleton(Net::HTTP::Delete))] + DEFAULT_HEADERS: Hash[String, String] + BODY_HEADERS: Hash[String, String] + IDEMPOTENT_METHODS: Array[Symbol] + + def self.idempotent?: (Symbol http_method) -> bool + def self.merge_headers: (Hash[String, String] headers, Hash[String, String] overrides) -> Hash[String, String] + def build: (http_method: Symbol, uri: URI::Generic, ?body: String?, ?headers: Hash[String, String], ?authenticator: Authenticator) -> (Net::HTTPRequest) + + private + def create_request: (http_method: Symbol, uri: URI::Generic, body: String?) -> (Net::HTTPRequest) + def add_authentication: (request: Net::HTTPRequest, authenticator: Authenticator) -> void + def add_headers: (request: Net::HTTPRequest, headers: Hash[String, String], body: String?) -> void + def escape_query_params: (URI::Generic uri) -> URI::Generic + def query_pairs: (String query) -> Array[[String, String?]] + end + + class RateLimitHandler + DEFAULT_MAX_RETRIES: Integer + DEFAULT_MAX_WAIT: Integer + UNREPORTED_RESET_WAIT: Integer + RESET_JITTER: Integer + RETRIES: Symbol + HANDED: Symbol + + attr_reader max_rate_limit_retries: Integer + attr_reader max_rate_limit_wait: Numeric + def initialize: (?max_rate_limit_retries: Integer, ?max_rate_limit_wait: Numeric) -> void + def handle: [T] () { () -> T } -> T + def counting: [T] () { () -> T } -> T + def handing_down: [T] () { () -> T } -> T + + private + def counted: (Integer retries) -> Integer + def wait_before_retry: (TooManyRequests error, Integer retries) -> Float + end + + module ConnectionRequest + NETWORK_ERRORS: Array[singleton(IOError) | singleton(Net::HTTPBadResponse) | singleton(Net::HTTPHeaderSyntaxError) | singleton(Net::ProtocolError) | singleton(OpenSSL::SSL::SSLError) | singleton(SocketError) | singleton(SystemCallError) | singleton(Net::OpenTimeout) | singleton(Net::ReadTimeout) | singleton(Net::WriteTimeout) | singleton(Zlib::Error)] + STALE_CONNECTION_ERRORS: Array[singleton(EOFError) | singleton(Errno::ECONNABORTED) | singleton(Errno::ECONNRESET) | singleton(Errno::EPIPE)] + + @pool: ConnectionPool + + private + def send_request: (Net::HTTPRequest request, [bool, String, Integer] key, ^() -> Net::HTTP open) -> Net::HTTPResponse + def idempotent?: (Net::HTTPRequest request) -> bool + end + + class RetryHandler + DEFAULT_MAX_RETRIES: Integer + INITIAL_WAIT: Integer + MAX_RETRY_AFTER: Integer + RETRIABLE_ERRORS: Array[singleton(NetworkError) | singleton(ServerError) | singleton(RequestTimeout)] + UNSENT_ERRORS: Array[singleton(Exception)] + + attr_reader max_retries: Integer + def initialize: (?max_retries: Integer) -> void + def handle: [T] (idempotent: bool, ?resend_unanswered: bool) { () -> T } -> T + + private + def resendable?: (NetworkError | ServerError error, bool resend_unanswered) -> bool + def retry_after: (NetworkError | ServerError error) -> Integer? + def backoff: (Integer retries) -> Float + end + + module Origin + extend Origin + + CREDENTIAL_HEADERS: Array[String] + + def credentials_for: (from: URI::Generic, to: URI::Generic, authenticator: Authenticator, headers: Hash[String, String]) -> [Authenticator, Hash[String, String]] + def same?: (URI::Generic uri, URI::Generic other) -> bool + def answered?: (HTTPError error, URI::Generic origin) -> bool + + private + def of: (URI::Generic uri) -> [String?, String?, Integer?] + def without_credentials: (Hash[String, String] headers) -> Hash[String, String] + end + + class RedirectHandler + DEFAULT_MAX_REDIRECTS: Integer + METHOD_PRESERVING_CODES: Array[Integer] + MOVED_CODES: Array[Integer] + FOLLOWED_CODES: Array[Integer] + + attr_reader connection: Connection + attr_reader request_builder: RequestBuilder + attr_reader max_redirects: Integer + def initialize: (?connection: Connection, ?request_builder: RequestBuilder, ?max_redirects: Integer) -> void + def handle: (response: Net::HTTPResponse, request: Net::HTTPRequest, ?headers: Hash[String, String], ?authenticator: Authenticator, ?redirect_count: Integer) -> Net::HTTPResponse + def follow: (response: Net::HTTPResponse, request: Net::HTTPRequest, ?headers: Hash[String, String], ?authenticator: Authenticator, ?redirect_count: Integer) -> [Net::HTTPResponse, Net::HTTPRequest] + + private + def followed?: (Net::HTTPResponse response) -> bool + def check_redirect_count: (Net::HTTPRequest request, Integer redirect_count) -> void + def build_new_uri: (Net::HTTPResponse response, URI::Generic uri) -> URI::Generic? + def preserves_method?: (Net::HTTPRequest request, Integer response_code) -> bool + def headers_for: (bool preserve, Hash[String, String] headers) -> Hash[String, String] + def build_request: (Net::HTTPRequest request, URI::Generic new_uri, bool preserve, Hash[String, String] headers, Authenticator authenticator) -> Net::HTTPRequest + end + + class ResponseParser + STATUS_CLASS_ERRORS: Hash[Integer, singleton(ClientError) | singleton(ServerError)] + ERROR_MAP: Hash[Integer, singleton(BadGateway) | singleton(BadRequest) | singleton(Conflict) | singleton(Forbidden) | singleton(GatewayTimeout) | singleton(Gone) | singleton(InternalServerError) | singleton(MethodNotAllowed) | singleton(NotAcceptable) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(PaymentRequired) | singleton(RequestTimeout) | singleton(ServiceUnavailable) | singleton(TooManyRequests) | singleton(Unauthorized) | singleton(UnavailableForLegalReasons) | singleton(UnprocessableEntity) | singleton(UnsupportedMediaType)] + + def parse: (response: Net::HTTPResponse, ?array_class: Class?, ?object_class: untyped, ?client: Client?, ?request: Net::HTTPRequest?) -> untyped + def decode: (String json, ?array_class: Class?, ?object_class: untyped, ?client: Client?) -> untyped + def error: (Net::HTTPResponse response, Net::HTTPRequest? request) -> HTTPError + + private + def blank?: (String body) -> bool + def error_class: (Net::HTTPResponse response) -> (singleton(HTTPError) | singleton(BadGateway) | singleton(BadRequest) | singleton(Conflict) | singleton(Forbidden) | singleton(GatewayTimeout) | singleton(Gone) | singleton(InternalServerError) | singleton(MethodNotAllowed) | singleton(NotAcceptable) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(PaymentRequired) | singleton(RequestTimeout) | singleton(ServiceUnavailable) | singleton(TooManyRequests) | singleton(Unauthorized) | singleton(UnavailableForLegalReasons) | singleton(UnprocessableEntity) | singleton(UnsupportedMediaType)) + end + + module ClientTokenRefresh : _TokenRefreshHost + SHARED_EXPIRATION: String + TOKEN_OPTIONS: Array[Symbol] + CREDENTIAL_OPTIONS: Array[Symbol] + + @authenticator: Authenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator + @connection: Connection + @given_authenticator: Authenticator? + @client_id: String? + @client_secret: String? + @access_token: String? + @refresh_token: String? + @expires_at: Time? + @scopes: Array[String]? + + @save_tokens: _TokenSaver? + @load_tokens: _TokenLoader? + + def expires_at: () -> Time? + def scopes: () -> Array[String]? + + private + def initialize_token_hooks: (save_tokens: _TokenSaver?, load_tokens: _TokenLoader?) -> void + def share_authenticator: (Client copy, Authenticator | AppOnlyAuthenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator other, Hash[Symbol, untyped] options) -> void + def share_oauth2: (Client copy, OAuth2Authenticator other, Hash[Symbol, untyped] options) -> void + def without_tokens_of_client: (Hash[Symbol, untyped] copied, Hash[Symbol, untyped] options) -> Hash[Symbol, untyped] + def keeps_authenticator?: (Hash[Symbol, untyped] copied, Hash[Symbol, untyped] options) -> bool + def share_app_only: (AppOnlyAuthenticator other, Hash[Symbol, untyped] options) -> void + def take: (Client client, Authenticator authenticator) -> Authenticator + def join: (Client client, OAuth2Authenticator authenticator) -> OAuth2Authenticator + def oauth2_authenticator_in_use: () -> OAuth2Authenticator? + def oauth2_credentials_in_use: () -> OAuth2Authenticator? + def oauth2_authenticator: (Client client) -> OAuth2Authenticator? + def access_token: () -> String? + def refresh_token: () -> String? + def new_oauth2_authenticator: (Client client, client_id: String, access_token: String, refresh_token: String?) -> OAuth2Authenticator + def refreshing_rejected_token: [T] (Client client) { () -> T } -> T + end + + module ClientSettings : _SettingsHost + extend Forwardable + @connection: Connection + @request_builder: RequestBuilder + @redirect_handler: RedirectHandler + @rate_limit_handler: RateLimitHandler + @retry_handler: RetryHandler + + attr_reader base_url: String + attr_reader default_array_class: Class + attr_reader default_object_class: object_class + attr_reader on_response: _ResponseHook? + attr_reader headers: Hash[String, String] + def open_timeout: () -> Numeric? + def read_timeout: () -> Numeric? + def write_timeout: () -> Numeric? + def keep_alive_timeout: () -> Numeric + def debug_output: () -> _DebugOutput? + def max_redirects: () -> Integer + def max_rate_limit_retries: () -> Integer + def max_rate_limit_wait: () -> Numeric + def max_retries: () -> Integer + def with_retries: [T] () { () -> T } -> T + + private + def share_connection: (Connection connection) -> void + def settings: () -> Hash[Symbol, untyped] + def proxy_url: () -> (URI::Generic | String)? + def initialize_settings: (base_url: String, default_array_class: Class, default_object_class: object_class, headers: headers, on_response: _ResponseHook?, max_redirects: Integer, max_rate_limit_retries: Integer, max_rate_limit_wait: Numeric, max_retries: Integer) -> void + def headers_for: (Hash[String, String] request_headers) -> Hash[String, String] + def report: (Symbol | String http_method, URI::Generic uri, Net::HTTPResponse response) ?{ (Response) -> void } -> void + end + + module ClientMemo + @memo: Hash[Symbol, [untyped, untyped]] + @memo_lock: Thread::Mutex + + def memoized: (Symbol key) -> untyped + def memoize: [T] (Symbol key, T value) -> T + + private + def initialize_memo: () -> void + def authenticator: () -> (Authenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator) + end + + module ClientAppOnly : _CredentialHost + NO_APP_CREDENTIALS: String + + @app_only: Client? + @app_only_monitor: Monitor + @app_token: AppOnlyAuthenticator? + @connection: Connection + + def app_only: (Client client) -> Client + + private + def app_only_copy: (Client client) -> Client + def build_app_only: (Client client) -> Client + def app_bearer_token: () -> String + def app_token: () -> AppOnlyAuthenticator + def share_app_token: (ClientInternals other) -> void + def app_credentials: () -> [String, String]? + def authenticator: () -> (Authenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator) + def credentials: () -> { api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?, client_id: String?, client_secret: String?, refresh_token: String?, expires_at: Time?, scopes: Array[String]? } + def api_key: () -> String? + def api_key_secret: () -> String? + def bearer_token: () -> String? + end + + module ClientCredentials : _CredentialHost + @connection: Connection + @given_authenticator: Authenticator? + + @authenticator: Authenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator + + attr_reader api_key: String? + @access_token: String? + @refresh_token: String? + @expires_at: Time? + @scopes: Array[String]? + attr_reader client_id: String? + def api_key_in_use: () -> String? + def client_id_in_use: () -> String? + + private + def oauth2_authenticator: (Client client) -> OAuth2Authenticator? + attr_reader api_key_secret: String? + attr_reader access_token_secret: String? + attr_reader bearer_token: String? + attr_reader client_secret: String? + def access_token: () -> String? + def refresh_token: () -> String? + def credentials: () -> { api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?, client_id: String?, client_secret: String?, refresh_token: String?, expires_at: Time?, scopes: Array[String]? } + def initialize_credentials: (api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?, client_id: String?, client_secret: String?, refresh_token: String?, expires_at: Time?, scopes: Array[String]?) -> void + def validate_credentials!: (Authenticator? authenticator) -> void + def initialize_authenticator: (Client client, Authenticator? given) -> void + def built_authenticator: (Client client) -> Authenticator + def with_credentials: (Hash[Symbol, untyped] options) -> Hash[Symbol, untyped] + def take: (Client client, Authenticator authenticator) -> Authenticator + def oauth1_authenticator: -> OAuth1Authenticator? + def bearer_authenticator: -> BearerTokenAuthenticator? + def app_only_authenticator: -> AppOnlyAuthenticator? + end + + module BuiltResponse + INVALID_STATUS: String + RESPONSE_AND_STATUS: String + NO_RESPONSE: String + STATUSES: Range[Integer] + extend BuiltResponse + + def of: (Net::HTTPResponse? http_response, status: Integer?, headers: Hash[String | Symbol, String]?, body: String?) -> Net::HTTPResponse + + private + def build: (Integer status, Hash[String | Symbol, String] headers, String? body) -> Net::HTTPResponse + def response_class_of: (Integer status) -> Class + def reason_of: (Class response_class) -> String + end + + module RequestEncoding + LEADING_SLASHES: Regexp + BODY_AND_FORM: String + INVALID_ENDPOINT: String + ENDPOINT_NOT_A_STRING: String + BODY_NOT_ENCODABLE: String + extend RequestEncoding + + def uri_for: (String base_url, String endpoint, params? params) -> URI::HTTP + def encode_body: (body? body, params? form) -> String? + + private + def endpoint_with: (String endpoint, params? params) -> String + def encode_fields: (Hash[untyped, untyped] fields) -> String + def query_value: (untyped value) -> untyped + end + + class ClientInternals + STREAM_HEADERS: Hash[String, String] + + include ClientAppOnly + include ClientCredentials + include ClientMemo + include ClientSettings + include CredentialHolder + include ClientTokenRefresh + include ProxySetting + + FORM_CONTENT_TYPE: String + + @authenticator: Authenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator + @connection: Connection + @app_only_monitor: Monitor + @request_builder: RequestBuilder + @redirect_handler: RedirectHandler + @rate_limit_handler: RateLimitHandler + @retry_handler: RetryHandler + @response_parser: ResponseParser + @given_authenticator: Authenticator? + @client_id: String? + @client_secret: String? + @access_token: String? + @refresh_token: String? + @expires_at: Time? + @scopes: Array[String]? + + attr_reader authenticator: Authenticator | AppOnlyAuthenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator + attr_reader save_tokens: _TokenSaver? + attr_reader load_tokens: _TokenLoader? + + def self.of: (Client client) -> ClientInternals + def initialize: (Client client, api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?, client_id: String?, client_secret: String?, refresh_token: String?, expires_at: Time?, scopes: Array[String]?, authenticator: Authenticator?, base_url: String, open_timeout: Numeric?, read_timeout: Numeric?, write_timeout: Numeric?, keep_alive_timeout: Numeric, debug_output: _DebugOutput?, proxy_url: (URI::Generic | String)?, default_array_class: Class, default_object_class: object_class, headers: headers, max_redirects: Integer, max_rate_limit_retries: Integer, max_rate_limit_wait: Numeric, max_retries: Integer, on_response: _ResponseHook?, save_tokens: _TokenSaver?, load_tokens: _TokenLoader?) -> void + def inspect: () -> String + def with: (Client client, Hash[Symbol, untyped] options) -> Client + def close: () -> void + def execute_request: (Client client, :delete | :get | :post | :put http_method, String endpoint, params: params?, headers: headers, array_class: Class, object_class: object_class, ?body: body?, ?form: params?) ?{ (Response) -> void } -> untyped + def retrying: [T] (Symbol http_method) { () -> T } -> T + def execute_stream: [T] (Client client, String endpoint, params: params?, headers: headers) { (StreamResponse) -> T } -> T + + private + def initialize_state: () -> void + def perform: (Client client, :delete | :get | :post | :put http_method, URI::Generic uri, body: String?, headers: Hash[String, String], array_class: Class, object_class: object_class) ?{ (Response) -> void } -> untyped + def perform_stream: [T] (URI::Generic uri, headers: Hash[String, String]) { (StreamResponse) -> T } -> T + def stream_failed: (URI::Generic uri, Net::HTTPResponse response, Net::HTTPRequest request) -> bot + def reading: [T] () { () -> T } -> T + end + + # What a Response and an HTTPError hold the headers of + interface _HTTPResponseHolder + def http_response: () -> Net::HTTPResponse + end + + module ResponseHeaders : _HTTPResponseHolder + def headers: () -> Hash[String, String] + end + + module SettingValidator + INVALID_COUNT: String + INVALID_COUNT_OR_INFINITY: String + INVALID_SECONDS: String + INVALID_FINITE_SECONDS: String + INVALID_TIMEOUT: String + INVALID_BASE_URL: String + BASE_URL_WITH_USERINFO: String + USERINFO: Regexp + INVALID_HEADERS: String + INVALID_HEADER: String + INVALID_CALLABLE: String + INVALID_ARRAY_CLASS: String + INVALID_OBJECT_CLASS: String + UNKNOWN_KEYWORDS: String + extend SettingValidator + + def count!: (Symbol name, untyped value) -> untyped + def seconds!: (Symbol name, untyped value) -> untyped + def finite_seconds!: (Symbol name, untyped value) -> untyped + def timeout!: (Symbol name, untyped value) -> untyped + def base_url!: (untyped value) -> String + def frozen: (String value) -> String + | (String? value) -> String? + def headers!: (untyped value) -> Hash[String, String] + def callable!: (Symbol name, untyped value) -> untyped + def array_class!: (Symbol name, untyped value) -> Class + def object_class!: (Symbol name, untyped value) -> object_class + def parsing_classes!: (array_class: untyped, object_class: untyped) -> void + def no_unknown_keywords!: (Symbol http_method, String endpoint, Hash[Symbol | String, untyped] keywords) -> void + + private + def invalid_header!: (untyped name, untyped header) -> bot + def header_name: (String | Symbol name) -> String + def header_name?: (untyped value) -> bool + def http_url?: (String value) -> bool + def count?: (untyped value) -> bool + def seconds?: (untyped value) -> bool + def finite_seconds?: (untyped value) -> bool + end + + module CredentialValidator + INCOMPLETE_CREDENTIALS: String + INVALID_EXPIRES_AT: String + EMPTY_CREDENTIAL: String + NOT_A_STRING: String + MISSING_CREDENTIAL: String + NOT_AN_AUTHENTICATOR: String + AUTHENTICATOR_AND_CREDENTIALS: String + CREDENTIAL_SETS: Array[Array[Symbol]] + OAUTH1_CREDENTIALS: Array[Symbol] + OAUTH2_CREDENTIALS: Array[Symbol] + OAUTH2_ONLY_CREDENTIALS: Array[Symbol] + UNUSED_OAUTH2_CREDENTIALS: String + UNUSED_EXPIRES_AT: String + UNUSED_SCOPES: String + INVALID_SCOPES: String + SCOPE: Regexp + extend CredentialValidator + + def validate_values!: (Hash[Symbol, String | Time | Array[String] | nil] credentials) -> void + def validate_required!: (Hash[Symbol, String?] required, ?Hash[Symbol, String | Time | Array[String] | nil] others) -> void + def validate_expires_at!: (untyped expires_at) -> void + def validate_scopes!: (untyped scopes) -> void + def scopes?: (untyped scopes) -> bool + def frozen_scopes: (Array[String] scopes) -> Array[String] + | (Array[String]? scopes) -> Array[String]? + def validate_authenticator!: (untyped authenticator, Hash[Symbol, String | Time | Array[String] | nil] credentials) -> void + def validate!: (Hash[Symbol, String | Time | Array[String] | nil] credentials) -> void + + private + def incomplete?: (Hash[Symbol, String | Time | Array[String] | nil] credentials) -> bool + def unused_oauth2_credentials: (Hash[Symbol, String | Time | Array[String] | nil] credentials) -> Array[Symbol] + def unused?: (Hash[Symbol, String | Time | Array[String] | nil] credentials, Symbol name) -> bool + end + + module StreamBody + SOCKET_ERRORS: ObjectSpace::WeakMap[Exception, Exception] + + def self.socket_error?: (Exception error) -> bool + def self.passing: (^(String) -> void sink, Array[Exception] failed) -> ^(String) -> void + def self.noting: [T] (Array[Exception] failed) { () -> T } -> T + end + + class Connection + DEFAULT_OPEN_TIMEOUT: Integer + DEFAULT_READ_TIMEOUT: Integer + DEFAULT_WRITE_TIMEOUT: Integer + DEFAULT_KEEP_ALIVE_TIMEOUT: Integer + SETTINGS: Array[Symbol] + + include ConnectionProxy + include ConnectionRequest + include ProxySetting + + attr_reader open_timeout : Numeric? + attr_reader read_timeout : Numeric? + attr_reader write_timeout : Numeric? + attr_reader keep_alive_timeout : Numeric + attr_reader debug_output : _DebugOutput? + + def initialize: (?open_timeout: Numeric?, ?read_timeout: Numeric?, ?write_timeout: Numeric?, ?keep_alive_timeout: Numeric, ?proxy_url: (URI::Generic | String)?, ?debug_output: _DebugOutput?) -> void + def inspect: () -> String + def perform: (request: Net::HTTPRequest) -> Net::HTTPResponse + def perform_stream: [T] (request: Net::HTTPRequest) { (Net::HTTPResponse) -> T } -> T + def self.network_error?: (Exception error) -> bool + + @pool: ConnectionPool + + def close: () -> void + def share_pool_of: (Connection other) -> void + + private + attr_reader pool: ConnectionPool + def host_and_port: (URI::Generic uri) -> [String, Integer] + def open_http_client: (URI::Generic uri, bool use_ssl) -> Net::HTTP + def build_http_client: (URI::Generic uri) -> Net::HTTP + def configure_http_client: (Net::HTTP http_client) -> Net::HTTP + end + + class RefreshReporter + @monitor: Monitor + @latest: OAuth2Tokens? + @hooks: (^() -> Array[_TokenSaver])? + + def initialize: () -> void + def issued: (OAuth2Tokens tokens) -> OAuth2Tokens + def to: (^() -> Array[_TokenSaver] hooks) -> ^() -> Array[_TokenSaver] + REPORT_FAILED: String + GUARD: Symbol + + def report: (OAuth2Tokens tokens, Client? client) -> void + + private + def guarded: () { () -> void } -> void + def pass: (OAuth2Tokens tokens, Client? client) -> void + end + end + + class BearerTokenAuthenticator < Authenticator + private + attr_reader bearer_token: String + end + + class Authenticator + include Core::CredentialHolder + end + + class OAuth2Authorization + include Core::CredentialHolder + + TOKEN_URL: String + STATE_BYTES: Integer + DEFAULT_ERROR_MESSAGE: String + INVALID_CALLBACK_MESSAGE: String + CREDENTIALS: Array[Symbol] + CREDENTIALS_GIVEN_MESSAGE: String + INVALID_REDIRECT_URI: String + INVALID_SCOPES: String + MISSING_STATE: String + + @pkce: SimpleOAuth::OAuth2::PKCE + @settings: {base_url: String, proxy_url: (URI::Generic | String)?, open_timeout: Numeric?, read_timeout: Numeric?, write_timeout: Numeric?, keep_alive_timeout: Numeric, debug_output: _DebugOutput?, headers: Hash[String, String]} + + private + def validate!: (client_id: String, redirect_uri: String, client_secret: String?, scopes: Array[String], state: String) -> void + attr_reader client_secret: String? + def base_url: () -> String + attr_reader connection: Core::Connection + def oauth2_client: (String base_url) -> SimpleOAuth::OAuth2::Client + def connection_for: (Hash[Symbol, untyped] options) -> Core::Connection + def exchange: (String | Hash[String | Symbol, String] callback, String base_url, Hash[String, String] headers, ?Core::Connection over) -> SimpleOAuth::OAuth2::Token + def code_of: (String | Hash[String | Symbol, String] callback) -> String + def client_of: (OAuth2Tokens tokens, Hash[Symbol, untyped] options) -> Client + def report_exchange: (Client client, OAuth2Tokens tokens) -> void + def tokens_from: (SimpleOAuth::OAuth2::Token token) -> OAuth2Tokens + end + + class AuthorizationDenied < Error + def self.from: (SimpleOAuth::OAuth2::Error error, String default_message) -> AuthorizationDenied + end + + class AppOnlyAuthenticator < Authenticator + TOKEN_URL: String + DEFAULT_ERROR_MESSAGE: String + + @bearer_token: String? + @mutex: Thread::Mutex + @taken: bool + @token_url: String + @token_headers: Hash[String, String] + + private + attr_reader connection: Core::Connection + def token_requests_over: (Core::Connection connection, String base_url, Hash[String, String] headers) -> self + def bearer_token: () -> String + attr_reader api_key_secret: String + def holds?: (Hash[Symbol, untyped] options) -> bool + def fetched_for?: (String base_url) -> bool + def retrying_rejected_token: [T] (URI::Generic origin) { () -> T } -> T + def drop_bearer_token: (String rejected) -> void + def fetch_bearer_token: () -> String + def token_request: () -> SimpleOAuth::OAuth2::Request + end + + class OAuth1Authenticator < Authenticator + FORM_CONTENT_TYPE: String + + private + attr_reader api_key_secret: String + attr_reader access_token: String + attr_reader access_token_secret: String + def credentials: -> Hash[Symbol, String] + def form_params: (_AuthenticatorRequest request) -> Array[[String, String]] + def form_body: (_AuthenticatorRequest request) -> String + def form_encoded?: (_AuthenticatorRequest request) -> bool + end + + class Problem + MARSHAL_FORMAT: Integer + YAML_KEYS: Array[String] + + private + def deep_freeze: (untyped value) -> untyped + end + + class HTTPError < Error + include Core::RequestContext + include Core::ResponseHeaders + + JSON_CONTENT_TYPE_REGEXP: Regexp + PROBLEM_KEYS: Array[String] + RETRY_AFTER_HEADER: String + RETRY_AFTER_SECONDS: Regexp + ANY_STATUS: String + + private def self.default_status: () -> Integer? + + private + def built_response: (Net::HTTPResponse? http_response, status: Integer?, headers: Hash[String | Symbol, String]?, body: String?) -> Net::HTTPResponse + def seconds_until: (String value) -> Integer? + def sent_at: () -> Time + def parsed_body: () -> Hash[String, untyped] + def describes_problem?: (Hash[String, untyped] body) -> bool + def message_from: (Hash[String, untyped] body) -> String? + def message_from_errors: (untyped errors) -> String? + def message_from_problem: (Hash[String, untyped] body) -> String? + def json?: () -> bool + end + + class NetworkError < Error + include Core::RequestContext + end + + class TooManyRedirects < Error + include Core::RequestContext + end + + class InvalidResponse < HTTPError + @body: String? + + private def self.default_status: () -> Integer + + private + def body_read: () -> String? + def message_from: (Hash[String, untyped] _body) -> String + end + + class TooManyRequests < ClientError + @rate_limits: Array[RateLimit] + end + + class StreamResponse + include Core::ResponseHeaders + + def initialize: (http_response: Net::HTTPResponse, uri: URI::Generic) -> void + end + + class Response + include Core::ResponseHeaders + + @body: String? + @parsed_body: Hash[String, untyped]? + + private + def parsed_body: () -> Hash[String, untyped] + def parse_body: () -> Hash[String, untyped] + end + + class RateLimit + FIELDS: Array[String] + COUNT: Regexp + + def initialize: (type: String, http_response: Net::HTTPResponse) -> void + def self.all_from: (Net::HTTPResponse http_response) -> Array[RateLimit] + def self.reported?: (String type, Net::HTTPResponse http_response) -> bool + + private + attr_reader http_response: Net::HTTPResponse + def field: (String name) -> Integer + end + + class OAuth2Tokens + NOT_A_STRING: String + NOT_A_STRING_OR_NIL: String + NOT_AN_OBJECT: String + NOT_A_TIME_STRING: String + FRACTION_DIGITS: Integer + MARSHAL_FORMAT: Integer + + def self.json_state: (String | Hash[String | Symbol, untyped] json) -> Hash[String, untyped] + def self.json_time: (String? expires_at) -> Time? + end + + class OAuth2Authenticator < Authenticator + include Core::OAuth2Refresh + + TOKEN_URL: String + EXPIRATION_BUFFER: Integer + NO_REFRESH_TOKEN: String + + @token_url: String + @token_headers: Hash[String, String] + @mutex: Thread::Mutex + @reporter: Core::RefreshReporter + @refreshed_at: Float? + @taken: bool + + private + attr_reader access_token: String + attr_reader refresh_token: String? + attr_reader connection: Core::Connection + attr_reader clients: _ClientRegistry + def token_requests_over: (Core::Connection connection, String base_url, Hash[String, String] headers) -> self + def holds?: (Hash[Symbol, untyped] options) -> bool + def report_refreshes_to: (^() -> Array[_TokenSaver] hooks) -> ^() -> Array[_TokenSaver] + attr_reader client_secret: String? + def oauth2_client: (String token_url) -> SimpleOAuth::OAuth2::Client + def token_url: (Client? client) -> String + end + + module Core + # Declares what an OAuth2Authenticator, which includes it, reads of itself as it refreshes + module OAuth2Refresh + DEFAULT_ERROR_MESSAGE: String + FRESH_TOKEN_SECONDS: Integer + NOT_TOKENS: String + REFUSED_REFRESH_TOKEN: Array[String] + + @mutex: Thread::Mutex + @load_tokens: _TokenLoader? + @spent_refresh_tokens: Set[String] + @reporter: RefreshReporter + @refreshed_at: Float? + @access_token: String + @refresh_token: String? + @expires_at: Time? + @scopes: Array[String]? + @token_headers: Hash[String, String] + @token_url: String + + def expires_at: () -> Time? + def scopes: () -> Array[String]? + def token_expired?: () -> bool + + private + def initialize_refresh: (_TokenLoader? load_tokens) -> void + def access_token: () -> String + def refresh_token: () -> String? + def oauth2_client: (String token_url) -> SimpleOAuth::OAuth2::Client + def clients: () -> _ClientRegistry + def refresh_expired_token: (Connection connection, ?Client? client) -> void + def refresh_rejected_token!: (String rejected_token, Connection connection, ?Client? client) -> bool + def retrying_rejected_token: [T] (URI::Generic origin, Connection connection, ?Client? client) { () -> T } -> T + def renew: (Connection connection, Client? client) -> OAuth2Tokens? + def refresh: (Connection connection, Client? client) -> OAuth2Tokens + def token_headers: (Client? client) -> Hash[String, String] + def token_url: (Client? client) -> String + def adopt_in_place_of: (AuthorizationError error) -> OAuth2Tokens? + def adopt_stored_tokens: () -> OAuth2Tokens? + def stored_tokens: () -> OAuth2Tokens? + def report_refresh: (OAuth2Tokens tokens, Client? client) -> void + def update_tokens: (SimpleOAuth::OAuth2::Token token) -> void + def fresh?: () -> bool + end + end + + interface _ClientRegistry + def []=: (untyped client, bool shared) -> bool + def delete: (untyped client) -> bool? + def keys: () -> Array[Client] + end + + interface _CredentialHost + def authenticator: () -> (Authenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator) + def scopes: () -> Array[String]? + def base_url: () -> String + def headers: () -> Hash[String, String] + def with: (Client client, Hash[Symbol, untyped] options) -> Client + def expires_at: () -> Time? + end + + interface _TokenRefreshHost + def base_url: () -> String + def headers: () -> Hash[String, String] + end + + interface _SettingsHost + def save_tokens: () -> _TokenSaver? + def load_tokens: () -> _TokenLoader? + end + + class Client + include Core::CredentialHolder + + @internals: Core::ClientInternals + end +end diff --git a/x-core/sig/manifest.yaml b/x-core/sig/manifest.yaml new file mode 100644 index 00000000..5fc48db4 --- /dev/null +++ b/x-core/sig/manifest.yaml @@ -0,0 +1,8 @@ +# The standard libraries the signatures of x-core refer to, which rbs collection loads for code that depends on it +# +# rbs collection reads every library named here as a standard library, so the gems x-core depends on are left to its +# gemspec, from which it installs their signatures. +dependencies: + - name: json + - name: net-http + - name: uri diff --git a/x-core/sig/patches/net_http.rbs b/x-core/sig/patches/net_http.rbs new file mode 100644 index 00000000..5b8f1949 --- /dev/null +++ b/x-core/sig/patches/net_http.rbs @@ -0,0 +1,16 @@ +# Widen Net::HTTP#set_debug_output to accept nil, which disables debug output, add the p_use_ssl of Net::HTTP.new, +# which net-http 0.6 takes to connect to a proxy over TLS, and add the setters of the encoding a body is tagged +# with, which the signatures of net-http leave out +module Net + class HTTP + def self.new: (String address, ?Integer? port, ?String | :ENV | nil p_addr, ?Integer? p_port, ?String? p_user, ?String? p_pass, ?untyped? p_no_proxy, ?bool? p_use_ssl) -> Net::HTTP + + def set_debug_output: (IO? output) -> void + + def response_body_encoding=: (Encoding | String | bool | nil value) -> (Encoding | String | bool | nil) + end + + class HTTPResponse + def body_encoding=: (Encoding | String | bool | nil value) -> (Encoding | String | bool | nil) + end +end diff --git a/x-core/sig/patches/weak_map.rbs b/x-core/sig/patches/weak_map.rbs new file mode 100644 index 00000000..f98145b8 --- /dev/null +++ b/x-core/sig/patches/weak_map.rbs @@ -0,0 +1,10 @@ +# Declare ObjectSpace::WeakMap, which the core signatures leave out, as far as x-core uses it +module ObjectSpace + class WeakMap[K, V] + def initialize: () -> void + def []=: (K key, V value) -> V + def delete: (K key) -> V? + def keys: () -> Array[K] + def []: (K key) -> V? + end +end diff --git a/x-core/sig/x-core.rbs b/x-core/sig/x-core.rbs new file mode 100644 index 00000000..e082e3f3 --- /dev/null +++ b/x-core/sig/x-core.rbs @@ -0,0 +1,396 @@ +# The public interface of x-core, as its @api public documentation states it. The modules X::Client and the errors +# include, the handlers, parsers, and connection of X::Core, and the constants, methods, and instance variables the +# code keeps private are declared in sig/internal/x-core.rbs, which reopens the same modules and classes, so that +# Steep type checks the gem against them, but which the gem does not ship. The public methods those modules give a +# class are declared here on the class. +module X + module Core + VERSION: String + + def self.gem_version: () -> Gem::Version + end + + # What an authenticator is guaranteed to read of the request it authenticates, which is all the authenticators of + # x-core read of it: its method, as a Symbol, the URI it is sent to, its body, and a header by its name + interface _AuthenticatorRequest + def http_method: () -> Symbol + def uri: () -> URI::Generic + def body: () -> String? + def []: (String name) -> String? + end + + class Authenticator + AUTHENTICATION_HEADER: String + + def user_id: () -> Integer? + def inspect: () -> String + def headers: (_AuthenticatorRequest request) -> Hash[String, String] + def marshal_dump: () -> bot + def encode_with: (untyped coder) -> bot + def as_json: (*untyped) -> bot + def to_json: (?JSON::State? state) -> bot + end + + class BearerTokenAuthenticator < Authenticator + def initialize: (bearer_token: String) -> void + def headers: (_AuthenticatorRequest? request) -> Hash[String, String] + end + + class OAuth2Authorization + AUTHORIZATION_URL: String + DEFAULT_SCOPES: Array[String] + + attr_reader client_id: String + attr_reader redirect_uri: String + attr_reader scopes: Array[String] + attr_reader state: String + attr_reader code_verifier: String + + def initialize: (client_id: String, redirect_uri: String, ?client_secret: String?, ?scopes: Array[String], ?state: String, ?code_verifier: String, ?base_url: String, ?proxy_url: (URI::Generic | String)?, ?open_timeout: Numeric?, ?read_timeout: Numeric?, ?write_timeout: Numeric?, ?keep_alive_timeout: Numeric, ?debug_output: _DebugOutput?, ?headers: headers) -> void + def inspect: () -> String + def url: () -> String + def tokens: (String | Hash[String | Symbol, String] callback) -> OAuth2Tokens + def client: (String | Hash[String | Symbol, String] callback, ?base_url: String, ?open_timeout: Numeric?, ?read_timeout: Numeric?, ?write_timeout: Numeric?, ?keep_alive_timeout: Numeric, ?debug_output: _DebugOutput?, ?proxy_url: (URI::Generic | String)?, ?default_array_class: Class, ?default_object_class: object_class, ?headers: headers, ?max_redirects: Integer, ?max_rate_limit_retries: Integer, ?max_rate_limit_wait: Numeric, ?max_retries: Integer, ?on_response: _ResponseHook?, ?save_tokens: _TokenSaver?, ?load_tokens: _TokenLoader?) -> Client + def marshal_dump: () -> bot + def encode_with: (untyped coder) -> bot + def as_json: (*untyped) -> bot + def to_json: (?JSON::State? state) -> bot + end + + class AuthorizationDenied < Error + attr_reader error_code: String? + + def initialize: (?String? message, ?error_code: String?) -> void + end + + class TokenReportFailed < Error + attr_reader client: Client? + attr_reader tokens: OAuth2Tokens? + + def initialize: (?String? message, ?client: Client?, ?tokens: OAuth2Tokens?) -> void + def to_s: () -> String + end + + class AppOnlyAuthenticator < Authenticator + + attr_reader api_key: String + def initialize: (api_key: String, api_key_secret: String, ?bearer_token: String?) -> void + def headers: (_AuthenticatorRequest? request) -> Hash[String, String] + end + + class OAuth1Authenticator < Authenticator + attr_reader api_key: String + def initialize: (api_key: String, api_key_secret: String, access_token: String, access_token_secret: String) -> void + def user_id: () -> Integer? + def headers: (_AuthenticatorRequest request) -> Hash[String, String] + end + + class Error < StandardError + end + + class ClientError < HTTPError + end + + class AuthorizationError < ClientError + def error_code: () -> String? + end + + class BadGateway < ServerError + end + + class BadRequest < ClientError + end + + class Conflict < ClientError + end + + # A resource that Problem#about? reads the identifier of, such as the resources of x-objects + interface _Identified + def id: () -> untyped + end + + class Problem + attr_reader attrs: Hash[String, untyped] + + def self.all_from: (Hash[String, untyped]? body) -> Array[Problem] + def initialize: (Hash[String | Symbol, untyped] attrs) -> void + def to_h: () -> Hash[String, untyped] + def title: () -> String? + def detail: () -> String? + def type: () -> String? + def resource_type: () -> String? + def resource_id: () -> String? + def parameter: () -> String? + def value: () -> untyped + def message: () -> String? + def not_found?: () -> bool + def disconnect?: () -> bool + def usage_capped?: () -> bool + def about?: (_Identified | Integer | String resource) -> bool + def ==: (untyped other) -> bool + def eql?: (untyped other) -> bool + def hash: () -> Integer + def as_json: (*untyped) -> Hash[String, untyped] + def to_json: (?JSON::State? state) -> String + def inspect: () -> String + def marshal_dump: () -> untyped + def marshal_load: (untyped state) -> void + def encode_with: (untyped coder) -> void + def init_with: (untyped coder) -> void + end + + class HTTPError < Error + attr_reader http_response : Net::HTTPResponse + attr_reader problems: Array[Problem] + attr_reader problem: Problem? + + def initialize: (?String? message, ?http_response: Net::HTTPResponse?, ?status: Integer?, ?headers: Hash[String | Symbol, String]?, ?body: String?, ?http_method: (Symbol | String)?, ?uri: URI::Generic?) -> void + def http_method: () -> Symbol? + def uri: () -> URI::Generic? + def headers: () -> Hash[String, String] + def status: () -> Integer + def body: () -> String? + def retry_after: () -> Integer? + end + + class PaymentRequired < ClientError + end + + class Forbidden < ClientError + end + + class GatewayTimeout < ServerError + end + + class Gone < ClientError + end + + class InternalServerError < ServerError + end + + class MethodNotAllowed < ClientError + end + + class NetworkError < Error + def initialize: (?String? message, ?http_method: (Symbol | String)?, ?uri: URI::Generic?) -> void + def http_method: () -> Symbol? + def uri: () -> URI::Generic? + end + + class NotAcceptable < ClientError + end + + class NotFound < ClientError + end + + class PayloadTooLarge < ClientError + end + + class RequestTimeout < ClientError + end + + class ServerError < HTTPError + end + + class ServiceUnavailable < ServerError + end + + class TooManyRedirects < Error + def initialize: (?String? message, ?http_method: (Symbol | String)?, ?uri: URI::Generic?) -> void + def http_method: () -> Symbol? + def uri: () -> URI::Generic? + end + + class UnavailableForLegalReasons < ClientError + end + + class UnsupportedMediaType < ClientError + end + + class UnsupportedOperation < Error + end + + class UnsupportedFormat < Error + end + + class InvalidResponse < HTTPError + def body: () -> String? + + def initialize: (?String? message, ?http_response: Net::HTTPResponse?, ?status: Integer?, ?headers: Hash[String | Symbol, String]?, ?body: String?, ?http_method: (Symbol | String)?, ?uri: URI::Generic?) -> void + end + + class TooManyRequests < ClientError + def rate_limits: -> Array[RateLimit] + def rate_limit: -> RateLimit? + def exhausted_rate_limits: -> Array[RateLimit] + def limiting_rate_limit: -> RateLimit? + def reset_at: -> Time? + def reset_in: -> Integer? + def retry_after: -> Integer? + end + + class Unauthorized < ClientError + end + + class UnprocessableEntity < ClientError + end + + # Where the requests and responses of a connection are written for debugging, as Net::HTTP writes them: an IO, + # a StringIO, a Logger, or anything else that takes a String with << + interface _DebugOutput + def <<: (String data) -> untyped + end + + interface _ResponseHook + def call: (Response response) -> untyped + end + + class StreamResponse + attr_reader uri: URI::Generic + attr_reader http_response: Net::HTTPResponse + + def headers: () -> Hash[String, String] + def status: () -> Integer + def rate_limits: () -> Array[RateLimit] + def rate_limit: () -> RateLimit? + def read_body: () -> String + | () { (String chunk) -> void } -> nil + end + + class Response + attr_reader http_method: Symbol + attr_reader uri: URI::Generic + attr_reader http_response: Net::HTTPResponse + + def initialize: (http_method: Symbol | String, uri: URI::Generic, ?http_response: Net::HTTPResponse?, ?status: Integer?, ?headers: Hash[String | Symbol, String]?, ?body: String?) -> void + def headers: () -> Hash[String, String] + def body: () -> String? + def status: () -> Integer + def success?: () -> bool + def rate_limits: () -> Array[RateLimit] + def rate_limit: () -> RateLimit? + def resource_counts: () -> Hash[String, Integer] + def resource_count: () -> Integer + end + + class RateLimit + RATE_LIMIT_TYPE: String + APP_LIMIT_TYPE: String + USER_LIMIT_TYPE: String + TYPES: Array[String] + + attr_reader type: String + def limit: -> Integer + def remaining: -> Integer + def exhausted?: -> bool + def reset_at: -> Time + def reset_in: -> Integer + end + + class OAuth2Authenticator < Authenticator + + attr_reader client_id: String + attr_reader expires_at: Time? + attr_reader scopes: Array[String]? + def initialize: (client_id: String, access_token: String, ?refresh_token: String?, ?client_secret: String?, ?expires_at: Time?, ?scopes: Array[String]?, ?load_tokens: _TokenLoader?) -> void + def inspect: () -> String + def token_expired?: -> bool + def refresh!: -> OAuth2Tokens + def headers: (_AuthenticatorRequest? request) -> Hash[String, String] + end + + class OAuth2Tokens + def self.from_json: (String | Hash[String | Symbol, untyped] json) -> OAuth2Tokens + attr_reader access_token: String + attr_reader refresh_token: String? + attr_reader expires_at: Time? + attr_reader scopes: Array[String]? + def initialize: (access_token: String, ?refresh_token: String?, ?expires_at: Time?, ?scopes: Array[String]?) -> void + def to_h: () -> {access_token: String, refresh_token: String?, expires_at: Time?, scopes: Array[String]?} + def ==: (untyped other) -> bool + def eql?: (untyped other) -> bool + def hash: () -> Integer + def as_json: (*untyped) -> Hash[String, untyped] + def to_json: (?JSON::State? state) -> String + def inspect: () -> String + def marshal_dump: () -> untyped + def marshal_load: (untyped state) -> void + def encode_with: (untyped coder) -> void + def init_with: (untyped coder) -> void + end + + interface _TokenSaver + def call: (OAuth2Tokens tokens) -> untyped + end + + # What reads the OAuth2Tokens in the storage that the processes sharing the tokens of a user read, or nil for none + interface _TokenLoader + def call: () -> OAuth2Tokens? + end + + # What builds the result of a request from the whole of its parsed body, as the object_class of a request, a + # stream, or a client; later versions of 1.x may pass it keywords of their own, which it accepts with ** + interface _ResponseBuilder + def from_response: (untyped body, client: Client, **untyped) -> untyped + end + + # The object_class of a request: a class JSON.parse builds each JSON object into, or a response builder + type object_class = Class | _ResponseBuilder + + type params = Hash[String | Symbol, untyped] + type headers = Hash[String | Symbol, String] + type body = String | Hash[untyped, untyped] | Array[untyped] + + class Client + DEFAULT_BASE_URL: String + DEFAULT_ARRAY_CLASS: singleton(Array) + DEFAULT_OBJECT_CLASS: singleton(Hash) + DEFAULT_OPEN_TIMEOUT: Float | Integer + DEFAULT_READ_TIMEOUT: Float | Integer + DEFAULT_WRITE_TIMEOUT: Float | Integer + DEFAULT_KEEP_ALIVE_TIMEOUT: Float | Integer + DEFAULT_MAX_REDIRECTS: Integer + DEFAULT_MAX_RATE_LIMIT_RETRIES: Integer + DEFAULT_MAX_RATE_LIMIT_WAIT: Float | Integer + DEFAULT_MAX_RETRIES: Integer + + attr_reader save_tokens: _TokenSaver? + attr_reader load_tokens: _TokenLoader? + attr_reader authenticator: Authenticator | AppOnlyAuthenticator | BearerTokenAuthenticator | OAuth1Authenticator | OAuth2Authenticator + def initialize: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?client_id: String?, ?client_secret: String?, ?refresh_token: String?, ?expires_at: Time?, ?scopes: Array[String]?, ?authenticator: Authenticator?, ?base_url: String, ?open_timeout: Numeric?, ?read_timeout: Numeric?, ?write_timeout: Numeric?, ?keep_alive_timeout: Numeric, ?debug_output: _DebugOutput?, ?proxy_url: (URI::Generic | String)?, ?default_array_class: Class, ?default_object_class: object_class, ?headers: headers, ?max_redirects: Integer, ?max_rate_limit_retries: Integer, ?max_rate_limit_wait: Numeric, ?max_retries: Integer, ?on_response: _ResponseHook?, ?save_tokens: _TokenSaver?, ?load_tokens: _TokenLoader?) -> void + def inspect: () -> String + def with: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?client_id: String?, ?client_secret: String?, ?refresh_token: String?, ?expires_at: Time?, ?scopes: Array[String]?, ?authenticator: Authenticator?, ?base_url: String, ?open_timeout: Numeric?, ?read_timeout: Numeric?, ?write_timeout: Numeric?, ?keep_alive_timeout: Numeric, ?debug_output: _DebugOutput?, ?proxy_url: (URI::Generic | String)?, ?default_array_class: Class, ?default_object_class: object_class, ?headers: headers, ?max_redirects: Integer, ?max_rate_limit_retries: Integer, ?max_rate_limit_wait: Numeric, ?max_retries: Integer, ?on_response: _ResponseHook?, ?save_tokens: _TokenSaver?, ?load_tokens: _TokenLoader?) -> Client + def app_only: () -> (Client | self) + def api_key: () -> String? + def client_id: () -> String? + def expires_at: () -> Time? + def scopes: () -> Array[String]? + def base_url: () -> String + def default_array_class: () -> Class + def default_object_class: () -> object_class + def on_response: () -> _ResponseHook? + def headers: () -> Hash[String, String] + def open_timeout: () -> Numeric? + def read_timeout: () -> Numeric? + def write_timeout: () -> Numeric? + def keep_alive_timeout: () -> Numeric + def debug_output: () -> _DebugOutput? + def max_redirects: () -> Integer + def max_rate_limit_retries: () -> Integer + def max_rate_limit_wait: () -> Numeric + def max_retries: () -> Integer + def get: (String endpoint, ?params: params?, ?headers: headers, ?array_class: Class, ?object_class: object_class) ?{ (Response) -> void } -> untyped + def post: (String endpoint, ?body? body, ?params: params?, ?form: params?, ?headers: headers, ?array_class: Class, ?object_class: object_class) ?{ (Response) -> void } -> untyped + def put: (String endpoint, ?body? body, ?params: params?, ?form: params?, ?headers: headers, ?array_class: Class, ?object_class: object_class) ?{ (Response) -> void } -> untyped + def delete: (String endpoint, ?params: params?, ?headers: headers, ?array_class: Class, ?object_class: object_class) ?{ (Response) -> void } -> untyped + def get_stream: [T] (String endpoint, ?params: params?, ?headers: headers) { (StreamResponse response) -> T } -> T + def close: () -> void + def with_retries: [T] () { () -> T } -> T + def memoized: (Symbol key) -> untyped + def memoize: [T] (Symbol key, T value) -> T + def marshal_dump: () -> bot + def encode_with: (untyped coder) -> bot + def as_json: (*untyped) -> bot + def to_json: (?JSON::State? state) -> bot + end +end diff --git a/x-core/test/test_helper.rb b/x-core/test/test_helper.rb new file mode 100644 index 00000000..4b09ad94 --- /dev/null +++ b/x-core/test/test_helper.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +unless $PROGRAM_NAME.include?("mutant") + require "simplecov" + + SimpleCov.start "strict" +end + +require "securerandom" +require "socket" +require "minitest/autorun" +require "minitest/mock" +# Mutant is in the bundle of CRuby alone, where the mutant job of CI runs. Without it, the expression a test +# declares it covers is read by nothing, so cover does nothing rather than fail the suite on another engine. +begin + require "mutant/minitest/coverage" +rescue LoadError + module Minitest + class Test + # Ignore the expression this test covers, which only Mutant reads + # + # @param _expression [Object] the expression the test covers + # @return [void] + def self.cover(_expression) = nil + end + end +end +require "webmock/minitest" +require "x/core" + +module Minitest + class Test + # Cover X::Client, its internals, and the modules of x-core that compose them + # + # Mutant matches a test to a subject by the expression the test covers, and a method a module mixes into the + # internals of the client is a subject of the module that defines it, so a test of the client names them here + # rather than one by one. + # + # @return [void] + def self.cover_client + cover X::Client + cover X::Core.const_get(:ClientInternals) + cover X::Core.const_get(:ClientAppOnly) + cover X::Core.const_get(:ClientCredentials) + cover X::Core.const_get(:ClientSettings) + cover X::Core.const_get(:ClientTokenRefresh) + cover X::Core.const_get(:RequestEncoding) + end + + # The internals of a client, which hold its credentials, settings, connection, and handlers + # + # @param client [X::Client] the client + # @return [Object] the internals the client delegates to + def internals(client) = client.instance_variable_get(:@internals) + end +end + +TEST_BEARER_TOKEN = "TEST_BEARER_TOKEN" +TEST_API_KEY = "TEST_API_KEY" +TEST_API_KEY_SECRET = "TEST_API_KEY_SECRET" +TEST_ACCESS_TOKEN = "TEST_ACCESS_TOKEN" +TEST_ACCESS_TOKEN_SECRET = "TEST_ACCESS_TOKEN_SECRET" +TEST_OAUTH_NONCE = "TEST_OAUTH_NONCE" +TEST_OAUTH_TIMESTAMP = Time.utc(1983, 11, 24).to_i.to_s +TEST_CLIENT_ID = "TEST_CLIENT_ID" +TEST_CLIENT_SECRET = "TEST_CLIENT_SECRET" +TEST_REFRESH_TOKEN = "TEST_REFRESH_TOKEN" +# The endpoints X exchanges credentials for tokens at, which the authenticators and the authorization name privately +APP_ONLY_TOKEN_URL = "https://api.x.com/oauth2/token" +OAUTH2_TOKEN_URL = "https://api.x.com/2/oauth2/token" +# The messages of X::Core.const_get(:CredentialValidator), which is private about the constants that hold them +TEST_INCOMPLETE_CREDENTIALS = "The credentials given do not form a complete set. Pass api_key, api_key_secret, " \ + "access_token, and access_token_secret for OAuth 1.0a; client_id and access_token, with the refresh_token that " \ + "refreshes it and the client_secret of a confidential client, for OAuth 2.0; bearer_token for the app's bearer " \ + "token; or api_key and api_key_secret to authenticate as the app. Leave out any credential of a set that is not " \ + "complete" +TEST_UNUSED_EXPIRES_AT = "expires_at is the time an OAuth 2.0 access token expires, so it is given beside the " \ + "client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \ + "bearer_token, an api_key and api_key_secret, or none, which would leave it unused. Leave it out" +TEST_INVALID_EXPIRES_AT = "expires_at must be a Time, such as Time.at(seconds) for a time stored as seconds since " \ + "the epoch, or nil if it is not known" + +def test_oauth_credentials + { + api_key: TEST_API_KEY, + api_key_secret: TEST_API_KEY_SECRET, + access_token: TEST_ACCESS_TOKEN, + access_token_secret: TEST_ACCESS_TOKEN_SECRET + } +end + +def test_oauth2_credentials + { + client_id: TEST_CLIENT_ID, + client_secret: TEST_CLIENT_SECRET, + access_token: TEST_ACCESS_TOKEN, + refresh_token: TEST_REFRESH_TOKEN + } +end + +# An OAuth 2.0 authenticator that reports its refreshes to hooks, as the authenticator a client builds reports them +def oauth2_authenticator_reporting_to(*hooks, **options) + X::OAuth2Authenticator.new(**test_oauth2_credentials, **options).tap do |authenticator| + authenticator.__send__(:report_refreshes_to, -> { hooks }) + end +end + +# Fix the nonce and the timestamp that OAuth headers are signed with, so signatures are deterministic +def with_fixed_oauth_params(nonce: TEST_OAUTH_NONCE, time: Time.utc(1983, 11, 24), &block) + SecureRandom.stub(:hex, nonce) do + Time.stub(:now, time, &block) + end +end + +# Build a GET request for the given URL +def get_request(url = "https://example.com/") + Net::HTTP::Get.new(URI(url)) +end + +# A class that builds objects from a whole response, as the object layer's resources do, accepting the keywords +# later versions of x-core may pass it +class ResponseBuilder + # Return what it was given, so a test can see the body and the client + def self.from_response(body, client:, **) = {body:, client:} +end + +# Answer one request from a server on the loopback interface, for the requests webmock cannot stand in for +module LocalServer + # A response that says nothing, which a server writes before it closes the connection + EMPTY_RESPONSE = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" + + # Answer the next request the server accepts with response, on a thread of its own + def serve_once(server, response) + Thread.new do + socket = server.accept + socket.gets("\r\n\r\n") + socket.write(response) + socket.close + end + end + + # Serve one HTTP response from a port of the given host, and yield that port with net connections allowed + def with_local_server(host: "127.0.0.1", response: EMPTY_RESPONSE) + server = TCPServer.new(host, 0) + thread = serve_once(server, response) + WebMock.allow_net_connect! + yield server.addr[1] + ensure + WebMock.disable_net_connect! + thread&.kill + server&.close + end + + # Answer each connection the server accepts with the responses listed for it, a request each, in turn, and close it + # after the last, on a thread of its own, adding each request it reads to requests + def serve_connections(server, connections, requests) + Thread.new do + connections.each do |responses| + socket = server.accept + responses.each { |response| socket.write(response) if requests << socket.gets("\r\n\r\n") } + socket.close + end + end + end + + # Serve connections from a port of the loopback interface, with webmock disabled, as Net::HTTP reads them, and yield + # that port and the requests the server read, which a test counts + def with_local_connections(*connections) + server = TCPServer.new("127.0.0.1", 0) + requests = [] + thread = serve_connections(server, connections, requests) + WebMock.disable! + yield server.addr[1], requests + ensure + WebMock.enable! + thread&.kill + server&.close + end +end diff --git a/x-core/test/x/core/app_only_authenticator_test.rb b/x-core/test/x/core/app_only_authenticator_test.rb new file mode 100644 index 00000000..559c95bd --- /dev/null +++ b/x-core/test/x/core/app_only_authenticator_test.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "base64" +require_relative "../../test_helper" + +module X + class AppOnlyAuthenticatorTest < Minitest::Test + cover AppOnlyAuthenticator + cover Core.const_get(:TokenEndpoint) + + def setup + @authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + end + + def test_initialize + assert_equal TEST_API_KEY, @authenticator.api_key + assert_instance_of Core.const_get(:Connection), @authenticator.send(:connection) + end + + def test_the_token_can_be_fetched_over_another_connection + connection = Core.const_get(:Connection).new(open_timeout: 5) + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + assert_same authenticator, authenticator.send(:token_requests_over, connection, Client::DEFAULT_BASE_URL, {}) + assert_same connection, authenticator.send(:connection) + end + + def test_the_connection_is_private + %i[connection token_requests_over].each { |name| refute_respond_to @authenticator, name } + end + + def test_inspect_hides_the_credentials + assert_equal "#", @authenticator.inspect + end + + def test_header_fetches_a_bearer_token + stub_token_request + + assert_equal({"Authorization" => "Bearer #{TEST_BEARER_TOKEN}"}, @authenticator.headers(nil)) + end + + def test_token_request_uses_basic_authentication_and_the_client_credentials_grant + stub_token_request + @authenticator.send(:bearer_token) + + assert_requested :post, APP_ONLY_TOKEN_URL, body: "grant_type=client_credentials", + headers: {"Authorization" => "Basic #{Base64.strict_encode64("#{TEST_API_KEY}:#{TEST_API_KEY_SECRET}")}", + "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json"} + end + + def test_token_request_uses_the_connection + stub_token_request + requests = [] + connection = Minitest::Mock.new + connection.expect(:perform, Net::HTTP.post(URI(APP_ONLY_TOKEN_URL), "")) { |request:| requests << request } + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).send(:token_requests_over, connection, Client::DEFAULT_BASE_URL, {}) + + assert_equal TEST_BEARER_TOKEN, authenticator.send(:bearer_token) + assert_instance_of Net::HTTP::Post, requests.first + assert_equal "grant_type=client_credentials", requests.first.body + end + + def test_bearer_token_is_fetched_once + stub_token_request + 3.times { @authenticator.headers(nil) } + + assert_equal TEST_BEARER_TOKEN, @authenticator.send(:bearer_token) + assert_requested :post, APP_ONLY_TOKEN_URL, times: 1 + end + + def test_bearer_token_is_fetched_once_across_threads + stub_request(:post, APP_ONLY_TOKEN_URL).to_return do + sleep 0.05 + {status: 200, body: {access_token: TEST_BEARER_TOKEN}.to_json} + end + tokens = Array.new(4) { Thread.new { @authenticator.send(:bearer_token) } }.map(&:value) + + assert_equal [TEST_BEARER_TOKEN] * 4, tokens + assert_requested :post, APP_ONLY_TOKEN_URL, times: 1 + end + + def test_the_bearer_token_is_kept_private + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "given") + + refute_respond_to authenticator, :bearer_token + assert_equal "given", authenticator.send(:bearer_token) + end + + def test_a_given_bearer_token_is_not_fetched + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "given") + + assert_equal({"Authorization" => "Bearer given"}, authenticator.headers(nil)) + assert_not_requested :post, APP_ONLY_TOKEN_URL + end + + def test_raises_with_the_error_description + stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 403, body: {error: "invalid_client", error_description: "Unable to verify your credentials"}.to_json) + error = assert_raises(AuthorizationError) { @authenticator.send(:bearer_token) } + + assert_equal ["POST /oauth2/token: Unable to verify your credentials", "invalid_client", 403], [error.message, error.error_code, error.status] + end + + def test_raises_with_the_error_code_without_a_description + stub_request(:post, APP_ONLY_TOKEN_URL).to_return(status: 403, body: {error: "invalid_client"}.to_json) + error = assert_raises(AuthorizationError) { @authenticator.send(:bearer_token) } + + assert_equal "POST /oauth2/token: invalid_client", error.message + end + + def test_raises_with_the_default_message + stub_request(:post, APP_ONLY_TOKEN_URL).to_return(status: 401, body: "Unauthorized") + error = assert_raises(AuthorizationError) { @authenticator.send(:bearer_token) } + + assert_equal "POST /oauth2/token: Bearer token request failed", error.message + end + + def test_a_token_endpoint_that_fails_to_answer_raises_the_error_of_its_status + stub_request(:post, APP_ONLY_TOKEN_URL).to_return({status: 503}, {status: 429}) + + assert_raises(ServiceUnavailable) { @authenticator.send(:bearer_token) } + assert_raises(TooManyRequests) { @authenticator.send(:bearer_token) } + end + + def test_a_failed_fetch_is_retried + stub_request(:post, APP_ONLY_TOKEN_URL).to_return(status: 500, body: "").then + .to_return(status: 200, body: {access_token: TEST_BEARER_TOKEN}.to_json) + assert_raises(InternalServerError) { @authenticator.send(:bearer_token) } + + assert_equal TEST_BEARER_TOKEN, @authenticator.send(:bearer_token) + end + + private + + def stub_token_request + stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + end + end +end diff --git a/x-core/test/x/core/app_only_token_rejection_test.rb b/x-core/test/x/core/app_only_token_rejection_test.rb new file mode 100644 index 00000000..6b48b5a7 --- /dev/null +++ b/x-core/test/x/core/app_only_token_rejection_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class AppOnlyTokenRejectionTest < Minitest::Test + cover_client + cover AppOnlyAuthenticator + cover Core.const_get(:Origin) + + POST_URL = "https://api.x.com/2/tweets/1" + + def setup + @token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return({status: 200, body: {access_token: "FIRST"}.to_json}, {status: 200, body: {access_token: "SECOND"}.to_json}) + end + + def test_a_rejected_token_is_fetched_again_and_the_request_sent_again + stub_request(:get, POST_URL).with(headers: {"Authorization" => "Bearer FIRST"}) + .to_return({status: 200, body: {data: {id: "1"}}.to_json}, {status: 401}) + stub_request(:get, POST_URL).with(headers: {"Authorization" => "Bearer SECOND"}).to_return(status: 200, body: {data: {id: "2"}}.to_json) + client = app_only_client + client.get("tweets/1") + + assert_equal({"data" => {"id" => "2"}}, client.get("tweets/1")) + assert_requested @token_request, times: 2 + end + + def test_a_given_token_that_is_rejected_is_replaced_with_one_fetched + stub_request(:get, POST_URL).with(headers: {"Authorization" => "Bearer GIVEN"}).to_return(status: 401) + stub_request(:get, POST_URL).with(headers: {"Authorization" => "Bearer FIRST"}).to_return(status: 200, body: "{}") + + assert_empty app_only_client(bearer_token: "GIVEN").get("tweets/1") + assert_requested @token_request, times: 1 + end + + def test_a_token_fetched_for_the_request_is_not_fetched_again + stub_request(:get, POST_URL).to_return(status: 401) + + assert_raises(Unauthorized) { app_only_client.get("tweets/1") } + assert_requested @token_request, times: 1 + assert_requested :get, POST_URL, times: 1 + end + + def test_a_token_fetched_in_place_of_a_rejected_one_that_is_rejected_raises + stub_request(:get, POST_URL).to_return(status: 401) + + assert_raises(Unauthorized) { app_only_client(bearer_token: "GIVEN").get("tweets/1") } + assert_requested @token_request, times: 1 + assert_requested :get, POST_URL, times: 2 + end + + def test_a_rejection_by_another_origin_drops_nothing + stub_request(:get, "https://other.example.com/tweets/1").to_return(status: 401) + client = app_only_client(bearer_token: "GIVEN") + + assert_raises(Unauthorized) { client.get("https://other.example.com/tweets/1") } + assert_not_requested @token_request + assert_equal "GIVEN", client.authenticator.__send__(:bearer_token) + end + + def test_a_rejection_that_names_no_uri_drops_nothing + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "GIVEN") + error = Unauthorized.new(http_response: Net::HTTPUnauthorized.new("1.1", "401", "Unauthorized")) + + assert_raises(Unauthorized) { authenticator.__send__(:retrying_rejected_token, URI("https://api.x.com/2/")) { raise error } } + assert_equal "GIVEN", authenticator.__send__(:bearer_token) + end + + def test_a_rejection_is_read_from_the_uri_of_the_response + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "GIVEN") + http_response = Net::HTTPUnauthorized.new("1.1", "401", "Unauthorized") + http_response.uri = URI(POST_URL) + attempts = [] + authenticator.__send__(:retrying_rejected_token, URI("https://api.x.com/2/")) do + attempts << authenticator.headers(nil) + raise Unauthorized.new(http_response:) if attempts.one? + end + + assert_equal ["Bearer GIVEN", "Bearer FIRST"], attempts.map { |header| header.fetch("Authorization") } + end + + def test_the_rejection_of_a_token_is_private + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + %i[retrying_rejected_token drop_bearer_token].each { |name| refute_respond_to authenticator, name } + end + + def test_a_token_another_request_already_replaced_is_kept + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "NEWER") + authenticator.__send__(:drop_bearer_token, "OLDER") + + assert_equal "NEWER", authenticator.__send__(:bearer_token) + assert_not_requested @token_request + end + + def test_a_token_is_dropped_under_the_lock_a_fetch_holds + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "GIVEN") + dropping = authenticator.instance_variable_get(:@mutex).synchronize do + Thread.new { authenticator.__send__(:drop_bearer_token, "GIVEN") }.tap do |thread| + Thread.pass until thread.status.eql?("sleep") + + assert_equal "GIVEN", authenticator.instance_variable_get(:@bearer_token) + end + end + dropping.join + + assert_nil authenticator.instance_variable_get(:@bearer_token) + end + + def test_the_app_only_copy_of_an_oauth1_client_fetches_a_rejected_token_again + stub_request(:get, POST_URL).with(headers: {"Authorization" => "Bearer FIRST"}).to_return(status: 401) + stub_request(:get, POST_URL).with(headers: {"Authorization" => "Bearer SECOND"}).to_return(status: 200, body: "{}") + + assert_empty Client.new(**test_oauth_credentials).app_only.get("tweets/1") + assert_requested @token_request, times: 2 + end + + private + + # A client that authenticates as the app with its API key and secret + def app_only_client(**options) = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, **options) + end +end diff --git a/x-core/test/x/core/authenticator_credentials_frozen_test.rb b/x-core/test/x/core/authenticator_credentials_frozen_test.rb new file mode 100644 index 00000000..71a0beee --- /dev/null +++ b/x-core/test/x/core/authenticator_credentials_frozen_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An authenticator built by hand keeps frozen copies of the credentials it was given, so a caller that changes its + # own Strings afterwards changes nothing the authenticator sends + class AuthenticatorCredentialsFrozenTest < Minitest::Test + cover OAuth1Authenticator + cover BearerTokenAuthenticator + cover AppOnlyAuthenticator + cover OAuth2Authenticator + cover Core.const_get(:ClientCredentials) + + def test_each_authenticator_holds_frozen_copies_of_its_credentials + authenticators.each do |authenticator, names| + names.each do |name| + value = authenticator.instance_variable_get(:"@#{name}") + + assert_equal [name.to_s.upcase, true], [value, value.frozen?], "#{authenticator.class} #{name}" + end + end + end + + private + + def authenticators + [[OAuth1Authenticator.new(**strings(:api_key, :api_key_secret, :access_token, :access_token_secret)), %i[api_key api_key_secret access_token access_token_secret]], + [BearerTokenAuthenticator.new(**strings(:bearer_token)), %i[bearer_token]], + [AppOnlyAuthenticator.new(**strings(:api_key, :api_key_secret, :bearer_token)), %i[api_key api_key_secret bearer_token]], + [OAuth2Authenticator.new(**strings(:client_id, :client_secret, :access_token, :refresh_token)), %i[client_id client_secret access_token refresh_token]], + [Core.const_get(:ClientInternals).allocate.tap { |internals| internals.__send__(:initialize_credentials, **strings(*CREDENTIALS), expires_at: nil, scopes: nil) }, CREDENTIALS]] + end + + CREDENTIALS = %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret refresh_token].freeze + + def strings(*names) = names.to_h { |name| [name, +name.to_s.upcase] } + end +end diff --git a/x-core/test/x/core/authenticator_credentials_test.rb b/x-core/test/x/core/authenticator_credentials_test.rb new file mode 100644 index 00000000..b650fb47 --- /dev/null +++ b/x-core/test/x/core/authenticator_credentials_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class AuthenticatorCredentialsTest < Minitest::Test + cover Core.const_get(:CredentialValidator) + cover BearerTokenAuthenticator + cover AppOnlyAuthenticator + cover OAuth1Authenticator + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + OAUTH2_CREDENTIALS = {client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN, refresh_token: TEST_REFRESH_TOKEN}.freeze + APP_CREDENTIALS = {api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET}.freeze + + def missing(name) = "#{name} is nil or empty. Pass the credential, which the authenticator cannot authenticate without" + + def empty(name) = "#{name} is empty. Pass the credential, or leave it out, since an empty one authenticates nothing" + + def assert_refused(message, &) + assert_equal message, assert_raises(ArgumentError, &).message + end + + def test_a_bearer_token_authenticator_refuses_a_missing_bearer_token + [nil, "", " \n"].each do |bearer_token| + assert_refused(missing(:bearer_token)) { BearerTokenAuthenticator.new(bearer_token:) } + end + end + + def test_a_bearer_token_authenticator_refuses_a_bearer_token_that_is_not_a_string + assert_refused("bearer_token must be a String, not a Integer") { BearerTokenAuthenticator.new(bearer_token: 123) } + end + + def test_an_oauth1_authenticator_refuses_each_credential_that_is_not_a_string + test_oauth_credentials.each_key do |name| + assert_refused("#{name} must be a String, not a Integer") { OAuth1Authenticator.new(**test_oauth_credentials, name => 123) } + end + end + + def test_an_oauth2_authenticator_refuses_each_credential_that_is_not_a_string + {**OAUTH2_CREDENTIALS, client_secret: TEST_CLIENT_SECRET}.each_key do |name| + assert_refused("#{name} must be a String, not a Symbol") { OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, name => :token) } + end + end + + def test_an_oauth1_authenticator_refuses_each_missing_credential + test_oauth_credentials.each_key do |name| + [nil, "", " "].each do |value| + assert_refused(missing(name)) { OAuth1Authenticator.new(**test_oauth_credentials, name => value) } + end + end + end + + def test_an_app_only_authenticator_refuses_each_missing_credential + APP_CREDENTIALS.each_key do |name| + assert_refused(missing(name)) { AppOnlyAuthenticator.new(**APP_CREDENTIALS, name => "") } + end + end + + def test_an_app_only_authenticator_refuses_an_empty_bearer_token + assert_refused(empty(:bearer_token)) { AppOnlyAuthenticator.new(**APP_CREDENTIALS, bearer_token: "") } + end + + def test_an_app_only_authenticator_takes_a_bearer_token_or_none + assert_equal ["Bearer #{TEST_BEARER_TOKEN}", true], [AppOnlyAuthenticator.new(**APP_CREDENTIALS, bearer_token: TEST_BEARER_TOKEN).headers(nil)["Authorization"], + AppOnlyAuthenticator.new(**APP_CREDENTIALS).is_a?(AppOnlyAuthenticator)] + end + + def test_an_oauth2_authenticator_refuses_each_missing_credential + %i[client_id access_token].each do |name| + assert_refused(missing(name)) { OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, name => nil) } + end + end + + def test_an_oauth2_authenticator_takes_no_refresh_token_but_refuses_an_empty_one + assert_instance_of OAuth2Authenticator, OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, refresh_token: nil) + assert_refused(empty(:refresh_token)) { OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, refresh_token: " ") } + end + + def test_an_oauth2_authenticator_refuses_an_empty_client_secret + assert_refused(empty(:client_secret)) { OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, client_secret: " ") } + end + + def test_an_oauth2_authenticator_refuses_an_expires_at_that_is_not_a_time + ["2026-09-28T00:00:00Z", 1_790_000_000].each do |expires_at| + assert_refused(TEST_INVALID_EXPIRES_AT) { OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, expires_at:) } + end + end + + def test_an_oauth2_authenticator_takes_a_client_secret_and_an_expires_at_or_neither + expires_at = Time.now + 60 + authenticator = OAuth2Authenticator.new(**OAUTH2_CREDENTIALS, client_secret: TEST_CLIENT_SECRET, expires_at:) + + assert_equal [expires_at, nil], [authenticator.expires_at, OAuth2Authenticator.new(**OAUTH2_CREDENTIALS).expires_at] + end + end +end diff --git a/x-core/test/x/core/authenticator_request_test.rb b/x-core/test/x/core/authenticator_request_test.rb new file mode 100644 index 00000000..2967d9b7 --- /dev/null +++ b/x-core/test/x/core/authenticator_request_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class AuthenticatorRequestTest < Minitest::Test + cover Core.const_get(:AuthenticatorRequest) + + def setup + request = Net::HTTP::Post.new(URI("https://api.x.com/2/tweets?a=1")) + request.body = '{"text":"Hi"}' + request["Content-Type"] = "application/json" + @request = Core.const_get(:AuthenticatorRequest).new(request) + end + + def test_reads_the_method_as_a_symbol + assert_equal :post, @request.http_method + end + + def test_reads_the_uri_and_the_body + assert_equal [URI("https://api.x.com/2/tweets?a=1"), '{"text":"Hi"}'], [@request.uri, @request.body] + end + + def test_reads_a_header_by_its_name_in_any_case + assert_equal ["application/json", "application/json", nil], [@request["Content-Type"], @request["content-type"], @request["X-None"]] + end + + def test_answers_nothing_else_of_the_request + refute_respond_to @request, :path + refute_respond_to @request, :each_header + end + end +end diff --git a/x-core/test/x/core/authenticator_test.rb b/x-core/test/x/core/authenticator_test.rb new file mode 100644 index 00000000..d69a1376 --- /dev/null +++ b/x-core/test/x/core/authenticator_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class AuthenticatorTest < Minitest::Test + cover Authenticator + + def setup + @authenticator = Authenticator.new + end + + def test_header + assert_equal({}, @authenticator.headers(nil)) + end + + def test_credentials_that_name_no_user_have_no_user_id + assert_nil @authenticator.user_id + assert_nil BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN).user_id + assert_nil OAuth2Authenticator.new(**test_oauth2_credentials).user_id + end + + def test_inspect + assert_equal "#", @authenticator.inspect + end + end +end diff --git a/x-core/test/x/core/authenticator_token_connection_test.rb b/x-core/test/x/core/authenticator_token_connection_test.rb new file mode 100644 index 00000000..018cd6dc --- /dev/null +++ b/x-core/test/x/core/authenticator_token_connection_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An authenticator that makes token requests makes them over the connection of the first client that takes it + class AuthenticatorTokenConnectionTest < Minitest::Test + cover AppOnlyAuthenticator + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + def authenticators + [AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET), OAuth2Authenticator.new(**test_oauth2_credentials)] + end + + def test_the_first_connection_an_authenticator_is_given_is_kept + authenticators.each do |authenticator| + first = Core.const_get(:Connection).new + authenticator.__send__(:token_requests_over, first, Client::DEFAULT_BASE_URL, {}) + + assert_same authenticator, authenticator.__send__(:token_requests_over, Core.const_get(:Connection).new, Client::DEFAULT_BASE_URL, {}) + assert_same first, authenticator.__send__(:connection) + end + end + + def test_a_connection_is_taken_under_the_lock_a_token_request_holds + authenticators.each do |authenticator| + connection = Core.const_get(:Connection).new + taking(authenticator, connection).join + + assert_same connection, authenticator.__send__(:connection) + end + end + + private + + # Start taking a connection while the lock of a token request is held, and check that it waits for the lock + def taking(authenticator, connection) + authenticator.instance_variable_get(:@mutex).synchronize do + Thread.new { authenticator.__send__(:token_requests_over, connection, Client::DEFAULT_BASE_URL, {}) }.tap do |thread| + Thread.pass until thread.status.eql?("sleep") + + refute_same connection, authenticator.__send__(:connection) + end + end + end + end +end diff --git a/x-core/test/x/core/authorization_error_cause_test.rb b/x-core/test/x/core/authorization_error_cause_test.rb new file mode 100644 index 00000000..85d73584 --- /dev/null +++ b/x-core/test/x/core/authorization_error_cause_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The cause of an AuthorizationError is the error of x-core it was raised in rescue of, and never the error of + # simple_oauth it was built from + class AuthorizationErrorCauseTest < Minitest::Test + cover AppOnlyAuthenticator + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover OAuth2Authorization + cover Core.const_get(:TokenEndpoint) + + USERS_ME = "https://api.x.com/2/users/me" + TWEET = "https://api.x.com/2/tweets/1" + + def test_a_refused_refresh_of_a_rejected_token_is_caused_by_the_rejection + refuse_token(OAUTH2_TOKEN_URL) + stub_request(:get, USERS_ME).to_return(status: 401) + error = assert_raises(AuthorizationError) { Client.new(**test_oauth2_credentials).get("users/me") } + + assert_instance_of Unauthorized, error.cause + assert_equal USERS_ME, error.cause.uri.to_s + end + + def test_a_refused_refresh_of_an_expired_token_has_no_cause + refuse_token(OAUTH2_TOKEN_URL) + error = assert_raises(AuthorizationError) { Client.new(**test_oauth2_credentials, expires_at: Time.now - 1).get("users/me") } + + assert_nil error.cause + end + + def test_a_refused_fetch_in_place_of_a_rejected_app_only_token_is_caused_by_the_rejection + refuse_token(APP_ONLY_TOKEN_URL) + stub_request(:get, TWEET).to_return(status: 401) + error = assert_raises(AuthorizationError) do + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "GIVEN").get("tweets/1") + end + + assert_instance_of Unauthorized, error.cause + end + + def test_a_refused_fetch_of_an_app_only_token_has_no_cause + refuse_token(APP_ONLY_TOKEN_URL) + error = assert_raises(AuthorizationError) { Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).get("tweets/1") } + + assert_nil error.cause + end + + def test_a_refused_authorization_code_has_no_cause + refuse_token(OAUTH2_TOKEN_URL) + authorization = OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: "https://example.com/callback", state: "STATE") + error = assert_raises(AuthorizationError) { authorization.tokens("state=STATE&code=CODE") } + + assert_nil error.cause + end + + private + + # Have a token endpoint refuse the grant it is sent + def refuse_token(url) + stub_request(:post, url).to_return(status: 400, body: {error: "invalid_grant"}.to_json) + end + end +end diff --git a/x-core/test/x/core/authorization_errors_test.rb b/x-core/test/x/core/authorization_errors_test.rb new file mode 100644 index 00000000..6c1445ce --- /dev/null +++ b/x-core/test/x/core/authorization_errors_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # X refusing a token raises AuthorizationError, a ClientError that holds the response of X, and the redirect back + # from X that says the app was not authorized raises AuthorizationDenied, which holds no response + class AuthorizationErrorsTest < Minitest::Test + cover AuthorizationError + cover AuthorizationDenied + + def test_the_error_code_is_read_from_the_json_of_the_body_whatever_its_content_type + assert_equal "invalid_grant", AuthorizationError.new(status: 400, body: '{"error":"invalid_grant"}').error_code + end + + def test_a_body_that_names_no_error_code_has_none + ["", "Unauthorized", "[]", '{"error":1}', "{}"].each do |body| + assert_nil AuthorizationError.new(status: 400, body:).error_code, body + end + assert_nil AuthorizationError.new(status: 401).error_code + end + + def test_an_authorization_error_is_raised_as_a_bad_request_of_its_own + error = assert_raises(ClientError) { raise AuthorizationError, "refused" } + + assert_equal [AuthorizationError, 400, "refused"], [error.class, error.status, error.message] + end + + def test_an_authorization_denied_holds_its_message_and_error_code + error = AuthorizationDenied.new("The user denied the request", error_code: "access_denied") + + assert_equal ["The user denied the request", "access_denied"], [error.message, error.error_code] + assert_nil AuthorizationDenied.new("The state does not match").error_code + end + end +end diff --git a/test/x/bearer_token_authenticator_test.rb b/x-core/test/x/core/bearer_token_authenticator_test.rb similarity index 53% rename from test/x/bearer_token_authenticator_test.rb rename to x-core/test/x/core/bearer_token_authenticator_test.rb index 19e086be..8d95fd51 100644 --- a/test/x/bearer_token_authenticator_test.rb +++ b/x-core/test/x/core/bearer_token_authenticator_test.rb @@ -1,4 +1,6 @@ -require_relative "../test_helper" +# frozen_string_literal: true + +require_relative "../../test_helper" module X class BearerTokenAuthenticatorTest < Minitest::Test @@ -8,13 +10,13 @@ def setup @authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) end - def test_initialize - assert_equal TEST_BEARER_TOKEN, @authenticator.bearer_token + def test_header + assert_kind_of Hash, @authenticator.headers(nil) + assert_equal "Bearer #{TEST_BEARER_TOKEN}", @authenticator.headers(nil)["Authorization"] end - def test_header - assert_kind_of Hash, @authenticator.header(nil) - assert_equal "Bearer #{TEST_BEARER_TOKEN}", @authenticator.header(nil)["Authorization"] + def test_inspect_hides_the_bearer_token + assert_equal "#", @authenticator.inspect end end end diff --git a/x-core/test/x/core/body_encoding_test.rb b/x-core/test/x/core/body_encoding_test.rb new file mode 100644 index 00000000..1893b22e --- /dev/null +++ b/x-core/test/x/core/body_encoding_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # Net::HTTP reads a body as binary, where webmock hands over the String it was given, so these read what a server on + # the loopback interface sends, with webmock disabled + class BodyEncodingTest < Minitest::Test + include LocalServer + + cover Core.const_get(:Connection) + cover Core.const_get(:ResponseParser) + + def http_response(status, body, content_type: "application/json") + "HTTP/1.1 #{status}\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}" + end + + def with_client(response, &) + WebMock.disable! + with_local_server(response:) do |port| + yield Client.new(bearer_token: TEST_BEARER_TOKEN, base_url: "http://127.0.0.1:#{port}/", max_retries: 0) + end + ensure + WebMock.enable! + end + + def test_the_body_of_a_response_is_utf_8 + body = nil + + with_client(http_response("200 OK", '{"data":{"name":"café"}}')) do |client| + assert_equal({"data" => {"name" => "café"}}, client.get("users/me") { |response| body = response.body }) + end + + assert_equal Encoding::UTF_8, body.encoding + assert_includes body, "é" + end + + def test_the_body_of_an_http_error_is_utf_8 + with_client(http_response("400 Bad Request", '{"title":"Invalid","detail":"café"}')) do |client| + error = assert_raises(BadRequest) { client.get("users/me") } + + assert_includes error.body, "é" + assert_equal "GET /users/me: Invalid: café", error.message + end + end + + def test_the_body_of_an_invalid_response_is_utf_8 + with_client(http_response("200 OK", "

café

", content_type: "text/html")) do |client| + error = assert_raises(InvalidResponse) { client.get("users/me") } + + assert_includes error.body, "é" + end + end + + def test_a_body_that_is_not_utf_8_keeps_its_bytes + with_client(http_response("200 OK", "caf\xE9".b, content_type: "text/plain")) do |client| + body = assert_raises(InvalidResponse) { client.get("users/me") }.body + + assert_equal [Encoding::UTF_8, false, "caf\xE9".b], [body.encoding, body.valid_encoding?, body.b] + end + end + end +end diff --git a/x-core/test/x/core/client_app_only_copy_test.rb b/x-core/test/x/core/client_app_only_copy_test.rb new file mode 100644 index 00000000..610bc939 --- /dev/null +++ b/x-core/test/x/core/client_app_only_copy_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientAppOnlyCopyTest < Minitest::Test + cover_client + + def setup + @token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + end + + def test_an_oauth1_client_returns_the_same_copy + client = Client.new(**test_oauth_credentials) + copy = client.app_only + + assert_same copy, client.app_only + assert_requested @token_request, times: 1 + end + + def test_threads_that_ask_for_the_copy_together_get_one_copy_and_fetch_one_token + WebMock.reset! + token_request = stub_request(:post, APP_ONLY_TOKEN_URL).to_return do + sleep 0.05 + {status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json} + end + client = Client.new(**test_oauth_credentials) + copies = Array.new(4) { Thread.new { client.app_only } }.map(&:value) + + assert_equal [copies.first] * 4, copies + assert_requested token_request, times: 1 + end + + def test_copies_of_a_client_share_the_token_it_fetched + client = Client.new(**test_oauth_credentials) + copies = Array.new(3) { client.with(headers: {"X-Request" => "copy"}) } + + refute_same client.app_only, copies.first.app_only + assert_equal [sent_token(client)] * 3, copies.map { |copy| sent_token(copy) } + assert_requested @token_request, times: 1 + end + + def test_a_copy_shares_the_token_the_client_has_yet_to_fetch + client = Client.new(**test_oauth_credentials) + client.with.app_only + client.app_only + + assert_requested @token_request, times: 1 + end + + def test_copies_made_together_on_threads_share_one_token + client = Client.new(**test_oauth_credentials) + build = AppOnlyAuthenticator.method(:new) + slow_build = lambda do |**options| + sleep 0.05 + build.call(**options) + end + copies = AppOnlyAuthenticator.stub(:new, slow_build) { Array.new(4) { Thread.new { client.with } }.map(&:value) } + copies.each(&:app_only) + + assert_requested @token_request, times: 1 + end + + def test_a_copy_of_a_copy_shares_the_token + client = Client.new(**test_oauth_credentials) + client.app_only + client.with.with.app_only + + assert_requested @token_request, times: 1 + end + + def test_a_copy_of_an_oauth2_client_shares_the_token + client = Client.new(**test_oauth2_credentials, api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + client.app_only + client.with(read_timeout: 5).app_only + + assert_requested @token_request, times: 1 + end + + def test_a_copy_with_other_credentials_of_the_app_fetches_a_token_of_its_own + client = Client.new(**test_oauth_credentials) + client.app_only + client.with(api_key: "OTHER_KEY").app_only + client.with(api_key_secret: "OTHER_SECRET").app_only + + assert_requested @token_request, times: 3 + end + + def test_a_copy_with_another_base_url_fetches_a_token_of_its_own + other_token_request = stub_request(:post, "https://other.example/oauth2/token") + .to_return(status: 200, body: {token_type: "bearer", access_token: "OTHER"}.to_json) + client = Client.new(**test_oauth_credentials) + client.app_only + + assert_equal "OTHER", sent_token(client.with(base_url: "https://other.example/2/")) + assert_requested @token_request, times: 1 + assert_requested other_token_request, times: 1 + end + + def test_a_copy_given_a_bearer_token_sends_it + client = Client.new(**test_oauth_credentials) + + assert_equal "OWN", sent_token(client.with(bearer_token: "OWN")) + assert_not_requested @token_request + end + + private + + # The bearer token the app-only copy of a client sends + def sent_token(client) = client.app_only.authenticator.headers(nil).fetch("Authorization").delete_prefix("Bearer ") + end +end diff --git a/x-core/test/x/core/client_app_only_oauth2_test.rb b/x-core/test/x/core/client_app_only_oauth2_test.rb new file mode 100644 index 00000000..9aed326a --- /dev/null +++ b/x-core/test/x/core/client_app_only_oauth2_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client that authenticates with OAuth 2.0 as a user may hold the credentials of the app beside the user's, and + # authenticates as the app with them + class ClientAppOnlyOAuth2Test < Minitest::Test + cover_client + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + + def setup + @token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + end + + def test_an_oauth2_client_with_a_bearer_token_copies_itself_with_it + client = Client.new(**test_oauth2_credentials, bearer_token: "APP_BEARER_TOKEN") + copies = [client.app_only, client.app_only] + + assert_same(*copies) + assert_equal({"Authorization" => "Bearer APP_BEARER_TOKEN"}, copies.first.authenticator.headers(nil)) + assert_not_requested @token_request + end + + def test_an_oauth2_client_with_an_api_key_and_secret_copies_itself_with_a_bearer_token_it_fetches + client = Client.new(**test_oauth2_credentials, api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + copy = client.app_only + + assert_equal({"Authorization" => "Bearer #{TEST_BEARER_TOKEN}"}, copy.authenticator.headers(nil)) + assert_instance_of OAuth2Authenticator, client.authenticator + assert_requested @token_request, times: 1 + end + end +end diff --git a/x-core/test/x/core/client_app_only_test.rb b/x-core/test/x/core/client_app_only_test.rb new file mode 100644 index 00000000..f7680a74 --- /dev/null +++ b/x-core/test/x/core/client_app_only_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientAppOnlyTest < Minitest::Test + cover_client + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + + def setup + @token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + end + + def test_an_oauth1_client_copies_itself_with_a_bearer_token_fetched_once + client = Client.new(**test_oauth_credentials, base_url: "https://api.x.com/2/") + copies = [client.app_only, client.app_only] + + assert_equal [AppOnlyAuthenticator] * 2, copies.map { |copy| copy.authenticator.class } + assert_requested @token_request, times: 1 + end + + def test_an_app_only_copy_keeps_the_settings_but_not_the_access_token + copy = Client.new(**test_oauth_credentials, base_url: "https://api.x.com/2/").app_only + + assert_equal [TEST_BEARER_TOKEN, nil, nil, "https://api.x.com/2/"], + [internals(copy).send(:bearer_token), internals(copy).send(:access_token), internals(copy).send(:access_token_secret), copy.base_url] + end + + def test_an_app_only_copy_holds_the_credentials_of_the_app_alone + client = Client.new(**test_oauth_credentials.slice(:api_key, :api_key_secret), **test_oauth2_credentials, expires_at: Time.now + 60) + copy = client.app_only + + assert_instance_of AppOnlyAuthenticator, copy.authenticator + assert_equal({api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: TEST_BEARER_TOKEN}, internals(copy).send(:credentials).compact) + end + + def test_the_token_is_fetched_over_the_client_connection + client = Client.new(**test_oauth_credentials) + connections = [] + fetch = Core.const_get(:TokenEndpoint).method(:fetch) + Core.const_get(:TokenEndpoint).stub(:fetch, ->(request, connection:, refusal:, headers:) { fetch.call(request, connection: connections.push(connection).last, refusal:, headers:) }) { client.app_only } + + assert_equal [internals(client).instance_variable_get(:@connection)], connections + end + + def test_the_token_is_fetched_with_the_api_key_and_secret + Client.new(**test_oauth_credentials).app_only + + assert_requested :post, APP_ONLY_TOKEN_URL, + headers: {"Authorization" => "Basic #{["#{TEST_API_KEY}:#{TEST_API_KEY_SECRET}"].pack("m0")}"} + end + + def test_an_app_only_client_fetches_its_token_over_the_client_connection + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, proxy_url: "http://proxy.example.com:8080", open_timeout: 5) + + assert_same internals(client).instance_variable_get(:@connection), client.authenticator.send(:connection) + end + + def test_an_oauth2_client_refreshes_its_token_over_the_client_connection + client = Client.new(**test_oauth2_credentials, proxy_url: "http://proxy.example.com:8080", read_timeout: 5) + + assert_same internals(client).instance_variable_get(:@connection), client.authenticator.send(:connection) + end + + def test_the_client_connection_settings_reach_the_token_request + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, proxy_url: "http://proxy.example.com:8080") + + assert_equal "http://proxy.example.com:8080", client.authenticator.send(:connection).send(:proxy_url) + end + + def test_a_given_bearer_token_is_used_without_a_request + client = Client.new(**test_oauth_credentials, bearer_token: "GIVEN") + + assert_equal "GIVEN", internals(client.app_only).send(:bearer_token) + assert_not_requested @token_request + end + + def test_other_clients_are_already_app_only_enough + [Client.new, Client.new(bearer_token: TEST_BEARER_TOKEN)].each do |client| + assert_same client, client.app_only + end + assert_not_requested @token_request + end + + def test_an_oauth2_user_client_holds_no_credentials_of_the_app + client = Client.new(**test_oauth2_credentials) + error = assert_raises(UnsupportedOperation) { client.app_only } + + assert_equal "A client that authenticates with OAuth 2.0 as a user, and holds neither the app's bearer token " \ + "nor its API key and secret, cannot authenticate as the app. Pass the client one of them, beside the OAuth 2.0 " \ + "credentials rather than an OAuth2Authenticator, which is given alone", error.message + assert_not_requested @token_request + end + end +end diff --git a/x-core/test/x/core/client_authenticator_copy_test.rb b/x-core/test/x/core/client_authenticator_copy_test.rb new file mode 100644 index 00000000..421ed344 --- /dev/null +++ b/x-core/test/x/core/client_authenticator_copy_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientAuthenticatorCopyTest < Minitest::Test + cover_client + + def setup + @authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + @client = Client.new(authenticator: @authenticator) + end + + def test_a_copy_shares_the_authenticator_the_client_was_given + copy = @client.with(base_url: "https://api.x.com/1.1/") + + assert_same @authenticator, copy.authenticator + assert_equal [true, true], [@authenticator.__send__(:clients)[@client], @authenticator.__send__(:clients)[copy]] + end + + def test_a_copy_given_a_credential_authenticates_with_it_in_place_of_the_authenticator + copy = @client.with(bearer_token: TEST_BEARER_TOKEN) + + assert_instance_of BearerTokenAuthenticator, copy.authenticator + assert_same @authenticator, copy.with(authenticator: @authenticator).authenticator + end + + def test_a_copy_given_nil_for_a_credential_holds_none + assert_instance_of Authenticator, @client.with(access_token: nil).authenticator + end + + def test_a_copy_given_nil_for_the_authenticator_holds_none + assert_instance_of Authenticator, @client.with(authenticator: nil).authenticator + end + + def test_a_copy_given_an_authenticator_authenticates_with_it_in_place_of_the_credentials + other = OAuth2Authenticator.new(**test_oauth2_credentials) + client = Client.new(**test_oauth2_credentials) + + assert_equal [other, other], [client.with(authenticator: other).authenticator, @client.with(authenticator: other).authenticator] + end + + def test_a_copy_given_nil_for_the_authenticator_keeps_the_credentials_of_the_client + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + oauth2_client = Client.new(**test_oauth2_credentials) + + assert_equal({bearer_token: TEST_BEARER_TOKEN}, internals(client.with(authenticator: nil)).send(:credentials).compact) + assert_same oauth2_client.authenticator, oauth2_client.with(authenticator: nil).authenticator + end + + def test_a_copy_given_an_authenticator_beside_a_credential_is_refused + assert_raises(ArgumentError) { Client.new.with(authenticator: @authenticator, bearer_token: TEST_BEARER_TOKEN) } + end + + def test_a_copy_given_an_expiration_time_beside_the_authenticator_is_refused + error = assert_raises(ArgumentError) { @client.with(expires_at: Time.now) } + + assert_equal "An authenticator holds the credentials it authenticates with, so it cannot be given beside " \ + "expires_at. Pass the authenticator, or the credentials, and leave out the other", error.message + end + + def test_a_copy_given_the_credentials_the_authenticator_holds_shares_it + assert_same @authenticator, @client.with(**test_oauth2_credentials).authenticator + end + end +end diff --git a/x-core/test/x/core/client_authenticator_test.rb b/x-core/test/x/core/client_authenticator_test.rb new file mode 100644 index 00000000..08a72afe --- /dev/null +++ b/x-core/test/x/core/client_authenticator_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientAuthenticatorTest < Minitest::Test + cover_client + cover Core.const_get(:CredentialValidator) + cover AppOnlyAuthenticator + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + def setup + stub_request(:post, OAUTH2_TOKEN_URL) + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + @app_token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + end + + def test_a_client_authenticates_with_the_authenticator_it_is_given + authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) + client = Client.new(authenticator:) + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer #{TEST_BEARER_TOKEN}"}) + client.get("users/me") + + assert_same authenticator, client.authenticator + assert_equal "#>", client.inspect + end + + def test_an_authenticator_that_is_not_one_is_refused_without_revealing_it + error = assert_raises(ArgumentError) { Client.new(authenticator: {bearer_token: "SECRET"}) } + + assert_equal "authenticator must be an X::Authenticator, such as an X::OAuth2Authenticator, or nil, not a Hash", error.message + end + + def test_an_authenticator_is_refused_beside_credentials_which_it_is_not_given + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + error = assert_raises(ArgumentError) { Client.new(authenticator:, bearer_token: TEST_BEARER_TOKEN, expires_at: Time.now) } + + assert_equal "An authenticator holds the credentials it authenticates with, so it cannot be given beside " \ + "bearer_token, expires_at. Pass the authenticator, or the credentials, and leave out the other", error.message + assert_empty authenticator.__send__(:clients).keys + end + + def test_an_authenticator_is_allowed_beside_credentials_that_are_nil + authenticator = Authenticator.new + + assert_same authenticator, Client.new(authenticator:, api_key: nil, expires_at: nil).authenticator + end + + def test_the_refreshes_of_an_oauth2_authenticator_reach_each_client_given_it + refreshed = [] + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + clients = %i[first second].map { |name| Client.new(authenticator:, save_tokens: ->(tokens) { refreshed << [name, tokens.refresh_token] }) } + authenticator.refresh! + + assert_equal [[:first, "NEW_REFRESH_TOKEN"], [:second, "NEW_REFRESH_TOKEN"]], refreshed.sort + assert_equal [authenticator.expires_at] * 2, clients.map(&:expires_at) + end + + def test_a_client_reads_the_expiration_time_of_an_oauth2_authenticator_it_is_given + expires_at = Time.now + 60 + + assert_equal expires_at, Client.new(authenticator: OAuth2Authenticator.new(**test_oauth2_credentials, expires_at:)).expires_at + end + + def test_an_authenticator_sends_its_token_requests_over_the_connection_of_the_first_client_given_it + [OAuth2Authenticator.new(**test_oauth2_credentials), AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET)].each do |authenticator| + first = Client.new(authenticator:) + Client.new(authenticator:) + + assert_same internals(first).instance_variable_get(:@connection), authenticator.__send__(:connection) + end + end + + def test_an_authenticator_a_client_built_keeps_the_connection_of_that_client_when_given_to_another + client = Client.new(**test_oauth2_credentials) + Client.new(authenticator: client.authenticator) + + assert_same internals(client).instance_variable_get(:@connection), client.authenticator.__send__(:connection) + end + + def test_a_client_given_an_app_only_authenticator_authenticates_as_the_app + client = Client.new(authenticator: AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET)) + stub_request(:get, "https://api.x.com/2/tweets/1").with(headers: {"Authorization" => "Bearer #{TEST_BEARER_TOKEN}"}) + client.get("tweets/1") + + assert_same client, client.app_only + assert_requested @app_token_request, times: 1 + end + + def test_a_client_given_an_oauth1_authenticator_fetches_the_app_token_with_its_api_key_and_secret + [OAuth1Authenticator, Class.new(OAuth1Authenticator)].each do |authenticator_class| + copy = Client.new(authenticator: authenticator_class.new(**test_oauth_credentials)).app_only + + assert_instance_of AppOnlyAuthenticator, copy.authenticator + assert_equal({api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: TEST_BEARER_TOKEN}, internals(copy).send(:credentials).compact) + end + assert_requested @app_token_request.with(basic_auth: [TEST_API_KEY, TEST_API_KEY_SECRET]), times: 2 + end + + def test_a_client_given_an_oauth2_authenticator_holds_no_credentials_of_the_app + client = Client.new(authenticator: OAuth2Authenticator.new(**test_oauth2_credentials)) + + assert_raises(UnsupportedOperation) { client.app_only } + end + end +end diff --git a/x-core/test/x/core/client_body_keywords_test.rb b/x-core/test/x/core/client_body_keywords_test.rb new file mode 100644 index 00000000..a77d239a --- /dev/null +++ b/x-core/test/x/core/client_body_keywords_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The fields of a body given without the braces of a Hash are refused with how to pass them + class ClientBodyKeywordsTest < Minitest::Test + cover Client + cover Core.const_get(:SettingValidator) + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_post_refuses_the_fields_of_a_body_given_without_braces_before_a_request + error = assert_raises(ArgumentError) { @client.post("tweets", text: "Hello") } + + assert_equal 'unknown keyword: :text; pass a body as a Hash in braces, as post("tweets", {text: "Hello"})', error.message + assert_not_requested :post, "https://api.x.com/2/tweets" + end + + def test_put_names_each_keyword_it_takes_none_of + error = assert_raises(ArgumentError) { @client.put("lists/1", name: "Rubyists", private: true) } + + assert_equal 'unknown keywords: :name, :private; pass a body as a Hash in braces, as put("lists/1", {name: "Rubyists", private: true})', + error.message + end + + def test_a_body_in_braces_is_sent + stub_request(:post, "https://api.x.com/2/tweets").to_return(headers: {"Content-Type" => "application/json"}, body: "{}") + @client.post("tweets", {text: "Hello"}) + + assert_requested :post, "https://api.x.com/2/tweets", body: {text: "Hello"}.to_json + end + end +end diff --git a/x-core/test/x/core/client_body_test.rb b/x-core/test/x/core/client_body_test.rb new file mode 100644 index 00000000..8e617fae --- /dev/null +++ b/x-core/test/x/core/client_body_test.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "json" +require "stringio" +require_relative "../../test_helper" + +module X + class ClientBodyTest < Minitest::Test + cover_client + + def setup + @client = Client.new + end + + def test_post_encodes_a_hash_body_as_json + stub_request(:post, "https://api.x.com/2/tweets") + @client.post("tweets", {text: "Hello"}) + + assert_requested :post, "https://api.x.com/2/tweets", body: '{"text":"Hello"}', + headers: {"Content-Type" => "application/json; charset=utf-8"} + end + + def test_put_encodes_a_hash_body_as_json + stub_request(:put, "https://api.x.com/2/tweets/1") + @client.put("tweets/1", {"text" => "Hello"}) + + assert_requested :put, "https://api.x.com/2/tweets/1", body: '{"text":"Hello"}' + end + + def test_post_encodes_a_hash_subclass_body_as_json + stub_request(:post, "https://api.x.com/2/tweets") + @client.post("tweets", Class.new(Hash).new.merge!(text: "Hello")) + + assert_requested :post, "https://api.x.com/2/tweets", body: '{"text":"Hello"}' + end + + def test_post_refuses_a_body_that_is_not_a_string_a_hash_or_an_array + error = assert_raises(ArgumentError) { @client.post("tweets", StringIO.new("payload")) } + + assert_equal "body must be a String, a Hash, or an Array, not a StringIO; read an IO, or call to_h, to give what it holds", error.message + assert_not_requested :post, "https://api.x.com/2/tweets" + end + + def test_put_refuses_a_symbol_body + error = assert_raises(ArgumentError) { @client.put("tweets/1", :text) } + + assert_match(/not a Symbol;/, error.message) + end + + def test_post_sends_a_string_body_as_given + stub_request(:post, "https://api.x.com/2/tweets") + @client.post("tweets", '{"text": "Hello"}') + + assert_requested :post, "https://api.x.com/2/tweets", body: '{"text": "Hello"}' + end + + def test_post_without_a_body + stub_request(:post, "https://api.x.com/2/tweets") + @client.post("tweets") + + assert_requested(:post, "https://api.x.com/2/tweets") { |request| request.body.to_s.empty? } + end + + def test_a_get_sends_no_content_type + stub_request(:get, "https://api.x.com/2/users/me") + @client.get("users/me") + + assert_requested(:get, "https://api.x.com/2/users/me") { |request| request.headers.to_h["Content-Type"].nil? } + end + + def test_a_delete_sends_no_content_type + stub_request(:delete, "https://api.x.com/2/tweets/1") + @client.delete("tweets/1") + + assert_requested(:delete, "https://api.x.com/2/tweets/1") { |request| request.headers.to_h["Content-Type"].nil? } + end + + def test_a_post_without_a_body_sends_no_content_type + stub_request(:post, "https://api.x.com/2/media/upload/1/finalize") + @client.post("media/upload/1/finalize") + + assert_requested(:post, "https://api.x.com/2/media/upload/1/finalize") { |request| request.headers.to_h["Content-Type"].nil? } + end + + def test_post_encodes_a_form + stub_request(:post, "https://api.x.com/1.1/account/settings.json") + @client.post("https://api.x.com/1.1/account/settings.json", form: {lang: "en", tile: true}) + + assert_requested :post, "https://api.x.com/1.1/account/settings.json", body: "lang=en&tile=true", + headers: {"Content-Type" => "application/x-www-form-urlencoded; charset=utf-8"} + end + + def test_post_encodes_a_form_as_it_encodes_query_parameters + stub_request(:post, "https://api.x.com/1.1/statuses/update.json") + @client.post("https://api.x.com/1.1/statuses/update.json", form: {media_ids: [1, 2], since: Time.utc(2024, 1, 1), place_id: nil}) + + assert_requested :post, "https://api.x.com/1.1/statuses/update.json", body: "media_ids=1%2C2&since=2024-01-01T00%3A00%3A00Z" + end + + def test_a_body_beside_a_form_is_refused_before_any_request + error = assert_raises(ArgumentError) { @client.put("settings", {dropped: true}, form: {lang: "en"}) } + + assert_equal "Pass a body or form fields, not both, since a request sends one body", error.message + assert_not_requested :put, "https://api.x.com/2/settings" + end + + def test_post_encodes_an_array_body_as_json + stub_request(:post, "https://api.x.com/2/tweets") + @client.post("tweets", [{text: "Hello"}, {text: "World"}]) + + assert_requested :post, "https://api.x.com/2/tweets", body: '[{"text":"Hello"},{"text":"World"}]', + headers: {"Content-Type" => "application/json; charset=utf-8"} + end + + def test_post_sends_a_string_subclass_body_as_given + stub_request(:post, "https://api.x.com/2/tweets") + @client.post("tweets", Class.new(String).new('{"text": "Hello"}')) + + assert_requested :post, "https://api.x.com/2/tweets", body: '{"text": "Hello"}' + end + + def test_form_headers_can_be_overridden + stub_request(:post, "https://api.x.com/2/settings") + @client.post("settings", form: {lang: "en"}, headers: {"Content-Type" => "text/plain"}) + + assert_requested :post, "https://api.x.com/2/settings", headers: {"Content-Type" => "text/plain"} + end + + def test_form_headers_survive_redirects + stub_request(:post, "https://api.x.com/2/old") + .to_return(status: 307, headers: {"Location" => "https://api.x.com/2/new"}) + stub_request(:post, "https://api.x.com/2/new") + @client.post("old", form: {lang: "en"}) + + assert_requested :post, "https://api.x.com/2/new", body: "lang=en", headers: {"Content-Type" => "application/x-www-form-urlencoded; charset=utf-8"} + end + + def test_form_body_is_signed + client = Client.new(**test_oauth_credentials) + stub_request(:post, "https://api.x.com/2/settings") + client.post("settings", form: {lang: "en"}) + + assert_requested(:post, "https://api.x.com/2/settings") { |request| request.headers["Authorization"].include?("oauth_signature") } + end + end +end diff --git a/x-core/test/x/core/client_callback_error_retries_test.rb b/x-core/test/x/core/client_callback_error_retries_test.rb new file mode 100644 index 00000000..08278302 --- /dev/null +++ b/x-core/test/x/core/client_callback_error_retries_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An error a callback of a request raises is raised as it was by Client#with_retries too, which runs outside the + # request, rather than taken for a failure of the API, which would send the request again though the API answered it + class ClientCallbackErrorRetriesTest < Minitest::Test + cover "X::Client#with_retries" + cover "X::Core::ClientSettings#with_retries" + cover Core.const_get(:CallbackError) + cover Core.const_get(:RetryHandler) + + URL = "https://api.x.com/2/tweets" + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + SUCCESS = {status: 200, body: '{"data":{"id":"1"}}', headers: {"Content-Type" => "application/json"}}.freeze + + def setup + stub_request(:post, URL).to_return(SUCCESS) + stub_request(:get, STREAM_URL).to_return(status: 200, body: "") + end + + def test_a_post_is_not_sent_again_for_a_server_error_the_hook_raises + failure = ServiceUnavailable.new(http_response: Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable")) + client = Client.new(on_response: ->(_) { raise failure }) + + assert_same failure, without_retries(client) { assert_raises(ServiceUnavailable) { client.with_retries { client.post("tweets", "{}") } } } + assert_requested :post, URL, times: 1 + end + + def test_a_post_is_not_sent_again_for_a_network_error_from_response_raises + failure = NetworkError.new("the lookup it made failed") + client = Client.new + object_class = Class.new { define_singleton_method(:from_response) { |_, client:| raise failure } } + + assert_same failure, without_retries(client) { assert_raises(NetworkError) { client.with_retries { client.post("tweets", "{}", object_class:) } } } + assert_requested :post, URL, times: 1 + end + + def test_a_stream_is_not_opened_again_for_a_server_error_its_block_raises + failure = InternalServerError.new(http_response: Net::HTTPInternalServerError.new("1.1", "500", "Internal Server Error")) + client = Client.new + + assert_same failure, without_retries(client) { assert_raises(InternalServerError) { client.with_retries { client.get_stream("tweets/sample/stream") { |_| raise failure } } } } + assert_requested :get, STREAM_URL, times: 1 + end + + def test_a_server_error_the_block_raises_itself_is_still_sent_again + failure = ServiceUnavailable.new(http_response: Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable")) + attempts = 0 + client = Client.new + + internals(client).instance_variable_get(:@retry_handler).stub(:sleep, nil) do + assert_equal 2, client.with_retries { ((attempts += 1) < 2) ? raise(failure) : attempts } + end + end + + def test_the_retry_handler_raises_at_once_for_an_error_a_callback_raised + [ServiceUnavailable.new(http_response: Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable")), + NetworkError.new("boom")].each do |failure| + untag(failure) + + assert_equal 1, attempts_to_send(failure) + end + end + + def test_untagging_notes_the_error_a_callback_raised + failure = ArgumentError.new("the callback failed") + before = Core.const_get(:CallbackError).untagged?(failure) + + assert_same failure, untag(failure) + assert_equal [false, true, false], [before, Core.const_get(:CallbackError).untagged?(failure), + Core.const_get(:CallbackError).untagged?(ArgumentError.new("the callback failed"))] + end + + def test_the_errors_untagged_are_named_privately + assert_raises(NameError) { Core.const_get(:CallbackError)::UNTAGGED } + end + + private + + # Run the block, failing the test if the client would wait to send a request again + def without_retries(client, &) + internals(client).instance_variable_get(:@retry_handler).stub(:sleep, ->(_) { flunk "sent again for a callback's error" }, &) + end + + # The times the retry handler runs a block that raises the error, which it raises in the end + def attempts_to_send(failure) + attempts = 0 + assert_raises(failure.class) do + Core.const_get(:RetryHandler).new.handle(idempotent: true, resend_unanswered: true) do + attempts += 1 + raise failure + end + end + attempts + end + + # Note an error as one a callback raised, as a request does once it is left + def untag(error) = Core.const_get(:CallbackError).untag(Core.const_get(:CallbackError).new(error)) + end +end diff --git a/x-core/test/x/core/client_callback_error_test.rb b/x-core/test/x/core/client_callback_error_test.rb new file mode 100644 index 00000000..b9fc373f --- /dev/null +++ b/x-core/test/x/core/client_callback_error_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An error a callback of a request raises is raised as it was, rather than taken for an error of the request, + # which would send it again, wait out a rate limit, or refresh a token for it + class ClientCallbackErrorTest < Minitest::Test + cover_client + cover Core.const_get(:CallbackError) + cover Core.const_get(:ResponseParser) + + URL = "https://api.x.com/2/users/me" + SUCCESS = {status: 200, body: '{"data":{"id":"1"}}', headers: {"Content-Type" => "application/json"}}.freeze + + # An object_class that builds its objects with a callable + class Building + def self.builder = @builder + + def self.with(builder) = Class.new(self) { @builder = builder } + + def self.from_response(body, client:) = builder.call(body, client) + end + + def setup + stub_request(:get, URL).to_return(SUCCESS) + end + + def test_a_server_error_the_hook_raises_is_not_sent_again + failure = ServiceUnavailable.new(http_response: Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable")) + client = Client.new(on_response: ->(_) { raise failure }) + + assert_same failure, assert_raises(ServiceUnavailable) { client.get("users/me") } + assert_requested :get, URL, times: 1 + end + + def test_a_server_error_the_block_raises_is_not_sent_again + failure = InternalServerError.new(http_response: Net::HTTPInternalServerError.new("1.1", "500", "Internal Server Error")) + + assert_same failure, assert_raises(InternalServerError) { Client.new.get("users/me") { |_| raise failure } } + assert_requested :get, URL, times: 1 + end + + def test_a_server_error_from_response_raises_is_not_sent_again + failure = BadGateway.new(http_response: Net::HTTPBadGateway.new("1.1", "502", "Bad Gateway")) + object_class = Building.with(->(_, _) { raise failure }) + + assert_same failure, assert_raises(BadGateway) { Client.new.get("users/me", object_class:) } + assert_requested :get, URL, times: 1 + end + + def test_a_rate_limit_a_callback_raises_is_not_waited_out + client = Client.new(max_rate_limit_retries: 1, on_response: ->(_) { raise TooManyRequests.new(http_response: Net::HTTPTooManyRequests.new("1.1", "429", "Too Many Requests")) }) + handler = internals(client).instance_variable_get(:@rate_limit_handler) + + handler.stub(:sleep, ->(_) { flunk "waited out a rate limit the hook raised" }) do + assert_raises(TooManyRequests) { client.get("users/me") } + end + assert_requested :get, URL, times: 1 + end + + def test_a_rejection_a_callback_raises_refreshes_no_token + refresh = stub_request(:post, "https://api.x.com/2/oauth2/token") + client = Client.new(**test_oauth2_credentials) + + rejection = Net::HTTPUnauthorized.new("1.1", "401", "Unauthorized").tap { |response| response.uri = URI("https://api.x.com/2/users/1") } + nested = Unauthorized.new(http_response: rejection) + + assert_same nested, assert_raises(Unauthorized) { client.get("users/me") { |_| raise nested } } + assert_not_requested refresh + assert_requested :get, URL, times: 1 + end + + def test_an_error_of_the_response_is_still_sent_again + stub_request(:get, URL).to_return({status: 503}, SUCCESS) + client = Client.new(max_retries: 1) + + internals(client).instance_variable_get(:@retry_handler).stub(:sleep, nil) do + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + end + end + + def test_what_from_response_builds_is_returned + object_class = Building.with(->(body, client) { [body, client] }) + client = Client.new + + assert_equal [{"data" => {"id" => "1"}}, client], client.get("users/me", object_class:) + end + + def test_a_document_that_is_not_json_still_raises_invalid_response + stub_request(:get, URL).to_return(status: 200, body: "{") + + assert_raises(InvalidResponse) { Client.new.get("users/me", object_class: Building.with(->(_, _) { flunk "built what is not JSON" })) } + end + + def test_an_error_tagged_twice_stands_in_for_the_error_a_callback_raised + failure = ArgumentError.new("the callback failed") + error = assert_raises(Core.const_get(:CallbackError)) { Core.const_get(:CallbackError).tagging { Core.const_get(:CallbackError).tagging { raise failure } } } + + assert_same failure, error.error + end + + def test_tagging_returns_what_the_callback_returned + assert_equal 1, Core.const_get(:CallbackError).tagging { 1 } + end + end +end diff --git a/x-core/test/x/core/client_close_test.rb b/x-core/test/x/core/client_close_test.rb new file mode 100644 index 00000000..d10d7545 --- /dev/null +++ b/x-core/test/x/core/client_close_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientCloseTest < Minitest::Test + cover_client + + def setup + stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + stub_request(:get, "https://api.x.com/2/tweets") + end + + def opened_by(client, &) + clients = [] + connection = internals(client).instance_variable_get(:@connection) + original = connection.method(:build_http_client) + connection.stub(:build_http_client, ->(*args) { original.call(*args).tap { |http_client| clients << http_client } }, &) + clients + end + + def test_close_closes_the_connections_kept_open + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + http_clients = opened_by(client) do + client.get("tweets") + client.close + end + + assert_equal [false], http_clients.map(&:started?) + end + + def test_a_request_after_close_opens_a_connection_again + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + http_clients = opened_by(client) do + client.get("tweets") + client.close + client.get("tweets") + end + + assert_equal [false, true], http_clients.map(&:started?) + end + + def test_close_closes_the_connections_of_the_app_only_copy + client = Client.new(**test_oauth_credentials) + http_clients = opened_by(client) do + client.app_only.get("tweets") + client.close + end + + assert_equal [false], http_clients.map(&:started?) + end + + def test_the_app_only_copy_reads_its_token_and_its_responses_over_one_connection + client = Client.new(**test_oauth_credentials) + http_clients = opened_by(client) { client.app_only.get("tweets") } + + assert_equal 1, http_clients.size + end + + def test_close_makes_no_app_only_copy + client = Client.new(**test_oauth_credentials) + client.close + + assert_nil internals(client).instance_variable_get(:@app_only) + end + end +end diff --git a/x-core/test/x/core/client_credentials_frozen_test.rb b/x-core/test/x/core/client_credentials_frozen_test.rb new file mode 100644 index 00000000..9909d7ff --- /dev/null +++ b/x-core/test/x/core/client_credentials_frozen_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client, its authenticators, and an authorization keep frozen copies of the credentials, tokens, headers, and + # URLs they were given, so neither their callers nor what their readers return can change what they send, or where + class ClientCredentialsFrozenTest < Minitest::Test + cover_client + cover OAuth1Authenticator + cover BearerTokenAuthenticator + cover AppOnlyAuthenticator + cover OAuth2Authenticator + cover OAuth2Authorization + cover Core.const_get(:CredentialValidator) + cover Core.const_get(:SettingValidator) + cover OAuth2Tokens + cover Core.const_get(:OAuth2Refresh) + + def test_a_bearer_token_changed_by_its_caller_changes_no_request + token = +"TOKEN" + client = Client.new(bearer_token: token) + token.replace("") + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + client.get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"Authorization" => "Bearer TOKEN"} + end + + def test_the_credentials_a_client_reads_are_frozen + client = Client.new(**test_oauth_credentials) + + assert_predicate client.api_key, :frozen? + assert_predicate client.authenticator.api_key, :frozen? + assert_predicate Client.new(**test_oauth2_credentials).client_id, :frozen? + end + + def test_the_header_values_a_client_reads_are_frozen_copies + agent = +"my-app/1.0" + client = Client.new(headers: {"User-Agent" => agent}) + agent.replace("changed") + + assert_equal "my-app/1.0", client.headers.fetch("User-Agent") + assert_predicate client.headers.fetch("User-Agent"), :frozen? + end + + def test_the_tokens_save_tokens_is_passed_share_no_string_a_hook_can_change + saved = [] + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 5, save_tokens: ->(tokens) { saved << tokens }) + stub_refresh + client.get("users/me") + + assert_equal [true] * 4, [*saved.first.to_h.values_at(:access_token, :refresh_token), *held_tokens(client)].map(&:frozen?) + end + + def test_tokens_hold_frozen_copies_of_the_strings_they_were_given + access, refresh = +"ACCESS", +"REFRESH" + tokens = OAuth2Tokens.new(access_token: access, refresh_token: refresh) + access.replace("CHANGED") + refresh.replace("CHANGED") + + assert_equal %w[ACCESS REFRESH], [tokens.access_token, tokens.refresh_token] + assert_predicate tokens.access_token, :frozen? + assert_predicate tokens.refresh_token, :frozen? + end + + def test_an_authorization_exchanges_its_code_at_the_base_url_it_was_given + base_url = +"https://api.x.com/2/" + authorization = OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: "https://example.com/cb", base_url:, state: "STATE") + base_url.replace("https://evil.example/2/") + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "AT"}.to_json) + authorization.tokens("https://example.com/cb?state=STATE&code=CODE") + + assert_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_an_authorization_holds_frozen_copies_of_its_credentials + authorization = OAuth2Authorization.new(client_id: +"ID", client_secret: +"SECRET", redirect_uri: +"https://example.com/cb") + + assert_equal [true] * 3, %i[@client_id @client_secret @redirect_uri].map { |name| authorization.instance_variable_get(name).frozen? } + end + + def test_an_authorization_holds_a_copy_of_a_proxy_url_given_as_a_uri + uri = URI("http://proxy.example.com:8080") + authorization = OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: "https://example.com/cb", proxy_url: uri) + uri.port = 9090 + + assert_equal "http://proxy.example.com:8080", authorization.instance_variable_get(:@settings).fetch(:proxy_url) + string = +"http://proxy.example.com:8080" + authorization = OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: "https://example.com/cb", proxy_url: string) + string.replace("http://elsewhere.example:1") + + assert_equal "http://proxy.example.com:8080", authorization.instance_variable_get(:@settings).fetch(:proxy_url) + end + + private + + def held_tokens(client) = %i[@access_token @refresh_token].map { |name| client.authenticator.instance_variable_get(name) } + + def stub_refresh + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW", refresh_token: "NEW_REFRESH", expires_in: 7200}.to_json) + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + end + end +end diff --git a/x-core/test/x/core/client_credentials_validation_test.rb b/x-core/test/x/core/client_credentials_validation_test.rb new file mode 100644 index 00000000..1f728d50 --- /dev/null +++ b/x-core/test/x/core/client_credentials_validation_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientCredentialsValidationTest < Minitest::Test + cover_client + cover Core.const_get(:CredentialValidator) + + def assert_incomplete(**credentials) + error = assert_raises(ArgumentError) { Client.new(**credentials) } + + assert_equal TEST_INCOMPLETE_CREDENTIALS, error.message + end + + def test_no_credentials_send_requests_without_them + assert_instance_of Authenticator, Client.new.authenticator + end + + def test_an_expiration_time_that_is_not_a_time_is_refused + error = assert_raises(ArgumentError) { Client.new(**test_oauth2_credentials, expires_at: 1_789_600_000) } + + assert_equal TEST_INVALID_EXPIRES_AT, error.message + end + + def test_an_expiration_time_that_is_not_a_time_is_refused_beside_a_bearer_token + error = assert_raises(ArgumentError) { Client.new(bearer_token: TEST_BEARER_TOKEN, expires_at: "2026-09-28T00:00:00Z") } + + assert_equal TEST_INVALID_EXPIRES_AT, error.message + end + + def test_an_expiration_time_of_a_subclass_of_time_is_allowed + expires_at = Class.new(Time).at(Time.now.to_i + 60) + + assert_same expires_at, Client.new(**test_oauth2_credentials, expires_at:).expires_at + end + + def test_copying_with_an_expiration_time_that_is_not_a_time_is_refused + client = Client.new(**test_oauth2_credentials, expires_at: Time.now + 60) + + assert_raises(ArgumentError) { client.with(expires_at: "2026-09-16T00:00:00Z") } + assert_kind_of Time, client.expires_at + end + + def test_each_credential_alone_is_incomplete + test_oauth_credentials.merge(test_oauth2_credentials).except(:api_key, :access_token).each do |name, value| + assert_incomplete(name => value) + end + end + + def test_an_access_token_alone_is_incomplete + assert_incomplete(access_token: TEST_ACCESS_TOKEN) + end + + def test_an_api_key_alone_is_incomplete + assert_incomplete(api_key: TEST_API_KEY) + end + + def test_an_access_token_without_its_secret_is_incomplete_rather_than_app_only + assert_incomplete(**test_oauth_credentials.except(:access_token_secret)) + end + + def test_an_access_token_beside_a_bearer_token_is_incomplete + assert_incomplete(bearer_token: TEST_BEARER_TOKEN, access_token: TEST_ACCESS_TOKEN) + end + + def test_a_credential_of_an_incomplete_set_beside_a_bearer_token_is_incomplete + test_oauth_credentials.merge(test_oauth2_credentials).each do |name, value| + assert_incomplete(:bearer_token => TEST_BEARER_TOKEN, name => value) + end + end + + def test_an_access_token_secret_beside_app_only_credentials_is_incomplete + assert_incomplete(**test_oauth_credentials.except(:access_token)) + end + + def test_a_credential_of_an_incomplete_set_beside_a_complete_one_is_incomplete + assert_incomplete(**test_oauth_credentials, refresh_token: TEST_REFRESH_TOKEN) + assert_incomplete(**test_oauth2_credentials, api_key: TEST_API_KEY) + assert_incomplete(**test_oauth2_credentials, access_token_secret: TEST_ACCESS_TOKEN_SECRET) + end + + def test_a_client_secret_without_the_rest_of_its_set_is_incomplete + assert_incomplete(**test_oauth_credentials, client_secret: TEST_CLIENT_SECRET) + end + + def test_an_oauth2_access_token_needs_no_refresh_token + [test_oauth2_credentials, test_oauth2_credentials.except(:client_secret)].each do |credentials| + assert_instance_of OAuth2Authenticator, Client.new(**credentials.except(:refresh_token)).authenticator + end + end + + def test_a_refresh_token_without_a_client_id_is_incomplete + assert_incomplete(**test_oauth2_credentials.except(:client_id)) + end + + def test_a_public_oauth2_client_needs_no_client_secret + assert_instance_of OAuth2Authenticator, Client.new(**test_oauth2_credentials.except(:client_secret)).authenticator + end + + def test_complete_sets_beside_each_other_are_allowed + assert_instance_of OAuth1Authenticator, Client.new(**test_oauth_credentials, bearer_token: TEST_BEARER_TOKEN).authenticator + assert_instance_of OAuth2Authenticator, Client.new(**test_oauth2_credentials, api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).authenticator + assert_instance_of AppOnlyAuthenticator, Client.new(bearer_token: TEST_BEARER_TOKEN, api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).authenticator + end + + def test_a_copy_with_an_incomplete_set_raises + client = Client.new(**test_oauth_credentials) + + assert_raises(ArgumentError) { client.with(access_token_secret: nil) } + end + end + + # OAuth 2.0 credentials share the access token of OAuth 1.0a ones, which a client authenticates with first, so a + # client refuses them beside a complete set of OAuth 1.0a credentials, where they would go unused + class ClientMixedCredentialsTest < Minitest::Test + cover_client + cover Core.const_get(:CredentialValidator) + + def test_oauth2_credentials_beside_oauth1_ones_are_refused + error = assert_raises(ArgumentError) do + Client.new(**test_oauth_credentials, client_id: TEST_CLIENT_ID, client_secret: TEST_CLIENT_SECRET, refresh_token: TEST_REFRESH_TOKEN) + end + + assert_equal "client_id, client_secret, refresh_token are OAuth 2.0 credentials, which a client given OAuth 1.0a " \ + "credentials would leave unused, since it authenticates with those, and the access_token they share is the " \ + "OAuth 1.0a one. Pass the credentials of one or the other", error.message + end + + def test_each_oauth2_credential_beside_oauth1_ones_is_refused + [{client_id: TEST_CLIENT_ID}, {client_id: TEST_CLIENT_ID, refresh_token: TEST_REFRESH_TOKEN}].each do |oauth2| + error = assert_raises(ArgumentError) { Client.new(**test_oauth_credentials, **oauth2) } + + assert_match(/\A#{oauth2.keys.join(", ")} are OAuth 2.0 credentials/, error.message) + end + end + + def test_oauth2_credentials_beside_part_of_oauth1_ones_build_an_oauth2_authenticator + client = Client.new(**test_oauth2_credentials, api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + assert_instance_of OAuth2Authenticator, client.authenticator + end + + def test_a_copy_that_completes_oauth1_credentials_beside_oauth2_ones_is_refused + client = Client.new(**test_oauth2_credentials, api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + assert_raises(ArgumentError) { client.with(access_token_secret: TEST_ACCESS_TOKEN_SECRET) } + end + end +end diff --git a/x-core/test/x/core/client_empty_credentials_test.rb b/x-core/test/x/core/client_empty_credentials_test.rb new file mode 100644 index 00000000..243501bb --- /dev/null +++ b/x-core/test/x/core/client_empty_credentials_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientEmptyCredentialsTest < Minitest::Test + cover_client + cover Core.const_get(:CredentialValidator) + + def test_an_empty_credential_is_refused + %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret refresh_token].each do |name| + error = assert_raises(ArgumentError) { Client.new(name => "") } + + assert_equal "#{name} is empty. Pass the credential, or leave it out, since an empty one authenticates nothing", error.message + end + end + + def test_a_credential_that_is_not_a_string_is_refused + %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret refresh_token].each do |name| + [123, :token].each do |value| + error = assert_raises(ArgumentError) { Client.new(name => value) } + + assert_equal "#{name} must be a String, not a #{value.class}", error.message + end + end + end + + def test_a_credential_that_is_not_a_string_beside_a_complete_set_is_refused + error = assert_raises(ArgumentError) { Client.new(**test_oauth_credentials, bearer_token: 123) } + + assert_equal "bearer_token must be a String, not a Integer", error.message + end + + def test_a_credential_of_whitespace_alone_is_refused + assert_raises(ArgumentError) { Client.new(bearer_token: " \t\n") } + end + + def test_an_empty_credential_beside_a_complete_set_is_refused + error = assert_raises(ArgumentError) { Client.new(**test_oauth_credentials, bearer_token: "") } + + assert_equal "bearer_token is empty. Pass the credential, or leave it out, since an empty one authenticates nothing", error.message + end + + def test_an_empty_credential_of_a_complete_set_is_refused_as_empty_rather_than_incomplete + error = assert_raises(ArgumentError) { Client.new(**test_oauth_credentials, access_token_secret: "") } + + assert_match(/\Aaccess_token_secret is empty/, error.message) + end + + def test_copying_with_an_empty_credential_is_refused + assert_raises(ArgumentError) { Client.new(**test_oauth_credentials).with(api_key: "") } + end + end +end diff --git a/x-core/test/x/core/client_endpoint_validation_test.rb b/x-core/test/x/core/client_endpoint_validation_test.rb new file mode 100644 index 00000000..fa971804 --- /dev/null +++ b/x-core/test/x/core/client_endpoint_validation_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientEndpointValidationTest < Minitest::Test + cover_client + + NOT_A_URL = "it is not a valid URL; escape what a URL may not hold, such as a space" + NOT_HTTP = "it does not name an http or https URL" + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_an_endpoint_that_is_not_a_string_raises_argument_error_naming_its_class + [[:users, "Symbol"], [URI("https://api.x.com/2/users/me"), "URI::HTTPS"], [nil, "NilClass"]].each do |endpoint, name| + error = assert_raises(ArgumentError) { @client.get(endpoint) } + + assert_equal %(endpoint must be a String, such as "users/me", not a #{name}), error.message + end + assert_raises(ArgumentError) { @client.get_stream(:users) { |_response| } } + assert_not_requested :any, /api\.x\.com/ + end + + def test_an_endpoint_of_a_subclass_of_string_is_a_string + stub_request(:get, "https://api.x.com/2/users/me") + @client.get(Class.new(String).new("users/me")) + + assert_requested :get, "https://api.x.com/2/users/me" + end + + def test_an_endpoint_that_holds_a_space_raises_argument_error_naming_it + error = assert_raises(ArgumentError) { @client.get("users/by/username/a b") } + + assert_equal %(Invalid endpoint "users/by/username/a b": #{NOT_A_URL}), error.message + assert_not_requested :any, /api\.x\.com/ + end + + def test_an_endpoint_with_a_malformed_percent_escape_raises_argument_error_naming_it + error = assert_raises(ArgumentError) { @client.get("tweets?query=a%zz") } + + assert_equal %(Invalid endpoint "tweets?query=a%zz": #{NOT_A_URL}), error.message + assert_not_requested :any, /api\.x\.com/ + end + + def test_an_endpoint_that_names_another_scheme_raises_argument_error_naming_it + error = assert_raises(ArgumentError) { @client.get("foo:bar") } + + assert_equal %(Invalid endpoint "foo:bar": #{NOT_HTTP}), error.message + end + + def test_an_endpoint_that_names_a_host_of_another_scheme_raises_argument_error_naming_it + error = assert_raises(ArgumentError) { @client.get("ftp://api.x.com/2/users") } + + assert_equal %(Invalid endpoint "ftp://api.x.com/2/users": #{NOT_HTTP}), error.message + end + + def test_an_endpoint_that_names_no_host_raises_argument_error_naming_it + error = assert_raises(ArgumentError) { @client.get("https:users") } + + assert_equal %(Invalid endpoint "https:users": #{NOT_HTTP}), error.message + end + + def test_a_write_to_an_invalid_endpoint_raises_before_it_is_sent + error = assert_raises(ArgumentError) { @client.post("tweets/a b", {text: "Hello"}) } + + assert_equal %(Invalid endpoint "tweets/a b": #{NOT_A_URL}), error.message + assert_not_requested :any, /api\.x\.com/ + end + + def test_a_valid_endpoint_that_names_another_host_is_sent_there + stub_request(:get, "https://upload.x.com/1.1/media/upload.json?command=STATUS") + @client.get("https://upload.x.com/1.1/media/upload.json", params: {command: "STATUS"}) + + assert_requested :get, "https://upload.x.com/1.1/media/upload.json?command=STATUS" + end + end +end diff --git a/x-core/test/x/core/client_expires_at_test.rb b/x-core/test/x/core/client_expires_at_test.rb new file mode 100644 index 00000000..41960268 --- /dev/null +++ b/x-core/test/x/core/client_expires_at_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An expiration time is that of an OAuth 2.0 access token, which a client given no OAuth 2.0 credentials to + # authenticate with would leave unused, so it is refused beside any others + class ClientExpiresAtTest < Minitest::Test + cover_client + cover Core.const_get(:CredentialValidator) + + def test_an_expiration_time_alone_is_refused_as_unused + assert_equal TEST_UNUSED_EXPIRES_AT, assert_raises(ArgumentError) { Client.new(expires_at: Time.now) }.message + end + + def test_an_expiration_time_is_refused_beside_credentials_other_than_those_of_oauth2 + [{bearer_token: TEST_BEARER_TOKEN}, test_oauth_credentials, test_oauth_credentials.slice(:api_key, :api_key_secret)].each do |credentials| + assert_equal TEST_UNUSED_EXPIRES_AT, assert_raises(ArgumentError) { Client.new(**credentials, expires_at: Time.now) }.message + end + end + + def test_oauth2_credentials_beside_oauth1_ones_are_refused_before_their_expiration_time + error = assert_raises(ArgumentError) { Client.new(**test_oauth_credentials, client_id: TEST_CLIENT_ID, expires_at: Time.now) } + + assert_match(/\Aclient_id are OAuth 2.0 credentials/, error.message) + end + + def test_an_expiration_time_is_allowed_beside_the_oauth2_credentials_the_client_authenticates_with + expires_at = Time.now + 60 + [test_oauth2_credentials, test_oauth2_credentials.slice(:client_id, :access_token)].each do |credentials| + client = Client.new(**credentials, bearer_token: TEST_BEARER_TOKEN, expires_at:) + + assert_same expires_at, client.expires_at + end + end + + def test_no_expiration_time_is_allowed_beside_any_credentials + assert_instance_of BearerTokenAuthenticator, Client.new(bearer_token: TEST_BEARER_TOKEN, expires_at: nil).authenticator + end + end +end diff --git a/x-core/test/x/core/client_get_stream_accept_encoding_test.rb b/x-core/test/x/core/client_get_stream_accept_encoding_test.rb new file mode 100644 index 00000000..6a2f349a --- /dev/null +++ b/x-core/test/x/core/client_get_stream_accept_encoding_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A stream asks for a body that is not compressed, since Net::HTTP would hold back each line of a compressed one until + # enough had arrived to fill a block, unless the headers of the request or of the client name an Accept-Encoding + class ClientGetStreamAcceptEncodingTest < Minitest::Test + cover_client + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + + def read(client, **) + client.get_stream("tweets/sample/stream", **) { |response| response.read_body { nil } } + end + + def test_a_stream_asks_for_a_body_that_is_not_compressed + stub_request(:get, STREAM_URL).with(headers: {"Accept-Encoding" => "identity"}).to_return(body: "") + read(Client.new(bearer_token: TEST_BEARER_TOKEN)) + + assert_requested :get, STREAM_URL, headers: {"Accept-Encoding" => "identity"} + end + + def test_an_accept_encoding_of_the_client_or_the_request_is_sent_in_its_place + stub_request(:get, STREAM_URL).to_return(body: "") + read(Client.new(bearer_token: TEST_BEARER_TOKEN, headers: {"accept-encoding" => "gzip"})) + read(Client.new(bearer_token: TEST_BEARER_TOKEN), headers: {"Accept-Encoding" => "br"}) + + assert_requested :get, STREAM_URL, headers: {"Accept-Encoding" => "gzip"} + assert_requested :get, STREAM_URL, headers: {"Accept-Encoding" => "br"} + end + + def test_a_request_that_is_not_a_stream_asks_for_no_encoding_of_its_own + stub_request(:get, "https://api.x.com/2/users/me").to_return(body: "{}", headers: {"Content-Type" => "application/json"}) + Client.new(bearer_token: TEST_BEARER_TOKEN).get("users/me") + + assert_not_requested :get, "https://api.x.com/2/users/me", headers: {"Accept-Encoding" => "identity"} + end + end +end diff --git a/x-core/test/x/core/client_get_stream_body_test.rb b/x-core/test/x/core/client_get_stream_body_test.rb new file mode 100644 index 00000000..5507b0e2 --- /dev/null +++ b/x-core/test/x/core/client_get_stream_body_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The body of a GET request the block reads raises a NetworkError for an error read_body raises from the socket, and + # raises any error of what the block passes the body to as it was, whatever its class + class ClientGetStreamBodyTest < Minitest::Test + include LocalServer + + cover_client + cover StreamResponse + cover Core.const_get(:StreamBody) + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + # A response whose connection drops partway through its body + TRUNCATED = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n{\"data\":\r\n" + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_a_body_that_drops_while_its_chunks_are_read_is_a_network_error + streaming_locally(TRUNCATED) do |client| + assert_raises(NetworkError) { client.get_stream("stream") { |response| response.read_body { |_chunk| } } } + end + end + + def test_a_body_that_drops_while_it_is_read_whole_is_a_network_error + streaming_locally(TRUNCATED) do |client| + assert_raises(NetworkError) { client.get_stream("stream", &:read_body) } + end + end + + # The response of the transport is an escape hatch, which notes no error of the socket, so the error is the block's + def test_a_body_that_drops_while_it_is_read_from_the_http_response_raises_the_error_of_the_socket_as_the_blocks_own + streaming_locally(TRUNCATED) do |client| + error = assert_raises(StandardError) { client.get_stream("stream") { |response| response.http_response.read_body { |_chunk| } } } + + refute_kind_of NetworkError, error + end + end + + def test_an_error_of_a_socket_the_block_passed_each_chunk_raises_is_raised_as_it_was + stub_request(:get, STREAM_URL).to_return(body: "{}") + full = Errno::ENOSPC.new("the disk is full") + + assert_same full, assert_raises(Errno::ENOSPC) { @client.get_stream("tweets/sample/stream") { |response| response.read_body { |_chunk| raise full } } } + end + + def test_a_body_read_through_a_block_passes_each_chunk_to_it_and_returns_nil + stub_request(:get, STREAM_URL).to_return(body: "{}") + chunks = [] + body = @client.get_stream("tweets/sample/stream") { |response| [response.read_body { |chunk| chunks << chunk }] } + + assert_equal [["{}"], [nil]], [chunks, body] + end + + def test_a_body_read_whole_is_the_body + stub_request(:get, STREAM_URL).to_return(body: "{}") + + assert_equal "{}", @client.get_stream("tweets/sample/stream", &:read_body) + end + + # An error is the socket's by its class as well as by where it was raised, so one that is not an error of a + # network, which the transport raised of its own while the body was read, is raised as it was + def test_an_error_read_body_raises_that_is_not_one_of_a_network_is_raised_as_it_was + stub_request(:get, STREAM_URL).to_return(body: "{}") + misread = ArgumentError.new("the body could not be read") + error = assert_raises(ArgumentError) do + @client.get_stream("tweets/sample/stream") do |response| + response.http_response.define_singleton_method(:read_body) { |&_block| raise misread } + response.read_body { |_chunk| } + end + end + + assert_same misread, error + end + + def test_an_error_read_body_did_not_raise_from_the_socket_is_not_the_sockets + assert_equal [false, false], [Core.const_get(:StreamBody).socket_error?(IOError.new), Core.const_get(:StreamBody).socket_error?(ArgumentError.new)] + end + + private + + # Stream from a server on the loopback interface with WebMock out of the way, which would read the body whole + # before the block were passed the response + def streaming_locally(response) + WebMock.disable! + with_local_server(response:) { |port| yield Client.new(bearer_token: TEST_BEARER_TOKEN, base_url: "http://127.0.0.1:#{port}/2/") } + ensure + WebMock.enable! + end + end +end diff --git a/x-core/test/x/core/client_get_stream_encoding_test.rb b/x-core/test/x/core/client_get_stream_encoding_test.rb new file mode 100644 index 00000000..9599e0cf --- /dev/null +++ b/x-core/test/x/core/client_get_stream_encoding_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The body of a stream that failed is read whole, by on_response and by its error, as UTF-8; webmock hands over the + # String it was given, so this reads what a server on the loopback interface sends, with webmock disabled + class ClientGetStreamEncodingTest < Minitest::Test + include LocalServer + + cover_client + + BODY = '{"title":"Invalid","detail":"café"}' + HTML = "Service Unavailable — try later" + + def test_the_body_of_a_stream_that_failed_is_utf_8 + bodies = [] + error = with_failed_stream do |port| + client = Client.new(base_url: "http://127.0.0.1:#{port}/", on_response: ->(response) { bodies << response.body }) + assert_raises(BadRequest) { client.get_stream("stream") { |_response| } } + end + + assert_equal [BODY, Encoding::UTF_8], [error.body, error.body.encoding] + assert_equal [Encoding::UTF_8], bodies.map(&:encoding) + end + + def test_the_body_of_a_stream_that_failed_is_read_after_the_stream_ends_with_no_on_response + error = with_failed_stream(content_type: "text/html", body: HTML) do |port| + assert_raises(ServiceUnavailable) { Client.new(base_url: "http://127.0.0.1:#{port}/").get_stream("stream") { |_response| } } + end + + assert_equal [HTML, Encoding::UTF_8], [error.body, error.body.encoding] + end + + private + + # Yield the port of a server that answers a stream with a failure, with webmock disabled + def with_failed_stream(content_type: "application/json", body: BODY, &) + status = content_type.eql?("text/html") ? "503 Service Unavailable" : "400 Bad Request" + WebMock.disable! + with_local_server(response: "HTTP/1.1 #{status}\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}", &) + ensure + WebMock.enable! + end + end +end diff --git a/x-core/test/x/core/client_get_stream_errors_test.rb b/x-core/test/x/core/client_get_stream_errors_test.rb new file mode 100644 index 00000000..92455c1c --- /dev/null +++ b/x-core/test/x/core/client_get_stream_errors_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A GET request whose body the block reads raises the error of a response that failed before the block reads it, + # the errors of a socket as a NetworkError, and any other error of the block as it was raised + class ClientGetStreamErrorsTest < Minitest::Test + cover_client + cover Core.const_get(:Connection) + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + + def setup + @responses = [] + @client = Client.new(bearer_token: TEST_BEARER_TOKEN, on_response: ->(response) { @responses << response }) + end + + def test_a_failed_response_raises_its_error_once_on_response_is_passed_it + stub_request(:get, STREAM_URL).to_return(status: 404, headers: {"Content-Type" => "application/json"}, body: "{\"title\":\"Not Found Error\",\"detail\":\"No stream\"}") + error = assert_raises(NotFound) { @client.get_stream("tweets/sample/stream") { |_response| flunk "the block was called" } } + + assert_equal "GET /2/tweets/sample/stream: Not Found Error: No stream", error.message + assert_equal [[404, :get, URI(STREAM_URL)]], @responses.map { |response| [response.status, response.http_method, response.uri] } + end + + def test_a_successful_response_is_not_passed_to_on_response + stub_request(:get, STREAM_URL) + @client.get_stream("tweets/sample/stream") { |_response| } + + assert_empty @responses + end + + def test_an_error_of_on_response_for_a_failed_response_is_raised_as_it_was + stub_request(:get, STREAM_URL).to_return(status: 503) + client = Client.new(bearer_token: TEST_BEARER_TOKEN, on_response: ->(_response) { raise IOError, "the hook failed" }) + + assert_equal "the hook failed", assert_raises(IOError) { client.get_stream("tweets/sample/stream") { |_response| } }.message + end + + def test_an_error_of_a_socket_the_block_raises_of_its_own_is_raised_as_it_was + stub_request(:get, STREAM_URL) + error = assert_raises(IOError) { @client.get_stream("tweets/sample/stream") { |_response| raise IOError, "the file closed" } } + + assert_equal "the file closed", error.message + end + + def test_any_other_error_the_block_raises_is_raised_as_it_was + stub_request(:get, STREAM_URL) + error = assert_raises(ArgumentError) { @client.get_stream("tweets/sample/stream") { |_response| raise ArgumentError, "the block failed" } } + + assert_equal "the block failed", error.message + end + + def test_an_unauthorized_the_block_raises_refreshes_no_token + token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + stub_request(:get, STREAM_URL) + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "GIVEN") + + response = Net::HTTPUnauthorized.new("1.1", "401", "Unauthorized").tap { |unauthorized| unauthorized.uri = URI(STREAM_URL) } + rejection = Unauthorized.new(http_response: response) + + assert_same rejection, assert_raises(Unauthorized) { client.get_stream("tweets/sample/stream") { |_response| raise rejection } } + assert_not_requested token_request + assert_requested :get, STREAM_URL, times: 1 + end + + def test_an_error_of_a_socket_while_connecting_is_a_network_error + stub_request(:get, STREAM_URL).to_raise(Errno::ECONNREFUSED) + + assert_raises(NetworkError) { @client.get_stream("tweets/sample/stream") { |_response| flunk "the block was called" } } + end + end +end diff --git a/x-core/test/x/core/client_get_stream_rate_limit_retries_test.rb b/x-core/test/x/core/client_get_stream_rate_limit_retries_test.rb new file mode 100644 index 00000000..0cd4797c --- /dev/null +++ b/x-core/test/x/core/client_get_stream_rate_limit_retries_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A stream counts as a request of its own, so a request its block sends counts its rate limit retries afresh, even + # when with_retries wraps the stream + class ClientGetStreamRateLimitRetriesTest < Minitest::Test + cover_client + + STREAM_URL = "https://api.x.com/2/tweets/search/stream" + USER_URL = "https://api.x.com/2/users/me" + + def test_each_request_a_stream_block_sends_inside_with_retries_counts_its_own_rate_limit_retries + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 1) + stub_request(:get, STREAM_URL).to_return(body: "1\n2\n3\n") + stub_request(:get, USER_URL).to_return(*[refused, {status: 200, body: "{}"}] * 3) + lookups = 0 + without_sleeping(client) do + client.with_retries { client.get_stream("tweets/search/stream") { |response| response.read_body { |chunk| chunk.lines.each { lookups += 1 if client.get("users/me") } } } } + end + + assert_equal 3, lookups + end + + private + + def refused + {status: 429, headers: {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => Time.now.to_i.to_s}} + end + + def without_sleeping(client, &) + handler = internals(client).instance_variable_get(:@rate_limit_handler) + handler.stub(:rand, 0.0) { handler.stub(:sleep, nil, &) } + end + end +end diff --git a/x-core/test/x/core/client_get_stream_test.rb b/x-core/test/x/core/client_get_stream_test.rb new file mode 100644 index 00000000..a1e6ee0b --- /dev/null +++ b/x-core/test/x/core/client_get_stream_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A GET request whose body the block reads as it arrives, which carries the credentials and headers of the client as + # any other request does + class ClientGetStreamTest < Minitest::Test + cover_client + cover Core.const_get(:Connection) + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN, headers: {"X-Client" => "client"}) + end + + def test_the_block_reads_the_body_as_it_arrives_and_its_value_is_returned + stub_request(:get, "#{STREAM_URL}?expansions=author_id").to_return(body: "{\"data\":1}\r\n") + chunks = [] + + assert_equal :read, @client.get_stream("tweets/sample/stream", params: {expansions: "author_id"}) { |response| + assert_instance_of StreamResponse, response + response.read_body { |chunk| chunks << chunk } + :read + } + assert_equal ["{\"data\":1}\r\n"], chunks + end + + def test_the_request_carries_the_credentials_and_headers_of_the_client + stub_request(:get, STREAM_URL) + @client.get_stream("tweets/sample/stream", headers: {"X-Stream" => "stream"}) { |_response| } + + assert_requested(:get, STREAM_URL, headers: {"Authorization" => "Bearer #{TEST_BEARER_TOKEN}", "X-Client" => "client", "X-Stream" => "stream"}) + end + + def test_a_request_to_another_origin_carries_none_of_the_credentials + stub_request(:get, "https://stream.example.com/sample") + @client.get_stream("https://stream.example.com/sample") { |_response| } + + assert_requested(:get, "https://stream.example.com/sample") { |request| !request.headers.key?("Authorization") } + end + + def test_a_block_is_required + error = assert_raises(ArgumentError) { @client.get_stream("tweets/sample/stream") } + + assert_equal "get_stream takes a block, which reads the body of the response", error.message + end + + def test_an_endpoint_that_is_not_a_url_raises_before_a_request + assert_raises(ArgumentError) { @client.get_stream("http://[bad") { |_response| } } + end + + def test_a_rejected_app_token_is_fetched_again_and_the_request_sent_again + token_request = stub_request(:post, APP_ONLY_TOKEN_URL).to_return(status: 200, body: {token_type: "bearer", access_token: "FETCHED"}.to_json) + stub_request(:get, STREAM_URL).with(headers: {"Authorization" => "Bearer STALE"}).to_return(status: 401) + stub_request(:get, STREAM_URL).with(headers: {"Authorization" => "Bearer FETCHED"}).to_return(body: "{}") + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "STALE") + + assert_equal "{}", client.get_stream("tweets/sample/stream") { |response| response.read_body { |chunk| break chunk } } + assert_requested token_request, times: 1 + end + end +end diff --git a/x-core/test/x/core/client_given_authenticator_credentials_test.rb b/x-core/test/x/core/client_given_authenticator_credentials_test.rb new file mode 100644 index 00000000..09165007 --- /dev/null +++ b/x-core/test/x/core/client_given_authenticator_credentials_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client given an authenticator in place of credentials reads the API key and client ID that are no secrets out of + # it, as it reads the expiration time and scopes of an OAuth 2.0 authenticator, rather than answer nil for them. + class ClientGivenAuthenticatorCredentialsTest < Minitest::Test + cover_client + + def test_a_client_given_an_oauth1_authenticator_reads_its_api_key + [OAuth1Authenticator, Class.new(OAuth1Authenticator)].each do |authenticator_class| + client = Client.new(authenticator: authenticator_class.new(**test_oauth_credentials)) + + assert_equal [TEST_API_KEY, nil], [client.api_key, client.client_id] + end + end + + def test_a_client_given_an_app_only_authenticator_reads_its_api_key + [AppOnlyAuthenticator, Class.new(AppOnlyAuthenticator)].each do |authenticator_class| + client = Client.new(authenticator: authenticator_class.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET)) + + assert_equal [TEST_API_KEY, nil], [client.api_key, client.client_id] + end + end + + def test_a_client_given_an_oauth2_authenticator_reads_its_client_id + [OAuth2Authenticator, Class.new(OAuth2Authenticator)].each do |authenticator_class| + client = Client.new(authenticator: authenticator_class.new(**test_oauth2_credentials)) + + assert_equal [nil, TEST_CLIENT_ID], [client.api_key, client.client_id] + end + end + + def test_a_client_given_a_bearer_token_authenticator_holds_neither + client = Client.new(authenticator: BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN)) + + assert_equal [nil, nil], [client.api_key, client.client_id] + end + + def test_a_client_given_credentials_reads_the_ones_it_was_given + assert_equal TEST_API_KEY, Client.new(**test_oauth_credentials).api_key + assert_equal TEST_CLIENT_ID, Client.new(**test_oauth2_credentials).client_id + end + + def test_a_copy_given_credentials_in_place_of_the_authenticator_reads_its_own + client = Client.new(authenticator: OAuth1Authenticator.new(**test_oauth_credentials)) + copy = client.with(bearer_token: TEST_BEARER_TOKEN) + + assert_equal [nil, nil], [copy.api_key, copy.client_id] + end + end +end diff --git a/x-core/test/x/core/client_headers_test.rb b/x-core/test/x/core/client_headers_test.rb new file mode 100644 index 00000000..e1af5524 --- /dev/null +++ b/x-core/test/x/core/client_headers_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientHeadersTest < Minitest::Test + cover_client + + def test_a_client_sends_no_headers_of_its_own_by_default + assert_empty Client.new.headers + end + + def test_the_headers_are_sent_with_every_request + client = Client.new(headers: {"X-Trace" => "abc"}) + stub_request(:get, "https://api.x.com/2/users/me") + stub_request(:delete, "https://api.x.com/2/tweets/1") + client.get("users/me") + client.delete("tweets/1") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"X-Trace" => "abc"} + assert_requested :delete, "https://api.x.com/2/tweets/1", headers: {"X-Trace" => "abc"} + end + + def test_the_headers_replace_a_default_of_the_gem + stub_request(:get, "https://api.x.com/2/users/me") + Client.new(headers: {"User-Agent" => "my-app/1.0"}).get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"User-Agent" => "my-app/1.0"} + end + + def test_a_header_of_a_request_replaces_one_of_the_client + stub_request(:get, "https://api.x.com/2/users/me") + Client.new(headers: {"X-Trace" => "client"}).get("users/me", headers: {"X-Trace" => "request"}) + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"X-Trace" => "request"} + end + + def test_a_header_of_a_request_replaces_one_of_the_client_named_in_another_case + stub_request(:get, "https://api.x.com/2/users/me") + Client.new(headers: {"user-agent" => "client"}).get("users/me", headers: {"User-Agent" => "request"}) + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"User-Agent" => "request"} + end + + def test_a_header_of_the_client_replaces_a_default_of_the_gem_named_in_another_case + stub_request(:get, "https://api.x.com/2/users/me") + Client.new(headers: {"user-agent" => "my-app/1.0"}).get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"User-Agent" => "my-app/1.0"} + end + + def test_the_form_content_type_is_sent_in_place_of_one_of_the_clients_headers_named_in_another_case + stub_request(:post, "https://api.x.com/2/settings") + Client.new(headers: {"content-type" => "application/json; charset=utf-8"}).post("settings", form: {lang: "en"}) + + assert_requested :post, "https://api.x.com/2/settings", + headers: {"Content-Type" => "application/x-www-form-urlencoded; charset=utf-8"} + end + + def test_the_form_content_type_is_sent_in_place_of_one_of_the_clients_headers + stub_request(:post, "https://api.x.com/2/settings") + Client.new(headers: {"Content-Type" => "application/json; charset=utf-8"}).post("settings", form: {lang: "en"}) + + assert_requested :post, "https://api.x.com/2/settings", + headers: {"Content-Type" => "application/x-www-form-urlencoded; charset=utf-8"} + end + + def test_a_header_named_by_a_symbol_names_the_header_its_underscores_name_with_hyphens + stub_request(:post, "https://api.x.com/2/settings") + Client.new(headers: {user_agent: "my-app/1.0"}).post("settings", "a=1", headers: {content_type: "text/plain"}) + + assert_requested(:post, "https://api.x.com/2/settings", headers: {"User-Agent" => "my-app/1.0", "Content-Type" => "text/plain"}) do |request| + request.headers.keys.none? { |name| name.include?("_") } + end + end + + def test_headers_of_a_request_that_are_not_a_hash_are_refused_before_it_is_sent + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + [->(headers) { client.get("users/me", headers:) }, ->(headers) { client.post("tweets", headers:) }, + ->(headers) { client.get_stream("tweets/sample/stream", headers:) { |_response| } }].each do |request| + error = assert_raises(ArgumentError) { request.call(nil) } + + assert_equal "headers must be a Hash of header names to values, not a NilClass", error.message + end + assert_not_requested :any, /api\.x\.com/ + end + + def test_a_header_of_a_request_that_is_not_a_string_is_refused_before_it_is_sent + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + [->(headers) { client.get("users/me", headers:) }, ->(headers) { client.get_stream("tweets/sample/stream", headers:) { |_response| } }].each do |request| + error = assert_raises(ArgumentError) { request.call({"X-Count" => 1}) } + + assert_equal "headers must name each header with a String or a Symbol and give it a String, not \"X-Count\" with a Integer", error.message + end + assert_not_requested :any, /api\.x\.com/ + end + + def test_the_headers_are_frozen_and_copied_from_the_hash_given + given = {"X-Trace" => "abc"} + client = Client.new(headers: given) + given["X-Trace"] = "changed" + + assert_predicate client.headers, :frozen? + assert_equal({"X-Trace" => "abc"}, client.headers) + end + + def test_a_copy_keeps_the_headers + assert_equal({"X-Trace" => "abc"}, Client.new(headers: {"X-Trace" => "abc"}).with(max_redirects: 1).headers) + end + + def test_a_copy_can_replace_the_headers + assert_equal({"X-Trace" => "xyz"}, Client.new(headers: {"X-Trace" => "abc"}).with(headers: {"X-Trace" => "xyz"}).headers) + end + + def test_a_redirect_to_another_origin_drops_a_header_of_the_client_that_carries_credentials + redirect_to("https://elsewhere.example.com/users/me", headers: {"Cookie" => "session=secret", "X-Trace" => "abc"}) + + assert_equal ["abc", nil], headers_sent_to("elsewhere.example.com").values_at("X-Trace", "Cookie") + end + + def test_a_redirect_to_the_same_origin_keeps_every_header_of_the_client + redirect_to("https://api.x.com/2/next", headers: {"Cookie" => "session=secret", "X-Trace" => "abc"}) + + assert_requested :get, "https://api.x.com/2/next", headers: {"Cookie" => "session=secret", "X-Trace" => "abc"} + end + + private + + # Send a request that is redirected to url, from a client that sends headers with every request + def redirect_to(url, headers:) + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 302, headers: {"Location" => url}) + stub_request(:get, url) + Client.new(headers:).get("users/me") + end + + # The headers of the request that reached a host + def headers_sent_to(host) + WebMock::RequestRegistry.instance.requested_signatures.hash.keys + .find { |signature| signature.uri.host.eql?(host) }.headers.to_h + end + end +end diff --git a/x-core/test/x/core/client_included_module_test.rb b/x-core/test/x/core/client_included_module_test.rb new file mode 100644 index 00000000..f12b88f1 --- /dev/null +++ b/x-core/test/x/core/client_included_module_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "json" +require "yaml" +require_relative "../../test_helper" + +module X + # A module included into a client, as x-objects and x-uploader are, may name its methods as it likes, and so may + # name one raise, format, or block_given?, which take the place of none of the methods of the client + class ClientIncludedModuleTest < Minitest::Test + cover Client + cover Core.const_get(:CredentialHolder) + + # A module whose methods have the names of Kernel methods a client could otherwise call on itself + KERNEL_NAMES = Module.new do + def raise(*) = :raised_by_the_module + def format(*) = :formatted_by_the_module + def block_given? = true + end + + def setup + @client = Class.new(Client) { include KERNEL_NAMES }.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_a_client_refuses_to_be_written_whatever_an_included_module_names_its_methods + [-> { Marshal.dump(@client) }, -> { @client.encode_with(nil) }, -> { @client.as_json }, -> { @client.to_json }].each do |write| + assert_match(/holds credentials/, assert_raises(TypeError, &write).message) + end + end + + def test_a_stream_without_a_block_is_refused_whatever_an_included_module_names_its_methods + error = assert_raises(ArgumentError) { @client.get_stream("tweets/sample/stream") } + + assert_equal "get_stream takes a block, which reads the body of the response", error.message + assert_not_requested :get, %r{tweets/sample/stream} + end + end +end diff --git a/x-core/test/x/core/client_initialization_test.rb b/x-core/test/x/core/client_initialization_test.rb new file mode 100644 index 00000000..5d88130e --- /dev/null +++ b/x-core/test/x/core/client_initialization_test.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require "ostruct" +require_relative "../../test_helper" + +module X + class ClientOAuthInitializationTest < Minitest::Test + cover_client + + def test_initialize_oauth_credentials + client = Client.new(**test_oauth_credentials) + + authenticator = client.authenticator + + assert_instance_of OAuth1Authenticator, authenticator + assert_equal TEST_API_KEY, authenticator.api_key + assert_equal TEST_ACCESS_TOKEN, authenticator.__send__(:access_token) + end + + def test_inspect_hides_the_credentials + client = Client.new(**test_oauth_credentials) + + assert_equal "#>", client.inspect + end + + def test_the_internals_of_a_client_hide_the_credentials_as_the_client_does + client = Client.new(**test_oauth_credentials, base_url: "https://api.x.com/1.1/") + + assert_equal "#>", + internals(client).inspect + end + + def test_missing_api_key_or_secret + %i[api_key api_key_secret].each do |missing_credential| + assert_raises(ArgumentError) { Client.new(**test_oauth_credentials.except(missing_credential)) } + end + end + + def test_missing_access_token_and_secret_authenticates_as_the_app + client = Client.new(**test_oauth_credentials.except(:access_token, :access_token_secret)) + + assert_instance_of AppOnlyAuthenticator, client.authenticator + end + end + + class ClientOAuth2InitializationTest < Minitest::Test + cover_client + + def test_initialize_oauth2_credentials + client = Client.new(**test_oauth2_credentials) + + authenticator = client.authenticator + + assert_instance_of OAuth2Authenticator, authenticator + assert_equal TEST_CLIENT_ID, authenticator.client_id + assert_equal TEST_ACCESS_TOKEN, authenticator.__send__(:access_token) + assert_equal TEST_REFRESH_TOKEN, authenticator.__send__(:refresh_token) + end + + def test_initialize_a_public_oauth2_client_without_a_client_secret + authenticator = Client.new(**test_oauth2_credentials.except(:client_secret)).authenticator + + assert_instance_of OAuth2Authenticator, authenticator + assert_equal TEST_REFRESH_TOKEN, authenticator.__send__(:refresh_token) + end + + def test_missing_oauth2_credentials + %i[client_id access_token].each do |missing_credential| + assert_raises(ArgumentError) { Client.new(**test_oauth2_credentials.except(missing_credential)) } + end + end + end + + class ClientAuthenticatorPrecedenceTest < Minitest::Test + cover_client + + def test_oauth2_takes_precedence_over_bearer_token + client = Client.new(**test_oauth2_credentials, bearer_token: TEST_BEARER_TOKEN) + + assert_instance_of OAuth2Authenticator, client.authenticator + end + + def test_a_bearer_token_alone_authenticates_with_it + client = Client.new(bearer_token: "bearer_token") + + assert_equal "bearer_token", internals(client).send(:bearer_token) + assert_instance_of BearerTokenAuthenticator, client.authenticator + end + end + + class ClientConnectionOptionsTest < Minitest::Test + cover_client + + def test_initialize_with_default_connection_options + client = Client.new + connection = internals(client).instance_variable_get(:@connection) + + assert_equal Core.const_get(:Connection)::DEFAULT_OPEN_TIMEOUT, connection.open_timeout + assert_equal Core.const_get(:Connection)::DEFAULT_READ_TIMEOUT, connection.read_timeout + assert_equal Core.const_get(:Connection)::DEFAULT_WRITE_TIMEOUT, connection.write_timeout + assert_nil connection.debug_output + assert_nil connection.send(:proxy_url) + end + + def test_initialize_connection_options + client = Client.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, + debug_output: $stderr, proxy_url: "https://user:pass@proxy.com:42") + connection = internals(client).instance_variable_get(:@connection) + + assert_equal 10, connection.open_timeout + assert_equal 20, connection.read_timeout + assert_equal 30, connection.write_timeout + assert_equal $stderr, connection.debug_output + assert_equal "https://user:pass@proxy.com:42", connection.send(:proxy_url) + end + end + + class ClientDefaultsTest < Minitest::Test + cover_client + + def test_defaults + client = Client.new + + assert_equal "https://api.x.com/2/", client.base_url + assert_equal 10, client.max_redirects + assert_equal Hash, client.default_object_class + assert_equal Array, client.default_array_class + end + + def test_a_slash_ends_the_base_url + client = Client.new(base_url: "https://api.x.com/2") + + assert_equal "https://api.x.com/2/", client.base_url + assert_equal "https://api.x.com/1.1/", client.with(base_url: "https://api.x.com/1.1").base_url + end + + def test_requests_keep_the_last_segment_of_a_base_url_without_a_slash + stub_request(:get, "https://api.x.com/2/users/me") + Client.new(base_url: "https://api.x.com/2").get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me" + end + + def test_overwrite_defaults + client = Client.new(base_url: "https://api.x.com/1.1/", max_redirects: 5, + default_object_class: OpenStruct, default_array_class: Set) + + assert_equal "https://api.x.com/1.1/", client.base_url + assert_equal 5, client.max_redirects + assert_equal OpenStruct, client.default_object_class + assert_equal Set, client.default_array_class + end + + def test_passes_options_to_redirect_handler + client = Client.new(max_redirects: 5) + redirect_handler = internals(client).instance_variable_get(:@redirect_handler) + + assert_equal internals(client).instance_variable_get(:@connection), redirect_handler.connection + assert_equal internals(client).instance_variable_get(:@request_builder), redirect_handler.request_builder + assert_equal 5, redirect_handler.instance_variable_get(:@max_redirects) + end + end + + class ClientAppOnlyInitializationTest < Minitest::Test + cover_client + + def test_initialize_app_only_credentials + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + assert_instance_of AppOnlyAuthenticator, client.authenticator + assert_equal TEST_API_KEY, client.authenticator.api_key + end + + def test_inspect_hides_the_credentials + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + assert_equal "#>", client.inspect + end + + def test_a_bearer_token_given_beside_the_api_key_is_sent_without_a_request + token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + stub_request(:get, "https://api.x.com/2/tweets/1").with(headers: {"Authorization" => "Bearer GIVEN"}) + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: "GIVEN") + client.get("tweets/1") + + assert_instance_of AppOnlyAuthenticator, client.authenticator + assert_not_requested token_request + end + + def test_requests_fetch_the_bearer_token + stub_request(:post, APP_ONLY_TOKEN_URL).to_return(status: 200, body: {access_token: TEST_BEARER_TOKEN}.to_json) + stub_request(:get, "https://api.x.com/2/tweets/1").with(headers: {"Authorization" => "Bearer #{TEST_BEARER_TOKEN}"}) + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + 2.times { client.get("tweets/1") } + + assert_requested :post, APP_ONLY_TOKEN_URL, times: 1 + assert_requested :get, "https://api.x.com/2/tweets/1", times: 2 + end + + def test_a_copy_given_access_tokens_switches_to_oauth + client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + copy = client.with(access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) + + assert_instance_of OAuth1Authenticator, copy.authenticator + end + end +end diff --git a/x-core/test/x/core/client_keep_alive_test.rb b/x-core/test/x/core/client_keep_alive_test.rb new file mode 100644 index 00000000..ac5e63c5 --- /dev/null +++ b/x-core/test/x/core/client_keep_alive_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientKeepAliveTest < Minitest::Test + cover_client + + def test_a_client_keeps_connections_open_for_the_default_time + assert_equal Core.const_get(:Connection)::DEFAULT_KEEP_ALIVE_TIMEOUT, Client.new.keep_alive_timeout + end + + def test_a_client_takes_a_keep_alive_timeout + client = Client.new(keep_alive_timeout: 5) + + assert_equal 5, client.keep_alive_timeout + assert_equal 5, internals(client).instance_variable_get(:@connection).keep_alive_timeout + end + + def test_the_keep_alive_timeout_of_a_copy_reaches_its_connection + copy = Client.new.with(keep_alive_timeout: 5) + + assert_equal 5, internals(copy).instance_variable_get(:@connection).keep_alive_timeout + end + + def test_a_copy_keeps_the_keep_alive_timeout + assert_equal 5, Client.new(keep_alive_timeout: 5).with(read_timeout: 10).keep_alive_timeout + end + end +end diff --git a/x-core/test/x/core/client_memo_test.rb b/x-core/test/x/core/client_memo_test.rb new file mode 100644 index 00000000..04c78192 --- /dev/null +++ b/x-core/test/x/core/client_memo_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientMemoTest < Minitest::Test + cover_client + cover Core.const_get(:ClientMemo) + + KEY = :x_objects_current_user_id + + def test_a_client_keeps_nothing_until_a_value_is_kept + assert_nil Client.new(bearer_token: TEST_BEARER_TOKEN).memoized(KEY) + end + + def test_a_client_reads_the_value_it_kept_under_its_key + client = Client.new(**test_oauth_credentials) + + assert_equal 9, client.memoize(KEY, 9) + assert_equal [9, nil], [client.memoized(KEY), client.memoized(:another_key)] + end + + def test_a_value_kept_again_replaces_the_one_before + client = Client.new(**test_oauth_credentials) + client.memoize(KEY, 9) + client.memoize(KEY, 10) + + assert_equal 10, client.memoized(KEY) + end + + def test_a_value_is_read_only_with_the_authenticator_it_was_kept_with + client = Client.new(**test_oauth_credentials) + client.memoize(KEY, 9) + internals(client).instance_variable_set(:@authenticator, OAuth1Authenticator.new(**test_oauth_credentials)) + + assert_nil client.memoized(KEY) + end + + def test_a_frozen_client_keeps_values + client = Client.new(**test_oauth_credentials).freeze + client.memoize(KEY, 9) + + assert_equal 9, client.memoized(KEY) + end + + def test_a_copy_made_with_dup_or_clone_shares_the_values_of_the_client + client = Client.new(**test_oauth_credentials) + copies = [client.dup, client.clone] + client.memoize(KEY, 9) + + assert_equal [9, 9], copies.map { |copy| copy.memoized(KEY) } + end + + def test_a_copy_made_with_with_keeps_values_of_its_own + client = Client.new(**test_oauth2_credentials) + client.memoize(KEY, 9) + copy = client.with(base_url: "https://api.x.com/1.1/") + copy.memoize(KEY, 10) + + assert_equal [9, 10], [client.memoized(KEY), copy.memoized(KEY)] + end + + # Run the block on a thread while the lock of the memo of the client is held, and return the thread, which waits + def waiting_for_the_lock(client, &) + internals(client).instance_variable_get(:@memo_lock).synchronize do + Thread.new(&).tap { |thread| assert_nil thread.join(0.05) } + end + end + + def test_values_are_read_and_kept_under_a_lock + client = Client.new(**test_oauth_credentials) + client.memoize(KEY, 9) + reader = waiting_for_the_lock(client) { client.memoized(KEY) } + writer = waiting_for_the_lock(client) { client.memoize(KEY, 10) } + + assert_equal [9, 10, 10], [reader.value, writer.value, client.memoized(KEY)] + end + end +end diff --git a/x-core/test/x/core/client_on_response_test.rb b/x-core/test/x/core/client_on_response_test.rb new file mode 100644 index 00000000..a189352c --- /dev/null +++ b/x-core/test/x/core/client_on_response_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientOnResponseTest < Minitest::Test + cover_client + + def setup + @responses = [] + @client = Client.new(on_response: ->(response) { @responses << response }) + end + + def test_on_response_receives_every_response + stub_request(:get, "https://api.x.com/2/users/me").to_return(body: {data: {id: "1"}}.to_json, headers: {"x-rate-limit-remaining" => "74", "x-rate-limit-limit" => "75", "x-rate-limit-reset" => "1"}) + @client.get("users/me") + + assert_equal [[:get, "https://api.x.com/2/users/me", 200, 1]], @responses.map { |response| [response.http_method, response.uri.to_s, response.status, response.resource_count] } + assert_equal 74, @responses.first.rate_limit.remaining + end + + def test_on_response_receives_a_failed_response_before_the_error + stub_request(:post, "https://api.x.com/2/tweets").to_return(status: 429, headers: {"x-rate-limit-remaining" => "0"}) + + assert_raises(TooManyRequests) { @client.post("tweets", {text: "hi"}) } + assert_equal [[:post, 429]], @responses.map { |response| [response.http_method, response.status] } + end + + def redirect_to_a_missing_resource + stub_request(:post, "https://api.x.com/2/old").to_return(status: 303, headers: {"Location" => "https://api.x.com/2/new"}) + stub_request(:get, "https://api.x.com/2/new").to_return(status: 404, body: '{"title":"Not Found"}', headers: {"Content-Type" => "application/json"}) + assert_raises(NotFound) { @client.post("old", {text: "hi"}) } + end + + def test_a_redirected_request_is_reported_for_the_request_the_response_answers + redirect_to_a_missing_resource + + assert_equal [[:get, "https://api.x.com/2/new", 404]], @responses.map { |response| [response.http_method, response.uri.to_s, response.status] } + end + + def test_the_error_of_a_redirected_request_names_the_request_the_response_answers + error = redirect_to_a_missing_resource + + assert_equal [:get, "https://api.x.com/2/new"], [error.http_method, error.uri.to_s] + assert_match(%r{\AGET /2/new: }, error.message) + end + + def test_on_response_is_optional_and_a_copy_can_add_one + stub_request(:delete, "https://api.x.com/2/tweets/1") + client = Client.new + client.delete("tweets/1") + + assert_nil client.on_response + client.with(on_response: ->(response) { @responses << response }).delete("tweets/1") + + assert_equal [:delete], @responses.map(&:http_method) + end + + def test_a_copy_keeps_on_response + assert_same @client.on_response, @client.with(base_url: "https://api.x.com/1.1/").on_response + end + end +end diff --git a/x-core/test/x/core/client_origin_test.rb b/x-core/test/x/core/client_origin_test.rb new file mode 100644 index 00000000..0108fa08 --- /dev/null +++ b/x-core/test/x/core/client_origin_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientOriginTest < Minitest::Test + cover_client + cover Core.const_get(:Origin) + + AUTHORIZATION = "Bearer #{TEST_BEARER_TOKEN}".freeze + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def authorization_sent_to(url) + WebMock::RequestRegistry.instance.requested_signatures.hash.keys + .find { |signature| signature.uri.to_s.eql?(url) }&.headers.to_h["Authorization"] + end + + def test_an_endpoint_of_the_base_url_carries_the_credentials + stub_request(:get, "https://api.x.com/2/users/me") + @client.get("users/me") + + assert_equal AUTHORIZATION, authorization_sent_to("https://api.x.com:443/2/users/me") + end + + def test_a_whole_url_of_the_same_origin_carries_the_credentials + stub_request(:get, "https://api.x.com/1.1/account/settings.json") + @client.get("https://API.x.com/1.1/account/settings.json") + + assert_equal AUTHORIZATION, authorization_sent_to("https://api.x.com:443/1.1/account/settings.json") + end + + def test_a_whole_url_of_another_host_carries_none + stub_request(:get, "https://example.com/steal") + @client.get("https://example.com/steal") + + assert_nil authorization_sent_to("https://example.com:443/steal") + end + + def test_a_whole_url_of_another_scheme_carries_none + stub_request(:get, "http://api.x.com/2/users/me") + @client.get("http://api.x.com/2/users/me") + + assert_nil authorization_sent_to("http://api.x.com:80/2/users/me") + end + + def test_a_whole_url_of_another_port_carries_none + stub_request(:get, "https://api.x.com:8443/2/users/me") + @client.get("https://api.x.com:8443/2/users/me") + + assert_nil authorization_sent_to("https://api.x.com:8443/2/users/me") + end + + def test_a_request_to_another_origin_drops_every_header_that_carries_credentials + stub_request(:get, "https://example.com/steal") + @client.get("https://example.com/steal", headers: {"cookie" => "session=secret", "Authorization" => "Basic secret", + "Proxy-Authorization" => "Basic proxy", "X-Custom" => "kept"}) + + assert_requested(:get, "https://example.com/steal", headers: {"X-Custom" => "kept"}) do |request| + request.headers.to_h.values_at("Authorization", "Cookie", "Proxy-Authorization").eql?([nil, nil, nil]) + end + end + + def test_a_request_to_another_origin_drops_a_header_that_carries_credentials_named_by_a_symbol + stub_request(:get, "https://example.com/steal") + @client.get("https://example.com/steal", headers: {proxy_authorization: "Basic proxy", x_custom: "kept"}) + + assert_requested(:get, "https://example.com/steal", headers: {"X-Custom" => "kept"}) do |request| + request.headers.keys.none? { |name| name.downcase.start_with?("proxy") } + end + end + + def test_a_request_to_another_origin_drops_the_headers_of_the_client_that_carry_credentials + client = Client.new(bearer_token: TEST_BEARER_TOKEN, headers: {"Cookie" => "session=secret", "X-Custom" => "kept"}) + stub_request(:get, "https://example.com/steal") + client.get("https://example.com/steal") + + assert_requested(:get, "https://example.com/steal", headers: {"X-Custom" => "kept"}) do |request| + request.headers.to_h["Cookie"].nil? + end + end + + def test_a_request_of_a_client_of_another_base_url_carries_the_credentials_there + client = @client.with(base_url: "https://example.com/v1/") + stub_request(:get, "https://example.com/v1/users/me") + client.get("users/me") + + assert_equal AUTHORIZATION, authorization_sent_to("https://example.com:443/v1/users/me") + end + end +end diff --git a/x-core/test/x/core/client_params_test.rb b/x-core/test/x/core/client_params_test.rb new file mode 100644 index 00000000..0cdb92a8 --- /dev/null +++ b/x-core/test/x/core/client_params_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "json" +require_relative "../../test_helper" + +module X + class ClientParamsTest < Minitest::Test + cover_client + + def setup + @client = Client.new + end + + def test_get_with_params + stub_request(:get, "https://api.x.com/2/users?ids=1,2&user.fields=id,username") + @client.get("users", params: {ids: [1, 2], "user.fields": %w[id username]}) + + assert_requested :get, "https://api.x.com/2/users?ids=1,2&user.fields=id,username" + end + + def test_params_join_array_subclasses + stub_request(:get, "https://api.x.com/2/users?ids=1,2") + @client.get("users", params: {ids: Class.new(Array).new([1, 2])}) + + assert_requested :get, "https://api.x.com/2/users?ids=1,2" + end + + def test_params_send_a_time_in_iso_8601_in_utc + stub_request(:get, "https://api.x.com/2/tweets/counts/recent?query=ruby&start_time=2026-09-16T18:30:00Z") + @client.get("tweets/counts/recent", params: {query: "ruby", start_time: Time.new(2026, 9, 16, 11, 30, 0, "-07:00")}) + + assert_requested :get, "https://api.x.com/2/tweets/counts/recent?query=ruby&start_time=2026-09-16T18:30:00Z" + end + + def test_params_drop_nil_values + stub_request(:get, "https://api.x.com/2/users?ids=1") + @client.get("users", params: {ids: 1, expansions: nil}) + + assert_requested :get, "https://api.x.com/2/users?ids=1" + end + + def test_an_endpoint_with_a_leading_slash_is_relative_to_the_base_url + stub_request(:get, "https://api.x.com/2/users/me") + @client.get("/users/me") + + assert_requested :get, "https://api.x.com/2/users/me" + end + + def test_an_endpoint_with_leading_slashes_never_names_another_host + stub_request(:get, "https://api.x.com/2/example.com/users?ids=1") + @client.get("//example.com/users", params: {ids: 1}) + + assert_requested :get, "https://api.x.com/2/example.com/users?ids=1" + end + + def test_only_the_slashes_that_begin_an_endpoint_are_removed + stub_request(:get, "https://api.x.com/2/users/by/username/sferik/") + @client.get("users/by/username/sferik/") + + assert_requested :get, "https://api.x.com/2/users/by/username/sferik/" + end + + def test_params_extend_an_existing_query_string + stub_request(:get, "https://api.x.com/2/users?ids=1&max_results=5") + @client.get("users?ids=1", params: {max_results: 5}) + + assert_requested :get, "https://api.x.com/2/users?ids=1&max_results=5" + end + + def test_empty_params_leave_the_endpoint_alone + stub_request(:get, "https://api.x.com/2/users/me") + @client.get("users/me", params: {}) + @client.get("users/me", params: {expansions: nil}) + + assert_requested :get, "https://api.x.com/2/users/me", times: 2 + end + + def test_params_are_encoded + stub_request(:get, "https://api.x.com/2/tweets/search/recent?query=ruby%20-is:retweet") + @client.get("tweets/search/recent", params: {query: "ruby -is:retweet"}) + + assert_requested :get, "https://api.x.com/2/tweets/search/recent?query=ruby%20-is:retweet" + end + + def test_delete_with_params + stub_request(:delete, "https://api.x.com/2/tweets/1?force=true") + @client.delete("tweets/1", params: {force: true}) + + assert_requested :delete, "https://api.x.com/2/tweets/1?force=true" + end + + def test_post_with_params + stub_request(:post, "https://api.x.com/2/tweets?dry_run=true") + @client.post("tweets", params: {dry_run: true}) + + assert_requested :post, "https://api.x.com/2/tweets?dry_run=true" + end + + def test_put_with_params + stub_request(:put, "https://api.x.com/2/tweets?dry_run=true") + @client.put("tweets", params: {dry_run: true}) + + assert_requested :put, "https://api.x.com/2/tweets?dry_run=true" + end + end +end diff --git a/x-core/test/x/core/client_parsing_classes_validation_test.rb b/x-core/test/x/core/client_parsing_classes_validation_test.rb new file mode 100644 index 00000000..5a29972f --- /dev/null +++ b/x-core/test/x/core/client_parsing_classes_validation_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "ostruct" +require_relative "../../test_helper" + +module X + # The classes a response is parsed into are checked when the client is built, and before a request is sent, rather + # than once the API has answered it + class ClientParsingClassesValidationTest < Minitest::Test + cover_client + cover Core.const_get(:SettingValidator) + + ARRAY_CLASS_MESSAGE = "%s must be a Class that JSON.parse builds each array into, such as Array, not %s" + OBJECT_CLASS_MESSAGE = "%s must be a Class that JSON.parse builds each object into, such as Hash, or respond to " \ + "from_response, as the resource classes of x-objects do, not %s" + + # What builds the result of a request from the whole body, as a resource class of x-objects does + module Builder + def self.from_response(body, client:, **) = [body, client] + end + + def message_of(&) = assert_raises(ArgumentError, &).message + + def test_a_default_array_class_that_is_not_a_class_is_refused_when_the_client_is_built + assert_equal format(ARRAY_CLASS_MESSAGE, :default_array_class, "5"), message_of { Client.new(default_array_class: 5) } + assert_equal format(ARRAY_CLASS_MESSAGE, :default_array_class, '"Array"'), message_of { Client.new(default_array_class: "Array") } + assert_equal format(ARRAY_CLASS_MESSAGE, :default_array_class, "X::ClientParsingClassesValidationTest::Builder"), + message_of { Client.new(default_array_class: Builder) } + end + + def test_a_default_object_class_that_is_neither_a_class_nor_builds_a_result_is_refused_when_the_client_is_built + assert_equal format(OBJECT_CLASS_MESSAGE, :default_object_class, '"Hash"'), message_of { Client.new(default_object_class: "Hash") } + assert_equal format(OBJECT_CLASS_MESSAGE, :default_object_class, "nil"), message_of { Client.new(default_object_class: nil) } + assert_raises(ArgumentError) { Client.new.with(default_object_class: Comparable) } + end + + def test_a_class_or_what_builds_a_result_is_allowed + client = Client.new(default_array_class: Set, default_object_class: Builder) + + assert_equal [Set, Builder], [client.default_array_class, client.default_object_class] + assert_equal OpenStruct, Client.new(default_object_class: OpenStruct).default_object_class + end + + def test_the_classes_of_a_request_are_checked_before_it_is_sent + client = Client.new + + assert_equal format(ARRAY_CLASS_MESSAGE, :array_class, "5"), message_of { client.get("users/me", array_class: 5) } + assert_equal format(OBJECT_CLASS_MESSAGE, :object_class, '"Hash"'), message_of { client.post("tweets", object_class: "Hash") } + assert_raises(ArgumentError) { client.put("tweets/1", array_class: nil) } + assert_raises(ArgumentError) { client.delete("tweets/1", object_class: nil) } + assert_not_requested :any, /api\.x\.com/ + end + end +end diff --git a/x-core/test/x/core/client_partial_credentials_test.rb b/x-core/test/x/core/client_partial_credentials_test.rb new file mode 100644 index 00000000..acb09021 --- /dev/null +++ b/x-core/test/x/core/client_partial_credentials_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client holds every credential of a complete set, since it checks them as it is built, but it builds its + # authenticator first: reading the credentials to check them reads the tokens of the OAuth 2.0 authenticator, so + # there has to be one. Each builder is therefore given credentials that may not form a set, and takes none of them + # unless its own set is whole. + class ClientPartialCredentialsTest < Minitest::Test + cover_client + + def test_oauth1_takes_none_of_its_credentials_unless_the_set_is_whole + test_oauth_credentials.each_key do |missing| + expected = %i[access_token access_token_secret].include?(missing) ? AppOnlyAuthenticator : Authenticator + + assert_instance_of expected, authenticator_for(test_oauth_credentials.except(missing)) + end + end + + def test_oauth2_takes_none_of_its_credentials_unless_the_set_is_whole + %i[client_id access_token].each do |missing| + assert_instance_of Authenticator, authenticator_for(test_oauth2_credentials.except(missing)) + end + end + + def test_oauth2_takes_a_client_id_and_access_token_without_a_refresh_token + assert_instance_of OAuth2Authenticator, authenticator_for(test_oauth2_credentials.except(:refresh_token)) + end + + def test_the_app_takes_neither_its_api_key_nor_the_secret_alone + [{api_key: TEST_API_KEY}, {api_key_secret: TEST_API_KEY_SECRET}].each do |credentials| + assert_instance_of Authenticator, authenticator_for(credentials) + end + end + + private + + # The authenticator a client builds from credentials that may not form a complete set + def authenticator_for(credentials) + client = Client.new + credentials.each { |name, value| internals(client).instance_variable_set(:"@#{name}", value) } + internals(client).send(:built_authenticator, client) + end + end +end diff --git a/x-core/test/x/core/client_public_interface_test.rb b/x-core/test/x/core/client_public_interface_test.rb new file mode 100644 index 00000000..b7e0b954 --- /dev/null +++ b/x-core/test/x/core/client_public_interface_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client answers its settings, and nothing it reads them with, so the gems that extend it, and the code that + # subclasses it, find no methods of Forwardable on the class + class ClientPublicInterfaceTest < Minitest::Test + cover_client + + def test_the_class_offers_no_way_to_define_delegators + %i[def_delegator def_delegators delegate def_instance_delegator def_instance_delegators instance_delegate].each do |name| + refute_respond_to Client, name + end + end + + def test_a_client_built_with_no_settings_takes_the_defaults + client = Client.new + + assert_equal [Client::DEFAULT_OPEN_TIMEOUT, Client::DEFAULT_READ_TIMEOUT, Client::DEFAULT_WRITE_TIMEOUT, Client::DEFAULT_KEEP_ALIVE_TIMEOUT], + [client.open_timeout, client.read_timeout, client.write_timeout, client.keep_alive_timeout] + assert_equal [Client::DEFAULT_MAX_REDIRECTS, Client::DEFAULT_MAX_RATE_LIMIT_RETRIES, Client::DEFAULT_MAX_RATE_LIMIT_WAIT, Client::DEFAULT_MAX_RETRIES], + [client.max_redirects, client.max_rate_limit_retries, client.max_rate_limit_wait, client.max_retries] + assert_nil client.debug_output + end + + def test_the_settings_are_the_ones_the_client_was_built_with + debug_output = StringIO.new + client = Client.new(open_timeout: 1, read_timeout: 2, write_timeout: 3, keep_alive_timeout: 4, debug_output:, + max_redirects: 5, max_rate_limit_retries: 6, max_rate_limit_wait: 7, max_retries: 8) + + assert_equal [1, 2, 3, 4, 5, 6, 7, 8], [client.open_timeout, client.read_timeout, client.write_timeout, client.keep_alive_timeout, + client.max_redirects, client.max_rate_limit_retries, client.max_rate_limit_wait, client.max_retries] + assert_same debug_output, client.debug_output + end + end +end diff --git a/x-core/test/x/core/client_rate_limit_retries_across_attempts_test.rb b/x-core/test/x/core/client_rate_limit_retries_across_attempts_test.rb new file mode 100644 index 00000000..749deef2 --- /dev/null +++ b/x-core/test/x/core/client_rate_limit_retries_across_attempts_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A request that a server error sends again is not rate limited anew on each attempt: its retries for a rate limit + # are counted across them, so max_rate_limit_retries bounds the retries of the request + class ClientRateLimitRetriesAcrossAttemptsTest < Minitest::Test + cover_client + + URL = "https://api.x.com/2/users/me" + SUCCESS = {status: 200, body: '{"data":{"id":"1"}}', headers: {"Content-Type" => "application/json"}}.freeze + UNAVAILABLE = {status: 503}.freeze + INNER_URL = "https://api.x.com/2/tweets/1" + TOKENS = {token_type: "bearer", access_token: "NEW", refresh_token: "NEW_REFRESH", expires_in: 7200}.to_json.freeze + + def setup + @rate_limit_sleeps = [] + end + + def test_rate_limit_retries_are_counted_across_the_attempts_a_server_error_sends_again + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 1) + stub_request(:get, URL).to_return(refused, UNAVAILABLE, refused, UNAVAILABLE, refused, SUCCESS) + + assert_raises(TooManyRequests) { without_sleeping(client) { client.get("users/me") } } + assert_equal 1, @rate_limit_sleeps.size + assert_requested :get, URL, times: 3 + end + + def test_a_request_within_its_rate_limit_retries_across_attempts_is_answered + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 2) + stub_request(:get, URL).to_return(refused, UNAVAILABLE, refused, SUCCESS) + + assert_equal({"data" => {"id" => "1"}}, without_sleeping(client) { client.get("users/me") }) + assert_equal 2, @rate_limit_sleeps.size + end + + def test_each_request_counts_its_rate_limit_retries_afresh + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 1) + stub_request(:get, URL).to_return(refused, SUCCESS, refused, SUCCESS) + + 2.times { without_sleeping(client) { client.get("users/me") } } + + assert_equal 2, @rate_limit_sleeps.size + assert_nil Thread.current[:x_core_rate_limit_retries] + end + + def test_a_request_on_response_sends_counts_its_own_rate_limit_retries + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 1, on_response: ->(response) { looked_up(client, response) }) + stub_request(:get, URL).to_return(refused, SUCCESS) + stub_request(:get, INNER_URL).to_return(refused, SUCCESS) + + assert_equal 1, answered_with_waits(client) + end + + def test_a_request_save_tokens_sends_counts_its_own_rate_limit_retries + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 5, max_rate_limit_retries: 1, save_tokens: ->(_) { client.get("tweets/1") }) + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status: 200, headers: {"Content-Type" => "application/json"}, body: TOKENS) + stub_request(:get, INNER_URL).to_return(refused, SUCCESS) + stub_request(:get, URL).to_return(refused, SUCCESS) + + assert_equal 1, answered_with_waits(client) + end + + private + + # Look a post up after the lookup of the user is answered, as a hook of the client may + def looked_up(client, response) + client.get("tweets/1") if response.uri.path.end_with?("me") && response.status.eql?(200) + end + + # Look the user up, and give the id of the user answered and the rate limit waits each request took apart + def answered_with_waits(client) + id = without_sleeping(client) { client.get("users/me") }.dig("data", "id").to_i + + assert_equal 2, @rate_limit_sleeps.size + id + end + + def refused + {status: 429, headers: {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => Time.now.to_i.to_s}} + end + + # Collect the rate limit waits instead of taking them, and take no wait between the attempts of a server error + def without_sleeping(client, &) + rate_limits = internals(client).instance_variable_get(:@rate_limit_handler) + retries = internals(client).instance_variable_get(:@retry_handler) + retries.stub(:sleep, nil) do + rate_limits.stub(:rand, 0.0) { rate_limits.stub(:sleep, ->(seconds) { @rate_limit_sleeps << seconds }, &) } + end + end + end +end diff --git a/x-core/test/x/core/client_rate_limit_retries_test.rb b/x-core/test/x/core/client_rate_limit_retries_test.rb new file mode 100644 index 00000000..c69199d1 --- /dev/null +++ b/x-core/test/x/core/client_rate_limit_retries_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientRateLimitRetriesTest < Minitest::Test + cover_client + + URL = "https://api.x.com/2/users/me" + SUCCESS = {status: 200, body: '{"data":{"id":"1"}}', headers: {"Content-Type" => "application/json"}}.freeze + + def setup + @sleeps = [] + @responses = [] + end + + def test_options_default_to_no_retries_and_a_15_minute_wait + client = Client.new + + assert_equal [0, 900], [client.max_rate_limit_retries, client.max_rate_limit_wait] + end + + def test_options_are_copied + client = Client.new(max_rate_limit_retries: 3, max_rate_limit_wait: 60) + + assert_equal [3, 60], [client.with.max_rate_limit_retries, client.with.max_rate_limit_wait] + copy = client.with(max_rate_limit_retries: 1, max_rate_limit_wait: 10) + + assert_equal [1, 10], [copy.max_rate_limit_retries, copy.max_rate_limit_wait] + end + + def test_a_request_is_signed_afresh_and_retried_after_the_reset + client = retrying_client(**test_oauth_credentials) + nonces = [] + stub_request(:get, URL).with { |request| nonces << nonce_of(request) }.to_return(refused, SUCCESS) + + assert_equal({"data" => {"id" => "1"}}, without_sleeping(client) { client.get("users/me") }) + assert_equal [[429, 200], [0], 2], [@responses, @sleeps, nonces.uniq.size] + end + + def test_a_request_without_retries_raises_at_once + client = Client.new + stub_request(:post, "https://api.x.com/2/tweets").to_return(refused) + + assert_raises(TooManyRequests) { without_sleeping(client) { client.post("tweets", {text: "hi"}) } } + assert_requested(:post, "https://api.x.com/2/tweets", times: 1) + assert_empty @sleeps + end + + private + + def retrying_client(**credentials) + Client.new(**credentials, max_rate_limit_retries: 1, on_response: ->(response) { @responses << response.status }) + end + + def nonce_of(request) + request.headers["Authorization"][/oauth_nonce="([^"]+)"/, 1] + end + + def refused + {status: 429, headers: {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => Time.now.to_i.to_s}} + end + + # Collect the waits instead of taking them, with the random share of each one fixed at none + def without_sleeping(client, &) + handler = internals(client).instance_variable_get(:@rate_limit_handler) + handler.stub(:rand, 0.0) { handler.stub(:sleep, ->(seconds) { @sleeps << seconds }, &) } + end + end +end diff --git a/x-core/test/x/core/client_redirect_content_type_test.rb b/x-core/test/x/core/client_redirect_content_type_test.rb new file mode 100644 index 00000000..812ac383 --- /dev/null +++ b/x-core/test/x/core/client_redirect_content_type_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientRedirectContentTypeTest < Minitest::Test + cover_client + cover Core.const_get(:RedirectHandler) + + FORM_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=utf-8" + + def setup + @client = Client.new + end + + def redirect(status, from, to, method: :post) + stub_request(method, "https://api.x.com/2/#{from}").to_return(status:, headers: {"Location" => "https://api.x.com/2/#{to}"}) + end + + def assert_requested_without_content_type(method, url) + assert_requested(method, url) { |request| request.body.to_s.empty? && request.headers.keys.none? { |name| name.casecmp?("Content-Type") } } + end + + def test_a_form_post_redirected_to_a_get_sends_no_content_type + [301, 302, 303].each do |status| + redirect(status, "old#{status}", "new#{status}") + stub_request(:get, "https://api.x.com/2/new#{status}") + @client.post("old#{status}", form: {lang: "en"}) + + assert_requested_without_content_type :get, "https://api.x.com/2/new#{status}" + end + end + + def test_a_json_post_redirected_to_a_get_sends_no_content_type + redirect(303, "old", "new") + stub_request(:get, "https://api.x.com/2/new") + @client.post("old", {text: "Hello"}) + + assert_requested_without_content_type :get, "https://api.x.com/2/new" + end + + def test_a_content_type_of_the_caller_is_dropped_whatever_its_case + redirect(303, "old", "new") + stub_request(:get, "https://api.x.com/2/new") + @client.post("old", "lang=en", headers: {"content-type" => "text/plain"}) + + assert_requested_without_content_type :get, "https://api.x.com/2/new" + end + + def test_a_content_type_of_the_caller_named_by_a_symbol_is_dropped + redirect(303, "old", "new") + stub_request(:get, "https://api.x.com/2/new") + @client.post("old", "lang=en", headers: {"Content-Type": "text/plain"}) + + assert_requested_without_content_type :get, "https://api.x.com/2/new" + end + + def test_a_form_post_redirected_with_308_keeps_its_content_type + redirect(308, "old", "new") + stub_request(:post, "https://api.x.com/2/new") + @client.post("old", form: {lang: "en"}) + + assert_requested :post, "https://api.x.com/2/new", body: "lang=en", headers: {"Content-Type" => FORM_CONTENT_TYPE} + end + + def test_a_get_redirected_again_with_307_sends_no_content_type + redirect(303, "old", "middle") + redirect(307, "middle", "new", method: :get) + stub_request(:get, "https://api.x.com/2/new") + @client.post("old", form: {lang: "en"}) + + assert_requested_without_content_type :get, "https://api.x.com/2/new" + end + end +end diff --git a/x-core/test/x/core/client_refused_authenticator_test.rb b/x-core/test/x/core/client_refused_authenticator_test.rb new file mode 100644 index 00000000..c5f42de7 --- /dev/null +++ b/x-core/test/x/core/client_refused_authenticator_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientRefusedAuthenticatorTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + @authenticator = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN, refresh_token: TEST_REFRESH_TOKEN) + end + + def refused_client(**options) + reported = [] + assert_raises(ArgumentError) do + Client.new(authenticator: @authenticator, save_tokens: ->(tokens) { reported << tokens }, proxy_url: "http://proxy.invalid:1", **options) + end + reported + end + + def test_a_client_refused_for_a_setting_leaves_the_authenticator_it_was_given_alone + reported = refused_client(max_retries: -1) + client = Client.new(authenticator: @authenticator) + @authenticator.refresh! + + assert_same internals(client).instance_variable_get(:@connection), @authenticator.send(:connection) + assert_empty reported + end + + def test_a_client_refused_for_its_base_url_leaves_the_authenticator_it_was_given_alone + reported = refused_client(base_url: "api.x.com/2/") + Client.new(authenticator: @authenticator) + @authenticator.refresh! + + assert_empty reported + end + + def test_a_copy_refused_for_a_setting_leaves_the_authenticator_it_shares_alone + client = Client.new(authenticator: @authenticator) + reported = [] + assert_raises(ArgumentError) { client.with(save_tokens: ->(tokens) { reported << tokens }, max_redirects: -1) } + @authenticator.refresh! + + assert_empty reported + end + end +end diff --git a/test/x/client_request_test.rb b/x-core/test/x/core/client_request_test.rb similarity index 50% rename from test/x/client_request_test.rb rename to x-core/test/x/core/client_request_test.rb index b4136ee5..600380b0 100644 --- a/test/x/client_request_test.rb +++ b/x-core/test/x/core/client_request_test.rb @@ -1,31 +1,35 @@ -require_relative "../test_helper" +# frozen_string_literal: true + +require "json" +require "ostruct" +require_relative "../../test_helper" module X class ClientRequestTest < Minitest::Test - cover Client + cover_client def setup @client = Client.new end - X::RequestBuilder::HTTP_METHODS.each_key do |http_method| + X::Core.const_get(:RequestBuilder)::HTTP_METHODS.each_key do |http_method| define_method :"test_#{http_method}_request" do - stub_request(http_method, "https://api.twitter.com/2/tweets") + stub_request(http_method, "https://api.x.com/2/tweets") @client.public_send(http_method, "tweets") - assert_requested http_method, "https://api.twitter.com/2/tweets" + assert_requested http_method, "https://api.x.com/2/tweets" end define_method :"test_#{http_method}_request_with_headers" do headers = {"User-Agent" => "Custom User Agent"} - stub_request(http_method, "https://api.twitter.com/2/tweets") + stub_request(http_method, "https://api.x.com/2/tweets") @client.public_send(http_method, "tweets", headers:) - assert_requested http_method, "https://api.twitter.com/2/tweets", headers: + assert_requested http_method, "https://api.x.com/2/tweets", headers: end define_method :"test_#{http_method}_request_with_custom_response_objects" do - stub_request(http_method, "https://api.twitter.com/2/tweets") + stub_request(http_method, "https://api.x.com/2/tweets") .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) ostruct = @client.public_send(http_method, "tweets", object_class: OpenStruct, array_class: Set) @@ -33,7 +37,7 @@ def setup end define_method :"test_#{http_method}_request_with_custom_response_objects_client_configuration" do - stub_request(http_method, "https://api.twitter.com/2/tweets") + stub_request(http_method, "https://api.x.com/2/tweets") .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) client = Client.new(default_object_class: OpenStruct, default_array_class: Set) ostruct = client.public_send(http_method, "tweets") @@ -43,10 +47,10 @@ def setup end def test_execute_request_with_custom_response_objects_client_configuration - stub_request(:get, "https://api.twitter.com/2/tweets") + stub_request(:get, "https://api.x.com/2/tweets") .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) client = Client.new(default_object_class: OpenStruct, default_array_class: Set) - ostruct = client.send(:execute_request, :get, "tweets") + ostruct = client.get("tweets") assert_kind_of OpenStruct, ostruct assert_kind_of Set, ostruct.set @@ -55,63 +59,74 @@ def test_execute_request_with_custom_response_objects_client_configuration def test_redirect_handler_preserves_authentication client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_redirects: 5) - stub_request(:get, "https://api.twitter.com/old_endpoint") + stub_request(:get, "https://api.x.com/2/old_endpoint") .with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) - .to_return(status: 301, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) - stub_request(:get, "https://api.twitter.com/new_endpoint") + .to_return(status: 301, headers: {"Location" => "https://api.x.com/new_endpoint"}) + stub_request(:get, "https://api.x.com/new_endpoint") .with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) - client.get("/old_endpoint") + client.get("old_endpoint") + + assert_requested :get, "https://api.x.com/2/old_endpoint" + assert_requested :get, "https://api.x.com/new_endpoint" + end + + def test_redirect_handler_preserves_custom_headers + headers = {"X-Custom" => "value"} + stub_request(:get, "https://api.x.com/2/old_endpoint") + .with(headers:) + .to_return(status: 301, headers: {"Location" => "https://api.x.com/new_endpoint"}) + stub_request(:get, "https://api.x.com/new_endpoint").with(headers:) + @client.get("old_endpoint", headers:) - assert_requested :get, "https://api.twitter.com/old_endpoint" - assert_requested :get, "https://api.twitter.com/new_endpoint" + assert_requested :get, "https://api.x.com/new_endpoint", headers: end def test_follows_301_redirect - stub_request(:get, "https://api.twitter.com/old_endpoint") - .to_return(status: 301, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) - stub_request(:get, "https://api.twitter.com/new_endpoint") - @client.get("/old_endpoint") + stub_request(:get, "https://api.x.com/2/old_endpoint") + .to_return(status: 301, headers: {"Location" => "https://api.x.com/new_endpoint"}) + stub_request(:get, "https://api.x.com/new_endpoint") + @client.get("old_endpoint") - assert_requested :get, "https://api.twitter.com/new_endpoint" + assert_requested :get, "https://api.x.com/new_endpoint" end def test_follows_302_redirect - stub_request(:get, "https://api.twitter.com/old_endpoint") - .to_return(status: 302, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) - stub_request(:get, "https://api.twitter.com/new_endpoint") - @client.get("/old_endpoint") + stub_request(:get, "https://api.x.com/2/old_endpoint") + .to_return(status: 302, headers: {"Location" => "https://api.x.com/new_endpoint"}) + stub_request(:get, "https://api.x.com/new_endpoint") + @client.get("old_endpoint") - assert_requested :get, "https://api.twitter.com/new_endpoint" + assert_requested :get, "https://api.x.com/new_endpoint" end def test_follows_307_redirect - stub_request(:post, "https://api.twitter.com/temporary_redirect") - .to_return(status: 307, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) + stub_request(:post, "https://api.x.com/2/temporary_redirect") + .to_return(status: 307, headers: {"Location" => "https://api.x.com/new_endpoint"}) body = {key: "value"}.to_json - stub_request(:post, "https://api.twitter.com/new_endpoint") + stub_request(:post, "https://api.x.com/new_endpoint") .with(body:) - @client.post("/temporary_redirect", body) + @client.post("temporary_redirect", body) - assert_requested :post, "https://api.twitter.com/new_endpoint", body: + assert_requested :post, "https://api.x.com/new_endpoint", body: end def test_follows_308_redirect - stub_request(:put, "https://api.twitter.com/temporary_redirect") - .to_return(status: 308, headers: {"Location" => "https://api.twitter.com/new_endpoint"}) + stub_request(:put, "https://api.x.com/2/temporary_redirect") + .to_return(status: 308, headers: {"Location" => "https://api.x.com/new_endpoint"}) body = {key: "value"}.to_json - stub_request(:put, "https://api.twitter.com/new_endpoint") + stub_request(:put, "https://api.x.com/new_endpoint") .with(body:) - @client.put("/temporary_redirect", body) + @client.put("temporary_redirect", body) - assert_requested :put, "https://api.twitter.com/new_endpoint", body: + assert_requested :put, "https://api.x.com/new_endpoint", body: end def test_avoids_infinite_redirect_loop - stub_request(:get, "https://api.twitter.com/infinite_loop") - .to_return(status: 302, headers: {"Location" => "https://api.twitter.com/infinite_loop"}) + stub_request(:get, "https://api.x.com/2/infinite_loop") + .to_return(status: 302, headers: {"Location" => "https://api.x.com/2/infinite_loop"}) assert_raises TooManyRedirects do - @client.get("/infinite_loop") + @client.get("infinite_loop") end end end diff --git a/x-core/test/x/core/client_response_block_test.rb b/x-core/test/x/core/client_response_block_test.rb new file mode 100644 index 00000000..be1b229b --- /dev/null +++ b/x-core/test/x/core/client_response_block_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientResponseBlockTest < Minitest::Test + cover_client + + URL = "https://api.x.com/2/users/me" + + def setup + @client = Client.new + @responses = [] + end + + def test_a_lookup_yields_the_summary_of_its_response + stub_request(:get, URL).to_return(body: '{"data":{"id":"1"}}', headers: {"x-rate-limit-limit" => "75", "x-rate-limit-remaining" => "74", "x-rate-limit-reset" => "1"}) + body = @client.get("users/me") { |response| @responses << response } + + assert_equal({"data" => {"id" => "1"}}, body) + assert_equal [[:get, URL, 200, 74]], @responses.map { |response| [response.http_method, response.uri.to_s, response.status, response.rate_limit.remaining] } + end + + def test_the_verbs_that_send_a_body_and_the_one_that_deletes_yield_their_responses + stub_request(:post, "https://api.x.com/2/tweets").to_return(body: "{}") + stub_request(:put, "https://api.x.com/2/tweets/1").to_return(body: "{}") + stub_request(:delete, "https://api.x.com/2/tweets/1").to_return(body: "{}") + collect = ->(response) { @responses << response.http_method } + @client.post("tweets", {text: "hi"}, &collect) + @client.put("tweets/1", {text: "hi"}, &collect) + @client.delete("tweets/1", &collect) + + assert_equal %i[post put delete], @responses + end + + def test_the_block_receives_a_refused_response_before_the_error + stub_request(:get, URL).to_return(status: 404, body: '{"title":"Not Found"}') + + assert_raises(NotFound) { @client.get("users/me") { |response| @responses << response.status } } + assert_equal [404], @responses + end + + def test_the_hook_of_the_client_and_the_block_of_the_request_share_one_summary + stub_request(:get, URL).to_return(body: "{}") + client = Client.new(on_response: ->(response) { @responses << [:hook, response] }) + client.get("users/me") { |response| @responses << [:block, response] } + + assert_equal %i[hook block], @responses.map(&:first) + assert_same @responses.first.last, @responses.last.last + end + + def test_a_request_the_api_failed_to_answer_yields_every_attempt + stub_request(:get, URL).to_return({status: 503}, {status: 200, body: "{}"}) + client = Client.new(max_retries: 1) + handler = internals(client).instance_variable_get(:@retry_handler) + handler.stub(:sleep, nil) { client.get("users/me") { |response| @responses << response.status } } + + assert_equal [503, 200], @responses + end + + # A request with nothing to report to is the common one, so it summarizes no response at all + def test_a_request_with_neither_a_block_nor_a_hook_summarizes_nothing + stub_request(:get, URL).to_return(body: "{}") + + Response.stub(:new, ->(*) { flunk "summarized a response with nothing to pass it to" }) { @client.get("users/me") } + end + end +end diff --git a/x-core/test/x/core/client_response_builder_test.rb b/x-core/test/x/core/client_response_builder_test.rb new file mode 100644 index 00000000..2b594f46 --- /dev/null +++ b/x-core/test/x/core/client_response_builder_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientResponseBuilderTest < Minitest::Test + cover_client + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + X::Core.const_get(:RequestBuilder)::HTTP_METHODS.each_key do |http_method| + define_method :"test_#{http_method}_request_passes_itself_to_a_response_builder" do + stub_request(http_method, "https://api.x.com/2/tweets") + .to_return(body: '{"data": {"id": "1"}}', headers: {"Content-Type" => "application/json"}) + built = @client.public_send(http_method, "tweets", object_class: ResponseBuilder) + + assert_equal({"data" => {"id" => "1"}}, built[:body]) + assert_same @client, built[:client] + end + end + end +end diff --git a/x-core/test/x/core/client_retries_test.rb b/x-core/test/x/core/client_retries_test.rb new file mode 100644 index 00000000..5aafb531 --- /dev/null +++ b/x-core/test/x/core/client_retries_test.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientRetriesTest < Minitest::Test + cover_client + + URL = "https://api.x.com/2/users/me" + SUCCESS = {status: 200, body: '{"data":{"id":"1"}}', headers: {"Content-Type" => "application/json"}}.freeze + + def setup + @sleeps = [] + @responses = [] + end + + def test_a_client_sends_an_idempotent_request_twice_more_by_default + assert_equal 2, Client.new.max_retries + end + + def test_a_lookup_the_api_fails_to_answer_is_sent_twice_more_by_default + client = Client.new(on_response: ->(response) { @responses << response.status }) + stub_request(:get, URL).to_return(status: 503) + + assert_raises(ServiceUnavailable) { without_sleeping(client) { client.get("users/me") } } + assert_equal [[503, 503, 503], [1, 2]], [@responses, @sleeps] + end + + def test_the_option_is_copied_and_can_be_replaced + client = Client.new(max_retries: 4) + + assert_equal [4, 1], [client.with.max_retries, client.with(max_retries: 1).max_retries] + end + + def test_a_lookup_is_sent_again_after_the_api_fails_to_answer + client = retrying_client + stub_request(:get, URL).to_return({status: 503}, SUCCESS) + + assert_equal({"data" => {"id" => "1"}}, without_sleeping(client) { client.get("users/me") }) + assert_equal [[503, 200], [1]], [@responses, @sleeps] + end + + def test_a_lookup_is_sent_again_after_a_network_error_that_kept_it_from_the_api + client = retrying_client + stub_request(:get, URL).to_raise(Errno::ECONNREFUSED).then.to_return(SUCCESS) + + assert_equal({"data" => {"id" => "1"}}, without_sleeping(client) { client.get("users/me") }) + assert_equal [1], @sleeps + end + + def test_a_lookup_that_timed_out_reading_its_answer_is_not_sent_again + client = retrying_client + stub_request(:get, URL).to_raise(Net::ReadTimeout).then.to_return(SUCCESS) + + assert_raises(NetworkError) { without_sleeping(client) { client.get("users/me") } } + assert_requested :get, URL, times: 1 + end + + def test_a_lookup_is_signed_afresh_each_time + client = retrying_client(**test_oauth_credentials) + nonces = [] + stub_request(:get, URL).with { |request| nonces << request.headers["Authorization"][/oauth_nonce="([^"]+)"/, 1] }.to_return({status: 500}, SUCCESS) + without_sleeping(client) { client.get("users/me") } + + assert_equal 2, nonces.uniq.size + end + + def test_a_lookup_is_sent_again_after_the_token_endpoint_fails_to_answer_a_refresh + client = retrying_client(**test_oauth2_credentials, expires_at: Time.now - 60) + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return({status: 503}, {status: 200, body: {access_token: "NEW_ACCESS_TOKEN", expires_in: 7200}.to_json}) + stub_request(:get, URL).with(headers: {"Authorization" => "Bearer NEW_ACCESS_TOKEN"}).to_return(SUCCESS) + + assert_equal({"data" => {"id" => "1"}}, without_sleeping(client) { client.get("users/me") }) + assert_equal [[200], [1]], [@responses, @sleeps] + end + + def test_a_post_is_not_sent_again + client = retrying_client + stub_request(:post, "https://api.x.com/2/tweets").to_return(status: 503) + + assert_raises(ServiceUnavailable) { without_sleeping(client) { client.post("tweets", {text: "hi"}) } } + assert_requested(:post, "https://api.x.com/2/tweets", times: 1) + assert_empty @sleeps + end + + def test_a_refusal_the_request_is_the_reason_for_is_raised_at_once + client = retrying_client + stub_request(:get, URL).to_return(status: 404) + + assert_raises(NotFound) { without_sleeping(client) { client.get("users/me") } } + assert_requested(:get, URL, times: 1) + end + + def test_the_error_is_raised_once_the_retries_run_out + client = retrying_client + stub_request(:get, URL).to_return(status: 502) + + assert_raises(BadGateway) { without_sleeping(client) { client.get("users/me") } } + assert_equal [[502, 502], [1]], [@responses, @sleeps] + end + + private + + def retrying_client(**credentials) + Client.new(**credentials, max_retries: 1, on_response: ->(response) { @responses << response.status }) + end + + # Collect the waits instead of taking them, with the random share of each one fixed at none + def without_sleeping(client, &) + handler = internals(client).instance_variable_get(:@retry_handler) + handler.stub(:rand, 0.0) { handler.stub(:sleep, ->(seconds) { @sleeps << seconds }, &) } + end + end +end diff --git a/x-core/test/x/core/client_scopes_refresh_test.rb b/x-core/test/x/core/client_scopes_refresh_test.rb new file mode 100644 index 00000000..23c485ce --- /dev/null +++ b/x-core/test/x/core/client_scopes_refresh_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A refresh takes the scopes X names of the token it issued, and keeps those it held when X names none, as OAuth 2.0 + # has it, and the tokens a refresh takes from the store bring their own + class ClientScopesRefreshTest < Minitest::Test + cover_client + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + TOKEN_URL = "https://api.x.com/2/oauth2/token" + SCOPES = %w[tweet.read users.read offline.access].freeze + + def stub_refresh(**body) + stub_request(:post, TOKEN_URL).to_return(status: 200, body: {access_token: "NEW_ACCESS", refresh_token: "NEW_REFRESH", **body}.to_json) + end + + def refreshed_scopes(**body) + stub_refresh(**body) + Client.new(**test_oauth2_credentials, scopes: SCOPES).tap { |client| client.authenticator.refresh! }.scopes + end + + def test_a_refresh_takes_the_scopes_x_names + scopes = refreshed_scopes(scope: "tweet.read users.read") + + assert_equal [%w[tweet.read users.read], true, true], [scopes, scopes.frozen?, scopes.first.frozen?] + end + + def test_a_refresh_that_names_no_scopes_keeps_those_held + [{}, {scope: ""}, {scope: " "}, {scope: %w[tweet.read]}].each do |body| + assert_equal SCOPES, refreshed_scopes(**body), body.inspect + end + end + + def test_save_tokens_is_passed_the_scopes_of_a_refresh + stub_refresh(scope: "tweet.read") + saved = [] + Client.new(**test_oauth2_credentials, scopes: SCOPES, save_tokens: ->(tokens) { saved << tokens.scopes }).authenticator.refresh! + + assert_equal [%w[tweet.read]], saved + end + + def test_stored_tokens_taken_in_place_of_a_refresh_bring_their_scopes + stored = OAuth2Tokens.new(access_token: "STORED_ACCESS", refresh_token: "STORED_REFRESH", expires_at: Time.now + 3600, scopes: %w[users.read]) + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, scopes: SCOPES, expires_at: Time.now - 1, load_tokens: -> { stored }) + authenticator.headers(nil) + + assert_equal %w[users.read], authenticator.scopes + end + end +end diff --git a/x-core/test/x/core/client_scopes_test.rb b/x-core/test/x/core/client_scopes_test.rb new file mode 100644 index 00000000..79479c7c --- /dev/null +++ b/x-core/test/x/core/client_scopes_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The scopes X granted an OAuth 2.0 access token are held beside it, by the tokens, the authenticator, and the + # client, and are refused, as an expiration time is, by a client that would leave them unused + class ClientScopesTest < Minitest::Test + cover_client + cover OAuth2Authenticator + cover Core.const_get(:CredentialValidator) + + SCOPES = %w[tweet.read users.read offline.access].freeze + UNUSED_SCOPES = "scopes are the scopes X granted an OAuth 2.0 access token, so they are given beside the " \ + "client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \ + "bearer_token, an api_key and api_key_secret, or none, which would leave them unused. Leave them out" + + def test_a_client_holds_the_scopes_it_was_given + client = Client.new(**test_oauth2_credentials, scopes: SCOPES) + + assert_equal [SCOPES, true], [client.scopes, client.scopes.frozen?] + assert_equal SCOPES, client.authenticator.scopes + end + + def test_the_scopes_held_are_frozen_apart_from_those_given + given = [+"tweet.read"] + client = Client.new(**test_oauth2_credentials, scopes: given) + given.first << ".x" + given << "users.read" + + assert_equal [%w[tweet.read], true, true], [client.scopes, client.scopes.frozen?, client.scopes.first.frozen?] + end + + def test_scopes_of_a_subclass_of_array_are_held_as_an_array + scopes = Client.new(**test_oauth2_credentials, scopes: Class.new(Array).new(%w[tweet.read])).scopes + + assert_equal [Array, %w[tweet.read]], [scopes.class, scopes] + end + + def test_scopes_of_a_subclass_of_string_are_accepted + assert_equal %w[tweet.read], Client.new(**test_oauth2_credentials, scopes: [Class.new(String).new("tweet.read")]).scopes + end + + def test_a_client_built_of_tokens_holds_their_scopes + tokens = OAuth2Tokens.new(access_token: TEST_ACCESS_TOKEN, refresh_token: TEST_REFRESH_TOKEN, scopes: SCOPES) + + assert_equal SCOPES, Client.new(client_id: TEST_CLIENT_ID, **tokens.to_h).scopes + assert_equal SCOPES, OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, **tokens.to_h).scopes + end + + def test_scopes_default_to_nil + assert_nil Client.new(**test_oauth2_credentials).scopes + assert_nil Client.new(bearer_token: TEST_BEARER_TOKEN).scopes + end + + def test_scopes_are_refused_beside_credentials_other_than_those_of_oauth2 + [{}, {bearer_token: TEST_BEARER_TOKEN}, test_oauth_credentials, test_oauth_credentials.slice(:api_key, :api_key_secret)].each do |credentials| + assert_equal UNUSED_SCOPES, assert_raises(ArgumentError) { Client.new(**credentials, scopes: SCOPES) }.message + end + end + + def test_scopes_are_refused_beside_an_authenticator + assert_raises(ArgumentError) { Client.new(authenticator: OAuth2Authenticator.new(**test_oauth2_credentials), scopes: SCOPES) } + end + + def test_a_copy_of_a_client_given_an_authenticator_is_refused_scopes_beside_it + client = Client.new(authenticator: OAuth2Authenticator.new(**test_oauth2_credentials)) + error = assert_raises(ArgumentError) { client.with(scopes: SCOPES) } + + assert_match(/cannot be given beside scopes\./, error.message) + end + + def test_scopes_that_are_not_an_array_of_scopes_are_refused + ["tweet.read", ["tweet.read", nil], ["tweet.read", "tweet read"], [""], [:"tweet.read"], ['tweet"read'], ["tweet\\read"]].each do |scopes| + assert_raises(ArgumentError, scopes.inspect) { Client.new(**test_oauth2_credentials, scopes:) } + assert_raises(ArgumentError, scopes.inspect) { OAuth2Authenticator.new(**test_oauth2_credentials, scopes:) } + end + end + + def test_scopes_that_are_not_an_array_of_scopes_are_refused_as_such_whatever_the_credentials + error = assert_raises(ArgumentError) { Client.new(bearer_token: TEST_BEARER_TOKEN, scopes: "tweet.read") } + + assert_equal "scopes must be an Array of Strings that each name a scope, such as %w[tweet.read users.read], " \ + "or nil if they are not known", error.message + end + + def test_a_copy_keeps_the_scopes_of_the_authenticator_it_shares + client = Client.new(**test_oauth2_credentials, scopes: SCOPES) + + assert_equal SCOPES, client.with(read_timeout: 5).scopes + end + + def test_a_copy_that_shares_the_authenticator_is_refused_scopes + client = Client.new(**test_oauth2_credentials, scopes: SCOPES) + error = assert_raises(ArgumentError) { client.with(scopes: %w[tweet.read]) } + + assert_equal "A copy that shares the access token of the client shares its expiration time and scopes, so it " \ + "cannot be given scopes. Pass it beside the access token and refresh token it belongs to", error.message + assert_match(/cannot be given expires_at or scopes\./, assert_raises(ArgumentError) { client.with(expires_at: nil, scopes: nil) }.message) + end + + def test_a_copy_given_other_tokens_is_given_their_scopes + client = Client.new(**test_oauth2_credentials, scopes: SCOPES) + + assert_equal %w[tweet.read], client.with(access_token: "OTHER", refresh_token: "OTHER_REFRESH", scopes: %w[tweet.read]).scopes + end + end +end diff --git a/x-core/test/x/core/client_settings_frozen_test.rb b/x-core/test/x/core/client_settings_frozen_test.rb new file mode 100644 index 00000000..aafdd86a --- /dev/null +++ b/x-core/test/x/core/client_settings_frozen_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client keeps a copy of the base URL and proxy URL it was given, so neither it nor its caller can change them + class ClientSettingsFrozenTest < Minitest::Test + cover_client + + def test_the_base_url_a_client_reads_is_frozen_and_its_own + base_url = +"https://api.x.com/2/" + client = Client.new(bearer_token: TEST_BEARER_TOKEN, base_url:) + base_url.replace("https://evil.example/2/") + + assert_equal "https://api.x.com/2/", client.base_url + assert_predicate client.base_url, :frozen? + assert_predicate Client.new(base_url: +"https://api.x.com/2").base_url, :frozen? + end + + def test_a_proxy_url_given_as_a_uri_a_caller_changes_later_leaves_the_proxy_of_a_copy_as_it_was + uri = URI("http://proxy.example.com:8080") + client = Client.new(proxy_url: uri) + uri.host = "elsewhere.example" + connection = internals(client.with(read_timeout: 1)).instance_variable_get(:@connection) + + assert_equal "proxy.example.com", connection.send(:build_http_client, URI("https://api.x.com/2/")).proxy_address + end + end +end diff --git a/x-core/test/x/core/client_settings_validation_test.rb b/x-core/test/x/core/client_settings_validation_test.rb new file mode 100644 index 00000000..63efae5d --- /dev/null +++ b/x-core/test/x/core/client_settings_validation_test.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientSettingsValidationTest < Minitest::Test + cover_client + cover Core.const_get(:SettingValidator) + cover Core.const_get(:RedirectHandler) + cover Core.const_get(:RateLimitHandler) + cover Core.const_get(:RetryHandler) + + def message_of(&) = assert_raises(ArgumentError, &).message + + def test_a_count_that_is_not_an_integer_of_at_least_zero_is_refused_when_the_client_is_built + %i[max_redirects max_rate_limit_retries max_retries].each do |name| + messages = ["3", nil, 1.0, -1].map { |value| message_of { Client.new(name => value) } } + + assert_equal ['"3"', "nil", "1.0", "-1"].map { |value| "#{name} must be an Integer of at least 0, not #{value}" }, messages + end + end + + def test_a_base_url_that_is_not_an_absolute_http_url_is_refused_when_the_client_is_built + values = ["api.x.com/2/", "ftp://api.x.com/2/", "https://", "https:api.x.com", "https://api x.com/", "", nil, URI("https://api.x.com/2/"), + "https://api.x.com/2?x=1", "https://api.x.com/2/#top", "https://api.x.com/2/?"] + messages = values.map { |base_url| message_of { Client.new(base_url:) } } + + assert_equal values.map { |value| "base_url must be an absolute http or https URL with no query or fragment, such as \"https://api.x.com/2/\", not #{value.inspect}" }, messages + assert_raises(ArgumentError) { Client.new.with(base_url: "api.x.com") } + end + + def test_a_base_url_that_holds_a_user_or_a_password_is_refused_without_revealing_it + values = %w[https://user:SECRET@api.x.com/2/ https://SECRET@api.x.com/2/ https://@api.x.com/2/ http://user:SECRET@localhost:3000/2/] + messages = values.map { |base_url| message_of { Client.new(base_url:) } } + + assert_equal ["base_url must hold no user or password, which no request sends"] * values.size, messages + assert_equal messages.first, message_of { Client.new(base_url: Class.new(String).new(values.first)) } + assert_raises(ArgumentError) { Client.new.with(base_url: "https://user:SECRET@api.x.com/2/") } + end + + def test_a_base_url_with_an_at_sign_after_its_host_is_not_read_as_holding_a_user + assert_equal "https://api.x.com/2/@x/", Client.new(base_url: "https://api.x.com/2/@x/").base_url + %w[api.x.com/@x //user@api.x.com/2/ https://api.x.com/2/?at=@x https://api.x.com/2/#@x].each do |base_url| + assert_includes message_of { Client.new(base_url:) }, "not #{base_url.inspect}" + end + end + + def test_a_base_url_that_is_an_absolute_http_or_https_url_is_allowed + assert_equal %w[https://api.x.com/1.1/ http://localhost:3000/2/], %w[https://api.x.com/1.1/ http://localhost:3000/2].map { |base_url| Client.new(base_url:).base_url } + assert_equal "https://api.x.com/2/", Client.new(base_url: Class.new(String).new("https://api.x.com/2/")).base_url + end + + def test_headers_that_are_not_a_hash_are_refused_without_revealing_them + assert_equal "headers must be a Hash of header names to values, not a NilClass", message_of { Client.new(headers: nil) } + assert_equal "headers must be a Hash of header names to values, not a Array", message_of { Client.new(headers: [%w[Authorization SECRET]]) } + end + + def test_a_header_whose_name_or_value_is_not_what_a_header_takes_is_refused_without_revealing_its_value + assert_equal "headers must name each header with a String or a Symbol and give it a String, not \"X-Count\" with a Integer", message_of { Client.new(headers: {"X-Count" => 1}) } + assert_equal "headers must name each header with a String or a Symbol and give it a String, not :authorization with a NilClass", message_of { Client.new(headers: {authorization: nil}) } + assert_equal "headers must name each header with a String or a Symbol and give it a String, not a Integer with a String", message_of { Client.new(headers: {1 => "SECRET"}) } + assert_raises(ArgumentError) { Client.new.with(headers: {"X-Count" => 1}) } + end + + def test_headers_named_by_a_string_or_a_symbol_are_allowed + assert_equal({"User-Agent" => "MyApp/1.0"}, Client.new(headers: {"User-Agent" => "MyApp/1.0"}).headers) + assert_equal({"accept" => "application/json"}, Client.new(headers: {accept: "application/json"}).headers) + end + + def test_headers_named_by_a_symbol_are_read_by_a_string + client = Client.new(headers: {"User-Agent": "MyApp/1.0", "X-Trace": "abc"}) + + assert_equal "MyApp/1.0", client.headers["User-Agent"] + assert_equal({"User-Agent" => "MyApp/1.0", "X-Trace" => "abc"}, client.with(max_redirects: 1).headers) + assert_predicate client.headers, :frozen? + end + + def test_a_header_of_the_client_named_by_a_symbol_is_read_by_the_name_it_is_sent_with + assert_equal({"user-agent" => "my-app/1.0", "x-trace" => "abc"}, Client.new(headers: {user_agent: "my-app/1.0", x_trace: "abc"}).headers) + end + + def test_a_header_named_by_a_string_keeps_its_underscores + assert_equal({"X_Trace" => "abc"}, Client.new(headers: {"X_Trace" => "abc"}).headers) + end + + def test_headers_of_subclasses_of_hash_and_string_are_allowed + assert_equal({"Accept" => "application/json"}, Client.new(headers: {Class.new(String).new("Accept") => "application/json"}).headers) + assert_equal({"Accept" => "application/json"}, Client.new(headers: Class.new(Hash).new.merge!("Accept" => Class.new(String).new("application/json"))).headers) + end + + def test_a_count_of_zero_is_allowed + client = Client.new(max_redirects: 0, max_rate_limit_retries: 0, max_retries: 0) + + assert_equal [0, 0, 0], [client.max_redirects, client.max_rate_limit_retries, client.max_retries] + end + + def test_a_wait_that_is_not_a_number_of_seconds_of_at_least_zero_is_refused + messages = ["900", nil, -1, Float::NAN, Complex(1, 0)].map { |value| message_of { Client.new(max_rate_limit_wait: value) } } + + assert_equal ['"900"', "nil", "-1", "NaN", "(1+0i)"].map { |value| "max_rate_limit_wait must be a number of seconds of at least 0, not #{value}" }, messages + end + + def test_a_wait_of_any_real_number_of_seconds_of_at_least_zero_is_allowed + assert_equal [0, 1.5, Float::INFINITY], [0, 1.5, Float::INFINITY].map { |value| Client.new(max_rate_limit_wait: value).max_rate_limit_wait } + end + + def test_a_copy_checks_the_settings_it_is_given + assert_equal 'max_retries must be an Integer of at least 0, not "1"', message_of { Client.new.with(max_retries: "1") } + end + + def test_hooks_that_do_not_respond_to_call_are_refused_without_revealing_them + messages = %i[on_response save_tokens].map { |name| message_of { Client.new(name => "SECRET") } } + + assert_equal %w[on_response save_tokens].map { |name| "#{name} must respond to call, as a Proc or a lambda does, or be nil, not a String" }, messages + assert_raises(ArgumentError) { Client.new.with(on_response: :log) } + end + + def test_hooks_that_respond_to_call_are_kept + on_response = ->(_) {} + save_tokens = Object.new.tap { |hook| hook.define_singleton_method(:call) { |_| nil } } + client = Client.new(on_response:, save_tokens:) + + assert_equal [on_response, save_tokens], [client.on_response, client.save_tokens] + end + + def test_the_validator_returns_what_it_checked + validator = Core.const_get(:SettingValidator) + + assert_equal [2, 900], [validator.count!(:max_retries, 2), validator.seconds!(:max_rate_limit_wait, 900)] + refute_respond_to validator, :count? + end + end +end diff --git a/x-core/test/x/core/client_shared_app_only_test.rb b/x-core/test/x/core/client_shared_app_only_test.rb new file mode 100644 index 00000000..4248abde --- /dev/null +++ b/x-core/test/x/core/client_shared_app_only_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A copy of a client that authenticates as the app with an API key and secret shares the authenticator of the + # client, and so the bearer token it fetched, unless the copy is given credentials of its own + class ClientSharedAppOnlyTest < Minitest::Test + cover_client + cover AppOnlyAuthenticator + + USERS_URL = "https://api.x.com/2/users/1" + + def setup + @token_request = stub_request(:post, APP_ONLY_TOKEN_URL) + .to_return(status: 200, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + stub_request(:get, USERS_URL).to_return(status: 200, body: "{}", headers: {"Content-Type" => "application/json"}) + @client = Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + end + + def test_a_copy_sends_the_bearer_token_the_client_fetched + @client.get("users/1") + copy = @client.with(read_timeout: 5) + copy.get("users/1") + + assert_same @client.authenticator, copy.authenticator + assert_requested @token_request, times: 1 + end + + def test_a_copy_given_the_api_key_and_secret_the_client_holds_shares_its_authenticator + assert_same @client.authenticator, @client.with(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).authenticator + end + + def test_a_copy_given_credentials_of_its_own_fetches_a_token_of_its_own + [{api_key: "OTHER_KEY"}, {api_key_secret: "OTHER_SECRET"}, {bearer_token: "OTHER_TOKEN"}].each do |options| + refute_same @client.authenticator, @client.with(**options).authenticator, options.inspect + end + end + + def test_a_copy_at_another_path_of_the_origin_shares_the_authenticator + assert_same @client.authenticator, @client.with(base_url: "https://api.x.com/1.1/").authenticator + end + + def test_a_copy_at_another_origin_fetches_a_token_of_its_own_there + @client.get("users/1") + staging_token = stub_request(:post, "https://staging.example/oauth2/token") + .to_return(status: 200, body: {token_type: "bearer", access_token: "STAGING_TOKEN"}.to_json) + stub_request(:get, "https://staging.example/2/users/1").to_return(status: 200, body: "{}") + copy = @client.with(base_url: "https://staging.example/2/") + copy.get("users/1") + + refute_same @client.authenticator, copy.authenticator + assert_requested staging_token, times: 1 + assert_requested :get, "https://staging.example/2/users/1", headers: {"Authorization" => "Bearer STAGING_TOKEN"} + end + + def test_a_copy_that_signs_as_a_user_does_not_share_it + copy = @client.with(access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) + + assert_instance_of OAuth1Authenticator, copy.authenticator + end + + def test_a_copy_of_a_client_that_signs_as_a_user_does_not_take_an_app_only_authenticator + client = Client.new(**test_oauth_credentials) + + assert_instance_of OAuth1Authenticator, client.with(read_timeout: 5).authenticator + end + end +end diff --git a/x-core/test/x/core/client_shared_authenticator_test.rb b/x-core/test/x/core/client_shared_authenticator_test.rb new file mode 100644 index 00000000..72089e4a --- /dev/null +++ b/x-core/test/x/core/client_shared_authenticator_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientSharedAuthenticatorTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + end + + def stub_users_me(access_token) + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer #{access_token}"}) + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, body: '{"data":{"id":"1"}}') + end + + def stub_token_refresh_without_a_lifetime + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + def test_a_refresh_that_reports_no_lifetime_clears_the_expiration_time_of_the_client + stub_token_refresh_without_a_lifetime + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1) + client.authenticator.refresh! + + assert_nil client.expires_at + end + + def test_a_copy_shares_the_authenticator_after_a_refresh_that_reports_no_lifetime + refresh = stub_token_refresh_without_a_lifetime + stub_users_me("NEW_ACCESS_TOKEN") + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1) + client.authenticator.refresh! + copy = client.with + copy.get("users/me") + + assert_same client.authenticator, copy.authenticator + assert_requested refresh, times: 1 + end + + def test_the_client_reads_an_empty_token_of_its_authenticator_rather_than_the_one_it_was_given + client = Client.new(**test_oauth2_credentials) + authenticator = client.authenticator + authenticator.instance_variable_set(:@access_token, nil) + authenticator.instance_variable_set(:@refresh_token, nil) + + assert_equal [nil, nil], [internals(client).send(:access_token), internals(client).send(:refresh_token)] + end + + def test_a_copy_with_another_credential_keeps_sharing_the_authenticator + client = Client.new(**test_oauth2_credentials, expires_at: Time.now + 3600) + copy = client.with(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + + assert_same copy.authenticator, client.authenticator + assert_equal [true, true], client.authenticator.__send__(:clients).then { |clients| [clients[client], clients[copy]] } + end + + def test_a_copy_with_another_oauth2_credential_builds_an_authenticator_of_its_own + %i[client_id client_secret access_token refresh_token].each do |credential| + client = Client.new(**test_oauth2_credentials) + copy = client.with(credential => "OTHER") + + refute_same client.authenticator, copy.authenticator + assert copy.authenticator.__send__(:holds?, credential => "OTHER") + end + end + + def test_a_copy_given_other_credentials_does_not_hear_of_the_refreshes_of_the_client + refreshed = [] + client = Client.new(**test_oauth2_credentials, save_tokens: ->(tokens) { refreshed << [:client, tokens.refresh_token] }) + client.with(access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN", + save_tokens: ->(tokens) { refreshed << [:copy, tokens.refresh_token] }) + client.authenticator.refresh! + + assert_equal [[:client, "NEW_REFRESH_TOKEN"]], refreshed + end + + def test_a_copy_given_other_credentials_reports_its_own_refreshes + refreshed = [] + client = Client.new(**test_oauth2_credentials, save_tokens: ->(_) { refreshed << :client }) + copy = client.with(access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN", save_tokens: ->(_) { refreshed << :copy }) + copy.authenticator.refresh! + + assert_equal [:copy], refreshed + end + + def test_a_copy_that_does_not_authenticate_with_oauth2_hears_of_no_refreshes + refreshed = [] + client = Client.new(**test_oauth2_credentials) + client.with(client_id: nil, client_secret: nil, access_token: nil, refresh_token: nil, + bearer_token: TEST_BEARER_TOKEN, save_tokens: ->(_) { refreshed << :copy }) + client.authenticator.refresh! + + assert_empty refreshed + end + end +end diff --git a/x-core/test/x/core/client_shared_expiration_test.rb b/x-core/test/x/core/client_shared_expiration_test.rb new file mode 100644 index 00000000..af3032a8 --- /dev/null +++ b/x-core/test/x/core/client_shared_expiration_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientSharedExpirationTest < Minitest::Test + cover_client + + def test_a_copy_keeps_the_expiration_time_of_the_authenticator_it_shares + expires_at = Time.now + 3600 + client = Client.new(**test_oauth2_credentials, expires_at:) + copy = client.with + + assert_equal [expires_at, expires_at], [client.expires_at, copy.expires_at] + end + + def test_a_copy_that_shares_the_authenticator_is_refused_another_expiration_time + expires_at = Time.now + 60 + client = Client.new(**test_oauth2_credentials, expires_at:) + error = assert_raises(ArgumentError) { client.with(expires_at: Time.now + 3600) } + + assert_equal "A copy that shares the access token of the client shares its expiration time and scopes, so it " \ + "cannot be given expires_at. Pass it beside the access token and refresh token it belongs to", error.message + assert_equal expires_at, client.expires_at + end + + def test_a_copy_that_shares_the_authenticator_is_refused_an_expiration_time_of_nil + expires_at = Time.now + 60 + client = Client.new(**test_oauth2_credentials, expires_at:) + + assert_raises(ArgumentError) { client.with(expires_at: nil) } + assert_equal expires_at, client.expires_at + end + + def test_a_copy_given_the_tokens_the_authenticator_holds_is_refused_an_expiration_time + client = Client.new(**test_oauth2_credentials) + + assert_raises(ArgumentError) { client.with(**test_oauth2_credentials, expires_at: Time.now + 3600) } + end + + def test_a_copy_given_tokens_of_its_own_takes_an_expiration_time + client = Client.new(**test_oauth2_credentials, expires_at: Time.now + 60) + expires_at = Time.now + 3600 + copy = client.with(access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN", expires_at:) + + refute_same client.authenticator, copy.authenticator + assert_equal expires_at, copy.expires_at + refute_equal expires_at, client.expires_at + end + end +end diff --git a/x-core/test/x/core/client_timeouts_validation_test.rb b/x-core/test/x/core/client_timeouts_validation_test.rb new file mode 100644 index 00000000..29c07370 --- /dev/null +++ b/x-core/test/x/core/client_timeouts_validation_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientTimeoutsValidationTest < Minitest::Test + cover_client + cover Core.const_get(:Connection) + cover Core.const_get(:SettingValidator) + cover OAuth2Authorization + + TIMEOUTS = %i[open_timeout read_timeout write_timeout].freeze + + def message_of(&) = assert_raises(ArgumentError, &).message + + def test_a_timeout_that_is_neither_finite_seconds_of_at_least_zero_nor_nil_is_refused_when_the_client_is_built + TIMEOUTS.each do |name| + messages = ["5", -1, Float::INFINITY, Float::NAN, Complex(1, 0)].map { |value| message_of { Client.new(name => value) } } + + assert_equal ['"5"', "-1", "Infinity", "NaN", "(1+0i)"].map { |value| "#{name} must be a finite number of seconds of at least 0, or nil for no timeout, not #{value}" }, messages + end + end + + def test_a_timeout_of_finite_seconds_of_at_least_zero_or_nil_is_allowed + TIMEOUTS.each do |name| + assert_equal [0, 1.5, Rational(1, 2), nil], [0, 1.5, Rational(1, 2), nil].map { |value| Client.new(name => value).public_send(name) } + end + end + + def test_a_keep_alive_timeout_that_is_not_finite_seconds_of_at_least_zero_is_refused + messages = ["30", nil, -1, Float::INFINITY, Float::NAN].map { |value| message_of { Client.new(keep_alive_timeout: value) } } + + assert_equal ['"30"', "nil", "-1", "Infinity", "NaN"].map { |value| "keep_alive_timeout must be a finite number of seconds of at least 0, not #{value}" }, messages + end + + def test_a_keep_alive_timeout_of_finite_seconds_of_at_least_zero_is_allowed + assert_equal [0, 2.5], [0, 2.5].map { |value| Client.new(keep_alive_timeout: value).keep_alive_timeout } + end + + def test_a_copy_checks_the_timeouts_it_is_given + assert_equal 'open_timeout must be a finite number of seconds of at least 0, or nil for no timeout, not "5"', message_of { Client.new.with(open_timeout: "5") } + end + + def test_an_authorization_checks_its_timeouts + assert_equal "write_timeout must be a finite number of seconds of at least 0, or nil for no timeout, not -1", + message_of { OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: "https://example.com/callback", write_timeout: -1) } + end + + def test_the_validator_returns_the_timeouts_it_checked + validator = Core.const_get(:SettingValidator) + + assert_equal [30, 60, nil], [validator.finite_seconds!(:keep_alive_timeout, 30), validator.timeout!(:read_timeout, 60), validator.timeout!(:read_timeout, nil)] + refute_respond_to validator, :seconds? + refute_respond_to validator, :finite_seconds? + end + end +end diff --git a/x-core/test/x/core/client_token_refresh_hook_test.rb b/x-core/test/x/core/client_token_refresh_hook_test.rb new file mode 100644 index 00000000..4abc20c5 --- /dev/null +++ b/x-core/test/x/core/client_token_refresh_hook_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientTokenRefreshHookTest < Minitest::Test + cover_client + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:RefreshReporter) + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + @refreshed = [] + end + + def test_an_authenticator_holds_no_callable_of_its_own + refute_respond_to Client.new(**test_oauth2_credentials, save_tokens: ->(_) {}).authenticator, :save_tokens + end + + def test_a_refresh_by_the_authenticator_of_a_client_reaches_the_hook_of_the_client + client = Client.new(**test_oauth2_credentials, save_tokens: ->(auth) { @refreshed << auth.access_token }) + client.authenticator.refresh! + + assert_equal ["NEW_ACCESS_TOKEN"], @refreshed + end + + def test_an_authenticator_that_reports_to_nothing_refreshes + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_equal "NEW_REFRESH_TOKEN", authenticator.refresh!.refresh_token + end + + def test_an_authenticator_passes_a_refresh_to_each_callable_it_reports_to_in_order + oauth2_authenticator_reporting_to(->(_) { @refreshed << :first }, ->(tokens) { @refreshed << tokens.refresh_token }).refresh! + + assert_equal [:first, "NEW_REFRESH_TOKEN"], @refreshed + end + + def test_what_an_authenticator_reports_to_is_private + refute_respond_to OAuth2Authenticator.new(**test_oauth2_credentials), :report_refreshes_to + end + end +end diff --git a/x-core/test/x/core/client_token_refresh_origin_test.rb b/x-core/test/x/core/client_token_refresh_origin_test.rb new file mode 100644 index 00000000..13a2d1f9 --- /dev/null +++ b/x-core/test/x/core/client_token_refresh_origin_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientTokenRefreshOriginTest < Minitest::Test + cover_client + + USERS_ME = "https://api.x.com/2/users/me" + + def setup + @refresh = stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + def test_a_rejection_by_another_origin_refreshes_nothing + stub_request(:get, "https://other.example.com/users/me").to_return(status: 401) + client = Client.new(**test_oauth2_credentials) + + assert_raises(Unauthorized) { client.get("https://other.example.com/users/me") } + assert_not_requested @refresh + assert_equal TEST_ACCESS_TOKEN, client.authenticator.__send__(:access_token) + end + + def test_a_rejection_after_a_redirect_to_another_origin_refreshes_nothing + stub_request(:get, USERS_ME).to_return(status: 302, headers: {"Location" => "https://other.example.com/users/me"}) + stub_request(:get, "https://other.example.com/users/me").to_return(status: 401) + client = Client.new(**test_oauth2_credentials) + + assert_raises(Unauthorized) { client.get("users/me") } + assert_not_requested @refresh + assert_requested :get, USERS_ME, times: 1 + end + end +end diff --git a/x-core/test/x/core/client_token_refresh_test.rb b/x-core/test/x/core/client_token_refresh_test.rb new file mode 100644 index 00000000..43db1af3 --- /dev/null +++ b/x-core/test/x/core/client_token_refresh_test.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientTokenRefreshTest < Minitest::Test + cover_client + + USERS_ME = "https://api.x.com/2/users/me" + + def setup + @refresh = stub_token_refresh("NEW_ACCESS_TOKEN", "NEW_REFRESH_TOKEN") + end + + def stub_token_refresh(access_token, refresh_token) + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token:, refresh_token:, expires_in: 7200}.to_json) + end + + def stub_users_me(access_token, status: 200) + stub_request(:get, USERS_ME).with(headers: {"Authorization" => "Bearer #{access_token}"}) + .to_return(status:, headers: {"Content-Type" => "application/json"}, body: '{"data":{"id":"1"}}') + end + + def test_a_request_refreshes_an_expired_token_first + stub_users_me("NEW_ACCESS_TOKEN") + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1) + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_requested @refresh, times: 1 + end + + def test_a_request_leaves_an_unexpired_token_alone + stub_users_me(TEST_ACCESS_TOKEN) + Client.new(**test_oauth2_credentials, expires_at: Time.now + 3600).get("users/me") + + assert_not_requested @refresh + end + + def test_a_rejected_token_is_refreshed_and_the_request_sent_again + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + stub_users_me("NEW_ACCESS_TOKEN") + client = Client.new(**test_oauth2_credentials) + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_requested @refresh, times: 1 + end + + def test_a_request_rejected_after_a_refresh_raises + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + stub_users_me("NEW_ACCESS_TOKEN", status: 401) + client = Client.new(**test_oauth2_credentials) + + assert_raises(Unauthorized) { client.get("users/me") } + assert_requested @refresh, times: 1 + end + + def test_a_refused_refresh_of_a_rejected_token_raises_an_authorization_error + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 400, body: {error: "invalid_request", error_description: "Value passed for the token was invalid."}.to_json) + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + error = assert_raises(AuthorizationError) { Client.new(**test_oauth2_credentials).get("users/me") } + + assert_equal ["POST /2/oauth2/token: Value passed for the token was invalid.", "invalid_request", 400, Unauthorized], [error.message, error.error_code, error.status, error.cause.class] + end + + def test_a_refresh_that_returns_the_rejected_token_raises_without_sending_again + stub_token_refresh(TEST_ACCESS_TOKEN, "NEW_REFRESH_TOKEN") + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + client = Client.new(**test_oauth2_credentials) + + assert_raises(Unauthorized) { client.get("users/me") } + assert_requested :get, USERS_ME, times: 1 + end + + def test_a_rejected_token_already_replaced_is_not_refreshed_again + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + stub_users_me("REPLACED") + authenticator = nil #: OAuth2Authenticator? + client = Client.new(**test_oauth2_credentials, on_response: ->(_) { authenticator&.instance_variable_set(:@access_token, "REPLACED") }) + authenticator = client.authenticator + authenticator.stub(:headers, ->(_) { {"Authorization" => "Bearer #{authenticator.__send__(:access_token)}"} }) do + client.get("users/me") + end + + assert_not_requested @refresh + end + + def test_a_subclass_of_the_oauth2_authenticator_refreshes_a_rejected_token + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + stub_users_me("NEW_ACCESS_TOKEN") + client = Client.new(**test_oauth2_credentials) + internals(client).instance_variable_set(:@authenticator, Class.new(OAuth2Authenticator).new(**test_oauth2_credentials)) + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_equal "NEW_REFRESH_TOKEN", internals(client).send(:refresh_token) + end + + def test_other_clients_do_not_refresh_on_unauthorized + stub_request(:get, USERS_ME).to_return(status: 401) + + [Client.new(bearer_token: TEST_BEARER_TOKEN), Client.new(**test_oauth_credentials)].each do |client| + assert_raises(Unauthorized) { client.get("users/me") } + end + assert_requested :get, USERS_ME, times: 2 + end + + def test_the_client_reads_the_tokens_of_the_last_refresh + client = Client.new(**test_oauth2_credentials) + client.authenticator.refresh! + + assert_equal ["NEW_ACCESS_TOKEN", "NEW_REFRESH_TOKEN"], [internals(client).send(:access_token), internals(client).send(:refresh_token)] + assert_in_delta Time.now + 7200, client.expires_at, 5 + end + + def test_save_tokens_receives_the_tokens + refreshed = [] + client = Client.new(**test_oauth2_credentials, save_tokens: ->(tokens) { refreshed << tokens }) + client.authenticator.refresh! + + assert_equal [OAuth2Tokens.new(access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_at: client.expires_at)], refreshed + end + + def test_save_tokens_is_optional_and_a_copy_can_add_one + client = Client.new(**test_oauth2_credentials) + client.authenticator.refresh! + refreshed = [] + copy = client.with(save_tokens: ->(tokens) { refreshed << tokens.refresh_token }) + stub_token_refresh("NEWER_ACCESS_TOKEN", "NEWER_REFRESH_TOKEN") + copy.authenticator.refresh! + + assert_equal ["NEWER_REFRESH_TOKEN"], refreshed + end + end + + class ClientTokenRefreshCredentialsTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + def test_expires_at_reaches_the_authenticator + expires_at = Time.now + 60 + + assert_equal expires_at, Client.new(**test_oauth2_credentials, expires_at:).authenticator.expires_at + end + + def test_a_copy_with_another_credential_keeps_the_refreshed_access_token_and_none_of_the_refresh_token + client = Client.new(**test_oauth2_credentials) + client.authenticator.refresh! + copy = client.with(client_secret: "NEW_CLIENT_SECRET") + + assert_equal ["NEW_ACCESS_TOKEN", nil], [copy.authenticator.__send__(:access_token), copy.authenticator.__send__(:refresh_token)] + refute_same client.authenticator, copy.authenticator + end + + def test_a_copy_given_a_token_holds_none_of_the_refresh_token_of_the_client + client = Client.new(**test_oauth2_credentials) + client.authenticator.refresh! + copy = client.with(access_token: "GIVEN_ACCESS_TOKEN") + + assert_equal ["GIVEN_ACCESS_TOKEN", nil], [copy.authenticator.__send__(:access_token), copy.authenticator.__send__(:refresh_token)] + end + + def test_a_copy_given_a_token_and_a_refresh_token_holds_both + copy = Client.new(**test_oauth2_credentials).with(access_token: "GIVEN_ACCESS_TOKEN", refresh_token: "GIVEN_REFRESH_TOKEN") + + assert_equal %w[GIVEN_ACCESS_TOKEN GIVEN_REFRESH_TOKEN], [copy.authenticator.__send__(:access_token), copy.authenticator.__send__(:refresh_token)] + end + + def test_an_oauth1_copy_signs_with_a_new_access_token + copy = Client.new(**test_oauth_credentials).with(access_token: "GIVEN_ACCESS_TOKEN") + + assert_equal "GIVEN_ACCESS_TOKEN", copy.authenticator.__send__(:access_token) + end + + def test_a_copy_shares_the_authenticator_so_a_refresh_reaches_both + client = Client.new(**test_oauth2_credentials, expires_at: Time.now + 3600) + copy = client.with(base_url: "https://api.x.com/1.1/") + copy.authenticator.refresh! + + assert_same client.authenticator, copy.authenticator + assert_equal %w[NEW_REFRESH_TOKEN NEW_REFRESH_TOKEN], [internals(client).send(:refresh_token), internals(copy).send(:refresh_token)] + end + + def test_a_refresh_calls_the_hook_of_each_client_that_shares_the_authenticator_once + refreshed = [] + hook = ->(tokens) { refreshed << [:client, tokens.refresh_token] } + client = Client.new(**test_oauth2_credentials, save_tokens: hook) + copies = [client.with(save_tokens: ->(tokens) { refreshed << [:copy, tokens.refresh_token] }), client.with] + copies.first.authenticator.refresh! + + assert_equal [[:client, "NEW_REFRESH_TOKEN"], [:copy, "NEW_REFRESH_TOKEN"]], refreshed.sort + end + + def test_a_copy_joins_the_clients_that_share_the_authenticator + client = Client.new(**test_oauth2_credentials) + copy = client.with + clients = client.authenticator.__send__(:clients) + + assert_same clients, copy.authenticator.__send__(:clients) + assert_equal [true, true], [clients[client], clients[copy]] + end + + def test_a_copy_shares_a_subclass_of_the_oauth2_authenticator + client = Client.new(**test_oauth2_credentials) + authenticator = Class.new(OAuth2Authenticator).new(**test_oauth2_credentials) + internals(client).instance_variable_set(:@authenticator, authenticator) + + assert_same authenticator, client.with.authenticator + end + + def test_a_copy_of_a_refreshed_client_shares_its_authenticator + client = Client.new(**test_oauth2_credentials) + client.authenticator.refresh! + + assert_same client.authenticator, client.with.authenticator + end + + def test_a_copy_with_other_oauth2_credentials_has_its_own_authenticator + client = Client.new(**test_oauth2_credentials) + + %i[client_id client_secret access_token refresh_token].each do |credential| + refute_same client.authenticator, client.with(credential => "OTHER").authenticator + end + end + + def test_an_oauth2_copy_of_a_client_without_oauth2_has_its_own_authenticator + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + copy = client.with(**test_oauth2_credentials, bearer_token: nil) + + assert_instance_of OAuth2Authenticator, copy.authenticator + end + + def test_a_copy_without_oauth2_shares_nothing + client = Client.new(**test_oauth2_credentials) + copy = client.with(client_id: nil, client_secret: nil, access_token: nil, refresh_token: nil, bearer_token: TEST_BEARER_TOKEN) + + assert_instance_of BearerTokenAuthenticator, copy.authenticator + oauth1 = Client.new(**test_oauth_credentials) + + refute_same oauth1.authenticator, oauth1.with.authenticator + end + end +end diff --git a/x-core/test/x/core/client_with_connection_test.rb b/x-core/test/x/core/client_with_connection_test.rb new file mode 100644 index 00000000..1ffdddd3 --- /dev/null +++ b/x-core/test/x/core/client_with_connection_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "socket" +require "stringio" +require_relative "../../test_helper" + +module X + # A copy that opens its connections as the client does shares the connections the client keeps open + class ClientWithConnectionTest < Minitest::Test + cover_client + cover Core.const_get(:Connection) + + def setup + @client = Client.new(bearer_token: "TEST_BEARER_TOKEN") + end + + def test_a_copy_that_sends_other_headers_shares_the_connections + with_keep_alive_server do |port, accepted| + client = Client.new(bearer_token: "TEST_BEARER_TOKEN", base_url: "http://127.0.0.1:#{port}/2/") + 3.times { |trace| client.with(headers: {"X-Trace" => trace.to_s}).get("users/me") } + client.get("users/me") + + assert_equal 1, accepted.size + ensure + client&.close + end + end + + def test_a_copy_that_sends_elsewhere_shares_the_connections + [{headers: {"X-Trace" => "abc"}}, {base_url: "https://api.x.com/1.1/"}, {bearer_token: "OTHER"}, {max_retries: 0}].each do |options| + assert_same pool_of(@client), pool_of(@client.with(**options)), options.inspect + end + end + + def test_a_copy_that_opens_its_connections_otherwise_keeps_its_own + [{open_timeout: 1}, {read_timeout: 1}, {write_timeout: 1}, {keep_alive_timeout: 1}, {debug_output: StringIO.new}, + {proxy_url: "http://proxy.example.com:8080"}].each do |options| + refute_same pool_of(@client), pool_of(@client.with(**options)), options.inspect + end + end + + def test_a_copy_given_the_settings_the_client_holds_shares_the_connections + output = StringIO.new + client = Client.new(bearer_token: "TEST_BEARER_TOKEN", read_timeout: 5, debug_output: output, proxy_url: "http://proxy.example.com:8080") + + assert_same pool_of(client), pool_of(client.with(read_timeout: 5.0, debug_output: output, proxy_url: "http://proxy.example.com:8080".dup)) + end + + private + + def pool_of(client) = internals(client).instance_variable_get(:@connection).__send__(:pool) + + # Serve every request on a port of the loopback, keeping each connection open, and yield the port and the + # connections accepted + def with_keep_alive_server + server = TCPServer.new("127.0.0.1", 0) + accepted = [] + thread = Thread.new { loop { accepted << serve(server.accept) } } + WebMock.allow_net_connect! + yield server.addr[1], accepted + ensure + WebMock.disable_net_connect! + thread&.kill + accepted&.each(&:kill) + server&.close + end + + # Answer each request a connection sends with an empty JSON object, until it closes + def serve(socket) + Thread.new do + while socket.gets + nil until socket.gets.eql?("\r\n") + socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}") + end + rescue IOError, SystemCallError + nil + ensure + socket.close + end + end + end +end diff --git a/x-core/test/x/core/client_with_refresh_origin_test.rb b/x-core/test/x/core/client_with_refresh_origin_test.rb new file mode 100644 index 00000000..22c8aa35 --- /dev/null +++ b/x-core/test/x/core/client_with_refresh_origin_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A copy that shares the OAuth 2.0 authenticator of its client but is pointed at another origin refreshes at the + # token endpoint of that origin, which its requests go to, rather than at the one of the client + class ClientWithRefreshOriginTest < Minitest::Test + cover_client + cover Core.const_get(:OAuth2Refresh) + cover OAuth2Authenticator + + TOKENS = {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.freeze + + def setup + @refreshes = %w[https://api.x.com/2/oauth2/token http://localhost:4000/2/oauth2/token].to_h do |url| + [url, stub_request(:post, url).to_return(status: 200, headers: {"Content-Type" => "application/json"}, body: TOKENS.to_json)] + end + end + + def test_a_copy_pointed_at_another_origin_refreshes_there + copy = expired_client.with(base_url: "http://localhost:4000/2/") + stub_request(:get, "http://localhost:4000/2/users/me").to_return(status: 200, body: "{}") + copy.get("users/me") + + assert_requested @refreshes.fetch("http://localhost:4000/2/oauth2/token") + assert_not_requested @refreshes.fetch("https://api.x.com/2/oauth2/token") + end + + def test_the_client_a_copy_was_made_from_refreshes_at_its_own_origin + client = expired_client + client.with(base_url: "http://localhost:4000/2/") + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + client.get("users/me") + + assert_requested @refreshes.fetch("https://api.x.com/2/oauth2/token") + assert_not_requested @refreshes.fetch("http://localhost:4000/2/oauth2/token") + end + + def test_a_refresh_for_no_client_goes_to_the_origin_of_the_client_that_took_the_authenticator_first + client = expired_client + client.with(base_url: "http://localhost:4000/2/").authenticator.refresh! + + assert_requested @refreshes.fetch("https://api.x.com/2/oauth2/token") + end + + private + + def expired_client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 5) + end +end diff --git a/x-core/test/x/core/client_with_refresh_test.rb b/x-core/test/x/core/client_with_refresh_test.rb new file mode 100644 index 00000000..c885a99b --- /dev/null +++ b/x-core/test/x/core/client_with_refresh_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientWithRefreshTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + end + + # A client whose credentials read the tokens it held before a refresh, as a copy built while another thread + # refreshes reads them + def client_read_before_a_refresh(**options) + Client.new(**test_oauth2_credentials, **options).tap do |client| + stale = internals(client).send(:credentials) + client.authenticator.refresh! + client.define_singleton_method(:credentials) { stale } + end + end + + def test_a_copy_built_while_the_tokens_are_refreshed_shares_the_authenticator + client = client_read_before_a_refresh + copy = client.with + + assert_same client.authenticator, copy.authenticator + assert_equal "NEW_REFRESH_TOKEN", internals(copy).send(:refresh_token) + end + + def test_a_copy_built_while_the_tokens_are_refreshed_keeps_the_expiration_time_of_the_refresh + client = client_read_before_a_refresh(expires_at: Time.now + 60) + expires_at = client.expires_at + client.with + + assert_equal expires_at, client.expires_at + end + + def test_a_copy_refreshes_an_expired_token_over_its_own_connection + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 60) + copy = client.with(proxy_url: "http://proxy.example.com:8080", read_timeout: 5) + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + + assert_same copy_connection(copy), connections_of_refreshes { copy.get("users/me") }.first + assert_same client.authenticator, copy.authenticator + end + + def test_a_copy_refreshes_a_rejected_token_over_its_own_connection + copy = Client.new(**test_oauth2_credentials).with(debug_output: StringIO.new) + stub_request(:get, "https://api.x.com/2/users/me").to_return({status: 401}, {status: 200, body: "{}"}) + + assert_same copy_connection(copy), connections_of_refreshes { copy.get("users/me") }.first + end + + def test_requests_an_endpoint_always_rejects_spend_one_refresh_between_them + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 401) + client = Client.new(**test_oauth2_credentials) + + 3.times { assert_raises(Unauthorized) { client.with.get("users/me") } } + assert_requested :post, "https://api.x.com/2/oauth2/token", times: 1 + end + + def test_a_copy_given_the_credentials_the_authenticator_holds_shares_it + client = Client.new(**test_oauth2_credentials) + copy = client.with(client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN) + + assert_same client.authenticator, copy.authenticator + end + + def test_a_copy_given_another_token_builds_an_authenticator_of_its_own + client = Client.new(**test_oauth2_credentials) + copy = client.with(access_token: "OTHER_ACCESS_TOKEN") + + refute_same client.authenticator, copy.authenticator + assert_equal ["OTHER_ACCESS_TOKEN", TEST_ACCESS_TOKEN], [copy.authenticator.__send__(:access_token), client.authenticator.__send__(:access_token)] + end + + def test_a_copy_given_another_client_secret_builds_an_authenticator_of_its_own + client = Client.new(**test_oauth2_credentials) + copy = client.with(client_secret: "OTHER_SECRET") + + refute_same client.authenticator, copy.authenticator + end + + def test_a_copy_that_signs_with_oauth1_keeps_its_own_authenticator + client = Client.new(**test_oauth2_credentials) + copy = client.with(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, access_token_secret: TEST_ACCESS_TOKEN_SECRET, + client_id: nil, client_secret: nil, refresh_token: nil) + + assert_instance_of OAuth1Authenticator, copy.authenticator + end + + private + + def copy_connection(copy) = internals(copy).instance_variable_get(:@connection) + + # The connections the token requests a block sends are sent over + def connections_of_refreshes + connections = [] + fetch = Core.const_get(:TokenEndpoint).method(:fetch) + Core.const_get(:TokenEndpoint).stub(:fetch, ->(request, connection:, refusal:, headers:) { fetch.call(request, connection: connections.push(connection).last, refusal:, headers:) }) { yield } + connections + end + end +end diff --git a/x-core/test/x/core/client_with_test.rb b/x-core/test/x/core/client_with_test.rb new file mode 100644 index 00000000..dc997266 --- /dev/null +++ b/x-core/test/x/core/client_with_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "ostruct" +require_relative "../../test_helper" + +module X + class ClientWithTest < Minitest::Test + cover_client + + def setup + @client = Client.new(**test_oauth_credentials, base_url: "https://example.com/2/", open_timeout: 5, read_timeout: 6, + write_timeout: 7, debug_output: $stdout, proxy_url: "http://proxy.example.com:8080", default_array_class: Set, + default_object_class: OpenStruct, max_redirects: 3) + end + + def test_a_copy_keeps_the_credentials + copy = @client.with(base_url: "https://api.x.com/1.1/") + + assert_instance_of OAuth1Authenticator, copy.authenticator + assert_equal [TEST_API_KEY, TEST_API_KEY_SECRET, TEST_ACCESS_TOKEN, TEST_ACCESS_TOKEN_SECRET], + [copy.api_key, internals(copy).send(:api_key_secret), internals(copy).send(:access_token), internals(copy).send(:access_token_secret)] + end + + def test_a_copy_keeps_the_other_credentials + client = Client.new(**test_oauth2_credentials) + copy = client.with(base_url: "https://api.x.com/1.1/") + + assert_equal [TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REFRESH_TOKEN], [copy.client_id, internals(copy).send(:client_secret), internals(copy).send(:refresh_token)] + assert_equal TEST_BEARER_TOKEN, internals(Client.new(bearer_token: TEST_BEARER_TOKEN).with(max_redirects: 1)).send(:bearer_token) + end + + def test_a_copy_keeps_the_settings + copy = @client.with(read_timeout: 60) + + assert_equal ["https://example.com/2/", 5, 60, 7, $stdout, "http://proxy.example.com:8080", Set, OpenStruct, 3], + [copy.base_url, copy.open_timeout, copy.read_timeout, copy.write_timeout, copy.debug_output, internals(copy).send(:proxy_url), + copy.default_array_class, copy.default_object_class, copy.max_redirects] + end + + def test_a_copy_keeps_the_hooks + on_response = ->(_) {} + save_tokens = ->(_) {} + copy = Client.new(on_response:, save_tokens:).with + + assert_equal [on_response, save_tokens], [copy.on_response, copy.save_tokens] + end + + def test_a_copy_changes_the_base_url + copy = @client.with(base_url: "https://api.x.com/1.1/") + + assert_equal "https://api.x.com/1.1/", copy.base_url + assert_equal "https://example.com/2/", @client.base_url + refute_same @client, copy + end + + def test_a_copy_removes_credentials + copy = @client.with(access_token: nil, access_token_secret: nil) + + assert_instance_of AppOnlyAuthenticator, copy.authenticator + assert_nil internals(copy).send(:access_token) + assert_instance_of OAuth1Authenticator, @client.authenticator + end + + def test_a_copy_without_options + copy = @client.with + + assert_equal @client.inspect, copy.inspect + assert_equal [5, 6, 7, $stdout, "http://proxy.example.com:8080", Set, OpenStruct, 3], + [copy.open_timeout, copy.read_timeout, copy.write_timeout, copy.debug_output, internals(copy).send(:proxy_url), + copy.default_array_class, copy.default_object_class, copy.max_redirects] + end + end +end diff --git a/x-core/test/x/core/client_with_tokens_authenticator_test.rb b/x-core/test/x/core/client_with_tokens_authenticator_test.rb new file mode 100644 index 00000000..22af8310 --- /dev/null +++ b/x-core/test/x/core/client_with_tokens_authenticator_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A copy that does not keep the authenticator of the client holds none of the client's token hooks, whatever the + # client authenticates with, so that it can neither read the stored tokens of the client's user nor store over them + class ClientWithTokensAuthenticatorTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + end + + HOOKS = {save_tokens: ->(_) {}, load_tokens: -> {}}.freeze + + def hooks_of(copy) = [copy.save_tokens, copy.load_tokens] + + def test_a_copy_of_a_client_given_its_authenticator_that_is_given_a_credential_or_an_authenticator_holds_none_of_the_token_hooks + client = Client.new(authenticator: OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN), + save_tokens: ->(_) {}, load_tokens: -> {}) + other = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: "OTHER_ACCESS_TOKEN") + + [{bearer_token: "APP_BEARER_TOKEN"}, {api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET}, {authenticator: other}].each do |options| + assert_equal [nil, nil], client.with(**options).then { |copy| [copy.save_tokens, copy.load_tokens] }, options.keys + end + end + + def test_a_copy_of_a_client_given_its_authenticator_that_shares_it_keeps_the_token_hooks + save_tokens = ->(_) {} + load_tokens = -> {} + authenticator = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN) + copy = Client.new(authenticator:, save_tokens:, load_tokens:).with(base_url: "https://api.x.com/1.1/") + + assert_same authenticator, copy.authenticator + assert_equal [save_tokens, load_tokens], [copy.save_tokens, copy.load_tokens] + end + + def test_a_user_client_derived_from_an_app_copy_takes_none_of_the_stored_tokens_of_the_first_user + stored = OAuth2Tokens.new(access_token: "FIRST_USER_ACCESS_TOKEN", refresh_token: "FIRST_USER_REFRESH_TOKEN") + client = Client.new(authenticator: OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN), + load_tokens: -> { stored }) + user = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN", + expires_at: Time.now - 5) + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + client.with(bearer_token: "APP_BEARER_TOKEN").with(authenticator: user).get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"Authorization" => "Bearer NEW_ACCESS_TOKEN"} + assert_requested :post, "https://api.x.com/2/oauth2/token", body: /refresh_token=OTHER_REFRESH_TOKEN/ + end + + def test_a_copy_of_a_client_that_does_not_use_oauth2_given_an_authenticator_or_a_credential_holds_none_of_the_token_hooks + other = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: "OTHER_ACCESS_TOKEN") + clients = [Client.new(bearer_token: TEST_BEARER_TOKEN, **HOOKS), Client.new(**test_oauth_credentials, **HOOKS), + Client.new(authenticator: BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN), **HOOKS)] + copies = clients.product([{authenticator: other}, {bearer_token: "OTHER"}]).map { |client, options| client.with(**options) } + + assert_equal [[nil, nil]], copies.map { |copy| hooks_of(copy) }.uniq + end + + def test_a_copy_of_a_bearer_client_given_oauth2_credentials_holds_none_of_the_token_hooks + copy = Client.new(bearer_token: TEST_BEARER_TOKEN, **HOOKS).with(client_id: TEST_CLIENT_ID, access_token: "OTHER_ACCESS_TOKEN") + + assert_equal [nil, nil], hooks_of(copy) + end + + def test_a_copy_of_a_client_that_does_not_use_oauth2_given_neither_keeps_the_token_hooks + save_tokens = ->(_) {} + copy = Client.new(bearer_token: TEST_BEARER_TOKEN, save_tokens:).with(base_url: "https://api.x.com/1.1/", authenticator: nil) + + assert_same save_tokens, copy.save_tokens + end + + def test_a_user_client_derived_from_a_bearer_client_takes_none_of_the_stored_tokens_of_its_user + stored = OAuth2Tokens.new(access_token: "FIRST_USER_ACCESS_TOKEN", refresh_token: "FIRST_USER_REFRESH_TOKEN") + client = Client.new(bearer_token: TEST_BEARER_TOKEN, load_tokens: -> { stored }) + user = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN", + expires_at: Time.now - 5) + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + client.with(authenticator: user).get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"Authorization" => "Bearer NEW_ACCESS_TOKEN"} + end + + def test_a_copy_of_a_client_built_from_oauth2_credentials_given_the_access_token_it_holds_keeps_the_token_hooks + copy = Client.new(**test_oauth2_credentials, **HOOKS).with(access_token: TEST_ACCESS_TOKEN) + + assert_equal HOOKS.values, hooks_of(copy) + end + end +end diff --git a/x-core/test/x/core/client_with_tokens_test.rb b/x-core/test/x/core/client_with_tokens_test.rb new file mode 100644 index 00000000..6ec5f3da --- /dev/null +++ b/x-core/test/x/core/client_with_tokens_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ClientWithTokensTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + end + + def test_a_copy_given_other_tokens_holds_none_of_the_expiration_time_and_scopes_of_the_client + client = Client.new(**test_oauth2_credentials, expires_at: Time.now + 60, scopes: %w[tweet.read]) + copy = client.with(access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN") + + assert_equal [nil, nil], [copy.expires_at, copy.scopes] + end + + def test_a_copy_given_other_tokens_holds_none_of_the_token_hooks_of_the_client + client = Client.new(**test_oauth2_credentials, save_tokens: ->(_) {}, load_tokens: -> {}) + copy = client.with(access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN") + + assert_equal [nil, nil], [copy.save_tokens, copy.load_tokens] + end + + def test_a_copy_given_other_tokens_keeps_the_token_hooks_it_is_given + save_tokens = ->(_) {} + load_tokens = -> {} + copy = Client.new(**test_oauth2_credentials, save_tokens: ->(_) {}).with(access_token: "OTHER_ACCESS_TOKEN", save_tokens:, load_tokens:) + + assert_equal [save_tokens, load_tokens], [copy.save_tokens, copy.load_tokens] + end + + def test_a_copy_given_another_authenticator_holds_none_of_the_token_hooks_of_the_client + client = Client.new(**test_oauth2_credentials, save_tokens: ->(_) {}, load_tokens: -> {}) + copy = client.with(authenticator: OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, access_token: "OTHER_ACCESS_TOKEN")) + + assert_equal [nil, nil], [copy.save_tokens, copy.load_tokens] + end + + def test_a_copy_that_shares_the_authenticator_keeps_the_token_hooks_of_the_client + save_tokens = ->(_) {} + load_tokens = -> {} + copy = Client.new(**test_oauth2_credentials, save_tokens:, load_tokens:).with(base_url: "https://api.x.com/1.1/") + + assert_equal [save_tokens, load_tokens], [copy.save_tokens, copy.load_tokens] + end + + def test_a_copy_given_no_authenticator_shares_the_authenticator_and_keeps_the_token_hooks_of_the_client + save_tokens = ->(_) {} + client = Client.new(**test_oauth2_credentials, save_tokens:) + copy = client.with(authenticator: nil) + + assert_same client.authenticator, copy.authenticator + assert_same save_tokens, copy.save_tokens + end + + def test_a_refresh_of_a_copy_given_another_user_s_tokens_takes_none_of_the_stored_tokens_of_the_client + stored = OAuth2Tokens.new(access_token: TEST_ACCESS_TOKEN, refresh_token: "STORED_REFRESH_TOKEN") + client = Client.new(**test_oauth2_credentials, load_tokens: -> { stored }) + copy = client.with(access_token: "OTHER_ACCESS_TOKEN", refresh_token: "OTHER_REFRESH_TOKEN", expires_at: Time.now - 5) + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 200, body: "{}") + copy.get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"Authorization" => "Bearer NEW_ACCESS_TOKEN"} + assert_requested :post, "https://api.x.com/2/oauth2/token", body: /refresh_token=OTHER_REFRESH_TOKEN/ + end + end +end diff --git a/x-core/test/x/core/connection_pool_test.rb b/x-core/test/x/core/connection_pool_test.rb new file mode 100644 index 00000000..14e77b97 --- /dev/null +++ b/x-core/test/x/core/connection_pool_test.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "net/http" +require_relative "../../test_helper" + +module X + class ConnectionPoolTest < Minitest::Test + cover Core.const_get(:ConnectionPool) + + KEY = [true, "example.com", 443].freeze + + def setup + stub_request(:get, "https://example.com/") + @pool = Core.const_get(:ConnectionPool).new + @opened = [] + end + + def open = -> { Net::HTTP.new("example.com", 443).tap { |http| http.use_ssl = true }.tap { |http| @opened << http } } + + def request(pool = @pool, key = KEY) = pool.with(key, open) { |http| http.request(Net::HTTP::Get.new(URI("https://example.com/"))) } + + def test_with_returns_what_the_block_returns_from_a_started_connection + assert_equal [true, :ok], @pool.with(KEY, open) { |http| [http.started?, :ok] } + end + + def test_with_tells_the_block_whether_the_connection_was_one_it_had_kept_open + assert_equal [false, true], Array.new(2) { @pool.with(KEY, open) { |_, pooled| pooled } } + end + + def test_with_tells_the_block_a_connection_it_opened_apart_from_one_it_kept + @pool.with(KEY, open) { nil } + + assert_equal [true, false], @pool.with(KEY, open) { |_, pooled| [pooled, @pool.with(KEY, open) { |_, nested| nested }] } + end + + def test_with_opens_a_fresh_connection_though_one_is_idle + @pool.with(KEY, open) { nil } + + assert_equal [false, 2], [@pool.with(KEY, open, fresh: true) { |_, pooled| pooled }, @opened.size] + end + + def test_with_keeps_a_fresh_connection_for_the_next_request + @pool.with(KEY, open, fresh: true) { nil } + + assert_same @opened.first, @pool.with(KEY, open) { |http| http } + end + + def test_reuses_the_connection_given_back_last + nest(2) + + assert_same @opened.first, @pool.with(KEY, open) { |http| http } + end + + def test_reuses_an_idle_connection + 2.times { request } + + assert_equal 1, @opened.size + assert_predicate @opened.first, :started? + end + + def test_keeps_connections_to_each_host_apart + request + request(@pool, [true, "example.com", 8443]) + + assert_equal 2, @opened.size + end + + def test_opens_a_connection_per_request_in_progress + @pool.with(KEY, open) { @pool.with(KEY, open) { nil } } + 2.times { request } + + assert_equal 2, @opened.size + end + + def test_keeps_no_more_than_the_maximum_idle + nest(Core.const_get(:ConnectionPool)::MAX_IDLE + 1) + + assert_equal Core.const_get(:ConnectionPool)::MAX_IDLE, @opened.count(&:started?) + nest(Core.const_get(:ConnectionPool)::MAX_IDLE) + + assert_equal Core.const_get(:ConnectionPool)::MAX_IDLE + 1, @opened.size + end + + def test_closes_a_connection_whose_block_raises + assert_raises(IOError) { @pool.with(KEY, open) { raise IOError } } + request + + refute_predicate @opened.first, :started? + assert_equal 2, @opened.size + end + + def test_a_connection_that_fails_to_open_is_not_closed + failing = -> { Net::HTTP.new("example.com", 443).tap { |http| http.define_singleton_method(:start) { raise SocketError } } } + + assert_raises(SocketError) { @pool.with(KEY, failing) { flunk "yielded" } } + end + + def test_clear_closes_idle_connections_and_opens_new_ones + request + @pool.clear + request + + assert_equal [false, true], @opened.map(&:started?) + end + + def test_clear_closes_a_connection_in_use_once_it_is_done + @pool.with(KEY, open) { @pool.clear } + request + + assert_equal [false, true], @opened.map(&:started?) + end + + def test_clear_ignores_a_connection_that_is_already_closed + request + @opened.first.define_singleton_method(:finish) { raise IOError, "closed stream" } + @pool.clear + request + + assert_equal 2, @opened.size + end + + def test_a_forked_process_opens_connections_of_its_own + request + Process.stub(:pid, Process.pid + 1) { 2.times { request } } + + assert_equal 2, @opened.size + assert_predicate @opened.first, :started? + end + + def test_a_forked_process_leaves_its_parents_connections_open_when_it_clears + request + Process.stub(:pid, Process.pid + 1) { @pool.clear } + + assert_predicate @opened.first, :started? + end + + private + + def nest(depth) + return if depth.zero? + + @pool.with(KEY, open) { nest(depth - 1) } + end + end + + class ConnectionPoolLockTest < Minitest::Test + cover Core.const_get(:ConnectionPool) + + KEY = ConnectionPoolTest::KEY + + def setup + stub_request(:get, "https://example.com/") + @pool = Core.const_get(:ConnectionPool).new + @opened = [] + end + + def open = -> { Net::HTTP.new("example.com", 443).tap { |http| http.use_ssl = true }.tap { |http| @opened << http } } + + def test_taking_a_connection_waits_for_the_lock + reached = Queue.new + assert_waits_for_lock { Thread.new { @pool.with(KEY, open) { reached << true } } } + + assert_equal 1, reached.size + end + + def test_clearing_waits_for_the_lock + assert_waits_for_lock { Thread.new { @pool.clear } } + end + + def test_giving_a_connection_back_waits_for_the_lock + inside, go, done = Array.new(3) { Queue.new } + thread = Thread.new { @pool.with(KEY, open) { (inside << true) && (done << go.pop) } } + inside.pop + assert_waits_for_lock(opened: 1) do + go << true + done.pop + thread + end + end + + private + + # Hold the pool's lock while a block starts a thread, and check that the thread waits for it + def assert_waits_for_lock(opened: 0) + thread = nil + @pool.instance_variable_get(:@lock).synchronize do + thread = yield + sleep 0.05 + + assert_predicate thread, :alive? + assert_equal opened, @opened.size + end + thread.join + end + end + + class ConnectionKeepAliveTest < Minitest::Test + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionRequest) + + def setup + stub_request(:get, "https://example.com/") + @connection = Core.const_get(:Connection).new + end + + def perform(connection = @connection) = connection.perform(request: Net::HTTP::Get.new(URI("https://example.com/"))) + + def opened(connection = @connection, &) + clients = [] + original = connection.method(:build_http_client) + connection.stub(:build_http_client, ->(*args) { original.call(*args).tap { |client| clients << client } }, &) + clients + end + + def test_requests_to_a_host_share_a_connection + clients = opened { 2.times { perform } } + + assert_equal 1, clients.size + assert_predicate clients.first, :started? + assert_predicate clients.first, :use_ssl? + end + + def test_requests_to_two_paths_of_one_host_share_a_connection + stub_request(:get, "https://example.com/other") + clients = opened do + perform + @connection.perform(request: Net::HTTP::Get.new(URI("https://example.com/other"))) + end + + assert_equal 1, clients.size + end + + def test_a_reused_connection_keeps_the_timeouts_it_was_opened_with + connection = Core.const_get(:Connection).new(open_timeout: 6, read_timeout: 5, write_timeout: 7) + clients = opened(connection) { 2.times { perform(connection) } } + + assert_equal 1, clients.size + assert_equal [5, 6, 7], [clients.first.read_timeout, clients.first.open_timeout, clients.first.write_timeout] + end + + def test_requests_to_another_scheme_host_or_port_open_their_own_connections + urls = %w[https://example.com/ http://example.com:443/ https://example.org/ https://example.com:8443/] + urls.each { |url| stub_request(:get, url) } + clients = opened { urls.each { |url| @connection.perform(request: Net::HTTP::Get.new(URI(url))) } } + + assert_equal 4, clients.size + end + + def test_a_request_to_http_does_not_use_ssl + stub_request(:get, "http://example.com/") + clients = opened { @connection.perform(request: Net::HTTP::Get.new(URI("http://example.com/"))) } + + refute_predicate clients.first, :use_ssl? + end + + def test_close_closes_the_connections_kept_open + clients = opened do + perform + @connection.close + end + + refute_predicate clients.first, :started? + end + + def test_a_network_error_closes_the_connection + stub_request(:get, "https://example.com/").to_raise(Errno::ECONNRESET).then.to_return(status: 200) + clients = opened do + assert_raises(NetworkError) { perform } + perform + end + + assert_equal [false, true], clients.map(&:started?) + end + end +end diff --git a/x-core/test/x/core/connection_proxy_cause_test.rb b/x-core/test/x/core/connection_proxy_cause_test.rb new file mode 100644 index 00000000..93c0393a --- /dev/null +++ b/x-core/test/x/core/connection_proxy_cause_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The error that refuses a proxy URL that cannot be parsed has no cause, whose message would hold the password + class ConnectionProxyCauseTest < Minitest::Test + cover Core.const_get(:ConnectionProxy) + + def test_an_unparseable_proxy_url_raises_an_error_without_a_cause_holding_the_password + error = assert_raises(ArgumentError) { Client.new(proxy_url: "http://user:se cret@example.com:8080") } + + assert_nil error.cause + refute_includes error.full_message, "se cret" + end + end +end diff --git a/x-core/test/x/core/connection_proxy_test.rb b/x-core/test/x/core/connection_proxy_test.rb new file mode 100644 index 00000000..ab46e377 --- /dev/null +++ b/x-core/test/x/core/connection_proxy_test.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require_relative "../../test_helper" + +module X + class ConnectionProxyTest < Minitest::Test + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionProxy) + + def setup + @connection = Core.const_get(:Connection).new + end + + def test_proxy + connection = Core.const_get(:Connection).new(proxy_url: "http://user:pass@example.com:8080") + http_client = connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal ["http://user:pass@example.com:8080", URI("http://user:pass@example.com:8080")], + [connection.send(:proxy_url), connection.send(:proxy_uri)] + assert_equal ["example.com", 8080, "user", "pass"], + [http_client.proxy_address, http_client.proxy_port, http_client.proxy_user, http_client.proxy_pass] + end + + def test_a_connection_reveals_nothing_of_its_proxy + connection = Core.const_get(:Connection).new(proxy_url: "http://user:pass@example.com:8080") + + %i[proxy_url proxy_uri proxy_host proxy_port proxy_user proxy_pass].each do |name| + refute_respond_to connection, name + end + end + + def test_a_client_does_not_reveal_its_proxy + client = Client.new(proxy_url: "http://user:pass@example.com:8080") + + refute_respond_to client, :proxy_url + end + + def test_invalid_proxy_url + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "ftp://ftp.twitter.com/") } + + assert_equal "Invalid proxy URL: ftp://ftp.twitter.com/", error.message + end + + def test_invalid_proxy_url_message_leaves_out_the_user_and_password + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "ftp://user:secret@ftp.twitter.com/") } + + assert_equal "Invalid proxy URL: ftp://ftp.twitter.com/", error.message + end + + def test_unparseable_proxy_url_raises_argument_error_without_the_password + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "http://user:se cret@example.com:8080") } + + assert_equal "Invalid proxy URL: http://example.com:8080", error.message + end + + def test_invalid_proxy_url_message_leaves_out_a_password_holding_an_at_sign + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "http://user:p@ss@proxy.com:8080 ") } + + assert_equal "Invalid proxy URL: http://proxy.com:8080 ", error.message + end + + def test_invalid_proxy_url_message_leaves_out_a_password_holding_a_slash + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "ftp://user:pa/ss@proxy.com:8080") } + + assert_equal "Invalid proxy URL: ftp://proxy.com:8080", error.message + end + + def test_invalid_proxy_url_message_leaves_out_a_password_holding_a_line_break + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "http://user:pa\nss@proxy.com:8080") } + + assert_equal "Invalid proxy URL: http://proxy.com:8080", error.message + end + + def test_invalid_proxy_url_message_leaves_out_the_password_of_a_url_without_a_scheme + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "user:secret@proxy.com:8080") } + + assert_equal "Invalid proxy URL: proxy.com:8080", error.message + end + + def test_invalid_proxy_url_message_keeps_the_slashes_of_a_url_without_a_scheme + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "//user:secret@proxy.com:8080") } + + assert_equal "Invalid proxy URL: //proxy.com:8080", error.message + end + + def test_inspect_leaves_out_a_password_holding_an_at_sign + connection = Core.const_get(:Connection).new(proxy_url: URI::HTTP.build(userinfo: "user:p%40ss", host: "proxy.com", port: 8080)) + + refute_includes connection.inspect, "user" + end + + def test_a_connection_built_without_a_proxy_has_none + assert_equal [nil, nil], [@connection.send(:proxy_url), @connection.send(:proxy_uri)] + end + + def test_the_proxy_host_is_given_without_the_brackets_of_an_ipv6_literal + http_client = Core.const_get(:Connection).new(proxy_url: "http://[::1]:8080").send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal "::1", http_client.proxy_address + end + + def test_proxy_user_and_password_are_decoded + connection = Core.const_get(:Connection).new(proxy_url: "http://us%40er:p%40ss%3Aword@example.com:8080") + http_client = connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal ["us@er", "p@ss:word"], [http_client.proxy_user, http_client.proxy_pass] + end + + def test_proxy_user_without_a_password + connection = Core.const_get(:Connection).new(proxy_url: "http://user@example.com:8080") + http_client = connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal ["user", nil], [http_client.proxy_user, http_client.proxy_pass] + end + + def test_inspect_without_a_proxy + assert_equal "#", @connection.inspect + end + + def test_inspect_hides_the_proxy_user_and_password_of_a_uri + connection = Core.const_get(:Connection).new(proxy_url: URI("http://user:secret@example.com:8080")) + + assert_equal "#", connection.inspect + end + + def test_invalid_proxy_url_message_leaves_out_an_empty_user + error = assert_raises(ArgumentError) { Core.const_get(:Connection).new(proxy_url: "ftp://@ftp.twitter.com/") } + + assert_equal "Invalid proxy URL: ftp://ftp.twitter.com/", error.message + end + + def test_inspect_hides_the_proxy_user_and_password + connection = Core.const_get(:Connection).new(open_timeout: 1, read_timeout: 2, write_timeout: 3, proxy_url: "http://user:secret@example.com:8080") + + assert_equal "#", connection.inspect + end + + def test_proxy_settings_are_respected_in_http_client + connection = Core.const_get(:Connection).new(proxy_url: "http://user:pass@example.com:8080") + http_client = connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal "example.com", http_client.proxy_address + assert_equal 8080, http_client.proxy_port + assert_equal "user", http_client.proxy_user + assert_equal "pass", http_client.proxy_pass + end + end + + class ConnectionProxyEnvironmentTest < Minitest::Test + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionProxy) + + def test_https_proxy_of_the_environment_is_used_for_an_https_request + with_proxy_env(https_proxy: "http://us%40er:p%40ss@example.com:8080") do + http_client = Core.const_get(:Connection).new.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_predicate http_client, :proxy? + assert_equal ["example.com", 8080, "us@er", "p@ss"], + [http_client.proxy_address, http_client.proxy_port, http_client.proxy_user, http_client.proxy_pass] + end + end + + def test_http_proxy_of_the_environment_is_used_for_an_http_request + with_proxy_env(http_proxy: "http://example.com:8080") do + assert_predicate Core.const_get(:Connection).new.send(:build_http_client, URI("http://api.x.com/2/tweets")), :proxy? + end + end + + def test_http_proxy_of_the_environment_is_not_used_for_an_https_request + with_proxy_env(http_proxy: "http://example.com:8080") do + refute_predicate Core.const_get(:Connection).new.send(:build_http_client, URI("https://api.x.com/2/tweets")), :proxy? + end + end + + def test_no_proxy_of_the_environment_reaches_the_host_directly + with_proxy_env(https_proxy: "http://example.com:8080", no_proxy: "api.x.com") do + refute_predicate Core.const_get(:Connection).new.send(:build_http_client, URI("https://api.x.com/2/tweets")), :proxy? + end + end + + def test_the_proxy_url_of_a_connection_is_used_rather_than_the_environment + with_proxy_env(https_proxy: "http://environment.example.com:8080") do + http_client = Core.const_get(:Connection).new(proxy_url: "http://given.example.com:3128").send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal "given.example.com", http_client.proxy_address + end + end + + private + + # Run a block with the proxy variables of the environment set to the values given, and the rest of them cleared + def with_proxy_env(http_proxy: nil, https_proxy: nil, no_proxy: nil) + values = {"http_proxy" => http_proxy, "https_proxy" => https_proxy, "no_proxy" => no_proxy} + names = values.keys.flat_map { |name| [name, name.upcase] } + original = names.to_h { |name| [name, ENV.fetch(name, nil)] } + names.each { |name| ENV[name] = values.fetch(name.downcase) } + yield + ensure + original&.each { |name, value| ENV[name] = value } + end + end + + class ConnectionProxyHTTPClientTest < Minitest::Test + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionProxy) + + def test_host_port_with_proxy + connection = Core.const_get(:Connection).new(proxy_url: "https://user:pass@example.com") + http_client = connection.send(:build_http_client, URI("https://example.com:8080/")) + + assert_predicate http_client, :proxy? + assert_equal "example.com", http_client.address + assert_equal 8080, http_client.port + end + + def test_http_proxy_is_connected_to_without_tls + http_client = Core.const_get(:Connection).new(proxy_url: "http://example.com:8080").send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_same false, http_client.instance_variable_get(:@proxy_use_ssl) + end + + def test_https_proxy_is_connected_to_over_tls + http_client = Core.const_get(:Connection).new(proxy_url: "https://user:pass@example.com:8443").send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_same true, http_client.instance_variable_get(:@proxy_use_ssl) + end + end +end diff --git a/x-core/test/x/core/connection_proxy_url_test.rb b/x-core/test/x/core/connection_proxy_url_test.rb new file mode 100644 index 00000000..4a9f3431 --- /dev/null +++ b/x-core/test/x/core/connection_proxy_url_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require_relative "../../test_helper" + +module X + # A proxy URL must name a host, which Net::HTTP would otherwise take for no proxy, and is parsed anew, so that a URI + # its caller changes later changes no proxy + class ConnectionProxyUrlTest < Minitest::Test + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionProxy) + + def test_a_proxy_url_that_names_no_host_is_refused + ["http:proxy.example.com:8080", "http:/proxy:8080", "https:proxy:8080", URI("http:proxy:8080")].each do |url| + assert_raises(ArgumentError, url.to_s) { Core.const_get(:Connection).new(proxy_url: url) } + end + end + + def test_a_proxy_url_given_as_a_uri_is_copied_so_a_later_change_to_it_changes_no_proxy + uri = URI("http://example.com:8080") + connection = Core.const_get(:Connection).new(proxy_url: uri) + uri.port = 9090 + + assert_equal 8080, connection.send(:build_http_client, URI("https://api.x.com/2/tweets")).proxy_port + end + end +end diff --git a/x-core/test/x/core/connection_stale_test.rb b/x-core/test/x/core/connection_stale_test.rb new file mode 100644 index 00000000..43addee6 --- /dev/null +++ b/x-core/test/x/core/connection_stale_test.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ConnectionStaleTest < Minitest::Test + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionRequest) + + URL = "https://api.x.com/2/tweets" + OTHER_URL = "https://example.com/2/tweets" + + def setup + @connection = Core.const_get(:Connection).new + end + + def test_a_request_on_a_connection_that_had_gone_stale_is_sent_again + stub_request(:get, URL).to_return(status: 200).then.to_raise(EOFError).then.to_return(status: 200) + @connection.perform(request: get_request(URL)) + + assert_equal "200", @connection.perform(request: get_request(URL)).code + assert_requested :get, URL, times: 3 + end + + def test_a_put_on_a_connection_that_had_gone_stale_is_sent_again + assert_equal "200", sent_again(Net::HTTP::Put, :put).code + end + + def test_a_delete_on_a_connection_that_had_gone_stale_is_sent_again + assert_equal "200", sent_again(Net::HTTP::Delete, :delete).code + end + + def test_a_post_on_a_connection_that_had_gone_stale_is_not_sent_again + stub_request(:post, URL).to_return(status: 200).then.to_raise(EOFError) + @connection.perform(request: Net::HTTP::Post.new(URI(URL))) + + assert_raises(NetworkError) { @connection.perform(request: Net::HTTP::Post.new(URI(URL))) } + assert_requested :post, URL, times: 2 + end + + def test_a_request_on_a_connection_opened_for_it_is_not_sent_again + stub_request(:get, URL).to_raise(EOFError) + + assert_raises(NetworkError) { @connection.perform(request: get_request(URL)) } + assert_requested :get, URL, times: 1 + end + + def test_a_request_sent_again_is_not_sent_a_third_time + stub_request(:get, URL).to_return(status: 200).then.to_raise(EOFError).then.to_raise(EOFError) + @connection.perform(request: get_request(URL)) + + assert_raises(NetworkError) { @connection.perform(request: get_request(URL)) } + assert_requested :get, URL, times: 3 + end + + def test_a_request_sent_again_opens_a_connection_rather_than_take_the_one_that_failed + stub_request(:get, URL).to_return(status: 200).then.to_raise(EOFError).then.to_return(status: 200) + + assert_equal 2, opened_while { 2.times { @connection.perform(request: get_request(URL)) } }.size + end + + def test_a_request_that_timed_out_on_a_kept_connection_is_not_sent_again + stub_request(:get, URL).to_return(status: 200).then.to_raise(Net::ReadTimeout).then.to_return(status: 200) + @connection.perform(request: get_request(URL)) + + assert_raises(NetworkError) { @connection.perform(request: get_request(URL)) } + assert_requested :get, URL, times: 2 + end + + def test_a_request_reset_on_a_kept_connection_is_sent_again + %w[ECONNRESET ECONNABORTED EPIPE].each do |name| + WebMock.reset! + stub_request(:get, URL).to_return(status: 200).then.to_raise(Errno.const_get(name)).then.to_return(status: 200) + @connection.perform(request: get_request(URL)) + + assert_equal "200", @connection.perform(request: get_request(URL)).code, name + end + end + + def test_a_request_sent_again_opens_a_connection_though_another_is_idle + stub_request(:get, URL).to_raise(EOFError).then.to_return(status: 200) + keep_idle(2) + + assert_equal 1, opened_while { @connection.perform(request: get_request(URL)) }.size + end + + def test_a_request_whose_connection_fails_to_open_is_not_sent_again + opened = 0 + failing = Net::HTTP.new("api.x.com", 443) + def failing.start = raise(Errno::ECONNRESET) + build = ->(*) { failing.tap { opened += 1 } } + + assert_raises(NetworkError) { @connection.stub(:build_http_client, build) { @connection.perform(request: get_request(URL)) } } + assert_equal 1, opened + end + + def test_the_connection_a_request_is_sent_again_on_is_kept_for_the_next_request + stub_request(:get, URL).to_raise(EOFError).then.to_return(status: 200) + keep_idle(1) + + assert_equal 1, opened_while { 2.times { @connection.perform(request: get_request(URL)) } }.size + end + + def test_the_connections_of_each_host_are_kept_apart + stub_request(:get, URL) + stub_request(:get, OTHER_URL) + opened = opened_while do + [URL, OTHER_URL, URL].each { |url| @connection.perform(request: get_request(url)) } + end + + assert_equal 2, opened.size + end + + private + + # Send a request of the given class twice, the second on a connection the peer had closed + def sent_again(http_method, verb) + stub_request(verb, URL).to_return(status: 200).then.to_raise(EOFError).then.to_return(status: 200) + @connection.perform(request: http_method.new(URI(URL))) + @connection.perform(request: http_method.new(URI(URL))) + end + + # Keep connections to the host of URL idle in the pool, as though earlier requests had used them + def keep_idle(count) + pool = @connection.instance_variable_get(:@pool) + open = -> { @connection.send(:build_http_client, URI(URL)).tap { |http| http.use_ssl = true } } + nest = ->(depth) { pool.with([true, "api.x.com", 443], open) { nest.call(depth - 1) if depth > 1 } } + nest.call(count) + end + + # The connections the block opened + def opened_while(&) + opened = [] + original = @connection.method(:build_http_client) + @connection.stub(:build_http_client, ->(*args) { original.call(*args).tap { |http| opened << http } }, &) + opened + end + end +end diff --git a/x-core/test/x/core/connection_stream_test.rb b/x-core/test/x/core/connection_stream_test.rb new file mode 100644 index 00000000..ac029a78 --- /dev/null +++ b/x-core/test/x/core/connection_stream_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require_relative "../../test_helper" + +module X + class ConnectionStreamTest < Minitest::Test + include LocalServer + + cover Core.const_get(:Connection) + cover Core.const_get(:CallbackError) + + def setup + @connection = Core.const_get(:Connection).new + end + + def test_perform_stream + stub_request(:get, "http://example.com:80") + request = Net::HTTP::Get.new(URI("http://example.com:80")) + response_received = false + @connection.perform_stream(request:) do |response| + response_received = true + + assert_kind_of Net::HTTPSuccess, response + end + + assert response_received + assert_requested :get, "http://example.com:80" + end + + def test_perform_stream_network_error + stub_request(:get, "https://example.com").to_raise(Errno::ECONNREFUSED) + request = Net::HTTP::Get.new(URI("https://example.com")) + error = assert_raises(NetworkError) do + @connection.perform_stream(request:) { |_response| flunk "unexpected yield" } + end + + assert_equal "GET /: Network error: #{Errno::ECONNREFUSED.new("Exception from WebMock").message}", error.message + end + + def test_perform_stream_reports_a_connection_that_drops_while_reading_as_a_network_error + truncated = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n{\"data\":\r\n" + with_local_server(response: truncated) do |port| + request = Net::HTTP::Get.new(URI("http://127.0.0.1:#{port}/")) + + assert_raises(NetworkError) { @connection.perform_stream(request:) { |response| response.read_body { |_chunk| } } } + end + end + + def test_perform_stream_raises_the_error_a_callback_of_the_stream_raised_tagged + stub_request(:get, "http://example.com:80") + request = Net::HTTP::Get.new(URI("http://example.com:80")) + error = assert_raises(Core.const_get(:CallbackError)) do + @connection.perform_stream(request:) { |_response| raise Core.const_get(:CallbackError), Errno::ECONNREFUSED.new } + end + + assert_kind_of Errno::ECONNREFUSED, error.error + end + + # Net::HTTP reads what is left of the body once the block of a request returns, which a stream never ends + class HTTPReadingTheRest + attr_writer :use_ssl + + def request(_request) + yield Net::HTTPOK.new("1.1", "200", "OK") + raise "the rest of the body was read" + end + end + + def test_perform_stream_returns_what_the_block_returns_without_reading_the_rest_of_the_body + request = Net::HTTP::Get.new(URI("https://example.com/stream")) + result = @connection.stub(:build_http_client, HTTPReadingTheRest.new) do + @connection.perform_stream(request:) { |response| [:stopped, response.code] } + end + + assert_equal [:stopped, "200"], result + end + + def test_an_error_of_a_socket_is_one_a_request_raises_as_a_network_error + assert Core.const_get(:Connection).network_error?(Errno::ECONNRESET.new) + assert Core.const_get(:Connection).network_error?(IOError.new) + refute Core.const_get(:Connection).network_error?(RuntimeError.new) + end + + def test_a_callback_error_holds_the_error_a_callback_raised_and_its_message + error = Core.const_get(:CallbackError).new(Errno::ECONNREFUSED.new("the hook failed")) + + assert_kind_of Errno::ECONNREFUSED, error.error + assert_equal Errno::ECONNREFUSED.new("the hook failed").message, error.message + end + end +end diff --git a/x-core/test/x/core/connection_test.rb b/x-core/test/x/core/connection_test.rb new file mode 100644 index 00000000..937df8a8 --- /dev/null +++ b/x-core/test/x/core/connection_test.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require_relative "../../test_helper" + +module X + class ConnectionTest < Minitest::Test + cover Core.const_get(:Connection) + + def setup + @connection = Core.const_get(:Connection).new + end + + def test_initialization_defaults + assert_equal Core.const_get(:Connection)::DEFAULT_OPEN_TIMEOUT, @connection.open_timeout + assert_equal Core.const_get(:Connection)::DEFAULT_READ_TIMEOUT, @connection.read_timeout + assert_equal Core.const_get(:Connection)::DEFAULT_WRITE_TIMEOUT, @connection.write_timeout + assert_nil @connection.debug_output + assert_nil @connection.send(:proxy_url) + end + + def test_the_errors_a_connection_reads_as_network_errors_are_private + assert_raises(NameError) { Core.const_get(:Connection)::NETWORK_ERRORS } + assert_raises(NameError) { Core.const_get(:Connection)::STALE_CONNECTION_ERRORS } + end + + def test_custom_initialization + connection = Core.const_get(:Connection).new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, + proxy_url: "http://example.com:8080") + + assert_equal 10, connection.open_timeout + assert_equal 20, connection.read_timeout + assert_equal 30, connection.write_timeout + assert_equal $stderr, connection.debug_output + assert_equal "http://example.com:8080", connection.send(:proxy_url) + end + + def test_http_client_defaults + http_client = @connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_equal "api.x.com", http_client.address + assert_equal 443, http_client.port + assert_equal Core.const_get(:Connection)::DEFAULT_OPEN_TIMEOUT, http_client.open_timeout + assert_equal Core.const_get(:Connection)::DEFAULT_READ_TIMEOUT, http_client.read_timeout + assert_equal Core.const_get(:Connection)::DEFAULT_WRITE_TIMEOUT, http_client.write_timeout + end + + def test_http_client_leaves_retries_to_the_caller + assert_equal 0, @connection.send(:build_http_client, URI("https://api.x.com/2/tweets")).max_retries + end + + def test_http_client_keeps_a_connection_open_beyond_a_burst_of_requests + assert_equal Core.const_get(:Connection)::DEFAULT_KEEP_ALIVE_TIMEOUT, @connection.keep_alive_timeout + assert_equal 30, @connection.send(:build_http_client, URI("https://api.x.com/2/tweets")).keep_alive_timeout + end + + def test_http_client_keeps_a_connection_open_for_the_keep_alive_timeout_given + connection = Core.const_get(:Connection).new(keep_alive_timeout: 5) + + assert_equal 5, connection.keep_alive_timeout + assert_equal 5, connection.send(:build_http_client, URI("https://api.x.com/2/tweets")).keep_alive_timeout + end + + def test_debug_output + http_client = @connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_nil http_client.instance_variable_get(:@debug_output) + end + + def test_client_properties + connection = Core.const_get(:Connection).new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, + proxy_url: "https://proxy.com") + http_client = connection.send(:build_http_client, URI("https://api.x.com/2/tweets")) + + assert_predicate http_client, :proxy? + assert_equal 10, http_client.open_timeout + assert_equal 20, http_client.read_timeout + assert_equal 30, http_client.write_timeout + assert_equal $stderr, http_client.instance_variable_get(:@debug_output) + end + + def test_perform + stub_request(:get, "http://example.com:80") + request = Net::HTTP::Get.new(URI("http://example.com:80")) + @connection.perform(request:) + + assert_requested :get, "http://example.com:80" + end + end + + class ConnectionIPv6Test < Minitest::Test + include LocalServer + + cover Core.const_get(:Connection) + + # The first net-http that can request a host named by an IPv6 literal, which Ruby 4.0 ships; the 0.6 of Ruby 3.4 + # builds the Host header of such a request without the brackets, then empties it, and raises before it connects + NET_HTTP_WITH_IPV6_LITERALS = Gem::Version.new("0.8") + + def setup + @connection = Core.const_get(:Connection).new + skip "net-http #{Net::HTTP::VERSION} cannot request an IPv6 literal host" if net_http < NET_HTTP_WITH_IPV6_LITERALS + end + + # The version of the net-http that Net::HTTP comes from + def net_http = Gem::Version.new(Net::HTTP::VERSION) + + def teardown + @connection.close + end + + def test_perform_connects_to_an_ipv6_literal_host + with_local_server(host: "::1") do |port| + assert_kind_of Net::HTTPSuccess, @connection.perform(request: Net::HTTP::Get.new(URI("http://[::1]:#{port}/"))) + end + rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError => e + skip "this host cannot serve ::1: #{e}" + end + + def test_perform_stream_connects_to_an_ipv6_literal_host + with_local_server(host: "::1", response: "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") do |port| + chunks = [] + @connection.perform_stream(request: Net::HTTP::Get.new(URI("http://[::1]:#{port}/"))) do |response| + response.read_body { |chunk| chunks << chunk } + end + + assert_equal ["{}"], chunks + end + rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError => e + skip "this host cannot serve ::1: #{e}" + end + end + + class ConnectionNetworkErrorTest < Minitest::Test + cover Core.const_get(:Connection) + + def setup + @connection = Core.const_get(:Connection).new + end + + def test_network_error + stub_request(:get, "https://example.com").to_raise(Errno::ECONNREFUSED) + request = Net::HTTP::Get.new(URI("https://example.com")) + error = assert_raises(NetworkError) { @connection.perform(request:) } + + assert_equal "GET /: Network error: #{Errno::ECONNREFUSED.new("Exception from WebMock").message}", error.message + end + + [IOError, Net::HTTPBadResponse, Net::ProtocolError, OpenSSL::SSL::SSLError, SocketError, SystemCallError, + Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Zlib::Error].each do |error_class| + define_method(:"test_wraps_#{error_class.name.downcase.tr(":", "_")}") do + stub_request(:get, "https://example.com").to_raise(error_class) + request = Net::HTTP::Get.new(URI("https://example.com")) + + assert_raises(NetworkError) { @connection.perform(request:) } + end + end + + def test_raises_a_timeout_error_of_no_socket_as_it_is + stub_request(:get, "https://example.com").to_raise(Timeout::Error) + + assert_raises(Timeout::Error) { @connection.perform(request: Net::HTTP::Get.new(URI("https://example.com"))) } + end + + def test_wraps_the_errors_that_descend_from_those_it_names + [Errno::ECONNABORTED, Errno::ENETDOWN, Net::ReadTimeout, Zlib::BufError, + Net::HTTPClientException.new("407 Proxy Authentication Required", nil)].each do |error| + stub_request(:get, "https://example.com").to_raise(error) + + assert_raises(NetworkError) { @connection.perform(request: Net::HTTP::Get.new(URI("https://example.com"))) } + end + end + end +end diff --git a/x-core/test/x/core/credential_marshal_test.rb b/x-core/test/x/core/credential_marshal_test.rb new file mode 100644 index 00000000..4fc8d08c --- /dev/null +++ b/x-core/test/x/core/credential_marshal_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "json" +require "yaml" +require_relative "../../test_helper" + +module X + # What holds credentials refuses Marshal, YAML, and JSON, which would write them in the clear wherever it is kept + class CredentialMarshalTest < Minitest::Test + cover Core.const_get(:CredentialHolder) + + def holders + client = Client.new(api_key: "KEY", api_key_secret: "SECRET", bearer_token: "BEARER") + [client, BearerTokenAuthenticator.new(bearer_token: "BEARER"), + OAuth1Authenticator.new(api_key: "KEY", api_key_secret: "SECRET", access_token: "1-TOKEN", access_token_secret: "TOKEN_SECRET"), + OAuth2Authenticator.new(client_id: "CLIENT", access_token: "ACCESS", refresh_token: "REFRESH"), + AppOnlyAuthenticator.new(api_key: "KEY", api_key_secret: "SECRET"), Class.new(Authenticator).new, + OAuth2Authorization.new(client_id: "CLIENT", client_secret: "CLIENT_SECRET", redirect_uri: "https://example.com/callback")] + end + + def test_what_holds_credentials_refuses_marshal + holders.each do |holder| + error = assert_raises(TypeError) { Marshal.dump(holder) } + + assert_equal "#{holder.class} holds credentials, which Marshal would write in the clear wherever it is kept; keep the credentials " \ + "in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them", error.message + end + end + + def test_what_holds_credentials_refuses_marshal_within_what_is_marshalled + assert_raises(TypeError) { Marshal.dump({"client" => Client.new(bearer_token: "BEARER")}) } + end + + def test_what_holds_credentials_refuses_yaml + holders.each do |holder| + error = assert_raises(TypeError) { YAML.dump(holder) } + + assert_equal "#{holder.class} holds credentials, which YAML would write in the clear wherever it is kept; keep the credentials " \ + "in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them", error.message + end + end + + def test_what_holds_credentials_refuses_yaml_within_what_is_written + assert_raises(TypeError) { YAML.dump({"client" => Client.new(bearer_token: "BEARER")}) } + assert_raises(TypeError) { Client.new(bearer_token: "BEARER").to_yaml } + end + + def test_what_holds_credentials_refuses_json + holders.product([[:as_json], [:as_json, {only: "id"}], [:to_json], [:to_json, JSON::State.new]]).each do |holder, call| + error = assert_raises(TypeError) { holder.public_send(*call) } + + assert_equal "#{holder.class} holds credentials, which JSON would write in the clear wherever it is kept; keep the credentials " \ + "in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them", error.message + end + end + + def test_what_holds_credentials_refuses_json_within_what_is_written + assert_raises(TypeError) { JSON.generate({"client" => Client.new(bearer_token: "BEARER")}) } + assert_raises(TypeError) { JSON.generate([BearerTokenAuthenticator.new(bearer_token: "BEARER")]) } + end + + def test_the_tokens_of_a_refresh_still_marshal + tokens = OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH") + + assert_equal tokens, Marshal.load(Marshal.dump(tokens)) + end + + def test_the_message_is_named_privately + assert_raises(NameError) { Core.const_get(:CredentialHolder)::REFUSAL_MESSAGE } + end + end +end diff --git a/x-core/test/x/core/credential_privacy_test.rb b/x-core/test/x/core/credential_privacy_test.rb new file mode 100644 index 00000000..2d09f9e2 --- /dev/null +++ b/x-core/test/x/core/credential_privacy_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client keeps the credentials that are secrets to itself, and so must the authenticator it signs with, which + # every client hands out. A reader of one is a way around the other. + class CredentialPrivacyTest < Minitest::Test + cover_client + cover Authenticator + cover OAuth1Authenticator + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover AppOnlyAuthenticator + cover BearerTokenAuthenticator + + SECRETS = %i[api_key_secret access_token_secret client_secret bearer_token].freeze + + def authenticators + [Authenticator.new, + OAuth1Authenticator.new(**test_oauth_credentials), + OAuth2Authenticator.new(**test_oauth2_credentials), + AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, bearer_token: TEST_BEARER_TOKEN), + BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN)] + end + + def test_no_authenticator_reveals_a_secret + authenticators.each do |authenticator| + SECRETS.each { |secret| refute_respond_to authenticator, secret, "#{authenticator.class} reveals #{secret}" } + end + end + + def test_no_client_reveals_the_secrets_of_its_authenticator + [test_oauth_credentials, test_oauth2_credentials, {bearer_token: TEST_BEARER_TOKEN}].each do |credentials| + client = Client.new(**credentials) + + SECRETS.each { |secret| refute_respond_to client.authenticator, secret } + end + end + + # The signature is the only way left to tell that a client passed each credential on to its authenticator, now + # that no secret is readable, so a request signed with a credential changed must differ from one signed without. + def authorization_for(**changes) + sent = nil + stub_request(:get, "https://api.x.com/2/tweets").with do |request| + sent = request.headers["Authorization"] + true + end.to_return(body: "{}", headers: {"Content-Type" => "application/json"}) + SecureRandom.stub(:hex, TEST_OAUTH_NONCE) do + Time.stub(:now, Time.utc(1983, 11, 24)) { Client.new(**test_oauth_credentials.merge(changes)).get("tweets") } + end + sent + end + + def test_a_client_signs_with_each_credential_it_was_given + baseline = authorization_for + + assert baseline.start_with?("OAuth ") + %i[api_key api_key_secret access_token access_token_secret].each do |credential| + refute_equal baseline, authorization_for(credential => "OTHER"), "#{credential} never reaches the signature" + end + end + end +end diff --git a/x-core/test/x/core/custom_authenticator_test.rb b/x-core/test/x/core/custom_authenticator_test.rb new file mode 100644 index 00000000..f6bcce8f --- /dev/null +++ b/x-core/test/x/core/custom_authenticator_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An authenticator of your own, a subclass of X::Authenticator that overrides headers, authenticates the requests of + # a client given it, as the authenticators of x-core do + class CustomAuthenticatorTest < Minitest::Test + cover_client + cover Core.const_get(:CredentialValidator) + cover Authenticator + + # Signs each request with its method and path, as a scheme of an application's own might + class SigningAuthenticator < Authenticator + attr_reader :signed + + def initialize + super + @signed = [] + end + + def headers(request) + @signed << [request.http_method, request.uri.to_s, request.body] + {AUTHENTICATION_HEADER => "Signed #{request.http_method} #{request.uri.path}", "X-Signature" => "sig"} + end + end + + def setup + @authenticator = SigningAuthenticator.new + @client = Client.new(authenticator: @authenticator) + end + + def test_a_client_sends_the_headers_its_authenticator_builds_for_each_request + stub_request(:post, "https://api.x.com/2/tweets").with(headers: {"Authorization" => "Signed post /2/tweets", "X-Signature" => "sig"}) + @client.post("tweets", {text: "Hi"}) + + assert_same @authenticator, @client.authenticator + assert_equal [[:post, "https://api.x.com/2/tweets", '{"text":"Hi"}']], @authenticator.signed + end + + def test_the_headers_of_the_authenticator_replace_headers_of_the_same_name + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Signed get /2/users/me"}) + @client.get("users/me", headers: {"Authorization" => "Bearer SOMETHING_ELSE"}) + + assert_requested :get, "https://api.x.com/2/users/me", headers: {"Authorization" => "Signed get /2/users/me"} + end + + def test_a_request_to_another_origin_is_not_passed_to_the_authenticator + stub_request(:get, "https://upload.x.com/1.1/media/upload.json") + @client.get("https://upload.x.com/1.1/media/upload.json") + + assert_empty @authenticator.signed + assert_requested(:get, "https://upload.x.com/1.1/media/upload.json") { |request| !request.headers.key?("Authorization") } + end + + def test_a_copy_and_the_app_only_client_authenticate_with_it + assert_same @client, @client.app_only + assert_same @authenticator, @client.with(max_retries: 0).authenticator + end + + def test_a_stream_is_opened_with_it + stub_request(:get, "https://api.x.com/2/tweets/sample/stream").with(headers: {"Authorization" => "Signed get /2/tweets/sample/stream"}) + .to_return(body: "{}") + + assert_equal "{}", @client.get_stream("tweets/sample/stream") { |response| response.read_body { |chunk| break chunk } } + end + end +end diff --git a/x-core/test/x/core/error_raised_bare_test.rb b/x-core/test/x/core/error_raised_bare_test.rb new file mode 100644 index 00000000..518dde93 --- /dev/null +++ b/x-core/test/x/core/error_raised_bare_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An error that takes a message is raised with none, as any exception is, as a test stub may raise it, and is named + # by its class, naming no request + class ErrorRaisedBareTest < Minitest::Test + cover NetworkError + cover TooManyRedirects + cover AuthorizationDenied + + URI_OF_REQUEST = URI("https://api.x.com/2/users/me") + + def test_errors_that_take_a_message_are_raised_with_none + [NetworkError, TooManyRedirects, AuthorizationDenied].each do |error_class| + error = assert_raises(error_class) { raise error_class } + + assert_equal error_class.name, error.message + end + end + + def test_an_error_given_no_message_names_no_request + [NetworkError, TooManyRedirects].each do |error_class| + error = error_class.new(http_method: :get, uri: URI_OF_REQUEST) + + assert_equal [error_class.name, :get, URI_OF_REQUEST], [error.message, error.http_method, error.uri] + end + end + + def test_an_error_given_a_message_still_names_the_request + [NetworkError, TooManyRedirects].each do |error_class| + assert_equal "GET /2/users/me: boom", error_class.new("boom", http_method: :get, uri: URI_OF_REQUEST).message + end + end + + def test_an_authorization_denied_given_no_message_holds_no_error_code + error = AuthorizationDenied.new + + assert_equal ["X::AuthorizationDenied", nil], [error.message, error.error_code] + end + end +end diff --git a/x-core/test/x/core/error_test.rb b/x-core/test/x/core/error_test.rb new file mode 100644 index 00000000..5ce82923 --- /dev/null +++ b/x-core/test/x/core/error_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "json" +require_relative "../../test_helper" + +module X + class ErrorsTest < Minitest::Test + cover_client + + def setup + # A client that raises at once, since these tests are about the error a failure raises rather than the + # retries a client makes before it + @client = Client.new(max_retries: 0) + end + + Core.const_get(:ResponseParser)::ERROR_MAP.each do |status, error_class| + name = error_class.name.split("::").last + define_method :"test_initialize_#{name.downcase}_error" do + response = Net::HTTPResponse::CODE_TO_OBJ[status.to_s].new("1.1", status, error_class.name) + exception = error_class.new(http_response: response) + + assert_equal error_class.name, exception.message + assert_equal response, exception.http_response + assert_equal status, exception.status + end + end + + def test_an_authorization_error_is_a_client_error + assert_equal [AuthorizationError, ClientError, HTTPError], AuthorizationError.ancestors.take(3) + end + + def test_an_authorization_denied_is_no_http_error + assert_equal [AuthorizationDenied, Error, StandardError], AuthorizationDenied.ancestors.take_while { |ancestor| !ancestor.eql?(Exception) } + end + + [IOError, Net::HTTPBadResponse, Net::ProtocolError, OpenSSL::SSL::SSLError, SocketError, SystemCallError, + Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Zlib::Error].each do |error_class| + define_method "test_#{error_class.name.gsub("::", "_").downcase}_raises_network_error" do + stub_request(:get, "https://api.x.com/2/tweets").to_raise(error_class) + + assert_raises NetworkError do + @client.get("tweets") + end + end + end + + def test_eof_error_raises_network_error + stub_request(:get, "https://api.x.com/2/tweets").to_raise(EOFError) + + assert_raises(NetworkError) { @client.get("tweets") } + end + + def test_an_unsupported_format_is_no_http_error + require "x/core/errors/unsupported_format" + + assert_equal [UnsupportedFormat, Error, StandardError], UnsupportedFormat.ancestors.take_while { |ancestor| !ancestor.eql?(Exception) } + end + + def test_connection_exception_is_gone + refute X.const_defined?(:ConnectionException) + end + + def test_unexpected_response + stub_request(:get, "https://api.x.com/2/tweets").to_return(status: 600) + + assert_raises Error do + @client.get("tweets") + end + end + + def test_problem_json + body = {error: "problem"}.to_json + stub_request(:get, "https://api.x.com/2/tweets") + .to_return(status: 400, headers: {"content-type" => "application/problem+json"}, body:) + + begin + @client.get("tweets") + rescue BadRequest => e + assert_equal "GET /2/tweets: problem", e.message + end + end + end +end diff --git a/x-core/test/x/core/http_error_body_test.rb b/x-core/test/x/core/http_error_body_test.rb new file mode 100644 index 00000000..7493cea7 --- /dev/null +++ b/x-core/test/x/core/http_error_body_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class HTTPErrorBodyTest < Minitest::Test + cover HTTPError + + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + HTML_HEADERS = {"Content-Type" => "text/html"}.freeze + + def setup + @response_parser = Core.const_get(:ResponseParser).new + @uri = URI("http://example.com") + end + + def response = Net::HTTP.get_response(@uri) + + def error_for(body, headers: JSON_HEADERS) + stub_request(:get, @uri.to_s).to_return(status: [400, "Bad Request"], body:, headers:) + assert_raises(BadRequest) { @response_parser.parse(response:) } + end + + def test_the_body_is_the_json_the_api_sent + body = '{"title": "Invalid Request", "detail": "One or more parameters are invalid"}' + + assert_equal body, error_for(body).body + end + + def test_the_body_is_whatever_was_sent_in_place_of_json + assert_equal "Bad", error_for("Bad", headers: HTML_HEADERS).body + end + + def test_a_response_without_a_body_has_none + response = Net::HTTPBadRequest.new("1.1", "400", "Bad Request") + response.instance_variable_set(:@read, true) + + assert_nil BadRequest.new(http_response: response).body + end + + def test_the_problem_is_the_problem_the_body_describes_beside_the_errors_it_names + body = {errors: [{parameters: {ids: ["abc"]}, message: "The ids query parameter is invalid"}], title: "Invalid Request", + detail: "One or more parameters to your request was invalid.", type: "https://api.twitter.com/2/problems/invalid-request"} + problem = error_for(body.to_json).problem + + assert_equal ["Invalid Request", "One or more parameters to your request was invalid.", "https://api.twitter.com/2/problems/invalid-request"], + [problem.title, problem.detail, problem.type] + assert_equal JSON.parse(body.to_json), problem.to_h + end + + def test_the_problem_of_a_body_that_names_errors_alone_is_the_first_of_them + body = {errors: [{parameters: {ids: ["abc"]}, message: "The ids query parameter is invalid"}, {message: "Second"}]} + error = error_for(body.to_json) + + assert_equal "The ids query parameter is invalid", error.problem.message + assert_same error.problems.first, error.problem + end + + def test_the_problem_is_the_body_of_a_response_that_describes_the_failure_itself + body = {type: "https://api.x.com/2/problems/invalid-request", title: "Invalid Request", detail: "One or more parameters are invalid"} + + assert_equal JSON.parse(body.to_json), error_for(body.to_json).problem.to_h + end + + def test_each_key_that_describes_a_failure_makes_the_body_the_problem + %w[title detail type error].each { |key| assert_equal({key => "value"}, error_for({key => "value"}.to_json).problem.to_h) } + end + + def test_an_errors_field_that_is_not_an_array_falls_back_to_the_problem_the_body_describes + body = '{"errors": {"message": "Some Error"}, "detail": "One or more parameters are invalid"}' + + assert_equal JSON.parse(body), error_for(body).problem.to_h + end + + def test_an_errors_array_of_anything_but_objects_falls_back_to_the_problem_the_body_describes + body = '{"errors": ["not an object"], "detail": "One or more parameters are invalid"}' + + assert_equal JSON.parse(body), error_for(body).problem.to_h + end + + def test_the_problem_is_frozen + assert_predicate error_for('{"error": "Some Error"}').problem.to_h, :frozen? + end + + def test_a_body_that_describes_no_problem_has_none + assert_nil error_for('{"data": []}').problem + end + + def test_a_body_that_is_not_json_describes_no_problem + assert_nil error_for("Bad", headers: HTML_HEADERS).problem + end + end +end diff --git a/x-core/test/x/core/http_error_headers_test.rb b/x-core/test/x/core/http_error_headers_test.rb new file mode 100644 index 00000000..10f35916 --- /dev/null +++ b/x-core/test/x/core/http_error_headers_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class HTTPErrorHeadersTest < Minitest::Test + cover HTTPError + cover Core.const_get(:ResponseHeaders) + + URL = "https://api.x.com/2/users/me" + + def test_the_headers_are_read_by_lowercase_name + error = error_for("Content-Type" => "application/json", "X-Rate-Limit-Remaining" => "0") + + assert_equal({"content-type" => "application/json", "x-rate-limit-remaining" => "0"}, error.headers) + end + + def test_a_header_sent_more_than_once_is_joined_with_a_comma + error = error_for({}) + error.http_response.add_field("x-label", "one") + error.http_response.add_field("x-label", "two") + + assert_equal "one, two", error.headers["x-label"] + end + + def test_the_headers_are_frozen_and_a_response_without_any_has_none + error = error_for({}) + + assert_predicate error.headers, :frozen? + assert_empty TooManyRequests.new(http_response: Net::HTTPTooManyRequests.new("1.1", "429", "")).headers + end + + private + + # The error of a response the API refused with the given headers + def error_for(headers) + stub_request(:get, URL).to_return(status: 429, headers:) + assert_raises(TooManyRequests) { Client.new.get("users/me") } + end + end +end diff --git a/x-core/test/x/core/http_error_message_problem_test.rb b/x-core/test/x/core/http_error_message_problem_test.rb new file mode 100644 index 00000000..9ce34a0a --- /dev/null +++ b/x-core/test/x/core/http_error_message_problem_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The message of an error whose body describes a problem by its title and detail says each once + class HTTPErrorMessageProblemTest < Minitest::Test + cover HTTPError + + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + + def setup + @response_parser = Core.const_get(:ResponseParser).new + @uri = URI("http://example.com") + end + + def response = Net::HTTP.get_response(@uri) + + def stub_json(status: 200, body: "{}") + stub_request(:get, @uri.to_s).to_return(status:, body:, headers: JSON_HEADERS) + end + + def test_error_whose_detail_is_its_title_says_it_once + stub_json(status: [401, "Unauthorized"], body: '{"title": "Unauthorized", "type": "about:blank", "status": 401, "detail": "Unauthorized"}') + + assert_equal "Unauthorized", assert_raises(Unauthorized) { @response_parser.parse(response:) }.message + end + + def test_error_whose_detail_is_its_title_says_it_in_place_of_the_status + stub_json(status: [400, "Bad Request"], body: '{"title": "Invalid Request", "detail": "Invalid Request"}') + + assert_equal "Invalid Request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_whose_detail_differs_from_its_title_in_case_alone_says_both + stub_json(status: 400, body: '{"title": "Invalid Request", "detail": "invalid request"}') + + assert_equal "Invalid Request: invalid request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + end +end diff --git a/x-core/test/x/core/http_error_message_test.rb b/x-core/test/x/core/http_error_message_test.rb new file mode 100644 index 00000000..a5fd8248 --- /dev/null +++ b/x-core/test/x/core/http_error_message_test.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class HTTPErrorMessageTest < Minitest::Test + cover HTTPError + + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + + def setup + @response_parser = Core.const_get(:ResponseParser).new + @uri = URI("http://example.com") + end + + def response = Net::HTTP.get_response(@uri) + + def stub_json(status: 200, body: "{}") + stub_request(:get, @uri.to_s).to_return(status:, body:, headers: JSON_HEADERS) + end + + def test_an_error_without_a_content_type_takes_the_status_message + assert_equal "Bad Request", BadRequest.new(http_response: Net::HTTPBadRequest.new("1.1", "400", "Bad Request")).message + end + + def test_the_error_holds_its_response_and_status + stub_json(status: [404, "Not Found"], body: '{"title": "Not Found Error", "detail": "Could not find user"}') + error = assert_raises(NotFound) { @response_parser.parse(response:) } + + assert_kind_of Net::HTTPNotFound, error.http_response + assert_equal [404, "Not Found Error: Could not find user"], [error.status, error.message] + end + + def test_error_with_title_only_falls_back_to_status + stub_json(status: [400, "Bad Request"], body: '{"title": "Some Error"}') + + assert_equal "Bad Request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_with_detail_only_falls_back_to_status + stub_json(status: [400, "Bad Request"], body: '{"detail": "Something went wrong"}') + + assert_equal "Bad Request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_with_title_and_detail + stub_json(status: 400, body: '{"title": "Some Error", "detail": "Something went wrong"}') + + assert_equal "Some Error: Something went wrong", + assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_with_error_field + stub_json(status: 400, body: '{"error": "Some Error"}') + + assert_equal "Some Error", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_with_errors_array + stub_json(status: 400, body: '{"errors": [{"message": "Error 1"}, {"message": "Error 2"}]}') + + assert_equal "Error 1, Error 2", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_errors_array_takes_priority_over_title_and_detail + body = {title: "Generic", detail: "Details", errors: [{message: "Specific error"}]}.to_json + stub_json(status: 400, body:) + + assert_equal "Specific error", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_with_non_array_errors_field + stub_json(status: 400, body: '{"errors": {"message": "Some Error"}}') + + assert_empty assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_json_body_without_a_json_content_type_falls_back_to_status + stub_request(:get, @uri.to_s) + .to_return(status: [400, "Bad Request"], body: '{"error": "Some Error"}', headers: {"Content-Type" => "text/plain"}) + + assert_equal "Bad Request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_problem_json_content_type + stub_request(:get, @uri.to_s) + .to_return(status: 400, body: '{"error": "problem"}', headers: {"Content-Type" => "application/problem+json"}) + + assert_equal "problem", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_non_json_error_response + stub_request(:get, @uri.to_s) + .to_return(status: [400, "Bad Request"], body: "Bad", headers: {"Content-Type" => "text/html"}) + + assert_equal "Bad Request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_errors_array_takes_detail_or_title_when_an_error_has_no_message + body = {errors: [{detail: "Detail", title: "Title"}, {title: "Title only"}, {message: 1, detail: "Not a number"}]}.to_json + stub_json(status: 400, body:) + + assert_equal "Detail, Title only, Not a number", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_errors_array_skips_errors_without_a_message + body = {errors: [{code: 1}, "not an object", {message: "Kept"}]}.to_json + stub_json(status: 400, body:) + + assert_equal "Kept", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_errors_array_without_messages_falls_back_to_title_and_detail + body = {title: "Some Error", detail: "Something went wrong", errors: [{code: 1}]}.to_json + stub_json(status: 400, body:) + + assert_equal "Some Error: Something went wrong", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_empty_errors_array_falls_back_to_error_field + stub_json(status: 400, body: '{"errors": [], "error": "Some Error"}') + + assert_equal "Some Error", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + def test_error_field_that_is_not_a_string_falls_back_to_status + stub_json(status: [400, "Bad Request"], body: '{"error": {"code": 1}}') + + assert_equal "Bad Request", assert_raises(BadRequest) { @response_parser.parse(response:) }.message + end + + {"invalid JSON" => "Oops", "empty" => "", "array" => "[1]", "string" => '"text"'}.each do |name, body| + define_method(:"test_#{name.tr(" ", "_")}_json_error_body_falls_back_to_status") do + stub_json(status: [503, "Service Unavailable"], body:) + + assert_equal "Service Unavailable", assert_raises(ServiceUnavailable) { @response_parser.parse(response:) }.message + end + end + + def test_json_error_without_a_body_falls_back_to_status + response = Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable") + response["content-type"] = "application/json" + response.instance_variable_set(:@read, true) + + assert_equal "Service Unavailable", ServiceUnavailable.new(http_response: response).message + end + end +end diff --git a/x-core/test/x/core/http_error_problems_test.rb b/x-core/test/x/core/http_error_problems_test.rb new file mode 100644 index 00000000..944cbb9c --- /dev/null +++ b/x-core/test/x/core/http_error_problems_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An error holds every problem the body of its response names, and the problem it describes the failure with + class HTTPErrorProblemsTest < Minitest::Test + cover HTTPError + + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + URL = "http://example.com" + + def error_for(body, headers: JSON_HEADERS) + stub_request(:get, URL).to_return(status: [400, "Bad Request"], body:, headers:) + assert_raises(BadRequest) { Core.const_get(:ResponseParser).new.parse(response: Net::HTTP.get_response(URI(URL))) } + end + + def test_the_problems_are_every_error_the_body_names + body = {title: "Invalid Request", errors: [{parameters: {ids: ["abc"]}, message: "First"}, "not an object", + {parameters: {"user.fields": ["foo"]}, message: "Second"}]} + error = error_for(body.to_json) + + assert_equal %w[First Second], error.problems.map(&:message) + assert_equal ["ids", "user.fields"], error.problems.flat_map { |problem| problem.to_h.fetch("parameters", {}).keys } + assert_predicate error.problems, :frozen? + end + + def test_the_problem_of_a_request_the_api_refused_a_parameter_of_is_the_problem_of_the_whole_response + error = error_for({errors: [{parameters: {ids: ["abc"]}, message: "The `ids` query parameter value [abc] is not valid"}], + title: "Invalid Request", detail: "One or more parameters to your request was invalid.", + type: "https://api.twitter.com/2/problems/invalid-request"}.to_json) + + assert_equal ["Invalid Request", "https://api.twitter.com/2/problems/invalid-request"], [error.problem.title, error.problem.type] + assert_equal ["The `ids` query parameter value [abc] is not valid"], error.problems.map(&:message) + assert_equal "The `ids` query parameter value [abc] is not valid", error.message + end + + def test_the_problems_are_the_body_of_a_response_that_describes_the_failure_itself + body = {"title" => "Unauthorized", "detail" => "Unauthorized", "type" => "about:blank", "status" => 401} + error = error_for(body.to_json) + + assert_equal [body], error.problems.map(&:to_h) + assert_same error.problem, error.problems.first + assert_predicate error.problems, :frozen? + end + + def test_a_body_that_describes_no_problem_has_no_problems + assert_empty error_for('{"data": []}').problems + assert_empty error_for("Bad", headers: {"Content-Type" => "text/html"}).problems + end + end +end diff --git a/x-core/test/x/core/http_error_raised_test.rb b/x-core/test/x/core/http_error_raised_test.rb new file mode 100644 index 00000000..5acd3c39 --- /dev/null +++ b/x-core/test/x/core/http_error_raised_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An HTTP error of a status is raised as any other exception is, with or without a message, as a test double that + # stands in for a client raises one + class HTTPErrorRaisedTest < Minitest::Test + cover HTTPError + cover InvalidResponse + + STATUSES = {BadRequest => 400, Unauthorized => 401, PaymentRequired => 402, Forbidden => 403, NotFound => 404, + MethodNotAllowed => 405, NotAcceptable => 406, RequestTimeout => 408, Conflict => 409, Gone => 410, + PayloadTooLarge => 413, UnsupportedMediaType => 415, UnprocessableEntity => 422, TooManyRequests => 429, + UnavailableForLegalReasons => 451, InternalServerError => 500, BadGateway => 502, + ServiceUnavailable => 503, GatewayTimeout => 504, ClientError => 400, ServerError => 500, + InvalidResponse => 200}.freeze + + def test_an_error_of_a_status_is_raised_without_arguments + error = assert_raises(NotFound) { raise NotFound } + + assert_equal [404, "Not Found", nil], [error.status, error.message, error.body] + end + + def test_an_error_of_a_status_is_raised_with_a_message + error = assert_raises(TooManyRequests) { raise TooManyRequests, "slow down" } + + assert_equal [429, "slow down"], [error.status, error.message] + end + + def test_each_error_of_a_status_is_built_with_its_status + assert_equal STATUSES, STATUSES.to_h { |error_class, _| [error_class, error_class.new.status] } + end + + def test_an_error_that_descends_from_one_of_a_status_is_built_with_its_status + assert_equal 404, Class.new(NotFound).new.status + assert_equal 400, Class.new(ClientError).new.status + assert_equal 200, Class.new(InvalidResponse).new.status + end + + def test_a_message_takes_the_place_of_the_one_the_body_describes + error = NotFound.new("gone", status: 404, headers: {"content-type" => "application/json"}, body: '{"title":"Not Found Error"}') + + assert_equal ["gone", "Not Found Error"], [error.message, error.problem.title] + end + + def test_a_message_names_the_request + error = NotFound.new("gone", http_method: :get, uri: URI("https://api.x.com/2/users/1")) + + assert_equal "GET /2/users/1: gone", error.message + end + + def test_a_status_given_is_the_status_of_the_error + assert_equal 418, ClientError.new(status: 418).status + end + + def test_an_invalid_response_is_raised_with_a_message + error = assert_raises(InvalidResponse) { raise InvalidResponse, "not JSON" } + + assert_equal [200, "not JSON", nil], [error.status, error.message, error.body] + end + + def test_an_http_error_itself_must_be_given_a_status + assert_raises(ArgumentError) { raise HTTPError } + assert_raises(ArgumentError) { HTTPError.new("failed") } + end + end +end diff --git a/x-core/test/x/core/http_error_retry_after_test.rb b/x-core/test/x/core/http_error_retry_after_test.rb new file mode 100644 index 00000000..8eb1f141 --- /dev/null +++ b/x-core/test/x/core/http_error_retry_after_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class HTTPErrorRetryAfterTest < Minitest::Test + cover HTTPError + + def error_for(retry_after, date: nil) + response = Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable") + response["retry-after"] = retry_after unless retry_after.nil? + response["date"] = date unless date.nil? + ServiceUnavailable.new(http_response: response) + end + + def test_a_response_that_asks_for_no_wait_asks_for_nothing + assert_nil error_for(nil).retry_after + end + + # An error whose response was sent at a time on the clock of the API, and asks for a wait of 30 seconds on it + def api_clock_error(sent) = error_for((sent + 30).httpdate, date: sent.httpdate) + + def test_the_header_counts_the_seconds_to_wait + assert_equal 42, error_for("42").retry_after + end + + def test_a_padded_header_counts_in_tens_rather_than_eights + assert_equal 60, error_for("060").retry_after + end + + def test_the_header_names_the_time_to_wait_until + Time.stub :now, Time.utc(1983, 11, 24) do + assert_equal 300, error_for((Time.now + 300).httpdate).retry_after + end + end + + def test_the_part_of_a_second_an_http_date_leaves_off_is_waited_out + Time.stub :now, Time.utc(1983, 11, 24, 0, 0, 0, 900_000) do + assert_equal 62, error_for((Time.now + 62).httpdate).retry_after + end + end + + def test_a_time_that_has_passed_asks_for_no_wait_rather_than_a_negative_one + Time.stub :now, Time.utc(1983, 11, 24) do + assert_equal 0, error_for((Time.now - 300).httpdate).retry_after + end + end + + def test_an_http_date_counts_from_the_date_the_response_was_sent_on_the_clock_of_the_api + Time.stub :now, Time.utc(1983, 11, 24) do + assert_equal [30, 30], [-3600, 3600].map { |offset| api_clock_error(Time.now + offset).retry_after } + end + end + + def test_an_http_date_counts_from_now_when_the_date_of_the_response_cannot_be_read + Time.stub :now, Time.utc(1983, 11, 24) do + assert_equal 300, error_for((Time.now + 300).httpdate, date: "yesterday").retry_after + end + end + + def test_a_header_that_names_no_time_asks_for_nothing + assert_nil error_for("whenever you like").retry_after + end + end +end diff --git a/x-core/test/x/core/invalid_response_test.rb b/x-core/test/x/core/invalid_response_test.rb new file mode 100644 index 00000000..21a69a12 --- /dev/null +++ b/x-core/test/x/core/invalid_response_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class InvalidResponseTest < Minitest::Test + cover InvalidResponse + + def setup + @response = Net::HTTPOK.new("1.1", "200", "OK") + @response["content-type"] = "text/html" + end + + def test_the_error_holds_the_response_and_the_body_it_is_given + error = InvalidResponse.new(http_response: @response, body: "") + + assert_same @response, error.http_response + assert_equal "", error.body + assert_equal "The body of the 200 response is not JSON (text/html)", error.message + end + + def test_the_body_given_is_tagged_utf_8_and_keeps_its_bytes + given = "\xFF".b + body = InvalidResponse.new(status: 200, body: given).body + + assert_equal [Encoding::UTF_8, "\xFF".b, false, Encoding::BINARY], [body.encoding, body.b, body.valid_encoding?, given.encoding] + end + + def test_the_error_reads_the_status_as_an_integer + assert_equal 200, InvalidResponse.new(http_response: @response).status + end + + def test_the_error_reads_the_headers_by_lowercase_name_and_joins_a_repeated_one + @response.add_field("X-Cache", "MISS") + @response.add_field("X-Cache", "HIT") + headers = InvalidResponse.new(http_response: @response).headers + + assert_equal({"content-type" => "text/html", "x-cache" => "MISS, HIT"}, headers) + assert_predicate headers, :frozen? + end + + def test_an_error_without_a_body_never_reads_the_body_of_the_response + @response.define_singleton_method(:body) { raise IOError, "attempt to read body out of block" } + + assert_nil InvalidResponse.new(http_response: @response).body + end + + def test_an_error_without_a_body_holds_the_body_of_a_response_read_whole + @response.instance_variable_set(:@body, "") + @response.instance_variable_set(:@read, true) + + assert_equal "", InvalidResponse.new(http_response: @response).body + end + + def test_an_error_without_a_body_holds_none_of_a_response_read_in_chunks + @response.instance_variable_set(:@body, Net::ReadAdapter.new(proc {})) + @response.instance_variable_set(:@read, true) + + assert_nil InvalidResponse.new(http_response: @response).body + end + + def test_the_error_is_an_http_error_that_describes_no_problem + @response["content-type"] = "application/json" + @response.instance_variable_set(:@body, '{"title": "Not JSON after all", "detail": "no"}') + @response.instance_variable_set(:@read, true) + error = InvalidResponse.new(http_response: @response, body: "") + + assert_kind_of HTTPError, error + assert_equal [nil, [], ""], [error.problem, error.problems, error.body] + assert_equal "The body of the 200 response is not JSON (application/json)", error.message + end + + def test_the_message_names_a_response_without_a_content_type + @response.delete("content-type") + + assert_equal "The body of the 200 response is not JSON (no content type)", InvalidResponse.new(http_response: @response).message + end + end +end diff --git a/x-core/test/x/core/malformed_response_test.rb b/x-core/test/x/core/malformed_response_test.rb new file mode 100644 index 00000000..0e95ce61 --- /dev/null +++ b/x-core/test/x/core/malformed_response_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A response whose headers Net::HTTP cannot read, read from a server on the loopback interface, since webmock builds + # the response itself, is a NetworkError, as every failure of the network is, whether it is requested or streamed + class MalformedResponseTest < Minitest::Test + include LocalServer + + cover_client + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionRequest) + + MALFORMED = { + "an unreadable Content-Length" => "HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n{}", + "an unreadable Content-Range" => "HTTP/1.1 200 OK\r\nContent-Range: zzz\r\nConnection: close\r\n\r\n{}", + "a bare CR in a header value" => "HTTP/1.1 200 OK\r\nX-A: a\rb\r\nContent-Length: 2\r\n\r\n{}" + }.freeze + + def client(port) = Client.new(bearer_token: TEST_BEARER_TOKEN, base_url: "http://127.0.0.1:#{port}/2/", max_retries: 0) + + def test_a_response_whose_headers_cannot_be_read_is_a_network_error + MALFORMED.each do |name, response| + with_local_server(response:) { |port| assert_raises(NetworkError, name) { client(port).get("users/me") } } + end + end + + def test_a_stream_whose_headers_cannot_be_read_is_a_network_error + MALFORMED.each do |name, response| + with_local_server(response:) do |port| + assert_raises(NetworkError, name) { client(port).get_stream("tweets/sample/stream") { |body| body.read_body { nil } } } + end + end + end + end +end diff --git a/x-core/test/x/core/oauth1_authenticator_test.rb b/x-core/test/x/core/oauth1_authenticator_test.rb new file mode 100644 index 00000000..74fa0e2e --- /dev/null +++ b/x-core/test/x/core/oauth1_authenticator_test.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require "net/http" +require_relative "../../test_helper" + +module X + class OAuth1AuthenticatorTest < Minitest::Test + cover OAuth1Authenticator + + def setup + @authenticator = OAuth1Authenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, + access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) + end + + def test_the_access_token_is_private + refute_respond_to OAuth1Authenticator.new(**test_oauth_credentials), :access_token + end + + def test_old_name_is_gone + refute X.const_defined?(:OAuthAuthenticator) + end + + def test_inspect_hides_the_secrets + assert_equal "#", @authenticator.inspect + end + + def test_initialization + assert_equal TEST_API_KEY, @authenticator.api_key + assert_equal TEST_ACCESS_TOKEN, @authenticator.__send__(:access_token) + end + + def test_the_access_token_names_the_user_it_acts_for + assert_equal 7505382, OAuth1Authenticator.new(**test_oauth_credentials, access_token: "7505382-abc").user_id + end + + def test_a_token_prefix_is_read_as_decimal_digits + assert_equal 10, OAuth1Authenticator.new(**test_oauth_credentials, access_token: "010-abc").user_id + end + + def test_an_access_token_that_names_no_user_has_no_user_id + ["abc", "-7505382", "7505382", "7505382abc", " 7505382-abc"].each do |access_token| + assert_nil OAuth1Authenticator.new(**test_oauth_credentials, access_token:).user_id + end + end + + def test_default_oauth_nonce + SecureRandom.stub :hex, TEST_OAUTH_NONCE do + assert_includes authorization_for(get_request), "oauth_nonce=\"#{TEST_OAUTH_NONCE}\"" + end + end + + def test_default_oauth_timestamp + Time.stub :now, Time.utc(1983, 11, 24) do + assert_includes authorization_for(get_request), "oauth_timestamp=\"#{TEST_OAUTH_TIMESTAMP}\"" + end + end + + def test_header_contains_authorization_key + header = @authenticator.headers(Core.const_get(:AuthenticatorRequest).new(get_request)) + + assert header.key?("Authorization"), "Header does not contain \"Authorization\" key" + end + + def test_header_starts_with_oauth + assert authorization_for(get_request).start_with?("OAuth ") + end + + def test_header_contains_required_oauth_fields + authorization = authorization_for(get_request) + + assert_includes authorization, "oauth_consumer_key=\"#{TEST_API_KEY}\"" + assert_includes authorization, "oauth_token=\"#{TEST_ACCESS_TOKEN}\"" + end + + def test_header_contains_oauth_signature_method + assert_includes authorization_for(get_request), "oauth_signature_method=\"HMAC-SHA1\"" + end + + def test_header_contains_oauth_version + assert_includes authorization_for(get_request), "oauth_version=\"1.0\"" + end + + def test_header_in_alphabetical_order + oauth_keys = authorization_for(get_request).scan(/oauth_[a-z0-9_]+/) + + assert_equal oauth_keys.sort, oauth_keys, "OAuth keys are not sorted in alphabetical order" + end + + def test_signs_the_query_parameters + expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ + "oauth_signature=\"1kHVZMzcNj51v60H63%2FTZErArAk%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ + "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" + + with_fixed_oauth_params do + assert_equal expected, authorization_for(get_request("https://example.com/?query=test")) + end + end + + def test_signs_a_query_parameter_that_repeats + with_fixed_oauth_params do + repeated = authorization_for(get_request("https://example.com/?id=1&id=2")) + single = authorization_for(get_request("https://example.com/?id=1")) + + refute_equal single, repeated + end + end + + private + + def authorization_for(request) + @authenticator.headers(Core.const_get(:AuthenticatorRequest).new(request))["Authorization"] + end + end + + # The worked example X publishes for OAuth 1.0a, which signs a form-encoded body + # @see https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/creating-a-signature + class OAuth1AuthenticatorDocumentedExampleTest < Minitest::Test + cover OAuth1Authenticator + + NONCE = "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg" + TIMESTAMP = 1_318_622_958 + URL = "https://api.twitter.com/1.1/statuses/update.json?include_entities=true" + BODY = "status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21" + SIGNATURE = "hCtSmYh%2BiHYCEqBWrE7C7hYmtUk%3D" + + def setup + @authenticator = OAuth1Authenticator.new(api_key: "xvz1evFS4wEEPTGEFPHBog", + api_key_secret: "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw", + access_token: "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb", + access_token_secret: "LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE") + end + + def test_matches_the_signature_x_documents + with_fixed_oauth_params(nonce: NONCE, time: Time.at(TIMESTAMP)) do + assert_includes authorization, "oauth_signature=\"#{SIGNATURE}\"" + end + end + + def test_signs_the_form_encoded_body + with_fixed_oauth_params(nonce: NONCE, time: Time.at(TIMESTAMP)) do + without_body = @authenticator.headers(Core.const_get(:AuthenticatorRequest).new(post_request(body: nil)))["Authorization"] + + refute_equal without_body, authorization + end + end + + def test_signs_a_request_that_answers_http_method_uri_body_and_a_header_alone + request = Class.new do + def http_method = :post + def uri = URI(URL) + def body = BODY + def [](name) = ("application/x-www-form-urlencoded" if name.casecmp?("Content-Type")) + end + + with_fixed_oauth_params(nonce: NONCE, time: Time.at(TIMESTAMP)) do + assert_includes @authenticator.headers(request.new)["Authorization"], "oauth_signature=\"#{SIGNATURE}\"" + end + end + + private + + def authorization + @authenticator.headers(Core.const_get(:AuthenticatorRequest).new(post_request))["Authorization"] + end + + def post_request(body: BODY, content_type: "application/x-www-form-urlencoded") + request = Net::HTTP::Post.new(URI(URL)) + request["Content-Type"] = content_type + request.body = body + request + end + end + + # Only a form-encoded body takes part in the signature + class OAuth1AuthenticatorBodyTest < Minitest::Test + cover OAuth1Authenticator + + URL = "https://api.x.com/2/tweets" + FORM_BODY = "status=Hello" + + def setup + @authenticator = OAuth1Authenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, + access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) + end + + def test_a_json_body_is_not_signed + with_fixed_oauth_params do + assert_equal signature(nil, "application/json; charset=utf-8"), + signature('{"text":"Hello"}', "application/json; charset=utf-8") + end + end + + def test_a_multipart_body_is_not_signed + with_fixed_oauth_params do + assert_equal signature(nil, "multipart/form-data; boundary=abc"), + signature("--abc\r\nstatus=Hello\r\n--abc--", "multipart/form-data; boundary=abc") + end + end + + def test_a_form_encoded_body_is_signed + with_fixed_oauth_params do + refute_equal signature(nil, "application/x-www-form-urlencoded"), + signature(FORM_BODY, "application/x-www-form-urlencoded") + end + end + + def test_a_form_content_type_with_a_charset_is_signed + with_fixed_oauth_params do + assert_equal signature(FORM_BODY, "application/x-www-form-urlencoded"), + signature(FORM_BODY, "application/x-www-form-urlencoded; charset=utf-8") + end + end + + def test_a_form_content_type_in_uppercase_and_padded_is_signed + with_fixed_oauth_params do + assert_equal signature(FORM_BODY, "application/x-www-form-urlencoded"), + signature(FORM_BODY, " Application/X-WWW-Form-Urlencoded ; charset=utf-8") + end + end + + def test_a_media_type_that_merely_begins_with_the_form_media_type_is_not_signed + with_fixed_oauth_params do + assert_equal signature(nil, "application/x-www-form-urlencoded-json"), + signature(FORM_BODY, "application/x-www-form-urlencoded-json") + end + end + + def test_a_form_encoded_request_without_a_body + with_fixed_oauth_params do + assert_equal signature(nil, "application/json"), signature(nil, "application/x-www-form-urlencoded") + end + end + + private + + def signature(body, content_type) + request = Net::HTTP::Post.new(URI(URL)) + request["Content-Type"] = content_type + request.body = body + @authenticator.headers(Core.const_get(:AuthenticatorRequest).new(request))["Authorization"][/oauth_signature="([^"]+)"/, 1] + end + end +end diff --git a/x-core/test/x/core/oauth2_authenticator_expiration_test.rb b/x-core/test/x/core/oauth2_authenticator_expiration_test.rb new file mode 100644 index 00000000..60965d6f --- /dev/null +++ b/x-core/test/x/core/oauth2_authenticator_expiration_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class OAuth2AuthenticatorExpirationTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + def test_the_refresh_of_a_rejected_token_is_private + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now + 60) + + %i[refresh_rejected_token! retrying_rejected_token].each do |name| + refute_respond_to authenticator, name + end + end + end +end diff --git a/x-core/test/x/core/oauth2_authenticator_refresh_order_test.rb b/x-core/test/x/core/oauth2_authenticator_refresh_order_test.rb new file mode 100644 index 00000000..9d47768e --- /dev/null +++ b/x-core/test/x/core/oauth2_authenticator_refresh_order_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The order in which an authenticator reports its refreshes, which is the order a store must keep them in + class OAuth2AuthenticatorRefreshOrderTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:RefreshReporter) + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "FIRST_ACCESS_TOKEN", refresh_token: "FIRST_REFRESH_TOKEN"}.to_json) + .then.to_return(status: 200, body: {access_token: "SECOND_ACCESS_TOKEN", refresh_token: "SECOND_REFRESH_TOKEN"}.to_json) + @stored = [] + end + + def test_a_refresh_another_replaced_before_it_was_reported_is_not_reported + authenticator = oauth2_authenticator_reporting_to(->(tokens) { @stored << tokens.refresh_token }) + first = authenticator.__send__(:refresh, authenticator.send(:connection), nil) + second = authenticator.__send__(:refresh, authenticator.send(:connection), nil) + authenticator.__send__(:report_refresh, second, nil) + authenticator.__send__(:report_refresh, first, nil) + + assert_equal ["SECOND_REFRESH_TOKEN"], @stored + end + + def test_a_refresh_waits_to_be_reported_until_the_one_before_it_has_been + authenticator = oauth2_authenticator_reporting_to(storing_the_first_slowly) + first = Thread.new { authenticator.refresh! } + @storing.pop + second = Thread.new { authenticator.refresh! } + Thread.pass until second.status.eql?("sleep") + + assert_empty @stored + @stored_first << :done + [first, second].each(&:join) + + assert_equal %w[FIRST_REFRESH_TOKEN SECOND_REFRESH_TOKEN], @stored + end + + def test_a_hook_that_raises_keeps_no_other_from_the_tokens + authenticator = oauth2_authenticator_reporting_to(->(_) { raise ArgumentError, "store is down" }, + ->(tokens) { @stored << tokens.refresh_token }, ->(_) { raise KeyError }) + error = assert_raises(TokenReportFailed) { authenticator.refresh! } + + assert_equal [ArgumentError, "store is down", ["FIRST_REFRESH_TOKEN"]], [error.cause.class, error.cause.message, @stored] + end + + private + + # A hook that stores the tokens of the first refresh only once told to, saying when it has begun to + def storing_the_first_slowly + @storing = Queue.new + @stored_first = Queue.new + lambda do |tokens| + @storing << tokens.refresh_token + @stored_first.pop if tokens.refresh_token.eql?("FIRST_REFRESH_TOKEN") + @stored << tokens.refresh_token + end + end + end +end diff --git a/x-core/test/x/core/oauth2_authenticator_save_tokens_test.rb b/x-core/test/x/core/oauth2_authenticator_save_tokens_test.rb new file mode 100644 index 00000000..3fa806c4 --- /dev/null +++ b/x-core/test/x/core/oauth2_authenticator_save_tokens_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class OAuth2AuthenticatorOnTokenRefreshTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:RefreshReporter) + + def setup + @refresh = stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + @refreshed = [] + end + + def authenticator(**options) + oauth2_authenticator_reporting_to(->(auth) { @refreshed << auth.access_token }, **options) + end + + def test_refresh_returns_the_authenticator_after_save_tokens + assert_equal ["NEW_REFRESH_TOKEN", ["NEW_ACCESS_TOKEN"]], [authenticator.refresh!.refresh_token, @refreshed] + end + + def test_header_passes_a_refresh_to_save_tokens + authenticator(expires_at: Time.now - 1).headers(nil) + + assert_equal ["NEW_ACCESS_TOKEN"], @refreshed + end + + def test_header_without_a_refresh_leaves_save_tokens_alone + authenticator(expires_at: Time.now + 3600).headers(nil) + + assert_empty @refreshed + end + + def test_refresh_rejected_token_passes_a_refresh_to_save_tokens + assert authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) + assert_equal ["NEW_ACCESS_TOKEN"], @refreshed + end + + def test_refresh_rejected_token_without_a_refresh_leaves_save_tokens_alone + assert authenticator.send(:refresh_rejected_token!, "OLDER_ACCESS_TOKEN", authenticator.send(:connection)) + assert_empty @refreshed + end + + def test_save_tokens_can_ask_the_authenticator_for_a_header + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", expires_in: 7200}.to_json) + headers = [] + authenticator = nil #: OAuth2Authenticator? + authenticator = oauth2_authenticator_reporting_to(->(_) { headers << authenticator&.headers(nil) }, expires_at: Time.now - 1) + + assert_equal({"Authorization" => "Bearer NEW_ACCESS_TOKEN"}, authenticator.headers(nil)) + assert_equal [{"Authorization" => "Bearer NEW_ACCESS_TOKEN"}], headers + end + + def test_save_tokens_can_refresh_a_rejected_token + replaced = [] + authenticator = nil #: OAuth2Authenticator? + authenticator = oauth2_authenticator_reporting_to(->(_) { replaced << authenticator&.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, Core.const_get(:Connection).new) }) + + assert authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) + assert_equal [true], replaced + assert_requested @refresh, times: 1 + end + end + + class ClientOnTokenRefreshTest < Minitest::Test + cover_client + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", expires_in: 7200}.to_json) + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer NEW_ACCESS_TOKEN"}) + .to_return(status: 200, headers: {"Content-Type" => "application/json"}, body: '{"data":{"id":"1"}}') + end + + def test_save_tokens_can_send_a_request_with_the_client + users = [] + client = nil #: Client? + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1, save_tokens: ->(_) { users << client&.get("users/me") }) + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_equal [{"data" => {"id" => "1"}}], users + end + end +end diff --git a/x-core/test/x/core/oauth2_authenticator_test.rb b/x-core/test/x/core/oauth2_authenticator_test.rb new file mode 100644 index 00000000..fb4bc2a1 --- /dev/null +++ b/x-core/test/x/core/oauth2_authenticator_test.rb @@ -0,0 +1,611 @@ +# frozen_string_literal: true + +require "base64" +require_relative "../../test_helper" + +module X + TOKEN_URL = OAUTH2_TOKEN_URL + + class OAuth2AuthenticatorInitializationTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + def test_initialize_with_required_credentials + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_equal TEST_CLIENT_ID, authenticator.client_id + assert_equal TEST_ACCESS_TOKEN, authenticator.__send__(:access_token) + assert_equal TEST_REFRESH_TOKEN, authenticator.__send__(:refresh_token) + end + + def test_the_connection_is_private + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + %i[connection token_requests_over].each { |name| refute_respond_to authenticator, name } + assert_instance_of Core.const_get(:Connection), authenticator.send(:connection) + end + + def test_the_tokens_can_be_refreshed_over_another_connection + connection = Core.const_get(:Connection).new(open_timeout: 5) + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_same authenticator, authenticator.send(:token_requests_over, connection, Client::DEFAULT_BASE_URL, {}) + assert_same connection, authenticator.send(:connection) + end + + def test_inspect_hides_the_secrets + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_equal "#", authenticator.inspect + end + + def test_initialize_with_expires_at + expires_at = Time.now + 7200 + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + assert_equal expires_at, authenticator.expires_at + end + + def test_initialize_without_expires_at + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_nil authenticator.expires_at + end + end + + class OAuth2AuthenticatorHeaderTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + def test_header_returns_bearer_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + header = authenticator.headers(nil) + + assert_equal({"Authorization" => "Bearer #{TEST_ACCESS_TOKEN}"}, header) + end + end + + class OAuth2AuthenticatorTokenExpirationTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + def test_token_expired_returns_false_when_no_expires_at + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + refute_predicate authenticator, :token_expired? + end + + def test_token_expired_returns_false_when_not_expired + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now + 3600) + + refute_predicate authenticator, :token_expired? + end + + def test_token_expired_returns_true_when_expired + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + + assert_predicate authenticator, :token_expired? + end + + def test_token_expired_returns_true_at_exact_expiration_time + now = Time.now + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: now) + + Time.stub :now, now do + assert_predicate authenticator, :token_expired? + end + end + + def test_token_expired_returns_true_at_buffer_boundary + now = Time.now + expires_at = now + 30 + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + Time.stub :now, now do + assert_predicate authenticator, :token_expired? + end + end + + def test_token_expired_returns_true_within_buffer + now = Time.now + expires_at = now + 29 + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + Time.stub :now, now do + assert_predicate authenticator, :token_expired? + end + end + + def test_token_expired_returns_false_just_outside_buffer + now = Time.now + expires_at = now + 31 + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + Time.stub :now, now do + refute_predicate authenticator, :token_expired? + end + end + end + + class OAuth2AuthenticatorRefreshTokenTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + def test_refresh_token_sends_correct_content_type + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .with(headers: {"Content-Type" => "application/x-www-form-urlencoded"}) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh! + end + + def test_refresh_token_sends_basic_auth_header + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + expected_auth = "Basic #{Base64.strict_encode64("#{TEST_CLIENT_ID}:#{TEST_CLIENT_SECRET}")}" + stub_request(:post, TOKEN_URL) + .with(headers: {"Authorization" => expected_auth}) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh! + end + + def test_refresh_token_of_a_public_client_sends_its_client_id_without_basic_auth + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials.except(:client_secret)) + refresh = stub_request(:post, TOKEN_URL) + .with(body: "grant_type=refresh_token&refresh_token=#{TEST_REFRESH_TOKEN}&client_id=#{TEST_CLIENT_ID}") { |request| !request.headers.key?("Authorization") } + .to_return(status: 200, body: {access_token: "new"}.to_json) + authenticator.refresh! + + assert_requested refresh + assert_equal "new", authenticator.__send__(:access_token) + end + + def test_refresh_token_sends_correct_request_body + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + expected_body = "grant_type=refresh_token&refresh_token=#{TEST_REFRESH_TOKEN}" + stub_request(:post, TOKEN_URL) + .with(body: expected_body) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh! + end + + def test_refresh_token_updates_access_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN"}.to_json) + + authenticator.refresh! + + assert_equal "NEW_ACCESS_TOKEN", authenticator.__send__(:access_token) + end + + def test_refresh_token_updates_refresh_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new", refresh_token: "NEW_REFRESH"}.to_json) + + authenticator.refresh! + + assert_equal "NEW_REFRESH", authenticator.__send__(:refresh_token) + end + + def test_refresh_returns_the_frozen_tokens_of_that_refresh + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new", refresh_token: "NEW_REFRESH", expires_in: 7200}.to_json) + + result = Time.stub(:now, Time.utc(2026, 1, 1)) { authenticator.refresh! } + + assert_instance_of OAuth2Tokens, result + assert_predicate result, :frozen? + assert_equal OAuth2Tokens.new(access_token: "new", refresh_token: "NEW_REFRESH", expires_at: Time.utc(2026, 1, 1) + 7200), result + end + + def test_refresh_returns_the_tokens_save_tokens_is_passed + reported = [] + client = Client.new(**test_oauth2_credentials, save_tokens: ->(tokens) { reported << tokens }) + stub_request(:post, TOKEN_URL).to_return(status: 200, body: {access_token: "new", refresh_token: "NEW_REFRESH"}.to_json) + + tokens = client.authenticator.refresh! + + assert_same reported.fetch(0), tokens + end + + def test_the_tokens_of_an_authenticator_are_private + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + refute_respond_to authenticator, :access_token + refute_respond_to authenticator, :refresh_token + end + + def test_refresh_token_names_the_token_alone + refute_respond_to OAuth2Authenticator.new(**test_oauth2_credentials), :refresh_token! + end + + def test_refresh_token_updates_expires_at + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new", expires_in: 7200}.to_json) + + before_refresh = Time.now + authenticator.refresh! + + assert_operator authenticator.expires_at, :>=, before_refresh + 7200 + end + + def test_refresh_token_forgets_expires_at_when_no_lifetime_is_returned + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now + 3600) + stub_request(:post, TOKEN_URL).to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh! + + assert_nil authenticator.expires_at + end + + def test_refresh_token_keeps_old_refresh_token_when_not_returned + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh! + + assert_equal TEST_REFRESH_TOKEN, authenticator.__send__(:refresh_token) + end + end + + class OAuth2AuthenticatorAutomaticRefreshTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + def setup + @refresh = stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + def test_header_refreshes_an_expired_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + + assert_equal({"Authorization" => "Bearer NEW_ACCESS_TOKEN"}, authenticator.headers(nil)) + assert_requested @refresh, times: 1 + end + + def test_header_refreshes_an_expired_token_once_when_no_lifetime_is_returned + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + 2.times { authenticator.headers(nil) } + + assert_requested @refresh, times: 1 + end + + def test_header_keeps_an_unexpired_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now + 3600) + + assert_equal({"Authorization" => "Bearer #{TEST_ACCESS_TOKEN}"}, authenticator.headers(nil)) + assert_not_requested @refresh + end + + def test_save_tokens_receives_the_tokens_the_refresh_issued + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_in: 7200}.to_json) + tokens = [] + authenticator = oauth2_authenticator_reporting_to(->(refreshed) { tokens << refreshed }) + authenticator.refresh! + + assert_equal [OAuth2Tokens.new(access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN", expires_at: authenticator.expires_at)], tokens + assert_in_delta Time.now + 7200, tokens.first.expires_at, 5 + end + + def test_refresh_rejected_token_refreshes_the_token_that_was_rejected + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) + assert_equal "NEW_ACCESS_TOKEN", authenticator.__send__(:access_token) + assert_requested @refresh, times: 1 + end + + def test_refresh_rejected_token_skips_a_token_already_replaced + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert authenticator.send(:refresh_rejected_token!, "OLDER_ACCESS_TOKEN", authenticator.send(:connection)) + assert_not_requested @refresh + end + + def test_refresh_rejected_token_skips_a_token_a_refresh_just_issued + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) + + refute authenticator.send(:refresh_rejected_token!, "NEW_ACCESS_TOKEN", authenticator.send(:connection)) + assert_requested @refresh, times: 1 + end + + def test_refresh_rejected_token_refreshes_a_token_a_refresh_issued_a_minute_ago + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + now = 1_000.0 # a clock that reads exact seconds, so a minute after it is exactly 60 seconds on + Process.stub(:clock_gettime, now) { authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) } + + refute Process.stub(:clock_gettime, now + 59.9) { authenticator.send(:refresh_rejected_token!, "NEW_ACCESS_TOKEN", authenticator.send(:connection)) } + Process.stub(:clock_gettime, now + 60) { authenticator.send(:refresh_rejected_token!, "NEW_ACCESS_TOKEN", authenticator.send(:connection)) } + + assert_requested @refresh, times: 2 + end + + def test_refresh_rejected_token_reports_a_refresh_that_returned_the_same_token + stub_request(:post, TOKEN_URL).to_return(status: 200, body: {access_token: TEST_ACCESS_TOKEN}.to_json) + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + refute authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) + end + end + + class OAuth2AuthenticatorRejectedTokenTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + def setup + @refresh = stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + API = URI("https://api.x.com/2/") + + def unauthorized(uri = URI("https://api.x.com/2/users/me")) + http_response = Net::HTTPUnauthorized.new("1.1", "401", "Unauthorized") + http_response.uri = uri if uri + Unauthorized.new(http_response:) + end + + # The connections the refreshes a block makes are sent over + def refreshes_over + connections = [] + fetch = Core.const_get(:TokenEndpoint).method(:fetch) + Core.const_get(:TokenEndpoint).stub(:fetch, ->(request, connection:, refusal:, headers:) { fetch.call(request, connection: connections.push(connection).last, refusal:, headers:) }) { yield } + connections + end + + def test_retrying_rejected_token_returns_what_the_request_returns + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_equal :ok, authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) { :ok } + assert_not_requested @refresh + end + + def test_retrying_rejected_token_refreshes_and_runs_the_request_again + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + tokens = [] + result = authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) do + tokens << authenticator.__send__(:access_token) + raise unauthorized if tokens.one? + + :ok + end + + assert_equal [:ok, [TEST_ACCESS_TOKEN, "NEW_ACCESS_TOKEN"]], [result, tokens] + end + + def test_retrying_rejected_token_refreshes_an_expired_token_before_the_request_over_the_connection_given + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 60) + connection = Core.const_get(:Connection).new + sent = [] + + assert_equal [connection], refreshes_over { authenticator.send(:retrying_rejected_token, API, connection) { sent << authenticator.__send__(:access_token) } } + assert_equal ["NEW_ACCESS_TOKEN"], sent + end + + def test_retrying_rejected_token_refreshes_a_rejected_token_over_the_connection_given + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + connection = Core.const_get(:Connection).new + attempts = 0 + + assert_equal [connection], refreshes_over { authenticator.send(:retrying_rejected_token, API, connection) { (attempts += 1).eql?(1) ? raise(unauthorized) : :ok } } + end + + def test_retrying_rejected_token_refreshes_the_token_the_request_was_sent_with + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + attempts = 0 + result = authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) do + attempts += 1 + authenticator.instance_variable_set(:@access_token, "REPLACED") if attempts.eql?(1) + raise unauthorized if attempts.eql?(1) + + :ok + end + + assert_equal [:ok, 2, "REPLACED"], [result, attempts, authenticator.__send__(:access_token)] + assert_not_requested @refresh + end + + def test_retrying_rejected_token_raises_a_second_rejection + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + attempts = 0 + + assert_raises(Unauthorized) do + authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) do + attempts += 1 + raise unauthorized + end + end + assert_equal 2, attempts + end + + def test_retrying_rejected_token_raises_when_a_refresh_keeps_the_token + stub_request(:post, TOKEN_URL).to_return(status: 200, body: {access_token: TEST_ACCESS_TOKEN}.to_json) + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + attempts = 0 + + assert_raises(Unauthorized) do + authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) do + attempts += 1 + raise unauthorized + end + end + assert_equal 1, attempts + end + + def test_retrying_rejected_token_raises_a_rejection_by_another_origin_without_refreshing + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + attempts = 0 + + assert_raises(Unauthorized) do + authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) do + attempts += 1 + raise unauthorized(URI("https://other.example.com/2/users/me")) + end + end + assert_equal [1, TEST_ACCESS_TOKEN], [attempts, authenticator.__send__(:access_token)] + assert_not_requested @refresh + end + + def test_retrying_rejected_token_raises_a_rejection_of_no_known_origin_without_refreshing + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_raises(Unauthorized) { authenticator.send(:retrying_rejected_token, API, authenticator.send(:connection)) { raise unauthorized(nil) } } + assert_not_requested @refresh + end + end + + class OAuth2AuthenticatorConcurrentRefreshTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + # Answer the token endpoint slowly, so that concurrent callers overlap the refresh + def stub_slow_refresh + stub_request(:post, TOKEN_URL).to_return do + sleep 0.05 + {status: 200, body: {access_token: "NEW_ACCESS_TOKEN", expires_in: 7200}.to_json} + end + end + + # Run a block in several threads at once, each after the first has begun + def concurrently(count = 4, &block) + first = Thread.new(&block) + sleep 0.01 + [first, *Array.new(count - 1) { Thread.new(&block) }].each(&:join) + end + + def test_concurrent_refreshes_of_a_rejected_token_refresh_once + stub_slow_refresh + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + concurrently { authenticator.send(:refresh_rejected_token!, TEST_ACCESS_TOKEN, authenticator.send(:connection)) } + + assert_requested :post, TOKEN_URL, times: 1 + end + + def test_concurrent_headers_refresh_an_expired_token_once + stub_slow_refresh + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + concurrently { authenticator.headers(nil) } + + assert_requested :post, TOKEN_URL, times: 1 + end + + def test_a_header_waits_for_a_refresh_in_progress + stub_slow_refresh + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + refreshing = Thread.new { authenticator.refresh! } + sleep 0.01 + + assert_equal({"Authorization" => "Bearer NEW_ACCESS_TOKEN"}, authenticator.headers(nil)) + refreshing.join + + assert_requested :post, TOKEN_URL, times: 1 + end + end + + class OAuth2AuthenticatorRefreshTokenErrorTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:TokenEndpoint) + + def test_refresh_token_raises_on_error_with_description + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 400, body: {error: "invalid_grant", error_description: "Token expired"}.to_json) + + error = assert_raises(AuthorizationError) { authenticator.refresh! } + assert_equal ["POST /2/oauth2/token: Token expired", "invalid_grant", 400], [error.message, error.error_code, error.status] + end + + def test_refresh_token_raises_on_error_without_description + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 400, body: {error: "invalid_grant"}.to_json) + + error = assert_raises(AuthorizationError) { authenticator.refresh! } + assert_equal "POST /2/oauth2/token: invalid_grant", error.message + end + + def test_refresh_token_raises_on_error_with_default_message + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 400, body: {}.to_json) + + error = assert_raises(AuthorizationError) { authenticator.refresh! } + assert_equal ["POST /2/oauth2/token: Token refresh failed", nil, 400], [error.message, error.error_code, error.status] + end + + def test_refresh_token_raises_on_invalid_json_response + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 401, body: "Unauthorized") + + error = assert_raises(AuthorizationError) { authenticator.refresh! } + assert_equal ["POST /2/oauth2/token: Token refresh failed", nil, "Unauthorized"], [error.message, error.error_code, error.body] + end + + def test_a_token_endpoint_that_fails_to_answer_raises_the_error_of_its_status + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL).to_return({status: 503, body: "Service Unavailable"}, {status: 500, body: {}.to_json}) + unavailable = assert_raises(ServiceUnavailable) { authenticator.refresh! } + + assert_raises(InternalServerError) { authenticator.refresh! } + assert_equal [503, :post, URI(TOKEN_URL)], [unavailable.status, unavailable.http_method, unavailable.uri] + end + + def test_a_token_endpoint_that_limits_a_refresh_raises_too_many_requests + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL).to_return(status: 429, headers: {"Retry-After" => "7"}) + + assert_equal 7, assert_raises(TooManyRequests) { authenticator.refresh! }.retry_after + assert_equal TEST_REFRESH_TOKEN, authenticator.__send__(:refresh_token) + end + end + + class OAuth2AuthenticatorHoldsTest < Minitest::Test + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + def setup + @authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + end + + def test_holds_the_credentials_it_was_given_and_ignores_other_options + assert @authenticator.send(:holds?, {**test_oauth2_credentials, api_key: TEST_API_KEY, base_url: "https://example.com/"}) + end + + def test_holds_when_no_credential_is_given + assert @authenticator.send(:holds?, {}) + end + + def test_does_not_hold_another_value_of_any_credential + %i[client_id client_secret access_token refresh_token].each do |name| + refute @authenticator.send(:holds?, {name => "OTHER"}), name + end + end + + def test_does_not_hold_a_credential_given_as_nil + refute @authenticator.send(:holds?, {access_token: nil}) + end + end +end diff --git a/x-core/test/x/core/oauth2_authorization_connection_test.rb b/x-core/test/x/core/oauth2_authorization_connection_test.rb new file mode 100644 index 00000000..d00b7e25 --- /dev/null +++ b/x-core/test/x/core/oauth2_authorization_connection_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class OAuth2AuthorizationConnectionTest < Minitest::Test + cover OAuth2Authorization + + REDIRECT_URI = "https://example.com/callback" + CALLBACK = "state=STATE&code=CODE" + TOKENS = {token_type: "bearer", access_token: "ACCESS", refresh_token: "REFRESH", expires_in: 7200}.freeze + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, state: "STATE", code_verifier: "a" * 43, **options) + end + + def stub_token(status: 200, body: TOKENS) + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status:, body: body.to_json) + end + + def test_the_code_is_exchanged_over_the_connection_settings_of_the_client + stub_token + output = StringIO.new + authorization = authorization(proxy_url: "http://proxy.example.com:8080", read_timeout: 2, write_timeout: 7, keep_alive_timeout: 4) + connection = exchanged_over do + authorization.client(CALLBACK, proxy_url: "http://other.example.com:3128", open_timeout: 1, read_timeout: 5, debug_output: output) + end + + assert_equal ["http://other.example.com:3128", 1, 5, 7, 4, output], + [connection.send(:proxy_url), connection.open_timeout, connection.read_timeout, connection.write_timeout, connection.keep_alive_timeout, connection.debug_output] + end + + def test_the_code_is_exchanged_over_the_connection_settings_of_the_authorization_the_client_is_not_given + stub_token + authorization = authorization(proxy_url: "http://proxy.example.com:8080", read_timeout: 2) + connection = exchanged_over { authorization.client(CALLBACK, max_retries: 0) } + + assert_equal ["http://proxy.example.com:8080", 2], [connection.send(:proxy_url), connection.read_timeout] + end + + def test_the_connection_of_the_exchange_of_a_client_is_closed_once_the_code_is_exchanged + stub_token + authorization = authorization() + + assert_predicate exchanged_over { authorization.client(CALLBACK) }, :closed? + end + + def test_the_connection_of_the_exchange_of_a_client_is_closed_when_the_exchange_fails + stub_token(status: 400, body: {error: "invalid_grant"}) + authorization = authorization() + connection = exchanged_over { assert_raises(AuthorizationError) { authorization.client(CALLBACK) } } + + assert_predicate connection, :closed? + end + + def test_the_credentials_are_exchanged_over_the_connection_of_the_authorization_which_is_closed_after + stub_token + authorization = authorization() + connection = exchanged_over { authorization.tokens(CALLBACK) } + + assert_same authorization.send(:connection), connection + assert_predicate connection, :closed? + end + + def test_the_connection_of_the_authorization_is_closed_when_the_exchange_fails + stub_token(status: 400, body: {error: "invalid_grant"}) + authorization = authorization() + connection = exchanged_over { assert_raises(AuthorizationError) { authorization.tokens(CALLBACK) } } + + assert_predicate connection, :closed? + end + + def test_the_tokens_leave_no_connection_open + stub_token + authorization = authorization() + authorization.tokens(CALLBACK) + + assert_empty authorization.send(:connection).instance_variable_get(:@pool).instance_variable_get(:@idle).values.flatten + end + + private + + # Record the connection each token request is sent over, which then answers closed? + def record(connections, fetch) + lambda do |request, connection:, refusal:, headers:| + connections << connection + connection.define_singleton_method(:close) { (@closed = true) && super() } + connection.define_singleton_method(:closed?) { @closed.eql?(true) } + fetch.call(request, connection:, refusal:, headers:) + end + end + + # The one connection the token requests of the block were sent over + def exchanged_over + token_endpoint = Core.const_get(:TokenEndpoint) + connections = [] + token_endpoint.stub(:fetch, record(connections, token_endpoint.method(:fetch))) { yield } + + assert_equal 1, connections.size + connections.first + end + end +end diff --git a/x-core/test/x/core/oauth2_authorization_headers_test.rb b/x-core/test/x/core/oauth2_authorization_headers_test.rb new file mode 100644 index 00000000..14f87c03 --- /dev/null +++ b/x-core/test/x/core/oauth2_authorization_headers_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The code of an authorization is exchanged with the User-Agent of the gem and the headers of the authorization, or + # of the client it is exchanged for + class OAuth2AuthorizationHeadersTest < Minitest::Test + cover OAuth2Authorization + cover Core.const_get(:TokenEndpoint) + + USER_AGENT = Core.const_get(:RequestBuilder)::DEFAULT_HEADERS.fetch("User-Agent") + GATEWAY = {"X-Gateway-Key" => "g"}.freeze + REDIRECT_URI = "https://example.com/callback" + CALLBACK = "state=STATE&code=CODE" + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, state: "STATE", code_verifier: "a" * 43, **options) + end + + def stub_refresh(**options) + stub_request(:post, OAUTH2_TOKEN_URL).with(**options).to_return(headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "new", refresh_token: "next", expires_in: 7200}.to_json) + end + + def test_the_code_is_exchanged_with_the_headers_of_the_authorization + exchange = stub_refresh(headers: {**GATEWAY, "User-Agent" => USER_AGENT}) + authorization(headers: {x_gateway_key: "g"}).tokens(CALLBACK) + + assert_requested exchange + end + + def test_the_code_is_exchanged_for_a_client_with_the_headers_of_the_authorization_it_is_not_given_others + exchange = stub_refresh(headers: GATEWAY) + + assert_equal GATEWAY, authorization(headers: GATEWAY).client(CALLBACK).headers + assert_requested exchange + end + + def test_the_code_is_exchanged_for_a_client_with_the_headers_it_is_given + exchange = stub_refresh(headers: {"X-Other" => "o"}) + authorization(headers: GATEWAY).client(CALLBACK, headers: {"X-Other" => "o"}) + + assert_requested exchange + assert_not_requested :post, OAUTH2_TOKEN_URL, headers: GATEWAY + end + + def test_an_authorization_refuses_headers_that_are_not_a_hash + error = assert_raises(ArgumentError) { authorization(headers: "X-Gateway-Key: g") } + + assert_equal "headers must be a Hash of header names to values, not a String", error.message + end + + def test_an_authorization_sends_no_headers_of_its_own_by_default + exchange = stub_refresh(headers: {"User-Agent" => USER_AGENT}) + client = authorization.client(CALLBACK) + + assert_requested exchange + assert_empty client.headers + end + end +end diff --git a/x-core/test/x/core/oauth2_authorization_test.rb b/x-core/test/x/core/oauth2_authorization_test.rb new file mode 100644 index 00000000..21ec3732 --- /dev/null +++ b/x-core/test/x/core/oauth2_authorization_test.rb @@ -0,0 +1,410 @@ +# frozen_string_literal: true + +require "base64" +require "digest" +require_relative "../../test_helper" + +module X + class OAuth2AuthorizationURLTest < Minitest::Test + cover OAuth2Authorization + cover Core.const_get(:TokenEndpoint) + + REDIRECT_URI = "https://example.com/callback" + CODE_VERIFIER = ("a" * 43).freeze + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, **options) + end + + def query_of(url) = URI.decode_www_form(URI(url).query).to_h + + def test_url_asks_x_to_authorize_the_app + url = authorization(state: "STATE", code_verifier: CODE_VERIFIER).url + challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(CODE_VERIFIER), padding: false) + + assert url.start_with?("https://x.com/i/oauth2/authorize?") + assert_equal({"response_type" => "code", "client_id" => TEST_CLIENT_ID, "redirect_uri" => REDIRECT_URI, + "scope" => "tweet.read users.read offline.access", "state" => "STATE", "code_challenge" => challenge, + "code_challenge_method" => "S256"}, query_of(url)) + end + + def test_url_asks_for_the_scopes_given + assert_equal "tweet.read tweet.write", query_of(authorization(scopes: %w[tweet.read tweet.write]).url)["scope"] + end + + def test_a_new_authorization_generates_its_state_and_code_verifier + first = authorization + second = authorization + + refute_equal first.state, second.state + refute_equal first.code_verifier, second.code_verifier + end + + def test_a_generated_state_and_code_verifier + authorization = authorization() + + assert_equal 43, authorization.state.size + assert_match(/\A[A-Za-z0-9\-_]{64}\z/, authorization.code_verifier) + assert_equal authorization.state, query_of(authorization.url)["state"] + end + + def test_attributes + authorization = authorization(client_secret: TEST_CLIENT_SECRET, scopes: %w[users.read], state: "STATE", + code_verifier: CODE_VERIFIER) + + assert_equal [TEST_CLIENT_ID, REDIRECT_URI, %w[users.read], "STATE", CODE_VERIFIER], + [authorization.client_id, authorization.redirect_uri, authorization.scopes, authorization.state, authorization.code_verifier] + end + + def test_the_connection_is_built_with_the_settings_given + output = StringIO.new + connection = authorization(proxy_url: "http://proxy.example.com:8080", open_timeout: 1, read_timeout: 2, write_timeout: 3, + keep_alive_timeout: 4, debug_output: output).send(:connection) + + assert_equal ["http://proxy.example.com:8080", 1, 2, 3, 4, output], + [connection.send(:proxy_url), connection.open_timeout, connection.read_timeout, connection.write_timeout, connection.keep_alive_timeout, connection.debug_output] + end + + def test_the_connection_is_private + refute_respond_to authorization, :connection + end + + def test_the_client_secret_is_kept_private + authorization = authorization(client_secret: TEST_CLIENT_SECRET) + + refute_respond_to authorization, :client_secret + assert_equal TEST_CLIENT_SECRET, authorization.send(:client_secret) + end + + def test_defaults + authorization = authorization() + + assert_nil authorization.send(:client_secret) + assert_equal OAuth2Authorization::DEFAULT_SCOPES, authorization.scopes + connection = authorization.send(:connection) + + assert_equal [nil, Client::DEFAULT_OPEN_TIMEOUT, Client::DEFAULT_READ_TIMEOUT, Client::DEFAULT_WRITE_TIMEOUT, Client::DEFAULT_KEEP_ALIVE_TIMEOUT, nil], + [connection.send(:proxy_url), connection.open_timeout, connection.read_timeout, connection.write_timeout, connection.keep_alive_timeout, connection.debug_output] + end + + def test_a_nil_state_is_refused + error = assert_raises(ArgumentError) { authorization(state: nil) } + + assert_equal "state must not be nil or empty; pass the state stored when the user was sent to X", error.message + end + + def test_an_empty_state_is_refused + assert_raises(ArgumentError) { authorization(state: "") } + end + + def test_an_invalid_code_verifier_is_refused + assert_raises(ArgumentError) { authorization(code_verifier: "short") } + end + + def test_inspect_hides_the_secrets + inspected = authorization(client_secret: TEST_CLIENT_SECRET, state: "STATE", code_verifier: CODE_VERIFIER).inspect + + assert_equal "#", inspected + end + end + + class OAuth2AuthorizationCodeTest < Minitest::Test + cover OAuth2Authorization + cover AuthorizationDenied + cover Core.const_get(:TokenEndpoint) + + REDIRECT_URI = "https://example.com/callback" + CODE_VERIFIER = ("a" * 43).freeze + TOKEN_BODY = "grant_type=authorization_code&code=CODE&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}&code_verifier=#{CODE_VERIFIER}".freeze + TOKENS = {token_type: "bearer", access_token: "ACCESS", refresh_token: "REFRESH", expires_in: 7200}.freeze + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, state: "STATE", code_verifier: CODE_VERIFIER, **options) + end + + def stub_token(body: TOKENS, status: 200) + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status:, body: body.to_json) + end + + def test_tokens_of_a_public_client + token = stub_token.with(body: "#{TOKEN_BODY}&client_id=#{TEST_CLIENT_ID}") { |request| !request.headers.key?("Authorization") } + tokens = Time.stub(:now, Time.at(1_000)) { authorization.tokens("#{REDIRECT_URI}?state=STATE&code=CODE") } + + assert_equal OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: Time.at(8_200), scopes: OAuth2Authorization::DEFAULT_SCOPES), tokens + assert_requested token + end + + def test_tokens_of_a_confidential_client + basic = "Basic #{Base64.strict_encode64("#{TEST_CLIENT_ID}:#{TEST_CLIENT_SECRET}")}" + token = stub_token.with(body: TOKEN_BODY, headers: {"Authorization" => basic}) + + tokens = authorization(client_secret: TEST_CLIENT_SECRET).tokens("state=STATE&code=CODE") + + assert_equal %w[ACCESS REFRESH], [tokens.access_token, tokens.refresh_token] + assert_requested token + end + + def test_tokens_hold_the_scopes_x_granted + stub_token(body: {**TOKENS, scope: "tweet.read users.read"}) + tokens = authorization.tokens("state=STATE&code=CODE") + + assert_equal [%w[tweet.read users.read], true], [tokens.scopes, tokens.scopes.frozen?] + end + + def test_tokens_hold_the_scopes_asked_for_when_x_names_none + stub_token(body: {**TOKENS, scope: ""}) + + assert_equal %w[users.read], authorization(scopes: %w[users.read]).tokens("state=STATE&code=CODE").scopes + end + + def test_tokens_from_query_parameters + stub_token + + assert_equal "ACCESS", authorization.tokens({state: "STATE", code: "CODE"}).access_token + end + + def test_tokens_without_a_refresh_token_are_those_of_the_user_without_one + stub_token(body: {token_type: "bearer", access_token: "ACCESS", expires_in: 7200}) + tokens = Time.stub(:now, Time.at(1_000)) { authorization.tokens("state=STATE&code=CODE") } + + assert_equal OAuth2Tokens.new(access_token: "ACCESS", refresh_token: nil, expires_at: Time.at(8_200), scopes: OAuth2Authorization::DEFAULT_SCOPES), tokens + end + + def test_tokens_of_a_confidential_client_without_a_refresh_token_or_a_lifetime_hold_neither + stub_token(body: {token_type: "bearer", access_token: "ACCESS"}) + + assert_equal OAuth2Tokens.new(access_token: "ACCESS", refresh_token: nil, expires_at: nil, scopes: OAuth2Authorization::DEFAULT_SCOPES), + authorization(client_secret: TEST_CLIENT_SECRET).tokens("state=STATE&code=CODE") + end + + def test_tokens_are_exchanged_over_the_connection + response = Net::HTTPOK.new("1.1", "200", "OK") + response.instance_variable_set(:@body, TOKENS.to_json) + response.instance_variable_set(:@read, true) + authorization = authorization() + requests = [] + authorization.send(:connection).stub(:perform, ->(request:) { requests << request.body and response }) do + authorization.tokens("state=STATE&code=CODE") + end + + assert_equal ["#{TOKEN_BODY}&client_id=#{TEST_CLIENT_ID}"], requests + end + + def test_a_denied_authorization_raises + error = assert_raises(AuthorizationDenied) do + authorization.tokens("error=access_denied&error_description=The+user+denied+the+request&state=STATE") + end + + assert_equal ["The user denied the request", "access_denied", nil], [error.message, error.error_code, error.cause] + assert_not_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_an_error_without_a_description_raises_its_code_or_else_the_default_message + assert_equal "access_denied", assert_raises(AuthorizationDenied) { authorization.tokens("error=access_denied") }.message + assert_equal "Authorization failed", assert_raises(AuthorizationDenied) { authorization.tokens({"error" => nil, "state" => "STATE"}) }.message + end + + def test_a_redirect_for_another_authorization_raises + error = assert_raises(AuthorizationDenied) { authorization.tokens("state=OTHER&code=CODE") } + + assert_equal ["The authorization response answers a different request", nil], [error.message, error.error_code] + assert_not_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_a_refused_code_raises + stub_token(status: 400, body: {error: "invalid_grant", error_description: "Value passed for the authorization code was invalid."}) + error = assert_raises(AuthorizationError) { authorization.tokens("state=STATE&code=CODE") } + + assert_equal ["POST /2/oauth2/token: Value passed for the authorization code was invalid.", "invalid_grant", 400], + [error.message, error.error_code, error.status] + assert_kind_of ClientError, error + end + + def test_a_redirect_that_is_not_a_valid_url_raises + error = assert_raises(AuthorizationDenied) { authorization.tokens("https://exa mple.com/callback?state=STATE&code=CODE") } + + assert_equal ["The redirect back from X is not a valid URL", nil, nil], [error.message, error.error_code, error.cause] + assert_not_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_a_failure_without_a_reason_raises_the_default_message + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status: 400, body: "") + error = assert_raises(AuthorizationError) { authorization.tokens("state=STATE&code=CODE") } + + assert_equal ["POST /2/oauth2/token: Authorization failed", nil], [error.message, error.error_code] + assert_kind_of Error, error + end + + def test_a_token_endpoint_that_fails_to_answer_raises_the_error_of_its_status + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status: 502, body: "") + + assert_raises(BadGateway) { authorization.tokens("state=STATE&code=CODE") } + end + end + + class OAuth2AuthorizationClientTest < Minitest::Test + cover OAuth2Authorization + + REDIRECT_URI = "https://example.com/callback" + CODE_VERIFIER = ("a" * 43).freeze + TOKENS = {token_type: "bearer", access_token: "ACCESS", refresh_token: "REFRESH", expires_in: 7200}.freeze + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, state: "STATE", code_verifier: CODE_VERIFIER, **options) + end + + def stub_token(body: TOKENS, status: 200) + stub_request(:post, "https://api.x.com/2/oauth2/token").to_return(status:, body: body.to_json) + end + + def test_client_acts_for_the_user + stub_token + client = authorization.client("state=STATE&code=CODE", base_url: "https://api.x.com/3/") + + assert_instance_of OAuth2Authenticator, client.authenticator + assert_equal ["ACCESS", "REFRESH", "https://api.x.com/3/"], [internals(client).send(:access_token), internals(client).send(:refresh_token), client.base_url] + end + + def test_the_client_of_a_confidential_app_holds_its_secret + stub_token + client = authorization(client_secret: TEST_CLIENT_SECRET).client("state=STATE&code=CODE") + + assert_equal TEST_CLIENT_SECRET, internals(client).send(:client_secret) + end + + def test_the_client_reaches_the_api_as_the_authorization_did + stub_token + client = authorization(proxy_url: "http://proxy.example.com:8080", read_timeout: 2, keep_alive_timeout: 4).client("state=STATE&code=CODE") + + assert_equal ["http://proxy.example.com:8080", 2, Client::DEFAULT_OPEN_TIMEOUT, 4], + [internals(client).send(:proxy_url), client.read_timeout, client.open_timeout, client.keep_alive_timeout] + end + + def test_the_client_holds_the_scopes_x_granted + stub_token(body: {**TOKENS, scope: "tweet.read users.read"}) + + assert_equal %w[tweet.read users.read], authorization.client("state=STATE&code=CODE").scopes + end + + def test_the_client_is_refused_scopes_of_its_own + error = assert_raises(ArgumentError) { authorization.client("state=STATE&code=CODE", scopes: %w[tweet.read]) } + + assert_match(/cannot be given scopes/, error.message) + end + + def test_the_options_of_the_client_replace_the_settings_of_the_authorization + stub_token + client = authorization(read_timeout: 2).client("state=STATE&code=CODE", read_timeout: 5) + + assert_equal 5, client.read_timeout + end + + def test_save_tokens_is_passed_the_tokens_of_the_exchange + stub_token + passed = [] + client = Time.stub(:now, Time.at(1_000)) { authorization.client("state=STATE&code=CODE", save_tokens: ->(tokens) { passed << tokens }) } + + assert_equal [OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: Time.at(8_200), scopes: OAuth2Authorization::DEFAULT_SCOPES)], passed + assert_instance_of Client, client + end + + def test_save_tokens_is_passed_the_tokens_of_an_exchange_without_a_refresh_token + stub_token(body: {token_type: "bearer", access_token: "ACCESS", expires_in: 7200}) + passed = [] + client = Time.stub(:now, Time.at(1_000)) { authorization.client("state=STATE&code=CODE", save_tokens: ->(tokens) { passed << tokens }) } + + assert_equal [OAuth2Tokens.new(access_token: "ACCESS", expires_at: Time.at(8_200), scopes: OAuth2Authorization::DEFAULT_SCOPES)], passed + assert_instance_of OAuth2Authenticator, client.authenticator + end + + def test_a_client_without_save_tokens_is_built_from_the_exchange + stub_token + + assert_instance_of OAuth2Authenticator, authorization.client("state=STATE&code=CODE").authenticator + end + + def test_an_error_of_save_tokens_for_the_tokens_of_the_exchange_keeps_the_client_and_tokens + stub_token + error = assert_raises(TokenReportFailed) { authorization.client("state=STATE&code=CODE", save_tokens: ->(_) { raise "unstored" }) } + + assert_equal ["ACCESS", "REFRESH"], [error.tokens.access_token, error.tokens.refresh_token] + assert_equal ["unstored", "The code was exchanged for tokens, but save_tokens raised for them: unstored"], [error.cause.message, error.message] + end + + def test_the_client_an_error_of_save_tokens_holds_acts_for_the_user + stub_token + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer ACCESS"}).to_return(status: 200, body: "{}", headers: {"Content-Type" => "application/json"}) + error = assert_raises(TokenReportFailed) { authorization.client("state=STATE&code=CODE", save_tokens: ->(_) { raise "unstored" }) } + + assert_equal({}, error.client.get("users/me")) + end + + def test_an_option_the_client_refuses_raises_before_the_code_is_exchanged + error = assert_raises(ArgumentError) { authorization.client("state=STATE&code=CODE", on_token_refersh: -> {}) } + + assert_equal "unknown keyword: :on_token_refersh", error.message + assert_not_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_a_setting_the_client_refuses_raises_before_the_code_is_exchanged + assert_raises(ArgumentError) { authorization.client("state=STATE&code=CODE", max_retries: -1) } + + assert_not_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_a_credential_given_to_the_client_raises_before_the_code_is_exchanged + %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret refresh_token + expires_at authenticator].each do |name| + error = assert_raises(ArgumentError) { authorization.client("state=STATE&code=CODE", name => nil, :base_url => "https://api.x.com/3/") } + + assert_equal "The client of an authorization authenticates with the tokens X exchanges the code for, so it " \ + "cannot be given #{name}", error.message + end + assert_not_requested :post, "https://api.x.com/2/oauth2/token" + end + + def test_credentials_given_to_the_client_are_named_together + error = assert_raises(ArgumentError) { authorization.client("state=STATE&code=CODE", access_token: "A", refresh_token: "R") } + + assert error.message.end_with?("cannot be given access_token, refresh_token") + end + end + + # The code is exchanged at the origin of the base URL of the client it is exchanged for, as the client refreshes its + # tokens there + class OAuth2AuthorizationTokenEndpointTest < Minitest::Test + cover OAuth2Authorization + + REDIRECT_URI = "https://example.com/callback" + CODE_VERIFIER = ("a" * 43).freeze + TOKENS = {token_type: "bearer", access_token: "ACCESS", refresh_token: "REFRESH", expires_in: 7200}.freeze + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, state: "STATE", code_verifier: CODE_VERIFIER, **options) + end + + def test_the_code_is_exchanged_at_the_origin_of_the_base_url_of_the_authorization + stub = stub_request(:post, "http://localhost:3000/2/oauth2/token").to_return(body: TOKENS.to_json) + client = authorization(base_url: "http://localhost:3000/2/").client("state=STATE&code=CODE") + + assert_requested stub + assert_equal "http://localhost:3000/2/", client.base_url + end + + def test_the_code_is_exchanged_at_the_origin_of_the_base_url_the_client_is_given + stub = stub_request(:post, "http://localhost:3000/2/oauth2/token").to_return(body: TOKENS.to_json) + client = authorization(base_url: "http://localhost:4000/2/").client("state=STATE&code=CODE", base_url: "http://localhost:3000/2/") + + assert_requested stub + assert_equal "http://localhost:3000/2/", client.base_url + end + + def test_the_tokens_are_exchanged_at_the_origin_of_the_base_url_of_the_authorization + stub = stub_request(:post, "http://localhost:3000/2/oauth2/token").to_return(body: TOKENS.to_json) + authorization(base_url: "http://localhost:3000/2/").tokens("state=STATE&code=CODE") + + assert_requested stub + end + end +end diff --git a/x-core/test/x/core/oauth2_authorization_validation_test.rb b/x-core/test/x/core/oauth2_authorization_validation_test.rb new file mode 100644 index 00000000..bf74c2f5 --- /dev/null +++ b/x-core/test/x/core/oauth2_authorization_validation_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An authorization refuses, when it is built, the arguments it would build a URL X refuses with + class OAuth2AuthorizationValidationTest < Minitest::Test + cover OAuth2Authorization + + REDIRECT_URI = "https://example.com/callback" + + def authorization(**options) + OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: REDIRECT_URI, **options) + end + + def test_a_client_id_that_is_nil_or_empty_is_refused + [nil, "", " "].each do |client_id| + error = assert_raises(ArgumentError) { authorization(client_id:) } + + assert_equal "client_id is nil or empty. Pass the credential, which the authenticator cannot authenticate without", error.message + end + end + + def test_an_empty_client_secret_is_refused + error = assert_raises(ArgumentError) { authorization(client_secret: "") } + + assert_equal "client_secret is empty. Pass the credential, or leave it out, since an empty one authenticates nothing", error.message + end + + def test_a_redirect_uri_that_is_nil_or_empty_is_refused + [nil, "", " ", :callback].each do |redirect_uri| + error = assert_raises(ArgumentError) { authorization(redirect_uri:) } + + assert_equal "redirect_uri must not be nil or empty; pass the URL X redirects the user back to, as registered for the app", error.message + end + end + + def test_scopes_that_are_not_an_array_of_strings_are_refused + error = assert_raises(ArgumentError) { authorization(scopes: "tweet.read users.read") } + + assert_equal 'scopes must be an Array of Strings that each name a scope, such as %w[tweet.read users.read offline.access], not "tweet.read users.read"', error.message + [[:"tweet.read"], nil].each { |scopes| assert_raises(ArgumentError) { authorization(scopes:) } } + end + + def test_a_string_that_names_no_scope_is_refused + ["tweet.read users.read", "", "tweet\"read"].each do |scope| + error = assert_raises(ArgumentError) { authorization(scopes: [scope]) } + + assert_includes error.message, "not #{[scope].inspect}" + end + end + + def test_the_scopes_are_a_frozen_copy_of_those_given + scopes = +"tweet.read" + given = [scopes] + authorization = authorization(scopes: given) + given << "users.read" + scopes << ".more" + + assert_equal [%w[tweet.read], true, true], [authorization.scopes, authorization.scopes.frozen?, authorization.scopes.first.frozen?] + end + + def test_a_base_url_that_is_not_one_a_client_takes_is_refused + ["api.x.com/2/", "https://user:SECRET@api.x.com/2/", nil].each do |base_url| + assert_raises(ArgumentError) { authorization(base_url:) } + end + end + + def test_an_empty_array_of_scopes_is_taken + assert_empty authorization(scopes: []).scopes + end + + # An Array of scopes of an app of its own + class Scopes < Array; end + + def test_scopes_of_a_subclass_of_array_are_taken + assert_equal %w[users.read], authorization(scopes: Scopes["users.read"]).scopes + end + end +end diff --git a/x-core/test/x/core/oauth2_load_tokens_test.rb b/x-core/test/x/core/oauth2_load_tokens_test.rb new file mode 100644 index 00000000..56fa6c67 --- /dev/null +++ b/x-core/test/x/core/oauth2_load_tokens_test.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The tokens and endpoints of the tests of load_tokens + module LoadTokensHelpers + TOKEN_URL = "https://api.x.com/2/oauth2/token" + USERS_ME = "https://api.x.com/2/users/me" + + # Tokens another process stored, whose access token expires at the time given + def stored(expires_at: Time.now + 3600) = OAuth2Tokens.new(access_token: "STORED_ACCESS", refresh_token: "STORED_REFRESH", expires_at:) + + def stub_refresh(refresh_token, status: 200, body: {access_token: "NEW_ACCESS", refresh_token: "NEW_REFRESH", expires_in: 7200}) + stub_request(:post, TOKEN_URL).with(body: hash_including(refresh_token:)).to_return(status:, body: body.to_json) + end + + def stub_users_me(access_token, status: 200) + stub_request(:get, USERS_ME).with(headers: {"Authorization" => "Bearer #{access_token}"}) + .to_return(status:, headers: {"Content-Type" => "application/json"}, body: '{"data":{"id":"1"}}') + end + end + + # Processes that share the tokens of a user store each refresh with save_tokens, and read the store with + # load_tokens before a refresh, since X accepts a refresh token once, and rotates it on the first refresh + class OAuth2LoadTokensTest < Minitest::Test + include LoadTokensHelpers + + cover_client + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + + def setup + @reported = [] + @loads = 0 + end + + # A client whose token has expired, which reads the store with a callable that returns each of the values given in + # turn, and then the last of them + def client_loading(*values, **options) + Client.new(**test_oauth2_credentials, expires_at: Time.now - 1, save_tokens: ->(tokens) { @reported << tokens }, + load_tokens: -> { values.fetch([(@loads += 1) - 1, values.size - 1].min) }, **options) + end + + def test_stored_tokens_that_have_not_expired_are_sent_without_a_refresh + stub_users_me("STORED_ACCESS") + client = client_loading(stored) + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_not_requested :post, TOKEN_URL + assert_equal [[], "STORED_REFRESH"], [@reported, client.authenticator.__send__(:refresh_token)] + end + + def test_stored_tokens_that_have_expired_are_refreshed_with_the_stored_refresh_token + refresh = stub_refresh("STORED_REFRESH") + stub_users_me("NEW_ACCESS") + client_loading(stored(expires_at: Time.now - 1)).get("users/me") + + assert_requested refresh, times: 1 + assert_equal ["NEW_REFRESH"], @reported.map(&:refresh_token) + end + + def test_stored_tokens_that_have_expired_are_refreshed_before_the_header_is_given + refresh = stub_refresh("STORED_REFRESH") + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1, load_tokens: -> { stored(expires_at: Time.now - 1) }) + + assert_equal({"Authorization" => "Bearer NEW_ACCESS"}, authenticator.headers(nil)) + assert_requested refresh, times: 1 + end + + def test_an_authenticator_clients_share_reads_the_store_with_the_first_load_tokens_among_them + stub_users_me("STORED_ACCESS") + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + clients = [Client.new(authenticator:), Client.new(authenticator:, load_tokens: -> { stored }), Client.new(authenticator:, load_tokens: -> {})] + clients.last.get("users/me") + + assert_not_requested :post, TOKEN_URL + end + + def test_a_rejected_token_is_sent_again_with_the_stored_tokens_without_a_refresh + stub_users_me(TEST_ACCESS_TOKEN, status: 401) + stub_users_me("STORED_ACCESS") + client = Client.new(**test_oauth2_credentials, load_tokens: -> { stored }) + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_not_requested :post, TOKEN_URL + end + + def test_stored_tokens_taken_just_after_a_refresh_are_refreshed_when_rejected + stub_refresh(TEST_REFRESH_TOKEN, body: {access_token: "SHORT_ACCESS", refresh_token: "SHORT_REFRESH", expires_in: 0}) + refresh = stub_refresh("STORED_REFRESH") + stub_request(:get, USERS_ME).with(headers: {"Authorization" => "Bearer STORED_ACCESS"}) + .to_return({headers: {"Content-Type" => "application/json"}, body: '{"data":{"id":"1"}}'}, {status: 401}) + stub_users_me("NEW_ACCESS") + client = client_loading(nil, stored) + client.get("users/me") + + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + assert_requested refresh, times: 1 + assert_equal %w[SHORT_REFRESH NEW_REFRESH], @reported.map(&:refresh_token) + end + + def test_a_refresh_refused_for_a_spent_refresh_token_takes_the_tokens_stored_since + refresh = stub_refresh(TEST_REFRESH_TOKEN, status: 400, body: {error: "invalid_request", error_description: "Value passed for the token was invalid."}) + stub_users_me("STORED_ACCESS") + client_loading(nil, stored).get("users/me") + + assert_requested refresh, times: 1 + assert_equal [2, []], [@loads, @reported] + end + + def test_a_refresh_refused_for_a_spent_refresh_token_raises_when_the_store_holds_no_other + stub_refresh(TEST_REFRESH_TOKEN, status: 400, body: {error: "invalid_grant"}) + held = OAuth2Tokens.new(access_token: TEST_ACCESS_TOKEN, refresh_token: TEST_REFRESH_TOKEN) + + assert_raises(AuthorizationError) { client_loading(held).get("users/me") } + assert_equal 2, @loads + end + + def test_refreshes_before_save_tokens_stores_any_take_none_of_the_tokens_they_spent + held = OAuth2Tokens.new(access_token: TEST_ACCESS_TOKEN, refresh_token: TEST_REFRESH_TOKEN) + authenticator = OAuth2Authenticator.new(client_id: TEST_CLIENT_ID, **held.to_h, load_tokens: -> { held }) + refreshes = [[TEST_REFRESH_TOKEN, "FIRST"], %w[FIRST SECOND], %w[SECOND THIRD]].map do |spent, issued| + stub_refresh(spent, body: {access_token: "#{issued}_ACCESS", refresh_token: issued, expires_in: 7200}) + end + + assert_equal %w[FIRST SECOND THIRD], Array.new(3) { authenticator.refresh!.refresh_token } + refreshes.each { |refresh| assert_requested refresh, times: 1 } + end + + def test_a_refresh_refused_for_another_reason_raises_without_reading_the_store_again + stub_refresh(TEST_REFRESH_TOKEN, status: 401, body: {error: "invalid_client"}) + + assert_equal "invalid_client", assert_raises(AuthorizationError) { client_loading(nil, stored).get("users/me") }.error_code + assert_equal 1, @loads + end + + def test_a_store_that_holds_nothing_refreshes_as_without_one + refresh = stub_refresh(TEST_REFRESH_TOKEN) + stub_users_me("NEW_ACCESS") + client_loading(nil).get("users/me") + + assert_requested refresh, times: 1 + assert_equal ["NEW_REFRESH"], @reported.map(&:refresh_token) + end + + def test_stored_tokens_of_the_refresh_token_held_refresh_as_without_a_store + refresh = stub_refresh(TEST_REFRESH_TOKEN) + stub_users_me("NEW_ACCESS") + client_loading(OAuth2Tokens.new(access_token: "OTHER", refresh_token: TEST_REFRESH_TOKEN)).get("users/me") + + assert_requested refresh, times: 1 + end + end + + # load_tokens that returns what is not tokens, as a store that reads them back as a Hash would, raises from the + # request, and the client keeps its own tokens + class OAuth2LoadTokensTypeTest < Minitest::Test + include LoadTokensHelpers + + cover Core.const_get(:OAuth2Refresh) + + # A client whose token has expired, which reads the store with a callable that returns each of the values given in + # turn, and then the last of them + def client_loading(*values) + Client.new(**test_oauth2_credentials, expires_at: Time.now - 1, load_tokens: -> { values.shift || values.last }) + end + + def test_load_tokens_that_return_what_is_not_tokens_raise_without_taking_it + client = client_loading(stored.to_h) + error = assert_raises(TypeError) { client.get("users/me") } + + assert_equal "load_tokens must return an X::OAuth2Tokens, or nil for none in the store, not a Hash. Build the " \ + "tokens from what the store holds with X::OAuth2Tokens.new", error.message + assert_equal TEST_ACCESS_TOKEN, internals(client).__send__(:access_token) + assert_not_requested :post, TOKEN_URL + end + + def test_a_client_recovers_once_load_tokens_returns_tokens + stub_users_me("STORED_ACCESS") + client = client_loading(stored.to_h, stored) + + assert_raises(TypeError) { client.get("users/me") } + assert_equal({"data" => {"id" => "1"}}, client.get("users/me")) + end + + def test_tokens_of_a_subclass_of_oauth2_tokens_are_taken + stub_users_me("STORED_ACCESS") + + assert_equal({"data" => {"id" => "1"}}, client_loading(Class.new(OAuth2Tokens).new(**stored.to_h)).get("users/me")) + end + end + + # The load_tokens of an authenticator, which refresh! reads, and which comes before the load_tokens of its client + class OAuth2AuthenticatorLoadTokensTest < Minitest::Test + include LoadTokensHelpers + + cover_client + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:SettingValidator) + + def test_stored_tokens_of_the_refresh_token_a_refresh_spent_are_not_taken + stub_refresh(TEST_REFRESH_TOKEN) + stub_refresh("NEW_REFRESH", body: {access_token: "NEWER_ACCESS", refresh_token: "NEWER_REFRESH"}) + before = OAuth2Tokens.new(access_token: TEST_ACCESS_TOKEN, refresh_token: TEST_REFRESH_TOKEN) + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, load_tokens: -> { before }) + authenticator.refresh! + + assert_equal "NEWER_REFRESH", authenticator.refresh!.refresh_token + end + + def test_stored_tokens_without_a_refresh_token_are_not_taken + stub_refresh(TEST_REFRESH_TOKEN) + stub_refresh("NEW_REFRESH", body: {access_token: "NEWER_ACCESS", refresh_token: "NEWER_REFRESH"}) + unrefreshed = OAuth2Tokens.new(access_token: "STORED_ACCESS") + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, load_tokens: -> { unrefreshed }) + authenticator.refresh! + + assert_equal "NEWER_REFRESH", authenticator.refresh!.refresh_token + end + + def test_refresh_refreshes_with_the_stored_refresh_token + refresh = stub_refresh("STORED_REFRESH") + tokens = OAuth2Authenticator.new(**test_oauth2_credentials, load_tokens: -> { stored }).refresh! + + assert_requested refresh, times: 1 + assert_equal "NEW_REFRESH", tokens.refresh_token + end + + def test_refresh_returns_the_stored_tokens_it_took_in_place_of_a_refusal + stub_refresh(TEST_REFRESH_TOKEN, status: 400, body: {error: "invalid_request"}) + loads = [nil, stored] + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, load_tokens: -> { loads.shift }) + + assert_equal stored.to_h.except(:expires_at), authenticator.refresh!.to_h.except(:expires_at) + end + + def test_the_load_tokens_of_an_authenticator_come_before_those_of_its_client + stub_users_me("STORED_ACCESS") + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1, load_tokens: -> { stored }) + Client.new(authenticator:, load_tokens: -> { flunk "the client's load_tokens was read" }).get("users/me") + + assert_not_requested :post, TOKEN_URL + end + + def test_a_copy_keeps_the_load_tokens_of_the_client + load_tokens = -> { stored } + + assert_same load_tokens, Client.new(**test_oauth2_credentials, load_tokens:).with(max_retries: 0).load_tokens + end + + def test_load_tokens_that_do_not_respond_to_call_are_refused + message = "load_tokens must respond to call, as a Proc or a lambda does, or be nil, not a String" + + assert_equal message, assert_raises(ArgumentError) { Client.new(**test_oauth2_credentials, load_tokens: "store") }.message + assert_equal message, assert_raises(ArgumentError) { OAuth2Authenticator.new(**test_oauth2_credentials, load_tokens: "store") }.message + end + end +end diff --git a/x-core/test/x/core/oauth2_tokens_json_test.rb b/x-core/test/x/core/oauth2_tokens_json_test.rb new file mode 100644 index 00000000..c46b4da3 --- /dev/null +++ b/x-core/test/x/core/oauth2_tokens_json_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "json" +require_relative "../../test_helper" + +module X + # JSON writes the tokens of a refresh led by the number of their format, with the expiration time as an ISO 8601 + # String, and from_json reads them back as the tokens they were + class OAuth2TokensJSONTest < Minitest::Test + cover OAuth2Tokens + + def setup + @expires_at = Time.at(1_790_000_000, 123_456_789, :nsec, in: "-07:00") + @tokens = OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: @expires_at, scopes: %w[tweet.read offline.access]) + end + + def test_as_json_is_led_by_the_format_with_the_expiration_time_in_utc_to_the_nanosecond + assert_equal({"format" => 1, "access_token" => "ACCESS", "refresh_token" => "REFRESH", + "expires_at" => "2026-09-21T14:13:20.123456789Z", "scopes" => %w[tweet.read offline.access]}, @tokens.as_json) + end + + def test_as_json_leaves_the_expiration_time_it_reads_in_its_own_zone + @tokens.as_json + + assert_equal(-25_200, @expires_at.utc_offset) + end + + def test_as_json_writes_nil_for_what_the_tokens_lack + assert_equal({"format" => 1, "access_token" => "A", "refresh_token" => nil, "expires_at" => nil, "scopes" => nil}, + OAuth2Tokens.new(access_token: "A").as_json) + end + + def test_to_json_writes_as_json + assert_equal JSON.generate(@tokens.as_json), @tokens.to_json + assert_equal %({"tokens":#{@tokens.to_json}}), JSON.generate({tokens: @tokens}) + end + + def test_to_json_passes_the_state_it_is_given + assert_equal JSON.pretty_generate(@tokens.as_json), JSON.pretty_generate(@tokens) + end + + def test_tokens_written_as_json_read_back_as_they_were + loaded = OAuth2Tokens.from_json(@tokens.to_json) + + assert_equal [@tokens, true, @expires_at], [loaded, loaded.frozen?, loaded.expires_at] + assert_equal OAuth2Tokens.new(access_token: "A"), OAuth2Tokens.from_json(OAuth2Tokens.new(access_token: "A").to_json) + end + + def test_tokens_read_back_from_json_in_a_subclass_of_string + assert_equal @tokens, OAuth2Tokens.from_json(Class.new(String).new(@tokens.to_json)) + end + + def test_tokens_read_back_from_the_hash_of_as_json + assert_equal @tokens, OAuth2Tokens.from_json(@tokens.as_json) + assert_equal @tokens, OAuth2Tokens.from_json(Class.new(Hash).new.merge!(@tokens.as_json)) + assert_equal @tokens, OAuth2Tokens.from_json(@tokens.as_json.merge("expires_at" => Class.new(String).new(@tokens.as_json["expires_at"]))) + end + + def test_tokens_read_back_from_a_hash_keyed_by_symbol + assert_equal @tokens, OAuth2Tokens.from_json(@tokens.as_json.transform_keys(&:to_sym)) + assert_equal @tokens, OAuth2Tokens.from_json(JSON.parse(@tokens.to_json, symbolize_names: true)) + end + + def test_tokens_a_later_release_added_to_read_back_as_they_were + assert_equal @tokens, OAuth2Tokens.from_json(@tokens.as_json.merge("token_type" => "bearer")) + end + + def test_json_of_another_format_is_refused + error = assert_raises(UnsupportedFormat) { OAuth2Tokens.from_json(@tokens.as_json.merge("format" => "1")) } + + assert_equal 'X::OAuth2Tokens reads format 1 of JSON, not "1"', error.message + assert_raises(UnsupportedFormat) { OAuth2Tokens.from_json(@tokens.as_json.except("format")) } + end + + def test_json_that_is_not_an_object_is_refused + error = assert_raises(ArgumentError) { OAuth2Tokens.from_json("[]") } + + assert_equal "the JSON of X::OAuth2Tokens must be an object, not a Array", error.message + assert_raises(ArgumentError) { OAuth2Tokens.from_json(nil) } + end + + def test_an_expiration_time_that_is_not_a_string_is_refused + error = assert_raises(ArgumentError) { OAuth2Tokens.from_json(@tokens.as_json.merge("expires_at" => 1_790_000_000)) } + + assert_equal "the expires_at of the JSON of X::OAuth2Tokens must be an ISO 8601 String or nil, not a Integer", error.message + assert_raises(ArgumentError) { OAuth2Tokens.from_json(@tokens.as_json.merge("expires_at" => "tomorrow")) } + end + + def test_tokens_the_constructor_refuses_are_refused + assert_raises(ArgumentError) { OAuth2Tokens.from_json(@tokens.as_json.merge("access_token" => nil)) } + assert_raises(ArgumentError) { OAuth2Tokens.from_json({"format" => 1}) } + end + + def test_what_the_json_leaves_out_reads_back_as_nil + assert_equal OAuth2Tokens.new(access_token: "A"), OAuth2Tokens.from_json({"format" => 1, "access_token" => "A"}) + end + + def test_json_that_does_not_parse_raises_a_parser_error + assert_raises(JSON::ParserError) { OAuth2Tokens.from_json("{") } + end + + def test_the_json_constants_are_named_privately + assert_raises(NameError) { OAuth2Tokens::NOT_AN_OBJECT } + assert_raises(NameError) { OAuth2Tokens::NOT_A_TIME_STRING } + assert_raises(NameError) { OAuth2Tokens::FRACTION_DIGITS } + end + end +end diff --git a/x-core/test/x/core/oauth2_tokens_marshal_test.rb b/x-core/test/x/core/oauth2_tokens_marshal_test.rb new file mode 100644 index 00000000..763b2f7b --- /dev/null +++ b/x-core/test/x/core/oauth2_tokens_marshal_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "yaml" +require_relative "../../test_helper" + +module X + # Marshal writes the tokens of a refresh as plain data, led by the number of their format, and reads them back frozen, + # holding the tokens, which are marshalled to be stored, while inspect still reveals neither + class OAuth2TokensMarshalTest < Minitest::Test + cover OAuth2Tokens + + def setup + @expires_at = Time.utc(2026, 9, 26) + @tokens = OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: @expires_at) + end + + def test_marshal_dump_is_plain_data_led_by_its_format + assert_equal [1, {access_token: "ACCESS", refresh_token: "REFRESH", expires_at: @expires_at, scopes: nil}], @tokens.marshal_dump + assert_equal [1, {access_token: "A", refresh_token: "R", expires_at: nil, scopes: nil}], OAuth2Tokens.new(access_token: "A", refresh_token: "R").marshal_dump + end + + def test_marshalled_tokens_read_back_as_they_were_frozen + loaded = Marshal.load(Marshal.dump(@tokens)) + + assert_equal @tokens, loaded + assert_equal [OAuth2Tokens, true], [loaded.class, loaded.frozen?] + end + + def test_marshalled_tokens_are_still_not_revealed_by_inspect + loaded = Marshal.load(Marshal.dump(@tokens)) + + assert_equal "#", loaded.inspect + refute_match(/ACCESS|REFRESH/, loaded.inspect) + end + + def test_tokens_a_later_release_added_to_read_back_as_they_were + loaded = OAuth2Tokens.allocate.tap { |tokens| tokens.marshal_load([1, {**@tokens.to_h, scope: "tweet.read"}, "added"]) } + + assert_equal @tokens, loaded + end + + def test_tokens_with_scopes_read_back_as_they_were + tokens = OAuth2Tokens.new(**@tokens.to_h, scopes: %w[tweet.read users.read]) + + assert_equal tokens, Marshal.load(Marshal.dump(tokens)) + assert_equal tokens, YAML.unsafe_load(YAML.dump(tokens)) + end + + def test_tokens_written_without_scopes_read_back_without_them + loaded = OAuth2Tokens.allocate.tap { |tokens| tokens.marshal_load([1, {access_token: "A", refresh_token: "R", expires_at: nil}]) } + + assert_nil loaded.scopes + end + + def test_tokens_of_another_format_are_refused + error = assert_raises(UnsupportedFormat) { OAuth2Tokens.allocate.marshal_load(["1", {access_token: "A", refresh_token: "R"}]) } + + assert_equal 'X::OAuth2Tokens reads format 1 of Marshal, not "1"', error.message + assert_raises(UnsupportedFormat) { OAuth2Tokens.allocate.marshal_load({access_token: "A"}) } + end + + def test_yaml_writes_the_format_and_each_token_under_its_name + assert_equal({"format" => 1, "access_token" => "ACCESS", "refresh_token" => "REFRESH", "expires_at" => @expires_at, "scopes" => nil}, + YAML.unsafe_load(YAML.dump(@tokens).sub("!ruby/object:X::OAuth2Tokens", ""))) + end + + def test_tokens_written_as_yaml_read_back_frozen + loaded = YAML.unsafe_load(YAML.dump(@tokens)) + + assert_equal [@tokens, true], [loaded, loaded.frozen?] + assert_equal @tokens, YAML.unsafe_load("#{YAML.dump(@tokens)}scope: tweet.read\n") + end + + def test_tokens_written_as_yaml_of_another_format_are_refused + error = assert_raises(UnsupportedFormat) { YAML.unsafe_load(YAML.dump(@tokens).sub("format: 1", "format: 2")) } + + assert_equal "X::OAuth2Tokens reads format 1 of Marshal, not 2", error.message + assert_raises(UnsupportedFormat) { YAML.unsafe_load("--- !ruby/object:X::OAuth2Tokens\nformat: 2\n") } + end + + def test_the_format_is_named_privately + assert_raises(NameError) { OAuth2Tokens::MARSHAL_FORMAT } + end + end +end diff --git a/x-core/test/x/core/oauth2_tokens_test.rb b/x-core/test/x/core/oauth2_tokens_test.rb new file mode 100644 index 00000000..45286c9e --- /dev/null +++ b/x-core/test/x/core/oauth2_tokens_test.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class OAuth2TokensTest < Minitest::Test + cover OAuth2Tokens + + def setup + @expires_at = Time.utc(2026, 9, 26) + @tokens = OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: @expires_at) + end + + def test_reads_the_tokens_and_their_expiration + assert_equal ["ACCESS", "REFRESH", @expires_at], [@tokens.access_token, @tokens.refresh_token, @tokens.expires_at] + end + + def test_is_frozen + assert_predicate @tokens, :frozen? + end + + def test_expires_at_defaults_to_nil + assert_nil OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH").expires_at + end + + def test_to_h + assert_equal({access_token: "ACCESS", refresh_token: "REFRESH", expires_at: @expires_at, scopes: nil}, @tokens.to_h) + end + + def test_scopes_default_to_nil + assert_nil @tokens.scopes + end + + def test_scopes_are_held_frozen_apart_from_those_given + given = [+"tweet.read", +"users.read"] + tokens = OAuth2Tokens.new(access_token: "ACCESS", scopes: given) + given.first << "x" + given << "offline.access" + + assert_equal %w[tweet.read users.read], tokens.scopes + assert_equal [true, true], [tokens.scopes.frozen?, tokens.scopes.first.frozen?] + assert_equal({access_token: "ACCESS", refresh_token: nil, expires_at: nil, scopes: %w[tweet.read users.read]}, tokens.to_h) + end + + def test_tokens_with_other_scopes_are_not_equal + refute_equal @tokens, OAuth2Tokens.new(**@tokens.to_h, scopes: %w[tweet.read]) + end + + def test_scopes_that_are_not_an_array_of_scopes_are_refused + ["tweet.read", ["tweet.read", nil], ["tweet read"], [""], [:"tweet.read"], ['tweet"read'], ["tweet\\read"]].each do |scopes| + error = assert_raises(ArgumentError, scopes.inspect) { OAuth2Tokens.new(access_token: "ACCESS", scopes:) } + + assert_equal "scopes must be an Array of Strings that each name a scope, such as %w[tweet.read users.read], " \ + "or nil if they are not known", error.message + end + end + + def test_scopes_of_a_subclass_of_array_are_held_as_an_array + assert_equal [Array, %w[tweet.read]], OAuth2Tokens.new(access_token: "ACCESS", scopes: Class.new(Array).new(%w[tweet.read])).scopes.then { [it.class, it] } + end + + def test_no_scopes_are_held_as_none + assert_equal [], OAuth2Tokens.new(access_token: "ACCESS", scopes: []).scopes + end + + def test_tokens_with_the_same_values_are_equal + same = OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: @expires_at) + + assert_equal @tokens, same + assert @tokens.eql?(same) + assert_equal @tokens.hash, same.hash + assert_equal 1, {@tokens => 1}.fetch(same) + assert_kind_of Integer, @tokens.hash + end + + def test_tokens_with_other_values_are_not_equal + refute_equal @tokens, OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "OTHER", expires_at: @expires_at) + refute_equal @tokens, @tokens.to_h + end + + def test_tokens_with_other_values_or_of_another_class_hash_differently + refute_equal @tokens.hash, OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "OTHER", expires_at: @expires_at).hash + refute_equal @tokens.hash, @tokens.to_h.hash + refute_equal @tokens.hash, Class.new(OAuth2Tokens).new(**@tokens.to_h).hash + end + + def test_inspect_reveals_no_token + assert_equal "#", @tokens.inspect + assert_equal "#", OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH").inspect + end + + def test_tokens_that_are_not_strings_are_refused_by_name_and_class + error = assert_raises(ArgumentError) { OAuth2Tokens.new(access_token: 1, refresh_token: "REFRESH") } + + assert_equal "access_token must be a String, not a Integer", error.message + assert_equal "access_token must be a String, not a NilClass", + assert_raises(ArgumentError) { OAuth2Tokens.new(access_token: nil, refresh_token: "REFRESH") }.message + end + + def test_a_refresh_token_that_is_neither_a_string_nor_nil_is_refused + assert_equal "refresh_token must be a String or nil, not a Integer", + assert_raises(ArgumentError) { OAuth2Tokens.new(access_token: "ACCESS", refresh_token: 1) }.message + end + + def test_tokens_issued_without_a_refresh_token + tokens = OAuth2Tokens.new(access_token: "ACCESS", expires_at: @expires_at) + + assert_nil tokens.refresh_token + assert_equal({access_token: "ACCESS", refresh_token: nil, expires_at: @expires_at, scopes: nil}, tokens.to_h) + assert_equal tokens, OAuth2Tokens.new(access_token: "ACCESS", refresh_token: nil, expires_at: @expires_at) + end + + def test_tokens_of_a_subclass_of_string_are_accepted + token = Class.new(String).new("ACCESS") + + assert_equal "ACCESS", OAuth2Tokens.new(access_token: token, refresh_token: "REFRESH").access_token + assert_equal "ACCESS", OAuth2Tokens.new(access_token: "ACCESS", refresh_token: token).refresh_token + end + + def test_empty_tokens_are_refused + assert_match(/\Aaccess_token is nil or empty/, assert_raises(ArgumentError) { OAuth2Tokens.new(access_token: " ", refresh_token: "REFRESH") }.message) + assert_match(/\Arefresh_token is empty/, assert_raises(ArgumentError) { OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "") }.message) + end + + def test_an_expiration_time_that_is_not_a_time_is_refused + [1_900_000_000, "2030-03-17T17:46:40Z"].each do |expires_at| + error = assert_raises(ArgumentError) { OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at:) } + + assert_match(/\Aexpires_at must be a Time/, error.message) + end + end + end +end diff --git a/x-core/test/x/core/oauth2_without_refresh_token_test.rb b/x-core/test/x/core/oauth2_without_refresh_token_test.rb new file mode 100644 index 00000000..6aa48526 --- /dev/null +++ b/x-core/test/x/core/oauth2_without_refresh_token_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # X issues no refresh token for an authorization without offline.access, and the access token it issues acts for + # the user all the same: a client built of it authenticates with OAuth 2.0 as that user, cannot refresh, and cannot + # authenticate as the app + class OAuth2WithoutRefreshTokenTest < Minitest::Test + cover_client + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover OAuth2Authorization + + TOKEN_URL = "https://api.x.com/2/oauth2/token" + CREDENTIALS = {client_id: TEST_CLIENT_ID, access_token: TEST_ACCESS_TOKEN}.freeze + + def test_a_client_authenticates_as_the_user_with_the_access_token + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer #{TEST_ACCESS_TOKEN}"}) + client = Client.new(**CREDENTIALS) + client.get("users/me") + + assert_instance_of OAuth2Authenticator, client.authenticator + assert_requested :get, "https://api.x.com/2/users/me" + end + + def test_a_client_cannot_authenticate_as_the_app + assert_raises(UnsupportedOperation) { Client.new(**CREDENTIALS).app_only } + end + + def test_an_expired_token_is_sent_as_it_is_rather_than_refreshed + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer #{TEST_ACCESS_TOKEN}"}) + Client.new(**CREDENTIALS, expires_at: Time.now - 60).get("users/me") + + assert_requested :get, "https://api.x.com/2/users/me" + assert_not_requested :post, TOKEN_URL + end + + def test_a_rejected_token_raises_unauthorized_without_a_refresh + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 401, headers: {"Content-Type" => "application/json"}, + body: '{"title":"Unauthorized","detail":"Unauthorized"}') + + assert_raises(Unauthorized) { Client.new(**CREDENTIALS).get("users/me") } + assert_requested :get, "https://api.x.com/2/users/me", times: 1 + assert_not_requested :post, TOKEN_URL + end + + def test_refresh_raises_unsupported_operation_before_any_request + error = assert_raises(UnsupportedOperation) { OAuth2Authenticator.new(**CREDENTIALS).refresh! } + + assert_match(/holds no refresh token/, error.message) + assert_not_requested :post, TOKEN_URL + end + + def test_a_copy_shares_the_authenticator + client = Client.new(**CREDENTIALS, client_secret: TEST_CLIENT_SECRET) + + assert_same client.authenticator, client.with(max_retries: 0).authenticator + end + + def test_inspect_reveals_no_token + client = Client.new(**CREDENTIALS) + + refute_includes client.inspect, TEST_ACCESS_TOKEN + assert_equal %(#), client.authenticator.inspect + end + + def test_the_client_of_an_authorization_without_offline_access_cannot_authenticate_as_the_app + stub_request(:post, TOKEN_URL).to_return(body: {token_type: "bearer", access_token: "ACCESS", expires_in: 7200}.to_json) + authorization = OAuth2Authorization.new(client_id: TEST_CLIENT_ID, redirect_uri: "https://example.com/callback", state: "STATE") + client = authorization.client("state=STATE&code=CODE") + + assert_raises(UnsupportedOperation) { client.app_only } + end + end +end diff --git a/x-core/test/x/core/private_internals_test.rb b/x-core/test/x/core/private_internals_test.rb new file mode 100644 index 00000000..11ebe996 --- /dev/null +++ b/x-core/test/x/core/private_internals_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # What one object of x-core reads of another is private, and read with __send__, since the signatures have no + # protected methods, and would call a protected method public, and the internals of X::Core are private constants, + # so that a caller cannot name one + class PrivateInternalsTest < Minitest::Test + def test_no_class_of_x_core_has_a_protected_method + [Client, Core.const_get(:Connection), OAuth2Authenticator].each do |klass| + assert_empty klass.protected_instance_methods, "Expected #{klass} to have no protected methods" + end + end + + def test_what_a_copy_reads_of_the_internals_of_a_client_is_private + %i[proxy_url settings share_authenticator share_connection refreshing_rejected_token oauth2_authenticator + oauth2_authenticator_in_use].each do |name| + assert_includes Core.const_get(:ClientInternals).private_instance_methods, name + end + end + + def test_a_client_has_no_private_method_but_initialize + assert_equal %i[initialize], Client.private_instance_methods(false).sort + (Client.ancestors - Object.ancestors - [Client]).each do |ancestor| + assert_empty ancestor.private_instance_methods(false), "Expected #{ancestor} to give a client no private methods" + end + end + + def test_x_core_names_its_version_alone + assert_equal [:VERSION], Core.constants + end + + def test_the_token_endpoints_are_named_privately + [AppOnlyAuthenticator, OAuth2Authenticator, OAuth2Authorization].each do |klass| + assert_raises(NameError) { klass::TOKEN_URL } + end + end + + def test_an_internal_cannot_be_named + %w[ClientInternals Connection RequestBuilder RetryHandler SettingValidator CallbackError].each do |name| + assert_raises(NameError) { Core.module_eval("Core::#{name}", __FILE__, __LINE__) } + end + end + end +end diff --git a/x-core/test/x/core/problem_attributes_test.rb b/x-core/test/x/core/problem_attributes_test.rb new file mode 100644 index 00000000..787da9e2 --- /dev/null +++ b/x-core/test/x/core/problem_attributes_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A problem is frozen, as every object the gems build from a response is, and so is what its attributes hold, so + # that a problem read on one thread cannot be changed under another. + class ProblemAttributesTest < Minitest::Test + cover Problem + + def deep_frozen + Problem.new({"title" => +"Not Found Error", "value" => [+"1", {"nested" => [+"2"]}], "count" => 1}) + end + + def test_attributes_are_deep_frozen + problem = deep_frozen + + assert_equal [true] * 3, [problem.attrs, problem.title, problem.value].map(&:frozen?) + end + + def test_what_the_attributes_hold_is_deep_frozen + string, hash = deep_frozen.value + array = hash["nested"] + + assert_equal [true] * 4, [string, hash, array, array.first].map(&:frozen?) + end + + def test_a_value_that_cannot_be_frozen_is_kept_as_it_is + assert_equal 1, deep_frozen.to_h["count"] + end + + def test_deep_freezing_copies_the_strings_it_freezes + title = +"Not Found Error" + + refute_predicate title, :frozen?, "the fixture must be unfrozen for the copy to be worth making" + assert_predicate Problem.new({"title" => title}).title, :frozen? + refute_predicate title, :frozen? + end + + def test_attributes_are_read_by_string_however_they_were_given + assert_equal "Not Found Error", Problem.new({title: "Not Found Error"}).title + end + end +end diff --git a/x-core/test/x/core/problem_kind_test.rb b/x-core/test/x/core/problem_kind_test.rb new file mode 100644 index 00000000..77ee9bb7 --- /dev/null +++ b/x-core/test/x/core/problem_kind_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A problem tells its kind by its type, whatever the host of the API that named it + class ProblemKindTest < Minitest::Test + cover Problem + + def test_not_found + assert_predicate Problem.new({"type" => "https://api.x.com/2/problems/resource-not-found"}), :not_found? + refute_predicate Problem.new({"type" => "https://api.x.com/2/problems/not-authorized-for-resource"}), :not_found? + refute_predicate Problem.new({}), :not_found? + end + + def test_disconnect + assert_predicate Problem.new({"type" => "https://api.twitter.com/2/problems/operational-disconnect"}), :disconnect? + refute_predicate Problem.new({"type" => "https://api.x.com/2/problems/operational-disconnect-soon"}), :disconnect? + refute_predicate Problem.new({"type" => "https://api.x.com/2/problems/streaming-connection"}), :disconnect? + refute_predicate Problem.new({"title" => "operational-disconnect"}), :disconnect? + end + + def test_usage_capped + assert_predicate Problem.new({"type" => "https://api.twitter.com/2/problems/usage-capped"}), :usage_capped? + refute_predicate Problem.new({"type" => "https://api.x.com/2/problems/usage-capped-soon"}), :usage_capped? + refute_predicate Problem.new({"type" => "https://api.x.com/2/problems/rate-limit"}), :usage_capped? + refute_predicate Problem.new({"title" => "UsageCapExceeded"}), :usage_capped? + end + end +end diff --git a/x-core/test/x/core/problem_marshal_test.rb b/x-core/test/x/core/problem_marshal_test.rb new file mode 100644 index 00000000..d75db277 --- /dev/null +++ b/x-core/test/x/core/problem_marshal_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "yaml" +require_relative "../../test_helper" + +module X + # Marshal writes a problem as plain data, led by the number of its format, and reads it back frozen + class ProblemMarshalTest < Minitest::Test + cover Problem + + ATTRS = {"title" => "Not Found Error", "resource_type" => "user", "resource_id" => "9", "errors" => [{"message" => "gone"}]}.freeze + + def setup + @problem = Problem.new(ATTRS) + end + + def test_marshal_dump_is_plain_data_led_by_its_format + assert_equal [1, ATTRS], @problem.marshal_dump + end + + def test_a_marshalled_problem_reads_back_as_it_was + loaded = Marshal.load(Marshal.dump(@problem)) + + assert_equal [ATTRS, "9", "Not Found Error"], [loaded.attrs, loaded.resource_id, loaded.title] + assert_instance_of Problem, loaded + end + + def test_a_marshalled_problem_reads_back_deep_frozen + loaded = Marshal.load(Marshal.dump(@problem)) + + assert_equal [true] * 4, [loaded, loaded.attrs, loaded.attrs["title"], loaded.attrs["errors"].first].map(&:frozen?) + end + + def test_a_problem_a_later_release_added_to_reads_back_as_it_was + loaded = Problem.allocate.tap { |problem| problem.marshal_load([1, ATTRS, "added"]) } + + assert_equal ATTRS, loaded.attrs + end + + def test_a_problem_of_another_format_is_refused + error = assert_raises(UnsupportedFormat) { Problem.allocate.marshal_load(["1", ATTRS]) } + + assert_equal 'X::Problem reads format 1 of Marshal, not "1"', error.message + assert_raises(UnsupportedFormat) { Problem.allocate.marshal_load(ATTRS) } + end + + def test_yaml_writes_the_state_marshal_writes_under_its_names + assert_equal({"format" => 1, "attrs" => ATTRS}, YAML.unsafe_load(YAML.dump(@problem).sub("!ruby/object:X::Problem", ""))) + end + + def test_a_problem_written_as_yaml_reads_back_deep_frozen + loaded = YAML.unsafe_load(YAML.dump(@problem)) + + assert_equal [Problem, ATTRS], [loaded.class, loaded.attrs] + assert_equal [true] * 4, [loaded, loaded.attrs, loaded.attrs["title"], loaded.attrs["errors"].first].map(&:frozen?) + end + + def test_a_problem_written_as_yaml_by_a_later_release_reads_back_as_it_was + assert_equal ATTRS, YAML.unsafe_load("#{YAML.dump(@problem)}added: true\n").attrs + end + + def test_a_problem_written_as_yaml_of_another_format_is_refused + error = assert_raises(UnsupportedFormat) { YAML.unsafe_load(YAML.dump(@problem).sub("format: 1", "format: 2")) } + + assert_equal "X::Problem reads format 1 of Marshal, not 2", error.message + assert_raises(UnsupportedFormat) { YAML.unsafe_load("--- !ruby/object:X::Problem\nformat: 2\n") } + end + + def test_the_format_is_named_privately + assert_raises(NameError) { Problem::MARSHAL_FORMAT } + end + end +end diff --git a/x-core/test/x/core/problem_test.rb b/x-core/test/x/core/problem_test.rb new file mode 100644 index 00000000..6fea2ca1 --- /dev/null +++ b/x-core/test/x/core/problem_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ProblemTest < Minitest::Test + cover Problem + + NOT_FOUND = {"title" => "Not Found Error", "detail" => "Could not find tweet with pinned_tweet_id: [1].", "type" => "https://api.x.com/2/problems/resource-not-found", + "resource_type" => "tweet", "resource_id" => "1", "parameter" => "pinned_tweet_id", "value" => "1"}.freeze + + def test_attributes + problem = Problem.new(NOT_FOUND) + + assert_equal ["Not Found Error", "Could not find tweet with pinned_tweet_id: [1].", "https://api.x.com/2/problems/resource-not-found"], [problem.title, problem.detail, problem.type] + assert_equal %w[tweet 1 pinned_tweet_id 1], [problem.resource_type, problem.resource_id, problem.parameter, problem.value] + assert_equal NOT_FOUND, problem.to_h + assert_predicate problem, :frozen? + assert_predicate problem.attrs, :frozen? + end + + def test_problems_of_the_same_attributes_are_equal + problem = Problem.new(NOT_FOUND) + same = Problem.new(NOT_FOUND.transform_keys(&:to_sym)) + + assert_equal problem, same + assert problem.eql?(same) + assert_equal 1, {problem => 1}.fetch(same) + assert_equal [problem], [problem, same, Marshal.load(Marshal.dump(same))].uniq + end + + def test_problems_of_other_attributes_are_not_equal + problem = Problem.new(NOT_FOUND) + other = Problem.new(NOT_FOUND.merge("value" => "2")) + + refute_equal problem, other + refute_equal problem.hash, other.hash + refute_equal problem, NOT_FOUND + end + + def test_problems_of_another_class_are_not_equal + problem = Problem.new(NOT_FOUND) + subclassed = Class.new(Problem).new(NOT_FOUND) + + refute_equal problem, subclassed + refute_equal problem.hash, subclassed.hash + end + + def identifiers(attrs) = Problem.new(attrs).then { |problem| [problem.resource_id, problem.value] } + + def test_an_identifier_is_the_string_the_api_gave_whatever_the_kind_of_resource + %w[user tweet post list dm_event community poll space media].each do |resource_type| + assert_equal %w[0123 0123], identifiers({"resource_type" => resource_type, "resource_id" => "0123", "parameter" => "ids", "value" => "0123"}) + end + assert_equal %w[sferik sferik], identifiers({"resource_type" => "user", "resource_id" => "sferik", "parameter" => "usernames", "value" => "sferik"}) + assert_equal %w[1234 1234], identifiers({"resource_id" => "1234", "value" => "1234"}) + end + + # A resource, as the object layer builds one, with an identifier + Identified = Struct.new(:id) + + def test_a_problem_is_about_the_resource_its_resource_id_names + problem = Problem.new(NOT_FOUND) + + assert_equal [true, true, true], [1, "1", Identified.new(1)].map { |resource| problem.about?(resource) } + assert_equal [false, false, false], [2, "01", Identified.new("2")].map { |resource| problem.about?(resource) } + assert Problem.new({"resource_id" => "sferik"}).about?("sferik") + end + + def test_a_problem_that_names_no_resource_is_about_none + problem = Problem.new({"title" => "Not Found Error"}) + + assert_equal [false, false], ["", Identified.new(nil)].map { |resource| problem.about?(resource) } + end + + def test_a_value_that_is_not_a_string_is_as_the_api_gave_it + assert_equal [nil, ["1"]], identifiers({"resource_type" => "user", "value" => ["1"]}) + assert_equal [nil, 1], identifiers({"resource_type" => "user", "value" => 1}) + end + + def test_the_message_of_an_error_the_api_named + assert_equal "Could not authenticate you", Problem.new({"message" => "Could not authenticate you", "code" => 32}).message + assert_nil Problem.new(NOT_FOUND).message + end + + def test_inspect + assert_equal "#", Problem.new(NOT_FOUND).inspect + assert_equal "#", Problem.new({"title" => "Not Found Error"}).inspect + end + + def test_inspect_reads_the_message_of_a_problem_that_has_no_detail + assert_equal "#", Problem.new({"message" => "Could not authenticate you", "code" => 32}).inspect + assert_equal "#", Problem.new({"title" => "Unauthorized", "message" => "Could not authenticate you"}).inspect + end + + def test_inspect_reads_the_detail_of_a_problem_that_has_both + assert_equal "#", Problem.new({"title" => "Not Found Error", "detail" => "Could not find user", "message" => "Could not find user with id: [1]."}).inspect + end + + def test_missing_attributes_are_nil + problem = Problem.new({}) + + assert_equal [nil] * 8, [problem.title, problem.detail, problem.type, problem.resource_type, problem.resource_id, problem.parameter, problem.value, problem.message] + refute_predicate problem, :not_found? + end + + def test_all_from + problems = Problem.all_from({"data" => {"id" => "1"}, "errors" => [NOT_FOUND, "not a problem"]}) + + assert_equal [Problem], problems.map(&:class) + assert_equal [NOT_FOUND], problems.map(&:to_h) + assert_predicate problems, :frozen? + assert_empty Problem.all_from({"data" => {"id" => "1"}}) + assert_empty Problem.all_from(nil) + end + + def test_serialization + problem = Problem.new({"title" => "Not Found Error"}) + + assert_equal({"title" => "Not Found Error"}, problem.as_json) + assert_same problem.attrs, problem.as_json + assert_equal '{"title":"Not Found Error"}', problem.to_json + assert_equal '{"problem":{"title":"Not Found Error"}}', {problem:}.to_json + end + + def test_to_json_is_generated_with_the_state_it_is_given + problem = Problem.new({"title" => "Not Found Error"}) + + assert_equal "{\n \"title\": \"Not Found Error\"\n}", problem.to_json(JSON::State.new(indent: " ", object_nl: "\n", space: " ")) + end + end +end diff --git a/x-core/test/x/core/public_constructors_built_test.rb b/x-core/test/x/core/public_constructors_built_test.rb new file mode 100644 index 00000000..04d9384c --- /dev/null +++ b/x-core/test/x/core/public_constructors_built_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "net/http" +require_relative "../../test_helper" + +module X + # The errors of a response, and the summary of one, are built by hand from its status, headers, and body, of which + # x-core builds the Net::HTTP response they hold, so that a test need not build one + class PublicConstructorsBuiltTest < Minitest::Test + cover HTTPError + cover InvalidResponse + cover Response + cover Core.const_get(:BuiltResponse) + + URI_OF_REQUEST = URI("https://api.x.com/2/users/1?user.fields=id") + NOT_FOUND = {status: 404, headers: {"content-type" => "application/json"}, + body: '{"title":"Not Found Error","detail":"Could not find user."}'}.freeze + + def not_found = NotFound.new(**NOT_FOUND).http_response + + def test_an_http_error_is_built_from_a_status_headers_and_body + error = NotFound.new(**NOT_FOUND) + + assert_equal [404, "Not Found Error", NOT_FOUND[:body]], [error.status, error.problem.title, error.body] + assert_equal({"content-type" => "application/json"}, error.headers) + assert_equal "Not Found Error: Could not find user.", error.message + assert_raises(NotFound) { raise error } + end + + def test_an_http_error_built_from_a_status_alone_is_named_by_its_reason_phrase + error = TooManyRequests.new(status: 429) + + assert_kind_of Net::HTTPTooManyRequests, error.http_response + assert_equal ["1.1", "429"], [error.http_response.http_version, error.http_response.code] + assert_equal ["Too Many Requests", nil, {}], [error.message, error.body, error.headers] + end + + def test_a_status_net_http_names_no_class_of_is_built_as_one_of_its_class_of_statuses + error = ClientError.new(status: 418) + + assert_kind_of Net::HTTPClientError, error.http_response + assert_equal [418, "Client Error"], [error.status, error.message] + end + + def test_a_status_named_by_initials_is_read_apart_from_its_words + assert_equal "URI Too Long", ClientError.new(status: 414).message + end + + def test_the_body_of_a_response_built_is_tagged_utf8_without_changing_the_body_given + body = (+"caf\xC3\xA9").force_encoding(Encoding::BINARY) + error = HTTPError.new(status: 500, body:) + + assert_equal [Encoding::UTF_8, "café"], [error.body.encoding, error.body] + assert_equal Encoding::BINARY, body.encoding + end + + def test_a_response_and_a_status_are_refused_together + error = assert_raises(ArgumentError) { HTTPError.new(http_response: not_found, status: 404) } + + assert_equal "Pass the http_response:, or the status:, headers:, and body: one is built of, not both", error.message + end + + def test_a_response_is_refused_beside_headers_or_a_body + assert_raises(ArgumentError) { HTTPError.new(http_response: not_found, headers: {}) } + assert_raises(ArgumentError) { HTTPError.new(http_response: not_found, body: "{}") } + end + + def test_an_error_is_refused_without_a_response_or_a_status + error = assert_raises(ArgumentError) { HTTPError.new } + + assert_equal "X::HTTPError is raised for a response of any status, so it is built with the status: of one, or " \ + "the http_response: itself; raise the error of the status, such as X::NotFound, to build one without either", error.message + assert_raises(ArgumentError) { HTTPError.new(headers: {}, body: "{}") } + end + + def test_a_summary_is_refused_without_a_response_or_a_status + error = assert_raises(ArgumentError) { Response.new(http_method: :get, uri: URI_OF_REQUEST) } + + assert_equal "Pass the status: of the response, with its headers: and body: if it has any, or the http_response: itself", error.message + end + + def test_a_status_http_does_not_define_is_refused + [99, 600, "404", 404.0].each do |status| + error = assert_raises(ArgumentError) { HTTPError.new(status:) } + + assert_equal "status must be an Integer from 100 to 599, not #{status.inspect}", error.message + end + assert_equal [100, 599], [Response.new(http_method: :get, uri: URI_OF_REQUEST, status: 100).status, ServerError.new(status: 599).status] + end + + def test_headers_are_named_in_any_case_by_a_string_or_a_symbol + assert_equal({"content-type" => "application/json"}, HTTPError.new(status: 404, headers: {"Content-Type": "application/json"}).headers) + end + + def test_headers_that_are_not_a_hash_of_names_to_values_are_refused + assert_raises(ArgumentError) { HTTPError.new(status: 404, headers: [["content-type", "text/html"]]) } + assert_raises(ArgumentError) { HTTPError.new(status: 404, headers: {"retry-after" => 60}) } + end + + def test_an_invalid_response_is_built_from_a_status_headers_and_body + error = InvalidResponse.new(status: 200, headers: {"content-type" => "text/html"}, body: "") + + assert_equal [200, "", ""], [error.status, error.body, error.http_response.body] + assert_equal "The body of the 200 response is not JSON (text/html)", error.message + end + + def test_an_invalid_response_is_refused_a_response_beside_a_status_or_headers + assert_raises(ArgumentError) { InvalidResponse.new(http_response: not_found, status: 404) } + assert_raises(ArgumentError) { InvalidResponse.new(http_response: not_found, headers: {}) } + end + + def test_a_response_is_built_from_a_status_headers_and_body + response = Response.new(http_method: :get, uri: URI_OF_REQUEST, status: 200, + headers: {"x-rate-limit-limit" => "75", "x-rate-limit-remaining" => "74", "x-rate-limit-reset" => "1700000000"}, + body: '{"data":{"id":"1"}}') + + assert_predicate response, :success? + assert_equal [200, '{"data":{"id":"1"}}', 74], [response.status, response.http_response.body, response.rate_limits.first.remaining] + end + + def test_a_response_is_refused_a_response_beside_a_status_or_headers + assert_raises(ArgumentError) { Response.new(http_response: not_found, http_method: :get, uri: URI_OF_REQUEST, status: 404) } + assert_raises(ArgumentError) { Response.new(http_response: not_found, http_method: :get, uri: URI_OF_REQUEST, headers: {}) } + assert_raises(ArgumentError) { Response.new(http_method: :get, uri: URI_OF_REQUEST) } + end + end +end diff --git a/x-core/test/x/core/public_constructors_test.rb b/x-core/test/x/core/public_constructors_test.rb new file mode 100644 index 00000000..90677c4a --- /dev/null +++ b/x-core/test/x/core/public_constructors_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "net/http" +require_relative "../../test_helper" + +module X + # The errors x-core raises, and the summary an on_response hook is passed, can be built by hand, so that code that + # rescues one, or a hook, can be tested, while a rate limit is built only from a response + class PublicConstructorsTest < Minitest::Test + cover HTTPError + cover NetworkError + cover InvalidResponse + cover TooManyRedirects + cover Response + cover RateLimit + cover Core.const_get(:BuiltResponse) + + URI_OF_REQUEST = URI("https://api.x.com/2/users/1?user.fields=id") + NOT_FOUND = {status: 404, headers: {"content-type" => "application/json"}, + body: '{"title":"Not Found Error","detail":"Could not find user."}'}.freeze + + def not_found = NotFound.new(**NOT_FOUND).http_response + + def test_an_http_error_is_built_from_a_response + error = NotFound.new(http_response: not_found) + + assert_equal [404, "Not Found Error"], [error.status, error.problem.title] + end + + def test_an_http_error_names_the_request_it_is_given + error = NotFound.new(http_response: not_found, http_method: :get, uri: URI_OF_REQUEST) + + assert_equal [:get, URI_OF_REQUEST], [error.http_method, error.uri] + assert_equal "GET /2/users/1: Not Found Error: Could not find user.", error.message + end + + def test_the_errors_of_a_request_are_built_with_a_message + [NetworkError, TooManyRedirects].each do |error_class| + error = error_class.new("went wrong", http_method: :get, uri: URI_OF_REQUEST) + + assert_equal [:get, URI_OF_REQUEST, "GET /2/users/1: went wrong"], [error.http_method, error.uri, error.message] + end + end + + def test_the_error_of_a_line_of_a_stream_names_the_request_of_the_stream + assert_equal [:get, URI_OF_REQUEST], InvalidResponse.new(http_response: not_found, body: "<", http_method: :get, uri: URI_OF_REQUEST).then { |error| [error.http_method, error.uri] } + end + + def test_an_invalid_response_is_built_from_a_response_and_the_body_that_is_not_json + error = InvalidResponse.new(http_response: not_found, body: "") + + assert_equal [404, "", NOT_FOUND[:body]], [error.status, error.body, error.http_response.body] + end + + def test_a_response_is_built_from_a_response + response = Response.new(http_response: not_found, http_method: :get, uri: URI("https://api.x.com/2/users/1")) + + assert_equal [:get, 404, NOT_FOUND[:body]], [response.http_method, response.status, response.body] + end + + def test_a_response_summarizes_the_part_of_the_body_it_is_given + response = Response.new(http_response: not_found, http_method: :get, uri: URI_OF_REQUEST, body: "{}") + + assert_equal ["{}", NOT_FOUND[:body]], [response.body, response.http_response.body] + end + + def test_a_rate_limit_is_built_only_from_a_response + assert_raises(NoMethodError) { RateLimit.new(type: RateLimit::RATE_LIMIT_TYPE, http_response: not_found) } + end + end +end diff --git a/x-core/test/x/core/rate_limit_handler_counting_test.rb b/x-core/test/x/core/rate_limit_handler_counting_test.rb new file mode 100644 index 00000000..6ad4a4e8 --- /dev/null +++ b/x-core/test/x/core/rate_limit_handler_counting_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The retries of a request for a rate limit are counted across its attempts, apart from those of a request one of + # its callbacks sends, and handed down across the attempts Client#with_retries sends + class RateLimitHandlerCountingTest < Minitest::Test + cover Core.const_get(:RateLimitHandler) + + def setup + @sleeps = [] + @attempts = 0 + end + + def test_counting_counts_the_retries_of_each_attempt_as_retries_of_one_request + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + handler.counting do + handle(handler) { (@attempts < 1) ? refuse(reset_in: 1) : :done } + + assert_raises(TooManyRequests) { handle(handler) { refuse(reset_in: 1) } } + end + + assert_equal [2, 1], [@attempts, @sleeps.size] + end + + def test_counting_within_counting_counts_afresh_and_leaves_the_outer_count_as_it_was + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + handler.counting do + handle(handler) { (@attempts < 1) ? refuse(reset_in: 1) : :done } + + assert_equal :done, handler.counting { handle(handler) { (@attempts < 2) ? refuse(reset_in: 1) : :done } } + refused_at_once(handler) + end + + assert_equal [2, nil], [@sleeps.size, Thread.current[Core.const_get(:RateLimitHandler)::RETRIES]] + end + + def test_handing_down_counts_on_across_the_requests_it_wraps + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + handler.handing_down do + handler.counting { handle(handler) { (@attempts < 1) ? refuse(reset_in: 1) : :done } } + + assert_equal :raised, handler.counting { refused_at_once(handler) && :raised } + end + + assert_equal [1, nil], [@sleeps.size, Thread.current[Core.const_get(:RateLimitHandler)::HANDED]] + end + + def test_a_request_within_a_request_with_retries_wraps_does_not_take_the_retries_handed_down + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + handler.handing_down do + handler.counting { handle(handler) { (@attempts < 1) ? refuse(reset_in: 1) : :done } } + + assert_equal :done, handler.counting { handler.counting { handle(handler) { (@attempts < 2) ? refuse(reset_in: 1) : :done } } } + end + + assert_equal 2, @sleeps.size + end + + def test_handing_down_within_handing_down_leaves_the_outer_retries_as_they_were + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + handler.handing_down do + handler.counting { handle(handler) { (@attempts < 1) ? refuse(reset_in: 1) : :done } } + handler.handing_down { handler.counting { :done } } + + handler.counting { refused_at_once(handler) } + end + + assert_equal 1, @sleeps.size + end + + private + + def refused_at_once(handler) + assert_raises(TooManyRequests) { handle(handler) { refuse(reset_in: 1) } } + end + + def handle(handler, random: 0.0, &) + Time.stub(:now, Time.utc(1983, 11, 24)) do + handler.stub(:rand, random) do + handler.stub(:sleep, ->(seconds) { @sleeps << seconds }) { handler.handle(&) } + end + end + end + + def refuse(reset_in: nil, headers: {}) + @attempts += 1 + response = Net::HTTPTooManyRequests.new("1.1", "429", "Too Many Requests") + headers = {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => (Time.now.to_i + reset_in).to_s} if reset_in + headers.each { |name, value| response[name] = value } + raise TooManyRequests.new(http_response: response) + end + end +end diff --git a/x-core/test/x/core/rate_limit_handler_test.rb b/x-core/test/x/core/rate_limit_handler_test.rb new file mode 100644 index 00000000..89ec9d87 --- /dev/null +++ b/x-core/test/x/core/rate_limit_handler_test.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RateLimitHandlerTest < Minitest::Test + cover Core.const_get(:RateLimitHandler) + + USAGE_CAPPED = %({"title":"UsageCapExceeded","detail":"Usage cap exceeded: Monthly product cap","type":"https://api.twitter.com/2/problems/usage-capped","period":"Monthly","scope":"Product"}) + + RATE_LIMITED = %({"title":"Too Many Requests","detail":"Too Many Requests","type":"about:blank","status":429}) + + def setup + @sleeps = [] + @attempts = 0 + end + + def test_defaults + handler = Core.const_get(:RateLimitHandler).new + + assert_equal [0, 900], [handler.max_rate_limit_retries, handler.max_rate_limit_wait] + end + + def test_returns_what_the_block_returns + assert_equal :done, handle(Core.const_get(:RateLimitHandler).new) { :done } + end + + def test_retries_nothing_by_default_and_reads_no_reset + error = assert_raises(TooManyRequests) { handle(Core.const_get(:RateLimitHandler).new) { refuse(headers: {"x-rate-limit-remaining" => "0"}) } } + + assert_equal [1, []], [@attempts, @sleeps] + assert_equal 429, error.status + end + + def test_retries_after_waiting_for_the_reset + result = handle(Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 2)) { (@attempts < 2) ? refuse(reset_in: 30) : @attempts += 1 } + + assert_equal [3, [30, 30]], [result, @sleeps] + end + + def test_raises_the_error_once_the_retries_run_out + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + + assert_raises(TooManyRequests) { handle(handler) { refuse(reset_in: 0) } } + assert_equal [2, [0]], [@attempts, @sleeps] + end + + def test_raises_at_once_when_the_limit_resets_after_the_maximum_wait + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 3, max_rate_limit_wait: 10) + + assert_raises(TooManyRequests) { handle(handler) { refuse(reset_in: 11) } } + assert_equal [1, []], [@attempts, @sleeps] + assert_raises(TooManyRequests) { handle(Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1, max_rate_limit_wait: 10)) { refuse(reset_in: 10) } } + assert_equal [3, [10]], [@attempts, @sleeps] + end + + def test_a_refusal_without_a_reset_time_waits_a_minute_and_doubles_the_wait + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 5, max_rate_limit_wait: 240) + + assert_raises(TooManyRequests) { handle(handler) { refuse(headers: {"x-rate-limit-remaining" => "0"}) } } + assert_equal [4, [60, 120, 240]], [@attempts, @sleeps] + end + + def test_adds_a_random_share_of_five_seconds_to_each_wait + result = handle(Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 2), random: 0.5) { (@attempts < 2) ? refuse(reset_in: 30) : @attempts += 1 } + + assert_equal [3, [32.5, 32.5]], [result, @sleeps] + end + + def test_measures_the_maximum_wait_against_the_reset_rather_than_the_random_share + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1, max_rate_limit_wait: 10) + + assert_raises(TooManyRequests) { handle(handler, random: 1.0) { refuse(reset_in: 10) } } + assert_equal [2, [15]], [@attempts, @sleeps] + end + + def test_raises_the_usage_cap_of_the_project_at_once + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 3) + + assert_raises(TooManyRequests) { handle(handler) { (@attempts += 1) && raise(TooManyRequests.new(status: 429, headers: {"content-type" => "application/problem+json"}, body: USAGE_CAPPED)) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_retries_a_refusal_that_names_a_problem_other_than_the_usage_cap + handler = Core.const_get(:RateLimitHandler).new(max_rate_limit_retries: 1) + + assert_raises(TooManyRequests) { handle(handler) { (@attempts += 1) && raise(TooManyRequests.new(status: 429, headers: {"content-type" => "application/problem+json"}, body: RATE_LIMITED)) } } + assert_equal [2, [60]], [@attempts, @sleeps] + end + + private + + # Run the block, collecting the waits, with the random share added to each one fixed + def handle(handler, random: 0.0, &) + Time.stub(:now, Time.utc(1983, 11, 24)) do + handler.stub(:rand, random) do + handler.stub(:sleep, ->(seconds) { @sleeps << seconds }) { handler.handle(&) } + end + end + end + + def refuse(reset_in: nil, headers: {}) + @attempts += 1 + response = Net::HTTPTooManyRequests.new("1.1", "429", "Too Many Requests") + headers = {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => (Time.now.to_i + reset_in).to_s} if reset_in + headers.each { |name, value| response[name] = value } + raise TooManyRequests.new(http_response: response) + end + end +end diff --git a/x-core/test/x/core/rate_limit_reported_test.rb b/x-core/test/x/core/rate_limit_reported_test.rb new file mode 100644 index 00000000..32f6158f --- /dev/null +++ b/x-core/test/x/core/rate_limit_reported_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RateLimitReportedTest < Minitest::Test + cover RateLimit + cover TooManyRequests + cover Response + + FULL = {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => "1"}.freeze + + def test_a_rate_limit_is_reported_only_with_all_three_headers + assert RateLimit.__send__(:reported?, "rate-limit", FULL) + FULL.each_key { |header| refute RateLimit.__send__(:reported?, "rate-limit", FULL.except(header)) } + refute RateLimit.__send__(:reported?, "app-limit-24hour", FULL) + end + + def test_an_exhausted_limit_without_a_reset_time_is_left_out + error = TooManyRequests.new(http_response: response(FULL.except("x-rate-limit-reset"))) + + assert_empty error.rate_limits + assert_nil error.rate_limit + assert_nil error.retry_after + end + + def test_all_from_reads_every_limit_a_response_reports_in_the_order_of_the_types + http_response = response(FULL.merge("x-user-limit-24hour-limit" => "25", "x-user-limit-24hour-remaining" => "5", + "x-user-limit-24hour-reset" => "2")) + + assert_equal [["rate-limit", 0], ["user-limit-24hour", 5]], + RateLimit.__send__(:all_from, http_response).map { |limit| [limit.type, limit.remaining] } + end + + def test_a_summary_leaves_out_a_limit_without_every_header + http_response = response(FULL.except("x-rate-limit-limit")) + + assert_empty Response.new(http_response:, http_method: :get, uri: URI("https://api.x.com/2/users/me")).rate_limits + end + + def test_a_rate_limit_is_read_in_base_10 + limit = RateLimit.__send__(:all_from, response("x-rate-limit-limit" => "050", "x-rate-limit-remaining" => "010", "x-rate-limit-reset" => "0100")).first + + assert_equal [50, 10, Time.at(100)], [limit.limit, limit.remaining, limit.reset_at] + end + + def test_a_rate_limit_whose_header_is_not_a_count_in_base_10_is_not_reported + FULL.each_key do |header| + ["0x10", "1e3", "-1", "", "later", "1\n2"].each do |value| + refute RateLimit.__send__(:reported?, "rate-limit", FULL.merge(header => value)), "#{header}: #{value.inspect}" + end + end + end + + def test_a_refusal_whose_reset_is_not_a_count_says_nothing_of_when_to_retry + error = TooManyRequests.new(http_response: response(FULL.merge("x-rate-limit-reset" => "soon"))) + + assert_empty error.rate_limits + assert_nil error.retry_after + end + + def test_a_refusal_whose_reset_is_not_a_count_raises_itself_from_a_request + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 429, headers: FULL.merge("x-rate-limit-reset" => "soon")) + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 1, max_rate_limit_wait: 0) + + assert_raises(TooManyRequests) { client.get("users/me") } + end + + private + + def response(headers) + Net::HTTPTooManyRequests.new("1.1", "429", "Too Many Requests").tap do |http_response| + headers.each { |name, value| http_response[name] = value } + end + end + end +end diff --git a/test/x/rate_limit_test.rb b/x-core/test/x/core/rate_limit_test.rb similarity index 58% rename from test/x/rate_limit_test.rb rename to x-core/test/x/core/rate_limit_test.rb index b6b1b6bb..203cb238 100644 --- a/test/x/rate_limit_test.rb +++ b/x-core/test/x/core/rate_limit_test.rb @@ -1,4 +1,6 @@ -require_relative "../test_helper" +# frozen_string_literal: true + +require_relative "../../test_helper" module X class RateLimitTest < Minitest::Test @@ -6,12 +8,12 @@ class RateLimitTest < Minitest::Test def setup Time.stub :now, Time.utc(1983, 11, 24) do - response = { + @response = { "x-rate-limit-limit" => "100", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => (Time.now.to_i + 60).to_s } - @rate_limit = RateLimit.new(type: "rate-limit", response:) + @rate_limit = RateLimit.__send__(:new, type: "rate-limit", http_response: @response) end end @@ -23,6 +25,13 @@ def test_remaining assert_equal 0, @rate_limit.remaining end + def test_exhausted + assert_predicate @rate_limit, :exhausted? + @response["x-rate-limit-remaining"] = "1" + + refute_predicate @rate_limit, :exhausted? + end + def test_reset_at Time.stub :now, Time.utc(1983, 11, 24) do assert_equal Time.at(Time.now.to_i + 60), @rate_limit.reset_at @@ -36,15 +45,17 @@ def test_reset_in end def test_reset_in_minimum_value - @rate_limit.response["x-rate-limit-reset"] = (Time.now.to_i - 60).to_s + @response["x-rate-limit-reset"] = (Time.now.to_i - 60).to_s assert_equal 0, @rate_limit.reset_in end def test_reset_in_ceil - @rate_limit.response["x-rate-limit-reset"] = (Time.now + 61).to_i.to_s + Time.stub :now, Time.utc(1983, 11, 24, 0, 0, 0, 900_000) do + @response["x-rate-limit-reset"] = (Time.now + 61).to_i.to_s - assert_equal 61, @rate_limit.reset_in + assert_equal 61, @rate_limit.reset_in + end end end end diff --git a/x-core/test/x/core/read_only_attributes_test.rb b/x-core/test/x/core/read_only_attributes_test.rb new file mode 100644 index 00000000..6a0fee94 --- /dev/null +++ b/x-core/test/x/core/read_only_attributes_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ReadOnlyAttributesTest < Minitest::Test + { + OAuth1Authenticator.new(**test_oauth_credentials) => %i[api_key], + OAuth2Authenticator.new(**test_oauth2_credentials) => %i[client_id expires_at], + RateLimit.__send__(:new, type: RateLimit::RATE_LIMIT_TYPE, http_response: Net::HTTPOK.new("1.1", "200", "OK")) => %i[type] + }.each do |object, attributes| + attributes.each do |attribute| + define_method(:"test_#{object.class.name.split("::").last.downcase}_#{attribute}_is_read_only") do + assert_respond_to object, attribute + refute_respond_to object, :"#{attribute}=" + end + end + end + end +end diff --git a/x-core/test/x/core/redirect_handler_method_test.rb b/x-core/test/x/core/redirect_handler_method_test.rb new file mode 100644 index 00000000..ab2aa143 --- /dev/null +++ b/x-core/test/x/core/redirect_handler_method_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RedirectHandlerMethodTest < Minitest::Test + cover Core.const_get(:RedirectHandler) + + def setup + @redirect_handler = Core.const_get(:RedirectHandler).new + end + + def redirect_to(location) + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = location + response + end + + def test_a_moved_redirect_keeps_the_method_and_the_body_of_a_put_or_a_delete + [[Net::HTTP::Put, :put, "301"], [Net::HTTP::Delete, :delete, "302"]].each do |request_class, method, code| + request = request_class.new(URI("http://example.com/#{method}")) + request.body = "{}" + stub_request(method, "http://example.com/#{method}/2") + response = Net::HTTPRedirection.new("1.1", code, "Moved") + response["Location"] = "http://example.com/#{method}/2" + + @redirect_handler.handle(response:, request:, headers: {"Content-Type" => "application/json"}) + + assert_requested method, "http://example.com/#{method}/2", body: "{}", headers: {"Content-Type" => "application/json"} + end + end + + def test_a_moved_redirect_follows_a_post_with_a_get + request = Net::HTTP::Post.new(URI("http://example.com/")) + request.body = "{}" + stub_request(:get, "http://example.com/2") + + @redirect_handler.handle(response: redirect_to("http://example.com/2"), request:) + + assert_requested(:get, "http://example.com/2") { |redirected| redirected.body.to_s.empty? } + end + + def test_a_see_other_redirect_follows_a_delete_with_a_get + request = Net::HTTP::Delete.new(URI("http://example.com/")) + stub_request(:get, "http://example.com/2") + response = Net::HTTPSeeOther.new("1.1", "303", "See Other") + response["Location"] = "http://example.com/2" + + @redirect_handler.handle(response:, request:, headers: {"X-Custom" => "value"}) + + assert_requested :get, "http://example.com/2", headers: {"X-Custom" => "value"} + end + + def test_follow_with_no_redirects_returns_the_response_and_its_request + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + response = Net::HTTPSuccess.new("1.1", "200", "OK") + + assert_equal [response, request], @redirect_handler.follow(response:, request:) + end + + def test_follow_returns_the_request_the_final_response_answers + request = Net::HTTP::Post.new(URI("http://example.com/old")) + response = Net::HTTPSeeOther.new("1.1", "303", "See Other") + response["Location"] = "http://example.com/new" + stub_request(:get, "http://example.com/new") + final_response, final_request = @redirect_handler.follow(response:, request:) + + assert_equal ["200", "GET", "http://example.com/new"], [final_response.code, final_request.method, final_request.uri.to_s] + end + end +end diff --git a/x-core/test/x/core/redirect_handler_test.rb b/x-core/test/x/core/redirect_handler_test.rb new file mode 100644 index 00000000..8694f0a1 --- /dev/null +++ b/x-core/test/x/core/redirect_handler_test.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RedirectHandlerTest < Minitest::Test + cover Core.const_get(:RedirectHandler) + + def setup + @connection = Core.const_get(:Connection).new + @request_builder = Core.const_get(:RequestBuilder).new + @redirect_handler = Core.const_get(:RedirectHandler).new(connection: @connection, request_builder: @request_builder) + end + + def redirect_to(location) + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = location + response + end + + def test_initialize_with_defaults + redirect_handler = Core.const_get(:RedirectHandler).new + + assert_instance_of Core.const_get(:Connection), redirect_handler.connection + assert_instance_of Core.const_get(:RequestBuilder), redirect_handler.request_builder + end + + def test_handle_with_no_redirects + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + + response = Net::HTTPSuccess.new("1.1", "200", "OK") + + assert_equal(response, @redirect_handler.handle(response:, request:)) + end + + def test_handle_with_one_redirect + authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) + request = Net::HTTP::Get.new(URI("http://example.com/")) + stub_request(:get, "http://example.com/2").with(headers: {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o}) + + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "http://example.com/2" + + @redirect_handler.handle(response:, request:, authenticator:) + + assert_requested :get, "http://example.com/2" + end + + def test_handle_with_two_redirects + request = Net::HTTP::Delete.new(URI("http://example.com/")) + stub_request(:delete, "http://example.com/2").to_return(status: 307, headers: {"Location" => "http://example.com/3"}) + stub_request(:delete, "http://example.com/3") + + response = Net::HTTPFound.new("1.1", "307", "Found") + response["Location"] = "http://example.com/2" + + @redirect_handler.handle(response:, request:) + + assert_requested :delete, "http://example.com/2" + assert_requested :delete, "http://example.com/3" + end + + def test_handle_preserves_authentication_across_redirects + authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) + authorization = {"Authorization" => /Bearer #{TEST_BEARER_TOKEN}/o} + stub_request(:get, "http://example.com/2") + .with(headers: authorization) + .to_return(status: 302, headers: {"Location" => "http://example.com/3"}) + stub_request(:get, "http://example.com/3").with(headers: authorization) + + @redirect_handler.handle(response: redirect_to("http://example.com/2"), request: Net::HTTP::Get.new(URI("http://example.com/")), authenticator:) + + assert_requested :get, "http://example.com/3", headers: authorization + end + + def test_handle_with_relative_url + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + stub_request(:get, "http://example.com/some_relative_path") + + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "/some_relative_path" + + @redirect_handler.handle(response:, request:) + + assert_requested :get, "http://example.com/some_relative_path" + end + + def test_handle_preserves_custom_headers_across_redirects + headers = {"X-Custom" => "value"} + stub_request(:get, "http://example.com/2") + .with(headers:) + .to_return(status: 302, headers: {"Location" => "http://example.com/3"}) + stub_request(:get, "http://example.com/3").with(headers:) + + @redirect_handler.handle(response: redirect_to("http://example.com/2"), request: Net::HTTP::Get.new(URI("http://example.com/")), headers:) + + assert_requested :get, "http://example.com/3", headers: + end + + def test_handle_with_too_many_redirects + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + stub_request(:get, "http://example.com/some_path").to_return(status: 302, headers: {"Location" => "http://example.com/some_path"}) + + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "http://example.com/some_path" + + e = assert_raises(TooManyRedirects) do + @redirect_handler.handle(response:, request:) + end + + assert_equal "GET /some_path: Too many redirects", e.message + assert_requested :get, "http://example.com/some_path", times: Core.const_get(:RedirectHandler)::DEFAULT_MAX_REDIRECTS + end + + def test_a_max_redirects_of_zero_follows_no_redirect + handler = Core.const_get(:RedirectHandler).new(max_redirects: 0) + + assert_raises(TooManyRedirects) { handler.handle(response: redirect_to("http://example.com/2"), request: Net::HTTP::Get.new(URI("http://example.com/"))) } + assert_not_requested :get, "http://example.com/2" + end + + def test_a_redirect_that_cannot_be_followed_is_returned_however_many_were_followed + not_modified = Net::HTTPNotModified.new("1.1", "304", "Not Modified") + handler = Core.const_get(:RedirectHandler).new(max_redirects: 0) + + assert_same not_modified, handler.handle(response: not_modified, request: Net::HTTP::Get.new(URI("http://example.com/"))) + end + + def test_handle_beyond_max_redirects + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "http://example.com/some_path" + redirect_count = Core.const_get(:RedirectHandler)::DEFAULT_MAX_REDIRECTS + 1 + + assert_raises(TooManyRedirects) do + @redirect_handler.handle(response:, request:, redirect_count:) + end + assert_not_requested :get, "http://example.com/some_path" + end + end + + class RedirectHandlerCredentialsTest < Minitest::Test + cover Core.const_get(:RedirectHandler) + cover Core.const_get(:Origin) + + AUTHORIZATION = "Bearer #{TEST_BEARER_TOKEN}".freeze + + def setup + @redirect_handler = Core.const_get(:RedirectHandler).new + @authenticator = BearerTokenAuthenticator.new(bearer_token: TEST_BEARER_TOKEN) + end + + def redirect(from, to, headers: {}) + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = to + @redirect_handler.handle(response:, request: Net::HTTP::Get.new(URI(from)), headers:, + authenticator: @authenticator) + end + + def authorizations_sent_to(url) + WebMock::RequestRegistry.instance.requested_signatures.hash.keys + .select { |signature| signature.uri.to_s.eql?(url) }.map { |signature| signature.headers.to_h["Authorization"] } + end + + def test_keeps_credentials_on_the_same_origin + stub_request(:get, "https://api.x.com:443/2/next") + redirect("https://api.x.com/2/users", "https://API.x.com/2/next") + + assert_equal [AUTHORIZATION], authorizations_sent_to("https://api.x.com:443/2/next") + end + + def test_drops_credentials_on_another_host + stub_request(:get, "https://example.com:443/steal") + redirect("https://api.x.com/2/users", "https://example.com/steal") + + assert_equal [nil], authorizations_sent_to("https://example.com:443/steal") + end + + def test_drops_credentials_on_another_scheme + stub_request(:get, "http://api.x.com:443/2/next") + redirect("https://api.x.com/2/users", "http://api.x.com:443/2/next") + + assert_equal [nil], authorizations_sent_to("http://api.x.com:443/2/next") + end + + def test_drops_credentials_on_another_port + stub_request(:get, "https://api.x.com:8443/2/next") + redirect("https://api.x.com/2/users", "https://api.x.com:8443/2/next") + + assert_equal [nil], authorizations_sent_to("https://api.x.com:8443/2/next") + end + + def test_drops_an_authorization_header_on_another_host + stub_request(:get, "https://example.com:443/steal") + redirect("https://api.x.com/2/users", "https://example.com/steal", headers: {"authorization" => "Basic secret", "X-Custom" => "kept"}) + + assert_requested :get, "https://example.com/steal", headers: {"X-Custom" => "kept"} + assert_equal [nil], authorizations_sent_to("https://example.com:443/steal") + end + + def headers_sent_to(url) + WebMock::RequestRegistry.instance.requested_signatures.hash.keys + .select { |signature| signature.uri.to_s.eql?(url) }.map { |signature| signature.headers.to_h } + end + + def test_drops_every_header_that_carries_credentials_on_another_host + stub_request(:get, "https://example.com:443/steal") + redirect("https://api.x.com/2/users", "https://example.com/steal", + headers: {"cookie" => "session=secret", "Authorization" => "Basic secret", "Proxy-Authorization" => "Basic proxy", "X-Custom" => "kept"}) + + headers = headers_sent_to("https://example.com:443/steal").fetch(0) + + assert_equal ["kept", nil, nil, nil], headers.values_at("X-Custom", "Authorization", "Cookie", "Proxy-Authorization") + end + + def test_keeps_every_header_that_carries_credentials_on_the_same_origin + stub_request(:get, "https://api.x.com:443/2/next") + redirect("https://api.x.com/2/users", "https://api.x.com/2/next", + headers: {"Cookie" => "session=secret", "Proxy-Authorization" => "Basic proxy"}) + + assert_requested :get, "https://api.x.com/2/next", headers: {"Cookie" => "session=secret", "Proxy-Authorization" => "Basic proxy"} + end + + def test_keeps_credentials_dropped_after_a_redirect_back + stub_request(:get, "https://example.com:443/back").to_return(status: 302, headers: {"Location" => "https://api.x.com/2/users"}) + stub_request(:get, "https://api.x.com:443/2/users") + redirect("https://api.x.com/2/users", "https://example.com/back") + + assert_equal [nil], authorizations_sent_to("https://api.x.com:443/2/users") + end + + def test_compares_origins_with_the_request + stub_request(:get, "https://upload.x.com:443/next") + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "https://upload.x.com/next" + @redirect_handler.handle(response:, request: Net::HTTP::Get.new(URI("https://upload.x.com/media")), authenticator: @authenticator) + + assert_equal [AUTHORIZATION], authorizations_sent_to("https://upload.x.com:443/next") + end + + def test_resolves_a_relative_location_against_the_host_of_the_request + stub_request(:get, "https://upload.x.com:443/2/media/next") + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "next" + @redirect_handler.handle(response:, request: Net::HTTP::Get.new(URI("https://upload.x.com/2/media/upload")), authenticator: @authenticator) + + assert_equal [AUTHORIZATION], authorizations_sent_to("https://upload.x.com:443/2/media/next") + end + + def test_resolves_a_relative_location_against_the_path_of_the_request + stub_request(:get, "https://api.x.com:443/2/users/next") + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "next" + @redirect_handler.handle(response:, request: Net::HTTP::Get.new(URI("https://api.x.com/2/users/me")), authenticator: @authenticator) + + assert_equal [AUTHORIZATION], authorizations_sent_to("https://api.x.com:443/2/users/next") + end + end + + class RedirectHandlerStatusTest < Minitest::Test + cover Core.const_get(:RedirectHandler) + + def setup + @connection = Core.const_get(:Connection).new + @request_builder = Core.const_get(:RequestBuilder).new + @redirect_handler = Core.const_get(:RedirectHandler).new(connection: @connection, request_builder: @request_builder) + end + + def test_handle_with_301_moved_permanently + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + stub_request(:get, "http://example.com/new_path") + + response = Net::HTTPMovedPermanently.new("1.1", "301", "Moved Permanently") + response["Location"] = "http://example.com/new_path" + + @redirect_handler.handle(response:, request:) + + assert_requested :get, "http://example.com/new_path" + end + + def test_handle_with_302_found + request = Net::HTTP::Get.new(URI("http://example.com/some_path")) + stub_request(:get, "http://example.com/temp_path") + + response = Net::HTTPFound.new("1.1", "302", "Found") + response["Location"] = "http://example.com/temp_path" + + @redirect_handler.handle(response:, request:) + + assert_requested :get, "http://example.com/temp_path" + end + + def test_handle_with_303_see_other + request = Net::HTTP::Post.new(URI("http://example.com/some_path")) + stub_request(:post, "http://example.com/some_path") + stub_request(:get, "http://example.com/other_path") + + response = Net::HTTPSeeOther.new("1.1", "303", "See Other") + response["Location"] = "http://example.com/other_path" + + @redirect_handler.handle(response:, request:) + + assert_requested :get, "http://example.com/other_path" + end + + def test_handle_with_307_temporary_redirect + request = Net::HTTP::Post.new(URI("http://example.com/some_path")) + request.body = "request_body" + stub_request(:post, "http://example.com/temp_path") + + response = Net::HTTPTemporaryRedirect.new("1.1", "307", "Temporary Redirect") + response["Location"] = "http://example.com/temp_path" + + @redirect_handler.handle(response:, request:) + + assert_requested :post, "http://example.com/temp_path", body: "request_body" + end + + def test_handle_with_308_permanent_redirect + request = Net::HTTP::Post.new(URI("http://example.com/some_path")) + request.body = "request_body" + stub_request(:post, "http://example.com/new_path") + + response = Net::HTTPPermanentRedirect.new("1.1", "308", "Permanent Redirect") + response["Location"] = "http://example.com/new_path" + + @redirect_handler.handle(response:, request:) + + assert_requested :post, "http://example.com/new_path", body: "request_body" + end + end +end diff --git a/x-core/test/x/core/redirect_handler_unfollowable_test.rb b/x-core/test/x/core/redirect_handler_unfollowable_test.rb new file mode 100644 index 00000000..68aba566 --- /dev/null +++ b/x-core/test/x/core/redirect_handler_unfollowable_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RedirectHandlerUnfollowableTest < Minitest::Test + cover Core.const_get(:RedirectHandler) + + def setup + @redirect_handler = Core.const_get(:RedirectHandler).new + @request = Net::HTTP::Get.new(URI("https://api.x.com/2/users/me")) + end + + def found(location) + Net::HTTPFound.new("1.1", "302", "Found").tap { |response| response["Location"] = location unless location.nil? } + end + + def test_a_response_that_is_not_a_redirect_is_returned_whatever_its_location + response = Net::HTTPCreated.new("1.1", "201", "Created").tap { |created| created["Location"] = "https://example.com/created" } + + assert_same response, @redirect_handler.handle(response:, request: @request, redirect_count: Core.const_get(:RedirectHandler)::DEFAULT_MAX_REDIRECTS) + assert_not_requested :get, "https://example.com/created" + end + + def test_a_redirect_to_an_http_url_of_no_host_is_returned + ["https:///users", "http:", "https:", "//:80", "http://:/"].each do |location| + response = found(location) + + assert_same response, @redirect_handler.handle(response:, request: @request), location + end + end + + def test_a_redirect_without_a_location_is_returned + response = found(nil) + + assert_same response, @redirect_handler.handle(response:, request: @request) + end + + def test_not_modified_is_returned + response = Net::HTTPNotModified.new("1.1", "304", "Not Modified") + + assert_same response, @redirect_handler.handle(response:, request: @request) + end + + def test_not_modified_is_returned_whatever_its_location + response = Net::HTTPNotModified.new("1.1", "304", "Not Modified").tap { |not_modified| not_modified["Location"] = "https://example.com/next" } + + assert_same response, @redirect_handler.handle(response:, request: @request) + assert_not_requested :get, "https://example.com/next" + end + + def test_multiple_choices_is_returned_whatever_its_location + response = Net::HTTPMultipleChoices.new("1.1", "300", "Multiple Choices").tap { |choices| choices["Location"] = "https://example.com/next" } + + assert_same response, @redirect_handler.handle(response:, request: @request) + assert_not_requested :get, "https://example.com/next" + end + + def test_use_proxy_is_returned_whatever_its_location + response = Net::HTTPUseProxy.new("1.1", "305", "Use Proxy").tap { |use_proxy| use_proxy["Location"] = "https://proxy.example.com/" } + + assert_same response, @redirect_handler.handle(response:, request: @request) + assert_not_requested :get, "https://proxy.example.com/" + end + + def test_a_redirect_to_a_url_that_is_not_http_is_returned + response = found("ftp://example.com/file") + + assert_same response, @redirect_handler.handle(response:, request: @request) + end + + def test_a_redirect_to_an_invalid_url_is_returned + response = found("ht tp://example.com") + + assert_same response, @redirect_handler.handle(response:, request: @request) + end + + def test_a_redirect_to_an_https_url_is_followed + stub_request(:get, "https://example.com/next") + response = @redirect_handler.handle(response: found("https://example.com/next"), request: @request) + + assert_equal "200", response.code + end + + def test_the_client_raises_an_http_error_for_a_redirect_it_cannot_follow + stub_request(:get, "https://api.x.com/2/users/me").to_return(status: 302, body: "") + client = Client.new(bearer_token: TEST_BEARER_TOKEN) + + error = assert_raises(HTTPError) { client.get("users/me") } + assert_equal 302, error.status + end + end +end diff --git a/x-core/test/x/core/refresh_report_guard_test.rb b/x-core/test/x/core/refresh_report_guard_test.rb new file mode 100644 index 00000000..6238061e --- /dev/null +++ b/x-core/test/x/core/refresh_report_guard_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The save_tokens of a refresh runs inside the guard a gem extending a client sets for the fiber, and outside any + # when none is set + class RefreshReportGuardTest < Minitest::Test + cover Core.const_get(:RefreshReporter) + + GUARD = :x_core_refresh_report_guard + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + def test_save_tokens_runs_inside_the_guard_of_the_fiber + events = [] + authenticator = oauth2_authenticator_reporting_to(->(tokens) { events << tokens.refresh_token }) + Thread.current[GUARD] = ->(&block) { events << :before && block.call.tap { events << :after } } + authenticator.refresh! + + assert_equal [:before, "NEW_REFRESH_TOKEN", :after], events + ensure + Thread.current[GUARD] = nil + end + + def test_save_tokens_runs_unguarded_when_the_fiber_has_no_guard + saved = [] + oauth2_authenticator_reporting_to(->(tokens) { saved << tokens.refresh_token }).refresh! + + assert_equal ["NEW_REFRESH_TOKEN"], saved + end + end +end diff --git a/x-core/test/x/core/refresh_report_timeout_test.rb b/x-core/test/x/core/refresh_report_timeout_test.rb new file mode 100644 index 00000000..8fc4cb35 --- /dev/null +++ b/x-core/test/x/core/refresh_report_timeout_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "timeout" +require_relative "../../test_helper" + +module X + # A Timeout::Error raised as save_tokens runs, whether by a timeout of its store or by Timeout.timeout around the + # request, fails save_tokens as any error does, so the error still holds the tokens it was passed + class RefreshReportTimeoutTest < Minitest::Test + cover Core.const_get(:RefreshReporter) + + def setup + stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + end + + def test_a_timeout_of_the_store_of_save_tokens_holds_the_tokens_and_the_rest_are_still_passed_them + saved = [] + authenticator = oauth2_authenticator_reporting_to(->(_tokens) { Timeout.timeout(0.01) { sleep 1 } }, ->(tokens) { saved << tokens }) + error = assert_raises(TokenReportFailed) { authenticator.refresh! } + + assert_kind_of Timeout::Error, error.cause + assert_equal ["NEW_REFRESH_TOKEN"] * 2, [error.tokens, *saved].map(&:refresh_token) + end + + def test_a_timeout_raised_around_the_request_as_save_tokens_runs_holds_the_tokens + authenticator = oauth2_authenticator_reporting_to(->(_tokens) { sleep 1 }) + error = assert_raises(TokenReportFailed) { Timeout.timeout(0.05, Timeout::Error) { authenticator.refresh! } } + + assert_equal "NEW_REFRESH_TOKEN", error.tokens.refresh_token + end + + def test_a_timeout_raised_without_a_class_around_the_request_as_save_tokens_runs_is_raised_as_it_is + authenticator = oauth2_authenticator_reporting_to(->(_tokens) { sleep 1 }) + + assert_instance_of Timeout::Error, assert_raises(Timeout::Error) { Timeout.timeout(0.05) { authenticator.refresh! } } + end + end +end diff --git a/x-core/test/x/core/request_builder_test.rb b/x-core/test/x/core/request_builder_test.rb new file mode 100644 index 00000000..bd768653 --- /dev/null +++ b/x-core/test/x/core/request_builder_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "uri" +require_relative "../../test_helper" + +module X + class RequestBuilderTest < Minitest::Test + cover Core.const_get(:RequestBuilder) + + def setup + @authenticator = OAuth1Authenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, + access_token: TEST_ACCESS_TOKEN, access_token_secret: TEST_ACCESS_TOKEN_SECRET) + @request_builder = Core.const_get(:RequestBuilder).new + @uri = URI("http://example.com") + end + + def test_merge_headers_replaces_a_header_whose_name_differs_in_case_alone + assert_equal({"X-Trace" => "a", "user-agent" => "b"}, + Core.const_get(:RequestBuilder).merge_headers({"X-Trace" => "a", "User-Agent" => "x"}, {"user-agent" => "b"})) + end + + def test_merge_headers_keeps_the_headers_that_are_not_overridden + assert_equal({"X-Trace" => "a", "X-Other" => "b"}, Core.const_get(:RequestBuilder).merge_headers({"X-Trace" => "a"}, {"X-Other" => "b"})) + end + + def test_build_get_request + expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ + "oauth_signature=\"YF2HnkQuY39Db8GywIJy%2BUfFxnc%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ + "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" + with_fixed_oauth_params do + request = @request_builder.build(http_method: :get, uri: @uri, authenticator: @authenticator) + + assert_equal "GET", request.method + assert_equal @uri, request.uri + assert_equal expected, request["Authorization"] + assert_nil request["Content-Type"] + end + end + + def test_idempotent_methods_may_be_sent_again + assert_equal [true, true, true, false], %i[get put delete post].map { |http_method| Core.const_get(:RequestBuilder).idempotent?(http_method) } + end + + def test_a_request_with_a_body_is_given_the_json_content_type + request = @request_builder.build(http_method: :post, uri: @uri, body: "{}", authenticator: @authenticator) + + assert_equal "application/json; charset=utf-8", request["Content-Type"] + end + + def test_a_request_with_a_body_is_given_the_other_default_headers_too + request = @request_builder.build(http_method: :post, uri: @uri, body: "{}", authenticator: @authenticator) + + assert_equal Core.const_get(:RequestBuilder)::DEFAULT_HEADERS.fetch("User-Agent"), request["User-Agent"] + end + + def test_a_request_without_a_body_is_given_no_content_type + request = @request_builder.build(http_method: :delete, uri: @uri, authenticator: @authenticator) + + assert_nil request["Content-Type"] + end + + def test_a_content_type_of_the_caller_replaces_the_one_a_body_is_given + request = @request_builder.build(http_method: :post, uri: @uri, body: "lang=en", + headers: {"Content-Type" => "application/x-www-form-urlencoded"}, authenticator: @authenticator) + + assert_equal "application/x-www-form-urlencoded", request["Content-Type"] + end + + def test_a_content_type_of_the_caller_is_sent_without_a_body + request = @request_builder.build(http_method: :get, uri: @uri, headers: {"Content-Type" => "text/plain"}, + authenticator: @authenticator) + + assert_equal "text/plain", request["Content-Type"] + end + + def test_build_post_request + expected = "OAuth oauth_consumer_key=\"TEST_API_KEY\", oauth_nonce=\"TEST_OAUTH_NONCE\", " \ + "oauth_signature=\"5TTQPQ7SqAxR74YSGbm%2FCmuts2I%3D\", oauth_signature_method=\"HMAC-SHA1\", " \ + "oauth_timestamp=\"438480000\", oauth_token=\"TEST_ACCESS_TOKEN\", oauth_version=\"1.0\"" + + with_fixed_oauth_params do + request = @request_builder.build(http_method: :post, uri: @uri, body: "{}", authenticator: @authenticator) + + assert_equal "POST", request.method + assert_equal @uri, request.uri + assert_equal "{}", request.body + assert_equal expected, request["Authorization"] + end + end + + def test_custom_headers + request = @request_builder.build(http_method: :get, uri: @uri, + headers: {"User-Agent" => "Custom User Agent"}, authenticator: @authenticator) + + assert_equal "Custom User Agent", request["User-Agent"] + end + + def test_build_without_authenticator_parameter + request = @request_builder.build(http_method: :get, uri: @uri) + + refute request.key?("Authorization") + end + + def test_unsupported_http_method + exception = assert_raises ArgumentError do + @request_builder.build(http_method: :unsupported, uri: @uri, authenticator: @authenticator) + end + + assert_equal "Unsupported HTTP method: unsupported", exception.message + end + + def test_escape_query_params + uri = "https://upload.twitter.com/1.1/media/upload.json?media_type=video/mp4" + request = @request_builder.build(http_method: :post, uri:, authenticator: @authenticator) + + assert_equal "media_type=video%2Fmp4", request.uri.query + end + + def test_escape_query_params_keeps_a_parameter_without_a_value_without_one_and_an_empty_one_empty + queries = ["flag&empty=&query=a%20b+c&sum=1%2B1&equation=a=b&user%20fields=id", "&a=1&&b"].map do |query| + @request_builder.build(http_method: :get, uri: "https://api.x.com/2/users?#{query}", authenticator: @authenticator).uri.query + end + + assert_equal ["flag&empty=&query=a+b+c&sum=1%2B1&equation=a%3Db&user+fields=id", "&a=1&&b"], queries + end + + def test_escape_query_params_with_commas + uri = "https://api.x.com/2/tweets/search/recent?query=%23ruby&expansions=author_id&user.fields=id,name,username" + request = @request_builder.build(http_method: :post, uri:, authenticator: @authenticator) + + assert_equal "query=%23ruby&expansions=author_id&user.fields=id,name,username", request.uri.query + end + end +end diff --git a/x-core/test/x/core/request_context_test.rb b/x-core/test/x/core/request_context_test.rb new file mode 100644 index 00000000..afe1a13b --- /dev/null +++ b/x-core/test/x/core/request_context_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RequestContextTest < Minitest::Test + # The errors that name the request they were raised for, and the parsers and the connection that build them + cover Core.const_get(:RequestContext) + cover Core.const_get(:ResponseParser) + cover Core.const_get(:Connection) + cover HTTPError + cover InvalidResponse + cover NetworkError + cover TooManyRedirects + cover Core.const_get(:RedirectHandler) + + def setup + # A client that raises at once, since these tests are about the error a failure raises rather than the + # retries a client makes before it + @client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_retries: 0) + end + + def test_the_error_of_a_refused_request_names_it + stub_request(:get, "https://api.x.com/2/users/1").to_return(status: 404, headers: {"content-type" => "application/json"}, body: '{"errors": [{"message": "Could not find user"}]}') + error = assert_raises(NotFound) { @client.get("users/1") } + + assert_equal "GET /2/users/1: Could not find user", error.message + assert_equal [:get, URI("https://api.x.com/2/users/1")], [error.http_method, error.uri] + end + + def test_the_error_of_a_write_names_the_method_it_was_sent_with + stub_request(:post, "https://api.x.com/2/tweets").to_return(status: 403, headers: {"content-type" => "application/json"}, body: '{"detail": "Not allowed", "title": "Forbidden"}') + error = assert_raises(Forbidden) { @client.post("tweets", {text: "Hello"}) } + + assert_equal "POST /2/tweets: Forbidden: Not allowed", error.message + assert_equal :post, error.http_method + end + + def test_the_message_leaves_the_query_out_while_the_uri_keeps_it + stub_request(:get, "https://api.x.com/2/users?ids=1,2").to_return(status: 400, headers: {"content-type" => "application/json"}, body: '{"error": "Bad ids"}') + error = assert_raises(BadRequest) { @client.get("users", params: {ids: [1, 2]}) } + + assert_equal "GET /2/users: Bad ids", error.message + assert_equal "ids=1,2", error.uri.query + end + + def test_the_error_of_a_request_that_got_no_response_names_it + stub_request(:get, "https://api.x.com/2/users/1").to_raise(Errno::ECONNREFUSED) + error = assert_raises(NetworkError) { @client.get("users/1") } + + assert_equal "GET /2/users/1: Network error: #{Errno::ECONNREFUSED.new("Exception from WebMock").message}", error.message + assert_equal [:get, URI("https://api.x.com/2/users/1")], [error.http_method, error.uri] + end + + def test_the_error_of_a_response_that_is_not_json_names_the_request + stub_request(:get, "https://api.x.com/2/users/1").to_return(status: 200, headers: {"content-type" => "application/json"}, body: "Not JSON") + error = assert_raises(InvalidResponse) { @client.get("users/1") } + + assert_equal "GET /2/users/1: The body of the 200 response is not JSON (application/json)", error.message + assert_equal [:get, URI("https://api.x.com/2/users/1")], [error.http_method, error.uri] + end + + def test_the_error_of_too_many_redirects_names_the_request_redirected_last + stub_request(:get, "https://api.x.com/2/users/1").to_return(status: 302, headers: {"Location" => "https://api.x.com/2/users/2"}) + stub_request(:get, "https://api.x.com/2/users/2").to_return(status: 302, headers: {"Location" => "https://api.x.com/2/users/1"}) + error = assert_raises(TooManyRedirects) { Client.new(bearer_token: TEST_BEARER_TOKEN, max_redirects: 1).get("users/1") } + + assert_equal "GET /2/users/2: Too many redirects", error.message + assert_equal [:get, URI("https://api.x.com/2/users/2")], [error.http_method, error.uri] + end + + def test_too_many_redirects_built_without_a_request_names_none + error = TooManyRedirects.new("Too many redirects") + + assert_equal "Too many redirects", error.message + assert_nil error.http_method + assert_nil error.uri + end + + def test_an_error_built_without_a_request_names_none + error = BadRequest.new(http_response: Net::HTTPBadRequest.new("1.1", "400", "Bad Request")) + + assert_equal "Bad Request", error.message + assert_nil error.http_method + assert_nil error.uri + end + + def test_the_error_of_a_response_parsed_without_a_request_names_none + stub_request(:get, "https://api.x.com/2/users/1").to_return(status: 404, headers: {"content-type" => "application/json"}, body: '{"errors": [{"message": "Could not find user"}]}') + error = assert_raises(NotFound) { Core.const_get(:ResponseParser).new.parse(response: Net::HTTP.get_response(URI("https://api.x.com/2/users/1"))) } + + assert_equal ["Could not find user", nil, nil], [error.message, error.http_method, error.uri] + end + + def test_a_network_error_built_without_a_request_names_none + error = NetworkError.new("Network error: Connection refused") + + assert_equal "Network error: Connection refused", error.message + assert_nil error.http_method + assert_nil error.uri + end + end +end diff --git a/x-core/test/x/core/request_line_test.rb b/x-core/test/x/core/request_line_test.rb new file mode 100644 index 00000000..f5d867a2 --- /dev/null +++ b/x-core/test/x/core/request_line_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # An error given the method and URI of a request names it by the method, in capitals, and the path of the URI + class RequestLineTest < Minitest::Test + cover Core.const_get(:RequestContext) + + def test_the_method_of_a_request_is_read_in_any_case + ["POST", :POST, "post"].each do |http_method| + error = NetworkError.new("went wrong", http_method:, uri: URI("https://api.x.com/2/users/1?user.fields=id")) + + assert_equal [:post, "POST /2/users/1: went wrong"], [error.http_method, error.message] + end + end + + def test_a_uri_without_a_path_is_named_as_the_request_for_its_root + assert_equal "GET /: went wrong", NetworkError.new("went wrong", http_method: :get, uri: URI("https://api.x.com")).message + end + + def test_an_error_given_a_method_or_a_uri_alone_names_no_request + assert_equal [:get, nil, "went wrong"], NetworkError.new("went wrong", http_method: :get).then { |error| [error.http_method, error.uri, error.message] } + assert_equal [nil, URI("https://api.x.com/2/users/1?user.fields=id"), "went wrong"], NetworkError.new("went wrong", uri: URI("https://api.x.com/2/users/1?user.fields=id")).then { |error| [error.http_method, error.uri, error.message] } + end + end +end diff --git a/x-core/test/x/core/response_parser_builder_test.rb b/x-core/test/x/core/response_parser_builder_test.rb new file mode 100644 index 00000000..c529452f --- /dev/null +++ b/x-core/test/x/core/response_parser_builder_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ResponseParserBuilderTest < Minitest::Test + cover Core.const_get(:ResponseParser) + + def setup + @response_parser = Core.const_get(:ResponseParser).new + @uri = URI("http://example.com") + end + + def response = Net::HTTP.get_response(@uri) + + def stub_json(body:) + stub_request(:get, @uri.to_s).to_return(status: 200, body:, headers: {"Content-Type" => "application/json"}) + end + + def test_response_builder_receives_the_whole_body_as_hashes + stub_json(body: '{"data": {"id": "1", "public_metrics": {"like_count": 2}}}') + built = @response_parser.parse(response:, object_class: ResponseBuilder, array_class: Set) + + assert_equal({"data" => {"id" => "1", "public_metrics" => {"like_count" => 2}}}, built[:body]) + assert_instance_of Hash, built[:body]["data"]["public_metrics"] + end + + def test_response_builder_receives_the_client + stub_json(body: "{}") + + assert_equal :client, @response_parser.parse(response:, object_class: ResponseBuilder, client: :client)[:client] + end + + def test_response_builder_is_skipped_for_invalid_json + stub_request(:get, @uri.to_s).to_return(status: 200, body: "not json") + + assert_raises(InvalidResponse) { @response_parser.parse(response:, object_class: ResponseBuilder) } + end + + def test_response_builder_is_skipped_for_an_empty_body + stub_request(:get, @uri.to_s).to_return(status: 200, body: "") + + assert_nil @response_parser.parse(response:, object_class: ResponseBuilder) + end + + def test_decode_into_the_default_classes + assert_equal({"data" => {"id" => "1"}}, @response_parser.decode('{"data": {"id": "1"}}')) + end + + def test_decode_without_a_client + assert_nil @response_parser.decode("{}", object_class: ResponseBuilder)[:client] + end + + def test_decode_raises_for_invalid_json + assert_raises(JSON::ParserError) { @response_parser.decode("not json", object_class: ResponseBuilder) } + end + end +end diff --git a/x-core/test/x/core/response_parser_test.rb b/x-core/test/x/core/response_parser_test.rb new file mode 100644 index 00000000..2cf10ff7 --- /dev/null +++ b/x-core/test/x/core/response_parser_test.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "ostruct" +require_relative "../../test_helper" + +module X + class ResponseParserTest < Minitest::Test + cover Core.const_get(:ResponseParser) + + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + + def setup + @response_parser = Core.const_get(:ResponseParser).new + @uri = URI("http://example.com") + end + + def response = Net::HTTP.get_response(@uri) + + def stub_json(status: 200, body: "{}") + stub_request(:get, @uri.to_s).to_return(status:, body:, headers: JSON_HEADERS) + end + + def test_success_response + stub_json(body: '{"message": "success"}') + + assert_equal({"message" => "success"}, @response_parser.parse(response:)) + end + + def test_non_json_success_response + stub_request(:get, @uri.to_s).to_return(body: "", headers: {"Content-Type" => "text/html"}) + error = assert_raises(InvalidResponse) { @response_parser.parse(response:) } + + assert_equal "The body of the 200 response is not JSON (text/html)", error.message + assert_equal ["", ""], [error.body, error.http_response.body] + assert_kind_of JSON::ParserError, error.cause + end + + def test_non_json_success_response_without_a_content_type + stub_request(:get, @uri.to_s).to_return(status: 201, body: "Created") + + assert_equal "The body of the 201 response is not JSON (no content type)", + assert_raises(InvalidResponse) { @response_parser.parse(response:) }.message + end + + def test_empty_success_response + stub_request(:get, @uri.to_s).to_return(status: 200, body: " \r\n") + + assert_nil @response_parser.parse(response:) + end + + def test_success_response_without_a_body + stub_request(:get, @uri.to_s).to_return(status: 202) + + assert_nil @response_parser.parse(response:) + end + + def test_204_no_content_response + stub_request(:get, @uri.to_s).to_return(status: 204) + + assert_nil @response_parser.parse(response:) + end + + def test_bad_request_error + stub_request(:get, @uri.to_s).to_return(status: 400) + exception = assert_raises(BadRequest) { @response_parser.parse(response:) } + + assert_kind_of Net::HTTPBadRequest, exception.http_response + assert_equal 400, exception.status + end + + def test_unknown_error_code + stub_request(:get, @uri.to_s).to_return(status: 418) + + assert_raises(Error) { @response_parser.parse(response:) } + end + + {418 => ClientError, 499 => ClientError, 501 => ServerError, 520 => ServerError, 599 => ServerError, 304 => HTTPError}.each do |status, error_class| + define_method(:"test_unmapped_#{status}_raises_#{error_class.name.split("::").last.downcase}") do + stub_request(:get, @uri.to_s).to_return(status:) + exception = assert_raises(HTTPError) { @response_parser.parse(response:) } + + assert_instance_of error_class, exception + end + end + + {402 => PaymentRequired, 405 => MethodNotAllowed, 408 => RequestTimeout, 415 => UnsupportedMediaType, 451 => UnavailableForLegalReasons}.each do |status, error_class| + define_method(:"test_#{status}_raises_#{error_class.name.split("::").last.downcase}") do + stub_request(:get, @uri.to_s).to_return(status:) + + assert_instance_of error_class, assert_raises(HTTPError) { @response_parser.parse(response:) } + end + end + + def test_too_many_requests_with_headers + stub_request(:get, @uri.to_s).to_return(status: 429, headers: {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => "1"}) + exception = assert_raises(TooManyRequests) { @response_parser.parse(response:) } + + assert_predicate exception.rate_limits.first.remaining, :zero? + end + + def test_default_response_objects + stub_json(body: '{"array": [1, 2, 2, 3]}') + hash = @response_parser.parse(response:) + + assert_kind_of Hash, hash + assert_equal [1, 2, 2, 3], hash["array"] + end + + def test_custom_response_objects + stub_json(body: '{"set": [1, 2, 2, 3]}') + ostruct = @response_parser.parse(response:, object_class: OpenStruct, array_class: Set) + + assert_kind_of OpenStruct, ostruct + assert_equal Set.new([1, 2, 3]), ostruct.set + end + end +end diff --git a/x-core/test/x/core/response_test.rb b/x-core/test/x/core/response_test.rb new file mode 100644 index 00000000..293fdc09 --- /dev/null +++ b/x-core/test/x/core/response_test.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class ResponseTest < Minitest::Test + cover Response + cover Core.const_get(:ResponseHeaders) + + URI_ME = URI("https://api.x.com/2/users/me") + + def test_request_details + response = summarize(Net::HTTPOK) + + assert_equal [:get, URI_ME, 200], [response.http_method, response.uri, response.status] + assert_predicate response, :success? + end + + def test_the_body_given_is_tagged_utf_8_and_keeps_its_bytes + given = "\xFF".b + body = Response.new(http_method: :get, uri: URI("https://api.x.com/2/users/me"), status: 200, body: given).body + + assert_equal [Encoding::UTF_8, "\xFF".b, false, Encoding::BINARY], [body.encoding, body.b, body.valid_encoding?, given.encoding] + end + + def test_the_http_method_is_read_as_a_lowercase_symbol_in_any_case + %w[GET get Get].push(:GET).each do |http_method| + assert_equal :get, Response.new(http_method:, uri: URI_ME, status: 200).http_method, http_method.inspect + end + end + + def test_a_failed_request + response = summarize(Net::HTTPNotFound, code: "404") + + assert_equal 404, response.status + refute_predicate response, :success? + end + + def test_rate_limits + response = summarize(Net::HTTPOK, headers: {"x-rate-limit-limit" => "75", "x-rate-limit-remaining" => "74", "x-rate-limit-reset" => "1789505092", + "x-user-limit-24hour-limit" => "100", "x-user-limit-24hour-remaining" => "3", "x-user-limit-24hour-reset" => "1789500000"}) + + assert_equal [["rate-limit", 74], ["user-limit-24hour", 3]], response.rate_limits.map { |limit| [limit.type, limit.remaining] } + assert_equal [75, "rate-limit"], [response.rate_limit.limit, response.rate_limit.type] + end + + def test_a_daily_limit_alone_has_no_rate_limit + response = summarize(Net::HTTPOK, headers: {"x-app-limit-24hour-limit" => "10", "x-app-limit-24hour-remaining" => "9", "x-app-limit-24hour-reset" => "1"}) + + assert_nil response.rate_limit + assert_equal ["app-limit-24hour"], response.rate_limits.map(&:type) + end + + def test_no_rate_limits + assert_empty summarize(Net::HTTPOK).rate_limits + assert_nil summarize(Net::HTTPOK).rate_limit + end + + def test_the_headers_are_read_by_lowercase_name + response = summarize(Net::HTTPOK, headers: {"Content-Type" => "application/json", "x-response-time" => "42"}) + + assert_equal({"content-type" => "application/json", "x-response-time" => "42"}, response.headers) + end + + def test_a_header_sent_more_than_once_is_joined_with_a_comma + response = summarize(Net::HTTPOK) + response.http_response.add_field("x-label", "one") + response.http_response.add_field("x-label", "two") + + assert_equal "one, two", response.headers["x-label"] + end + + def test_the_headers_are_frozen_and_a_response_without_any_has_none + assert_predicate summarize(Net::HTTPOK).headers, :frozen? + assert_empty Response.new(http_response: Net::HTTPOK.new("1.1", "200", ""), http_method: :get, uri: URI_ME).headers + end + + def test_resource_counts_of_an_object_with_includes + response = summarize(Net::HTTPOK, body: {data: {id: "1", name: "Erik", username: "sferik"}, includes: {posts: [{id: "2"}], users: [{id: "3"}, {id: "4"}]}}.to_json) + + assert_equal({"data" => 1, "posts" => 1, "users" => 2}, response.resource_counts) + assert_equal 4, response.resource_count + end + + def test_resource_counts_of_a_collection + response = summarize(Net::HTTPOK, body: {data: [{id: "1"}, {id: "2"}], meta: {result_count: 2}}.to_json) + + assert_equal({"data" => 2}, response.resource_counts) + assert_equal({"data" => 0, "users" => 0}, summarize(Net::HTTPOK, body: {data: nil, includes: {users: nil}}.to_json).resource_counts) + end + + def test_resource_counts_without_resources + assert_equal({"data" => 0}, summarize(Net::HTTPOK, body: {errors: [{title: "Not Found Error"}]}.to_json).resource_counts) + assert_equal({"data" => 0}, summarize(Net::HTTPNoContent, body: nil).resource_counts) + assert_equal({"data" => 0}, summarize(Net::HTTPOK, body: "not json").resource_counts) + assert_equal({"data" => 0}, summarize(Net::HTTPOK, body: "[1, 2]").resource_counts) + assert_equal 0, summarize(Net::HTTPOK, body: "[1, 2]").resource_count + end + + def test_resource_counts_of_includes_that_are_not_an_object + assert_equal({"data" => 1}, summarize(Net::HTTPOK, body: {data: {id: "1"}, includes: [1]}.to_json).resource_counts) + assert_equal({"data" => 0}, summarize(Net::HTTPOK, body: {includes: [["users", [{id: "1"}]]]}.to_json).resource_counts) + assert_equal({"data" => 0}, summarize(Net::HTTPOK, body: {includes: "users"}.to_json).resource_counts) + end + + def test_the_body_is_parsed_once_however_many_counts_are_read + response = summarize(Net::HTTPOK, body: {data: [{id: "1"}, {id: "2"}]}.to_json) + parses = 0 + parse = lambda do |_json| + parses += 1 + {"data" => [{"id" => "1"}, {"id" => "2"}]} + end + counts = JSON.stub(:parse, parse) { [response.resource_counts, response.resource_count, response.resource_counts] } + + assert_equal 1, parses + assert_equal [{"data" => 2}, 2, {"data" => 2}], counts + end + + def test_a_part_of_the_body + http_response = summarize(Net::HTTPOK, body: '{"data":[{"id":"1"},{"id":"2"}]}').http_response + response = Response.new(http_response:, http_method: :get, uri: URI_ME, body: '{"data":{"id":"1"}}') + + assert_equal '{"data":{"id":"1"}}', response.body + assert_equal 1, response.resource_count + assert_equal '{"data":[{"id":"1"},{"id":"2"}]}', Response.new(http_response:, http_method: :get, uri: URI_ME).body + end + + private + + def summarize(klass, code: "200", headers: {}, body: "{}") + http_response = klass.new("1.1", code, "") + headers.each { |name, value| http_response[name] = value } + http_response.instance_variable_set(:@body, body) + http_response.instance_variable_set(:@read, true) + Response.new(http_response:, http_method: :get, uri: URI_ME) + end + end +end diff --git a/x-core/test/x/core/retry_handler_network_test.rb b/x-core/test/x/core/retry_handler_network_test.rb new file mode 100644 index 00000000..a960bec8 --- /dev/null +++ b/x-core/test/x/core/retry_handler_network_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RetryHandlerNetworkTest < Minitest::Test + cover Core.const_get(:RetryHandler) + + def setup + @sleeps = [] + @attempts = 0 + end + + def test_sends_a_request_again_that_timed_out_opening_its_connection + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { fail_with(NetworkError, cause: Net::OpenTimeout.new) } } + assert_equal [2, [1]], [@attempts, @sleeps] + end + + def test_sends_a_request_again_to_a_host_that_could_not_be_resolved + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { fail_with(NetworkError, cause: Socket::ResolutionError.new("getaddrinfo: nodename nor servname provided")) } } + assert_equal [2, [1]], [@attempts, @sleeps] + end + + def test_sends_a_request_again_to_a_host_that_could_not_be_reached + [Errno::EHOSTUNREACH, Errno::ENETUNREACH].each do |error_class| + @attempts = 0 + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { fail_with(NetworkError, cause: error_class.new) } } + assert_equal 2, @attempts + end + end + + def test_raises_at_once_for_a_request_that_timed_out_reading_its_answer + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(NetworkError, cause: Net::ReadTimeout.new) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_raises_at_once_for_a_request_whose_connection_dropped_once_it_was_sent + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(NetworkError, cause: Errno::ECONNRESET.new) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_raises_at_once_for_a_network_error_with_no_cause + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(NetworkError, cause: nil) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_sends_a_request_again_that_timed_out_reading_its_answer_when_asked_to + assert_raises(NetworkError) do + handle(Core.const_get(:RetryHandler).new(max_retries: 2), resend_unanswered: true) { fail_with(NetworkError, cause: Net::ReadTimeout.new) } + end + assert_equal [3, [1, 2]], [@attempts, @sleeps] + end + + def test_raises_at_once_for_a_request_that_is_not_idempotent_even_when_asked_to_resend_it + assert_raises(NetworkError) do + handle(Core.const_get(:RetryHandler).new(max_retries: 2), idempotent: false, resend_unanswered: true) { fail_with(NetworkError, cause: Net::ReadTimeout.new) } + end + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_the_backoff_doubles_up_to_the_longest_wait_a_response_may_ask_for + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 9)) { fail_with(NetworkError, cause: Errno::ECONNREFUSED.new) } } + assert_equal [1, 2, 4, 8, 16, 32, 60, 60, 60], @sleeps + end + + private + + # Run the block, collecting the waits, with no share of each one taken off + def handle(handler, idempotent: true, **options, &) + handler.stub(:rand, 0.0) do + handler.stub(:sleep, ->(seconds) { @sleeps << seconds }) { handler.handle(idempotent:, **options, &) } + end + end + + # Raise a NetworkError with the given cause, counting the attempt it ends + def fail_with(error_class, cause:) + @attempts += 1 + raise error_class, "boom", cause: + end + end +end diff --git a/x-core/test/x/core/retry_handler_test.rb b/x-core/test/x/core/retry_handler_test.rb new file mode 100644 index 00000000..90a5207f --- /dev/null +++ b/x-core/test/x/core/retry_handler_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class RetryHandlerTest < Minitest::Test + cover Core.const_get(:RetryHandler) + + def setup + @sleeps = [] + @attempts = 0 + end + + def test_defaults_to_sending_a_request_twice_more + assert_equal 2, Core.const_get(:RetryHandler).new.max_retries + end + + def test_returns_what_the_block_returns + assert_equal :done, handle(Core.const_get(:RetryHandler).new) { :done } + end + + def test_sends_an_idempotent_request_twice_more_by_default + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new) { fail_with(NetworkError) } } + assert_equal [3, [1, 2]], [@attempts, @sleeps] + end + + def test_retries_nothing_once_retries_are_turned_off + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 0)) { fail_with(NetworkError) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_sends_an_idempotent_request_again_after_a_network_error + result = handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { (@attempts < 1) ? fail_with(NetworkError) : @attempts += 1 } + + assert_equal [2, [1]], [result, @sleeps] + end + + def test_sends_an_idempotent_request_again_after_the_api_fails_to_answer + result = handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { (@attempts < 1) ? fail_with(ServiceUnavailable) : @attempts += 1 } + + assert_equal [2, [1]], [result, @sleeps] + end + + def test_sends_an_idempotent_request_again_after_the_api_gave_up_waiting_for_it + result = handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { (@attempts < 1) ? fail_with(RequestTimeout) : @attempts += 1 } + + assert_equal [2, [1]], [result, @sleeps] + end + + def test_waits_a_second_and_doubles_the_wait_before_each_retry + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 3)) { fail_with(NetworkError) } } + assert_equal [4, [1, 2, 4]], [@attempts, @sleeps] + end + + def test_takes_up_to_half_of_each_wait_off_at_random + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 3), random: 1.0) { fail_with(NetworkError) } } + assert_equal [4, [0.5, 1, 2]], [@attempts, @sleeps] + end + + def test_takes_a_share_of_each_wait_off_in_proportion_to_the_random_number + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 2), random: 0.5) { fail_with(NetworkError) } } + assert_equal [3, [0.75, 1.5]], [@attempts, @sleeps] + end + + def test_raises_the_error_once_the_retries_run_out + assert_raises(InternalServerError) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(InternalServerError) } } + assert_equal [3, [1, 2]], [@attempts, @sleeps] + end + + def test_raises_at_once_for_a_request_that_is_not_idempotent + assert_raises(NetworkError) { handle(Core.const_get(:RetryHandler).new(max_retries: 2), idempotent: false) { fail_with(NetworkError) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_raises_at_once_for_a_failure_the_request_is_the_reason_for + assert_raises(NotFound) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(NotFound) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_waits_as_long_as_the_response_asks + assert_raises(ServiceUnavailable) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(ServiceUnavailable, retry_after: "30") } } + assert_equal [3, [30, 30]], [@attempts, @sleeps] + end + + def test_waits_out_the_backoff_when_it_is_longer_than_the_wait_the_response_asks_for + assert_raises(ServiceUnavailable) { handle(Core.const_get(:RetryHandler).new(max_retries: 3)) { fail_with(ServiceUnavailable, retry_after: "3") } } + assert_equal [4, [3, 3, 4]], [@attempts, @sleeps] + end + + def test_waits_until_the_time_the_response_names + Time.stub(:now, Time.utc(1983, 11, 24)) do + assert_raises(ServiceUnavailable) { handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { fail_with(ServiceUnavailable, retry_after: (Time.now + 45).httpdate) } } + end + assert_equal [2, [45]], [@attempts, @sleeps] + end + + def test_raises_at_once_for_a_response_that_asks_for_a_longer_wait_than_a_request_waits_out + assert_raises(ServiceUnavailable) { handle(Core.const_get(:RetryHandler).new(max_retries: 2)) { fail_with(ServiceUnavailable, retry_after: "61") } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_waits_out_the_longest_wait_a_response_may_ask_for + assert_raises(ServiceUnavailable) { handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { fail_with(ServiceUnavailable, retry_after: "60") } } + assert_equal [2, [60]], [@attempts, @sleeps] + end + + def test_backs_off_after_a_response_that_asks_for_no_wait + assert_raises(ServiceUnavailable) { handle(Core.const_get(:RetryHandler).new(max_retries: 1)) { fail_with(ServiceUnavailable, retry_after: "whenever you like") } } + assert_equal [2, [1]], [@attempts, @sleeps] + end + + private + + # Run the block, collecting the waits, with the random share of each one fixed + def handle(handler, idempotent: true, random: 0.0, **options, &) + handler.stub(:rand, random) do + handler.stub(:sleep, ->(seconds) { @sleeps << seconds }) { handler.handle(idempotent:, **options, &) } + end + end + + # Raise the given error, counting the attempt it ends; a NetworkError is caused by a refused connection unless + # another cause is given + def fail_with(error_class, retry_after: nil, cause: Errno::ECONNREFUSED.new) + @attempts += 1 + raise error_class, "boom", cause: cause if error_class.equal?(NetworkError) + + response = Net::HTTPResponse.new("1.1", status_of(error_class), "Boom") + response["retry-after"] = retry_after unless retry_after.nil? + raise error_class.new(http_response: response) + end + + def status_of(error_class) + {ServiceUnavailable => "503", InternalServerError => "500", NotFound => "404", RequestTimeout => "408"}.fetch(error_class) + end + end +end diff --git a/x-core/test/x/core/stream_response_test.rb b/x-core/test/x/core/stream_response_test.rb new file mode 100644 index 00000000..08adcbfb --- /dev/null +++ b/x-core/test/x/core/stream_response_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # The response Client#get_stream passes its block is x-core's own, which reads the status, headers, and rate limits + # of the response of the transport, so that the block reads none of them from Net::HTTP + class StreamResponseTest < Minitest::Test + cover_client + cover StreamResponse + + STREAM_URL = "https://api.x.com/2/tweets/sample/stream" + RATE_LIMIT = {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "49", "x-rate-limit-reset" => "438480000"}.freeze + APP_LIMIT = {"x-app-limit-24hour-limit" => "500", "x-app-limit-24hour-remaining" => "7", "x-app-limit-24hour-reset" => "438480000"}.freeze + + def setup + @client = Client.new(bearer_token: TEST_BEARER_TOKEN) + end + + def test_the_status_is_an_integer + stub_request(:get, STREAM_URL).to_return(status: 206) + + assert_equal 206, stream(&:status) + end + + def test_the_uri_is_that_of_the_request + stub_request(:get, "#{STREAM_URL}?expansions=author_id") + + assert_equal URI("#{STREAM_URL}?expansions=author_id"), @client.get_stream("tweets/sample/stream", params: {expansions: "author_id"}, &:uri) + end + + def test_the_headers_are_lowercase_and_frozen + stub_request(:get, STREAM_URL).to_return(headers: {"X-Response-Time" => "12", "Content-Type" => "application/json"}) + headers = stream(&:headers) + + assert_equal({"x-response-time" => "12", "content-type" => "application/json"}, headers) + assert_predicate headers, :frozen? + end + + def test_the_rate_limits_are_those_the_response_reports + stub_request(:get, STREAM_URL).to_return(headers: APP_LIMIT.merge(RATE_LIMIT)) + + assert_equal [["rate-limit", 49], ["app-limit-24hour", 7]], stream(&:rate_limits).map { |limit| [limit.type, limit.remaining] } + end + + def test_the_rate_limit_is_the_15_minute_limit_of_the_endpoint + stub_request(:get, STREAM_URL).to_return(headers: APP_LIMIT.merge(RATE_LIMIT)) + + assert_equal ["rate-limit", 50, 49], stream { |response| [response.rate_limit.type, response.rate_limit.limit, response.rate_limit.remaining] } + end + + def test_a_response_that_reports_no_15_minute_limit_has_none + stub_request(:get, STREAM_URL).to_return(headers: APP_LIMIT) + + assert_equal [nil, 1], stream { |response| [response.rate_limit, response.rate_limits.size] } + end + + def test_the_http_response_is_the_response_of_the_transport + stub_request(:get, STREAM_URL) + + assert_equal [Net::HTTPOK, "200"], stream { |response| [response.http_response.class, response.http_response.code] } + end + + def test_a_stream_response_is_built_by_x_core_alone + assert_raises(NoMethodError) { StreamResponse.new(http_response: Net::HTTPOK.new("1.1", "200", "OK"), uri: URI(STREAM_URL)) } + end + + private + + def stream(&) = @client.get_stream("tweets/sample/stream", &) + end +end diff --git a/x-core/test/x/core/token_endpoint_answer_test.rb b/x-core/test/x/core/token_endpoint_answer_test.rb new file mode 100644 index 00000000..d044d1e1 --- /dev/null +++ b/x-core/test/x/core/token_endpoint_answer_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A token endpoint that answers with a token or a refusal is read as OAuth 2.0 answers, and any other response, such + # as the page of a proxy, firewall, or captive portal, raises the error of its status rather than AuthorizationError, + # which tells a caller to ask the user to authorize the app again + class TokenEndpointAnswerTest < Minitest::Test + cover Core.const_get(:TokenEndpoint) + + TOKEN_URL = OAUTH2_TOKEN_URL + PAGE = {headers: {"Content-Type" => "text/html"}, body: "Sign in to the network"}.freeze + + def setup + @authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + end + + def refused(status, body) + stub_request(:post, TOKEN_URL).to_return(status:, body:) + assert_raises(AuthorizationError) { @authenticator.refresh! } + end + + def test_a_successful_page_that_is_not_json_raises_invalid_response_holding_it + stub_request(:post, TOKEN_URL).to_return(status: 200, **PAGE) + error = assert_raises(InvalidResponse) { @authenticator.refresh! } + + assert_equal [200, PAGE.fetch(:body), :post, URI(TOKEN_URL)], [error.status, error.body, error.http_method, error.uri] + assert_equal "text/html", error.headers.fetch("content-type") + assert_equal TEST_REFRESH_TOKEN, @authenticator.__send__(:refresh_token) + end + + def test_a_successful_response_of_json_that_is_not_an_object_raises_invalid_response + stub_request(:post, TOKEN_URL).to_return(status: 200, body: "[]") + + assert_raises(InvalidResponse) { @authenticator.refresh! } + end + + def test_a_successful_response_of_json_without_a_token_raises_invalid_response_holding_it + stub_request(:post, TOKEN_URL).to_return(status: 200, headers: {"Content-Type" => "application/json", "X-Transaction-Id" => "1"}, body: "{}") + error = assert_raises(InvalidResponse) { @authenticator.refresh! } + + assert_equal ["POST /2/oauth2/token: token response has no access_token", 200, "{}"], [error.message, error.status, error.body] + assert_equal "1", error.headers.fetch("x-transaction-id") + assert_equal TEST_REFRESH_TOKEN, @authenticator.__send__(:refresh_token) + end + + def test_a_forbidden_page_that_is_not_json_raises_forbidden + stub_request(:post, TOKEN_URL).to_return(status: 403, **PAGE) + error = assert_raises(Forbidden) { @authenticator.refresh! } + + assert_equal [403, :post, URI(TOKEN_URL)], [error.status, error.http_method, error.uri] + end + + def test_a_redirect_raises_the_error_of_its_status + stub_request(:post, TOKEN_URL).to_return(status: 302, headers: {"Location" => "https://portal.example.com/"}) + + assert_equal 302, assert_raises(HTTPError) { @authenticator.refresh! }.status + end + + def test_a_bad_request_or_unauthorized_is_a_refusal_whatever_its_body + assert_equal 400, refused(400, PAGE.fetch(:body)).status + assert_equal 401, refused(401, "").status + end + + def test_another_client_error_of_json_is_a_refusal + error = refused(403, {error: "invalid_client", error_description: "Unable to verify your credentials"}.to_json) + + assert_equal ["POST /2/oauth2/token: Unable to verify your credentials", "invalid_client", 403], [error.message, error.error_code, error.status] + assert_equal [:post, URI(TOKEN_URL)], [error.http_method, error.uri] + end + + def test_a_client_error_of_json_that_is_not_an_object_raises_the_error_of_its_status + stub_request(:post, TOKEN_URL).to_return(status: 404, body: "[]") + + assert_raises(NotFound) { @authenticator.refresh! } + end + + def test_too_many_requests_of_json_raises_too_many_requests + stub_request(:post, TOKEN_URL).to_return(status: 429, body: {error: "rate_limited"}.to_json) + + assert_raises(TooManyRequests) { @authenticator.refresh! } + end + + def test_the_bearer_token_of_an_app_raises_the_error_of_a_page_that_is_not_json + stub_request(:post, APP_ONLY_TOKEN_URL).to_return(status: 403, **PAGE) + + assert_raises(Forbidden) { AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).__send__(:bearer_token) } + end + end +end diff --git a/x-core/test/x/core/token_endpoint_origin_test.rb b/x-core/test/x/core/token_endpoint_origin_test.rb new file mode 100644 index 00000000..6fe1d898 --- /dev/null +++ b/x-core/test/x/core/token_endpoint_origin_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A client pointed at another host than X requests the token endpoints there, as it sends its requests + class TokenEndpointOriginTest < Minitest::Test + cover Core.const_get(:TokenEndpoint) + cover AppOnlyAuthenticator + cover OAuth2Authenticator + + TEST_SERVER = "http://localhost:3000/2/" + + def test_the_url_of_a_token_endpoint_is_at_the_origin_of_the_base_url + assert_equal "http://localhost:3000/oauth2/token", Core.const_get(:TokenEndpoint).url_at(TEST_SERVER, APP_ONLY_TOKEN_URL) + assert_equal "http://localhost:3000/2/oauth2/token", Core.const_get(:TokenEndpoint).url_at(TEST_SERVER, OAUTH2_TOKEN_URL) + assert_equal APP_ONLY_TOKEN_URL, Core.const_get(:TokenEndpoint).url_at(Client::DEFAULT_BASE_URL, APP_ONLY_TOKEN_URL) + end + + def test_the_url_of_a_token_endpoint_is_under_the_path_the_base_url_serves_the_api_at + url_at = Core.const_get(:TokenEndpoint).method(:url_at) + + assert_equal "https://gateway.example/x/oauth2/token", url_at.call("https://gateway.example/x/2/", APP_ONLY_TOKEN_URL) + assert_equal "https://gateway.example/x/2/oauth2/token", url_at.call("https://gateway.example/x/2/", OAUTH2_TOKEN_URL) + assert_equal "https://gateway.example/x/api/oauth2/token", url_at.call("https://gateway.example/x/api/1.1/", APP_ONLY_TOKEN_URL) + end + + def test_a_base_url_whose_path_ends_in_no_version_of_the_api_serves_the_api_at_its_whole_path + url_at = Core.const_get(:TokenEndpoint).method(:url_at) + + assert_equal "https://gateway.example/x/oauth2/token", url_at.call("https://gateway.example/x/", APP_ONLY_TOKEN_URL) + assert_equal "https://gateway.example/v2x/oauth2/token", url_at.call("https://gateway.example/v2x/", APP_ONLY_TOKEN_URL) + assert_equal "https://api.x.com/oauth2/token", url_at.call("https://api.x.com/1.1", APP_ONLY_TOKEN_URL) + end + + def test_a_client_of_a_gateway_fetches_and_refreshes_its_tokens_under_the_path_of_the_gateway + app_token = stub_request(:post, "https://gateway.example/x/oauth2/token").to_return(headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + refresh = stub_request(:post, "https://gateway.example/x/2/oauth2/token").to_return(headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "new", refresh_token: "next", expires_in: 7200}.to_json) + stub_request(:get, "https://gateway.example/x/2/users/me").to_return(headers: {"Content-Type" => "application/json"}, body: "{}") + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, base_url: "https://gateway.example/x/2/").get("users/me") + Client.new(client_id: "id", access_token: "old", refresh_token: "refresh", base_url: "https://gateway.example/x/2/").authenticator.refresh! + + assert_requested app_token + assert_requested refresh + end + + def test_an_app_only_client_fetches_its_bearer_token_at_the_origin_of_its_base_url + token_request = stub_request(:post, "http://localhost:3000/oauth2/token").to_return(headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + stub_request(:get, "#{TEST_SERVER}users/me").to_return(headers: {"Content-Type" => "application/json"}, body: "{}") + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, base_url: TEST_SERVER).get("users/me") + + assert_requested token_request + assert_not_requested :post, APP_ONLY_TOKEN_URL + end + + def test_an_oauth2_client_refreshes_its_token_at_the_origin_of_its_base_url + refresh = stub_request(:post, "http://localhost:3000/2/oauth2/token").to_return(headers: {"Content-Type" => "application/json"}, + body: {token_type: "bearer", access_token: "new", refresh_token: "next", expires_in: 7200}.to_json) + client = Client.new(client_id: "id", access_token: "old", refresh_token: "refresh", base_url: TEST_SERVER) + client.authenticator.refresh! + + assert_requested refresh + assert_not_requested :post, OAUTH2_TOKEN_URL + end + + def test_an_authenticator_requests_its_token_endpoint_at_the_origin_of_the_first_client_that_takes_it + authenticator = AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET) + authenticator.__send__(:token_requests_over, Core.const_get(:Connection).new, TEST_SERVER, {}) + authenticator.__send__(:token_requests_over, Core.const_get(:Connection).new, Client::DEFAULT_BASE_URL, {}) + + assert_equal "http://localhost:3000/oauth2/token", authenticator.__send__(:token_request).url + end + end +end diff --git a/x-core/test/x/core/token_endpoint_unreadable_test.rb b/x-core/test/x/core/token_endpoint_unreadable_test.rb new file mode 100644 index 00000000..c7e14874 --- /dev/null +++ b/x-core/test/x/core/token_endpoint_unreadable_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A token endpoint that answers with no body, or with a token that cannot be read as the tokens a client holds, raises + # InvalidResponse, an X::Error, before a client takes any of it + class TokenEndpointUnreadableTest < Minitest::Test + cover Core.const_get(:TokenEndpoint) + + TOKEN_URL = OAUTH2_TOKEN_URL + TOKEN = {"token_type" => "bearer", "access_token" => "issued", "refresh_token" => "next", "expires_in" => 7200, "scope" => "tweet.read"}.freeze + + def setup + @authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + end + + def test_a_response_without_a_body_holds_no_json_object + refute Core.const_get(:TokenEndpoint).__send__(:json_object?, nil) + end + + def test_a_token_that_cannot_be_read_raises_invalid_response_and_is_not_taken + [{"refresh_token" => true}, {"refresh_token" => ""}, {"scope" => "a\"b"}, {"access_token" => "a\nb"}, {"access_token" => " "}].each do |unreadable| + error = refusing(TOKEN.merge(unreadable)) + + assert_equal "POST /2/oauth2/token: The token endpoint answered with a token that cannot be read", error.message + assert_equal [200, TOKEN.merge(unreadable).to_json], [error.status, error.body] + assert_equal TEST_REFRESH_TOKEN, @authenticator.__send__(:refresh_token) + end + end + + def test_a_token_that_can_be_read_is_taken + stub_request(:post, TOKEN_URL).to_return(status: 200, headers: {"Content-Type" => "application/json"}, body: TOKEN.to_json) + @authenticator.refresh! + + assert_equal "next", @authenticator.__send__(:refresh_token) + end + + private + + # Refresh against a token endpoint that answers with a token, and return the InvalidResponse it raises + def refusing(token) + stub_request(:post, TOKEN_URL).to_return(status: 200, headers: {"Content-Type" => "application/json"}, body: token.to_json) + assert_raises(InvalidResponse, token.inspect) { @authenticator.refresh! } + end + end +end diff --git a/x-core/test/x/core/token_refresh_report_failed_test.rb b/x-core/test/x/core/token_refresh_report_failed_test.rb new file mode 100644 index 00000000..8fbe9a09 --- /dev/null +++ b/x-core/test/x/core/token_refresh_report_failed_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A refresh whose tokens save_tokens raised for raises TokenReportFailed, which holds them, since the refresh + # token they replaced is spent + class TokenRefreshReportFailedTest < Minitest::Test + cover_client + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover Core.const_get(:RefreshReporter) + + USERS_ME = "https://api.x.com/2/users/me" + + # An error whose message is what a store that is down may raise + class StorageDown < StandardError; end + + def setup + @refresh = stub_request(:post, "https://api.x.com/2/oauth2/token") + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN", refresh_token: "NEW_REFRESH_TOKEN"}.to_json) + @failing = ->(_) { raise StorageDown, "connection refused" } + end + + def test_a_refresh_raises_with_the_tokens_and_the_error_of_the_hook_as_its_cause + authenticator = oauth2_authenticator_reporting_to(@failing) + error = assert_raises(TokenReportFailed) { authenticator.refresh! } + + assert_equal %w[NEW_ACCESS_TOKEN NEW_REFRESH_TOKEN], [error.tokens.access_token, error.tokens.refresh_token] + assert_instance_of StorageDown, error.cause + assert_nil error.client + assert_equal "The tokens were refreshed, but save_tokens raised for them: connection refused", error.message + end + + def test_a_refresh_before_a_header_raises_with_the_tokens + error = assert_raises(TokenReportFailed) { oauth2_authenticator_reporting_to(@failing, expires_at: Time.now - 1).headers(nil) } + + assert_equal ["NEW_REFRESH_TOKEN", nil], [error.tokens.refresh_token, error.client] + end + + def test_a_refresh_before_a_request_raises_with_the_client_and_the_tokens + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1, save_tokens: @failing) + error = assert_raises(TokenReportFailed) { client.get("users/me") } + + assert_same client, error.client + assert_equal "NEW_REFRESH_TOKEN", error.tokens.refresh_token + assert_instance_of StorageDown, error.cause + end + + def test_a_refresh_of_a_token_a_stream_was_refused_for_raises_with_the_client + stub_request(:get, "https://api.x.com/2/tweets/sample/stream").to_return(status: 401) + client = Client.new(**test_oauth2_credentials, save_tokens: @failing) + error = assert_raises(TokenReportFailed) { client.get_stream("tweets/sample/stream") { |_response| } } + + assert_same client, error.client + end + + def test_a_refresh_of_a_rejected_token_raises_with_the_client_and_the_tokens + stub_request(:get, USERS_ME).to_return(status: 401) + client = Client.new(**test_oauth2_credentials, save_tokens: @failing) + error = assert_raises(TokenReportFailed) { client.get("users/me") } + + assert_same client, error.client + assert_equal "NEW_REFRESH_TOKEN", error.tokens.refresh_token + assert_instance_of StorageDown, error.cause + assert_requested :get, USERS_ME, times: 1 + end + + def test_the_client_holds_the_tokens_the_error_does + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1, save_tokens: @failing) + error = assert_raises(TokenReportFailed) { client.get("users/me") } + + assert_equal error.tokens.access_token, client.authenticator.send(:access_token) + end + end +end diff --git a/x-core/test/x/core/token_report_failed_test.rb b/x-core/test/x/core/token_report_failed_test.rb new file mode 100644 index 00000000..6073c605 --- /dev/null +++ b/x-core/test/x/core/token_report_failed_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class TokenReportFailedTest < Minitest::Test + cover TokenReportFailed + + def test_holds_the_client_and_the_tokens + client = Client.new + tokens = OAuth2Tokens.new(access_token: "ACCESS", refresh_token: "REFRESH", expires_at: nil) + error = TokenReportFailed.new(client:, tokens:) + + assert_same client, error.client + assert_same tokens, error.tokens + end + + def test_holds_nothing_when_given_nothing + error = TokenReportFailed.new + + assert_nil error.client + assert_nil error.tokens + end + + def test_says_the_tokens_were_not_stored + assert_equal "The code was exchanged for tokens, but save_tokens raised for them", TokenReportFailed.new.message + end + + def test_takes_a_message_of_its_own + assert_equal "Not stored", TokenReportFailed.new("Not stored").message + end + + # An error whose message is not what to_s answers, as an error that builds its message may be + class StorageDown < StandardError + def message = "connection refused" + end + + def test_ends_the_message_with_that_of_its_cause + error = assert_raises(TokenReportFailed) do + raise StorageDown + rescue + raise TokenReportFailed, "Not stored" + end + + assert_equal "Not stored: connection refused", error.message + end + + def test_is_an_error_of_the_gems + assert_operator TokenReportFailed, :<, Error + end + end +end diff --git a/x-core/test/x/core/token_request_headers_test.rb b/x-core/test/x/core/token_request_headers_test.rb new file mode 100644 index 00000000..a3c60771 --- /dev/null +++ b/x-core/test/x/core/token_request_headers_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A token request is sent with the User-Agent of the gem and the headers of the client that sends it, beneath the + # headers of the token request itself + class TokenRequestHeadersTest < Minitest::Test + cover Core.const_get(:TokenEndpoint) + cover AppOnlyAuthenticator + cover OAuth2Authenticator + cover Core.const_get(:OAuth2Refresh) + cover_client + + USER_AGENT = Core.const_get(:RequestBuilder)::DEFAULT_HEADERS.fetch("User-Agent") + GATEWAY = {"X-Gateway-Key" => "g"}.freeze + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + REFRESHED = {token_type: "bearer", access_token: "new", refresh_token: "next", expires_in: 7200}.to_json + + def setup + stub_request(:get, "https://api.x.com/2/users/me").to_return(headers: JSON_HEADERS, body: "{}") + end + + def stub_bearer_token(**headers) + stub_request(:post, APP_ONLY_TOKEN_URL).with(headers:) + .to_return(headers: JSON_HEADERS, body: {token_type: "bearer", access_token: TEST_BEARER_TOKEN}.to_json) + end + + def stub_refresh(**options) + stub_request(:post, OAUTH2_TOKEN_URL).with(**options).to_return(headers: JSON_HEADERS, body: REFRESHED) + end + + def test_the_bearer_token_is_fetched_with_the_user_agent_of_the_gem + token = stub_bearer_token("User-Agent" => USER_AGENT) + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).get("users/me") + + assert_requested token + end + + def test_the_bearer_token_is_fetched_with_the_headers_of_the_client + token = stub_bearer_token(**GATEWAY, "User-Agent" => USER_AGENT) + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, headers: GATEWAY).get("users/me") + + assert_requested token + assert_requested :get, "https://api.x.com/2/users/me", headers: GATEWAY + end + + def test_a_user_agent_of_the_client_replaces_the_one_of_the_gem_in_a_token_request + token = stub_bearer_token("User-Agent" => "my-app/1.0") + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, headers: {"user-agent" => "my-app/1.0"}).get("users/me") + + assert_requested token + end + + def test_the_headers_of_the_token_request_replace_those_of_the_client + basic = "Basic #{["#{TEST_API_KEY}:#{TEST_API_KEY_SECRET}"].pack("m0")}" + token = stub_bearer_token("Authorization" => basic, "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json") + headers = {"authorization" => "Bearer other", "content-type" => "text/plain", "Accept" => "text/html"} + Client.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET, headers:).get("users/me") + + assert_requested token + end + + def test_the_authorization_header_of_the_client_is_not_sent_with_a_token_request_that_carries_none + refresh = stub_refresh(headers: GATEWAY) + Client.new(client_id: TEST_CLIENT_ID, access_token: "old", refresh_token: "refresh", headers: {"authorization" => "Bearer other", **GATEWAY}) + .authenticator.refresh! + + assert_requested refresh + assert_requested(:post, OAUTH2_TOKEN_URL) { |request| !request.headers.key?("Authorization") } + end + + def test_a_refresh_is_sent_with_the_headers_of_the_client_that_took_the_authenticator_first + refresh = stub_refresh(headers: {**GATEWAY, "User-Agent" => USER_AGENT}) + client = Client.new(**test_oauth2_credentials, headers: GATEWAY) + client.with(headers: {"X-Other" => "o"}).authenticator.refresh! + + assert_requested refresh + end + + def test_a_token_request_of_an_authenticator_no_client_took_is_sent_with_the_user_agent_of_the_gem + token = stub_bearer_token("User-Agent" => USER_AGENT) + refresh = stub_refresh(headers: {"User-Agent" => USER_AGENT}) + AppOnlyAuthenticator.new(api_key: TEST_API_KEY, api_key_secret: TEST_API_KEY_SECRET).headers(nil) + OAuth2Authenticator.new(**test_oauth2_credentials).refresh! + + assert_requested token + assert_requested refresh + end + + def test_a_refresh_before_a_request_is_sent_with_the_headers_of_the_client_that_sends_it + refresh = stub_refresh(headers: {"X-Other" => "o", "User-Agent" => USER_AGENT}) + client = Client.new(**test_oauth2_credentials, expires_at: Time.now - 1, headers: GATEWAY) + client.with(headers: {"X-Other" => "o"}).get("users/me") + + assert_requested refresh + assert_not_requested :post, OAUTH2_TOKEN_URL, headers: GATEWAY + end + + def test_a_refresh_of_a_rejected_token_is_sent_with_the_headers_of_the_client_that_sends_it + stub_request(:get, "https://api.x.com/2/users/me").with(headers: {"Authorization" => "Bearer #{TEST_ACCESS_TOKEN}"}) + .to_return(status: 401, headers: JSON_HEADERS, body: "{}") + refresh = stub_refresh(headers: {"X-Other" => "o"}) + Client.new(**test_oauth2_credentials, headers: GATEWAY).with(headers: {"X-Other" => "o"}).get("users/me") + + assert_requested refresh + end + end +end diff --git a/x-core/test/x/core/too_many_requests_test.rb b/x-core/test/x/core/too_many_requests_test.rb new file mode 100644 index 00000000..6fd6b9e0 --- /dev/null +++ b/x-core/test/x/core/too_many_requests_test.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +# Build a 429 that reports a 15-minute limit and a 24-hour app limit, both used up +module RateLimitedResponse + def rate_limited_error + response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") + + rate_limit(response) + app_limit(response) + user_limit(response) + + X::TooManyRequests.new(http_response: response) + end + + def rate_limit(response) + Time.stub :now, Time.utc(1983, 11, 24) do + response["x-rate-limit-reset"] = (Time.now + 60).to_i.to_s + end + response["x-rate-limit-limit"] = "100" + response["x-rate-limit-remaining"] = "0" + end + + def app_limit(response) + Time.stub :now, Time.utc(1983, 11, 24) do + response["x-app-limit-24hour-reset"] = (Time.now + 61).to_i.to_s + end + response["x-app-limit-24hour-limit"] = "100" + response["x-app-limit-24hour-remaining"] = "0" + end + + def user_limit(response) + Time.stub :now, Time.utc(1983, 11, 24) do + response["x-user-limit-24hour-remaining"] = (Time.now + 60).to_i.to_s + end + response["x-user-limit-24hour-reset"] = "100" + response["x-user-limit-24hour-reset"] = "0" + end +end + +module X + class TooManyRequestsTest < Minitest::Test + include RateLimitedResponse + + cover TooManyRequests + + def setup + @exception = rate_limited_error + end + + def test_initialize_with_empty_response + response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") + exception = TooManyRequests.new(http_response: response) + + assert_equal 0, exception.rate_limits.count + assert_nil exception.reset_at + assert_nil exception.reset_in + assert_nil exception.retry_after + assert_equal "Too Many Requests", exception.message + end + + def test_rate_limit_is_the_fifteen_minute_limit + Time.stub :now, Time.utc(1983, 11, 24) do + assert_equal ["rate-limit", Time.now + 60], [@exception.rate_limit.type, @exception.rate_limit.reset_at] + end + end + + def test_rate_limit_is_nothing_without_a_fifteen_minute_limit + response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") + app_limit(response) + + assert_nil TooManyRequests.new(http_response: response).rate_limit + end + + def test_rate_limits + limits = @exception.rate_limits + + assert_equal 2, limits.count + assert_equal "rate-limit", limits.first.type + assert_equal "app-limit-24hour", limits.last.type + end + + def test_rate_limits_include_the_limits_with_requests_left + @exception.http_response["x-app-limit-24hour-remaining"] = "1" + + assert_equal %w[rate-limit app-limit-24hour], @exception.rate_limits.map(&:type) + end + + def test_rate_limits_are_read_once + assert_same @exception.rate_limits, @exception.rate_limits + end + + def test_rate_limits_are_frozen + assert_predicate @exception.rate_limits, :frozen? + end + + def test_exhausted_rate_limits_leave_out_the_limits_with_requests_left + @exception.http_response["x-rate-limit-remaining"] = "3" + + assert_equal ["app-limit-24hour"], @exception.exhausted_rate_limits.map(&:type) + end + + def test_limiting_rate_limit_is_the_exhausted_limit_that_resets_last + assert_equal "app-limit-24hour", @exception.limiting_rate_limit.type + end + + def test_limiting_rate_limit_is_nothing_when_no_limit_is_exhausted + @exception.http_response["x-rate-limit-remaining"] = "3" + @exception.http_response["x-app-limit-24hour-remaining"] = "1" + + assert_nil @exception.limiting_rate_limit + end + end + + class TooManyRequestsResetTest < Minitest::Test + include RateLimitedResponse + + cover TooManyRequests + + def setup + @exception = rate_limited_error + end + + def test_reset_at + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["x-app-limit-24hour-remaining"] = "0" + @exception.http_response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s + + assert_equal Time.at(Time.now.to_i + 200), @exception.reset_at + end + end + + def test_reset_in + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["x-app-limit-24hour-remaining"] = "0" + @exception.http_response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s + + assert_equal 200, @exception.reset_in + end + end + + def test_reset_in_is_never_negative + @exception.http_response["x-rate-limit-reset"] = (Time.now - 60).to_i.to_s + @exception.http_response["x-app-limit-24hour-reset"] = (Time.now - 61).to_i.to_s + + assert_equal 0, @exception.reset_in + end + + def test_reset_in_ceil + Time.stub :now, Time.utc(1983, 11, 24, 0, 0, 0, 900_000) do + @exception.http_response["x-rate-limit-reset"] = (Time.now + 62).to_i.to_s + + assert_equal 62, @exception.reset_in + end + end + + def test_retry_after + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["x-app-limit-24hour-remaining"] = "0" + @exception.http_response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s + + assert_equal 200, @exception.retry_after + end + end + + def test_retry_after_waits_for_a_daily_limit_the_fifteen_minute_limit_has_not_reached + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["x-rate-limit-remaining"] = "3" + @exception.http_response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s + + assert_equal 200, @exception.retry_after + end + end + end + + class TooManyRequestsRetryAfterHeaderTest < Minitest::Test + include RateLimitedResponse + + cover TooManyRequests + + def setup + @exception = rate_limited_error + end + + def test_retry_after_counts_the_seconds_the_header_asks_for + @exception.http_response["retry-after"] = "42" + + assert_equal 42, @exception.retry_after + end + + def test_retry_after_counts_a_padded_header_in_tens_rather_than_eights + @exception.http_response["retry-after"] = "060" + + assert_equal 60, @exception.retry_after + end + + def test_retry_after_prefers_the_header_to_the_reset_time_of_the_limit + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["retry-after"] = "42" + + assert_equal [42, 61], [@exception.retry_after, @exception.reset_in] + end + end + + def test_retry_after_waits_until_the_time_the_header_names + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["retry-after"] = (Time.now + 300).httpdate + + assert_equal 300, @exception.retry_after + end + end + + def test_retry_after_waits_out_the_part_of_a_second_an_http_date_leaves_off + Time.stub :now, Time.utc(1983, 11, 24, 0, 0, 0, 900_000) do + @exception.http_response["retry-after"] = (Time.now + 62).httpdate + + assert_equal 62, @exception.retry_after + end + end + + def test_retry_after_is_never_negative_for_a_time_that_has_passed + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["retry-after"] = (Time.now - 300).httpdate + + assert_equal 0, @exception.retry_after + end + end + + def test_retry_after_falls_back_on_the_reset_time_for_a_header_that_says_no_time + Time.stub :now, Time.utc(1983, 11, 24) do + @exception.http_response["retry-after"] = "whenever you like" + + assert_equal 61, @exception.retry_after + end + end + + def test_retry_after_is_nothing_when_a_response_reports_no_limit_and_names_no_time + response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests") + response["retry-after"] = "whenever you like" + + assert_nil TooManyRequests.new(http_response: response).retry_after + end + end +end diff --git a/x-core/test/x/core/truncated_response_test.rb b/x-core/test/x/core/truncated_response_test.rb new file mode 100644 index 00000000..4ce750be --- /dev/null +++ b/x-core/test/x/core/truncated_response_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A response whose connection drops before its body is read whole, read from a server on the loopback interface, + # since webmock passes a response to the block of a request only once it has read the body + class TruncatedResponseTest < Minitest::Test + include LocalServer + + cover_client + cover Core.const_get(:Connection) + cover Core.const_get(:ConnectionRequest) + + OK = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"data\":{}}" + CHUNKED = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n{\"data\":\r\n" + SHORT = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"data\":" + + def url(port) = "http://127.0.0.1:#{port}/2/users/me" + + def client(port) = Client.new(bearer_token: TEST_BEARER_TOKEN, base_url: "http://127.0.0.1:#{port}/2/", max_retries: 1) + + def test_a_get_on_a_kept_connection_whose_body_is_cut_off_is_not_sent_again + with_local_connections([OK, CHUNKED], [OK]) do |port, requests| + connection = Core.const_get(:Connection).new + connection.perform(request: get_request(url(port))) + error = assert_raises(NetworkError) { connection.perform(request: get_request(url(port))) } + + assert_equal [EOFError, 2], [error.cause.class, requests.size] + end + end + + def test_a_body_shorter_than_its_content_length_raises_a_network_error + with_local_connections([SHORT]) do |port| + error = assert_raises(NetworkError) { Core.const_get(:Connection).new.perform(request: get_request(url(port))) } + + assert_kind_of EOFError, error.cause + end + end + + def test_a_lookup_whose_body_is_cut_off_is_not_sent_again + with_local_connections([SHORT], [OK]) do |port, requests| + assert_raises(NetworkError) { client(port).get("users/me") } + assert_equal 1, requests.size + end + end + + def test_a_lookup_whose_body_is_cut_off_is_sent_again_by_with_retries + with_local_connections([SHORT], [OK]) do |port, requests| + client = client(port) + handler = internals(client).instance_variable_get(:@retry_handler) + + assert_equal({"data" => {}}, handler.stub(:sleep, nil) { client.with_retries { client.get("users/me") } }) + assert_equal 2, requests.size + end + end + end +end diff --git a/x-core/test/x/core/version_test.rb b/x-core/test/x/core/version_test.rb new file mode 100644 index 00000000..8a887873 --- /dev/null +++ b/x-core/test/x/core/version_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + class VersionTest < Minitest::Test + cover "X::Core.gem_version" + + def test_that_it_has_a_version_number + refute_nil Core::VERSION + end + + def test_version_string + assert_kind_of String, Core::VERSION + end + + def test_version_frozen + assert_predicate Core::VERSION, :frozen? + end + + def test_gem_version + assert_kind_of Gem::Version, Core.gem_version + end + + def test_gem_version_reads_version + assert_equal Core::VERSION, Core.gem_version.to_s + end + + def test_segments_array + assert_kind_of Array, Core.gem_version.segments + end + + def test_major_version_integer + assert_kind_of Integer, Core.gem_version.segments[0] + end + + def test_minor_version_integer + assert_kind_of Integer, Core.gem_version.segments[1] + end + + def test_patch_version_integer + assert_kind_of Integer, Core.gem_version.segments[2] + end + end +end diff --git a/x-core/test/x/core/with_retries_test.rb b/x-core/test/x/core/with_retries_test.rb new file mode 100644 index 00000000..a4db3b4e --- /dev/null +++ b/x-core/test/x/core/with_retries_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module X + # A request that is safe to send again is sent again after any failure of the API or of the network + class WithRetriesTest < Minitest::Test + cover "X::Client#with_retries" + cover "X::Core::ClientSettings#with_retries" + + def setup + @sleeps = [] + @attempts = 0 + end + + def test_returns_what_the_block_returns + assert_equal :done, with_retries { :done } + end + + def test_sends_a_request_twice_more_by_default + assert_raises(ServiceUnavailable) { with_retries { fail_with(ServiceUnavailable) } } + assert_equal [3, [1, 2]], [@attempts, @sleeps] + end + + def test_sends_a_request_again_as_often_as_it_is_told + assert_raises(ServiceUnavailable) { with_retries(max_retries: 3) { fail_with(ServiceUnavailable) } } + assert_equal [4, [1, 2, 4]], [@attempts, @sleeps] + end + + def test_sends_a_request_again_after_it_timed_out_reading_its_answer + assert_equal 2, with_retries(max_retries: 1) { (@attempts < 1) ? fail_with(NetworkError, Net::ReadTimeout.new) : @attempts += 1 } + assert_equal [1], @sleeps + end + + def test_raises_at_once_for_a_failure_the_request_is_the_reason_for + assert_raises(BadRequest) { with_retries { fail_with(BadRequest) } } + assert_equal [1, []], [@attempts, @sleeps] + end + + def test_counts_the_rate_limit_retries_of_a_request_across_the_attempts_it_sends_again + stub_request(:post, "https://api.x.com/2/media/upload/1/append").to_return(refused, {status: 503}, refused, {status: 200}) + client = Client.new(bearer_token: TEST_BEARER_TOKEN, max_rate_limit_retries: 1) + rate_limits = internals(client).instance_variable_get(:@rate_limit_handler) + rate_limits.define_singleton_method(:sleep) { |_| nil } + + assert_raises(TooManyRequests) { with_retries_of(client) { client.post("media/upload/1/append", "chunk") } } + assert_requested :post, "https://api.x.com/2/media/upload/1/append", times: 3 + end + + private + + # Run the block with the retries of a client, collecting their waits, with no share taken off them + def with_retries(**options, &) = with_retries_of(Client.new(**options), &) + + # Run the block with the retries of the client given, collecting their waits, with no share taken off them + def with_retries_of(client, &) + sleeps = @sleeps + handler = internals(client).instance_variable_get(:@retry_handler) + handler.define_singleton_method(:rand) { 0.0 } + handler.define_singleton_method(:sleep) { |seconds| sleeps << seconds } + client.with_retries(&) + end + + def refused + {status: 429, headers: {"x-rate-limit-limit" => "50", "x-rate-limit-remaining" => "0", "x-rate-limit-reset" => Time.now.to_i.to_s}} + end + + # Raise the given error, counting the attempt it ends + def fail_with(error_class, cause = nil) + @attempts += 1 + raise error_class, "boom", cause: cause if error_class.equal?(NetworkError) + + raise error_class.new(http_response: Net::HTTPResponse.new("1.1", {ServiceUnavailable => "503", BadRequest => "400"}.fetch(error_class), "Boom")) + end + end +end diff --git a/x-core/x-core.gemspec b/x-core/x-core.gemspec new file mode 100644 index 00000000..9d42ef4b --- /dev/null +++ b/x-core/x-core.gemspec @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# The version every gem in this repository is released at +version = File.read(File.expand_path("../VERSION", __dir__)).strip + +Gem::Specification.new do |spec| + spec.name = "x-core" + spec.version = version + spec.authors = ["Erik Berlin"] + spec.email = ["sferik@gmail.com"] + + spec.summary = "The HTTP layer of the X gem: authentication, requests, and errors." + spec.homepage = "https://sferik.github.io/x-ruby" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.4" + spec.platform = Gem::Platform::RUBY + + spec.metadata = { + "allowed_push_host" => "https://rubygems.org", + "bug_tracker_uri" => "https://github.com/sferik/x-ruby/issues", + "changelog_uri" => "https://github.com/sferik/x-ruby/blob/main/x-core/CHANGELOG.md", + "documentation_uri" => "https://rubydoc.info/gems/x-core/", + "funding_uri" => "https://github.com/sponsors/sferik/", + "homepage_uri" => spec.homepage, + "rubygems_mfa_required" => "true", + "source_code_uri" => "https://github.com/sferik/x-ruby/tree/main/x-core" + } + + spec.files = Dir.glob([ + "lib/**/*.rb", + "sig/*.rbs", + "sig/manifest.yaml", + ".yardopts", + "*.md", + "LICENSE.txt" + ], base: __dir__) + spec.require_paths = ["lib"] + # net-http is a default gem, and the 0.6 that Ruby 3.4 ships cannot request a host named by an IPv6 literal, + # such as a base_url of http://[::1]:8080/, and on Ruby 4.0 the net-http before 0.9.1 raises IO::TimeoutError + # rather than Net::OpenTimeout for a connection that times out opening, which is then not retried, so the + # version that does both is asked for here + spec.add_dependency("net-http", "~> 0.9", ">= 0.9.1") + spec.add_dependency("simple_oauth", "~> 1.0") +end diff --git a/x-objects/.mutant.yml b/x-objects/.mutant.yml new file mode 100644 index 00000000..73f95cca --- /dev/null +++ b/x-objects/.mutant.yml @@ -0,0 +1,65 @@ +--- +coverage_criteria: + process_abort: true +fail_fast: true +includes: +- lib +integration: + name: minitest +matcher: + # x-core is loaded too, and is mutated in its own suite, so the classes x-objects declares directly under X are named + # one by one rather than matched by X* + subjects: + - X::Objects* + - X::BookmarkFolder* + - X::Community* + - X::Cursor* + - X::DirectMessage* + - X::InvalidAttribute* + - X::List* + - X::MatchingRule* + - X::Media* + - X::MissingClient* + - X::MissingResource* + - X::Page* + - X::PersonalizedTrend* + - X::Place* + - X::Poll* + - X::Post* + - X::Resource* + - X::Space* + - X::Topic* + - X::Trend* + - X::PostUsage* + - X::User* + # Each class of resource makes X::Resource.from_id and X::Resource.from_response, which X::Resource keeps private, + # public on itself, which mutant would match as methods of that class, but are the ones of X::Resource, which are + # mutated as such + ignore: + - X::BookmarkFolder.from_id + - X::Community.from_id + - X::DirectMessage.from_id + - X::List.from_id + - X::Place.from_id + - X::Poll.from_id + - X::Post.from_id + - X::Space.from_id + - X::Topic.from_id + - X::User.from_id + - X::BookmarkFolder.from_response + - X::Community.from_response + - X::DirectMessage.from_response + - X::List.from_response + - X::Media.from_response + - X::Place.from_response + - X::Poll.from_response + - X::Post.from_response + - X::Space.from_response + - X::Topic.from_response + - X::User.from_response +mutation: + operators: full + timeout: 10.0 +requires: +- x/objects +usage: opensource diff --git a/x-objects/.yardopts b/x-objects/.yardopts new file mode 100644 index 00000000..246d9b7b --- /dev/null +++ b/x-objects/.yardopts @@ -0,0 +1,8 @@ +--markup markdown +--readme README.md +--hide-api private +--embed-mixins +lib/**/*.rb +- +CHANGELOG.md +LICENSE.txt diff --git a/x-objects/CHANGELOG.md b/x-objects/CHANGELOG.md new file mode 100644 index 00000000..b5b636d8 --- /dev/null +++ b/x-objects/CHANGELOG.md @@ -0,0 +1,253 @@ +# Changelog + +All notable changes to `x-objects` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +`x-objects` is released in lockstep with the other gems of the [x-ruby](https://github.com/sferik/x-ruby) repository, at one version across `x-core`, `x-uploader`, `x-streaming`, `x-objects`, and `x`. This file holds the changes to the object layer; [the changelog of the repository](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) holds the changes to every gem. + +## [1.0.0] - 2026-10-06 + +The first release of `x-objects`, which 1.0.0 split out of the `x` gem. `x` 0.19, the last release before the split, had no object layer, so every entry below is new; see [UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) for the changes that code written for 0.19 needs. It requires Ruby 3.4 or later. + +### Added +* Add `X::Objects.gem_version`, which returns `VERSION` as a `Gem::Version` +* Split `x` into gems released in lockstep: `x-core`, `x-uploader`, `x-streaming`, `x-objects`, and the `x` meta-gem + * `x-core` is the HTTP client and declares `X::Error`, the base of every error the gems raise + * `x-objects` makes no request of its own; it asks the client it is given to make them + * Public classes are named directly under `X`, whichever gem declares them + * Each gem depends on the others with `>= 1.0.0, < 2`, so a 1.x release installs beside any later 1.x of the others, never an earlier one +* Add `X::Objects::Error`, to rescue the failures of the object layer alone + * `X::MissingResource`, `X::UnreadableResponse`, `X::MissingClient`, and `X::PageLimitReached` descend from it + * `X::InvalidAttribute` descends from `X::UnreadableResponse` +* Add immutable, thread-safe resource classes that descend from `X::Resource` + * `X::User`, `X::Post` (aliased `X::Tweet`), `X::List`, `X::DirectMessage`, `X::Space`, `X::Media`, and `X::Poll` + * `X::Place`, and `X::Community`, found with `find_community(!)` and `search_communities`, and read as `post.community` + * Post in a community with the `community:` of `create_post` + * Lookups, cursors' `to_a`, and the collections a resource holds are frozen Arrays; copy one to change it + * A list the response omits, such as `post.urls`, reads as an empty Array; `user.connection_status` reads nil + * Text reads as the API sends it, so `post.text` and `direct_message.text` hold `&`, `<`, and `>` throughout 1.x + * Nested data, such as `post.entities` and `post.public_metrics`, reads as frozen Hashes keyed by String throughout 1.x + * Nested data the API sends as anything but an object, or a list of them, raises `X::InvalidAttribute` +* Compare resources by class and ID with `==`, `eql?`, and `hash` +* Resolve references such as `post.author` to included objects or ID stubs, sharing one object per resource +* Add `hydrate`, which fetches and memoizes the full resource, and `refresh`, which fetches it again + * A resource without a client, such as one read back by `Marshal`, raises `X::MissingClient` from any request + * A lookup whose parameters leave out default fields or expansions returns resources that are not hydrated + * `FIELDS` and `EXPANSIONS` may grow in a minor release; add to `default_params` rather than list every value +* Add `X::Cursor`, an `Enumerable` that fetches pages lazily, caches them, and offers `refresh` and `prefetch` + * A page a prefetch failed to fetch raises the prefetch's error once it is reached, rather than be requested again + * `first`, `take`, `any?`, `none?`, `one?`, and `empty?` answer from the pages already held, and keep what they read + * Without a block or pattern, `any?`, `none?`, and `empty?` request one resource and `one?` two, not a full page + * That is raised to the endpoint's minimum page size: 5 for a user's posts, mentions, and liked posts; 10 for post and community searches and quotes + * `first` and `take` request no larger a page than needed; a count that does not convert raises `TypeError` + * A page that names a token already read as its next raises `X::UnreadableResponse`, rather than page forever + * `page` takes an Integer index from zero; another type raises `TypeError`, a negative index `ArgumentError` +* Add `X::Cursor#each_page`, which yields each page as an `X::Page` + * A page holds `items`, `meta`, `result_count`, `next_token`, `previous_token`, and `problems`, and reads its items as an Array does + * `X::Page.new` takes an Array of resources and `meta:` and `problems:`; other problems raise `ArgumentError` + * Pages are equal when they hold the same resources, in order, meta, and problems +* Add `X::Cursor#published_count`, the number the API publishes for a collection, without paging it + * For a user's followers, followed users, and list memberships, and a list's members and followers + * It is nil for any other collection; `count` pages through the collection, as `Enumerable` does +* Add `X::Cursor#ids`, which requests identifiers alone, and `stubs`, which scans a collection as stubs +* Read the collections of a resource as cursors + * A user's `followers`, `following`, `affiliates`, `posts`, `mentions`, `liked_posts`, and `owned_lists` + * A user's `list_memberships`, `followed_lists`, `pinned_lists`, `home_timeline`, `blocking`, and `muting` + * A list's `members`, `followers`, and `posts`, and a post's `quotes` + * A space's `posts`, and its `buyers`, which the API reads for OAuth 2.0 user context alone +* Add `X::Post#reply?`, `quote?`, and `repost?`, and read the post referred to with `replied_to`, `quoted`, `reposted` +* Add `X::Post#liked_by`, `reposted_by`, and `reposts`, and `references`, the posts a post or direct message refers to +* Look users and posts up by ID in parallel batches of 100 with `X::User.find_all` and `X::Post.find_all` + * They return one resource per ID found, in the order asked, so an ID given twice comes back twice + * Once a batch fails, or the waiting thread is interrupted, no batch not yet begun is sent +* Look many resources up at once with `find_all_users`, `find_all_posts`, `find_all_spaces`, and `find_all_media` + * `find_all_users` takes a mix of IDs and usernames, batching each kind separately + * `find_all_users_by_username` takes usernames alone, even ones that are all digits +* Set how many batches a lookup requests at once with `concurrency:`, 4 by default + * Taken by `find_all`, `find_all_by_username`, `hydrate_all`, `find_all_by_creator`, and their client methods + * Anything but an Integer of at least 1 raises `ArgumentError` +* Hydrate many resources in parallel batches with `hydrate_all` on `X::User`, `X::Post`, `X::Space`, and `X::Media` + * It drops nil and resources not found, and looks up none that is already hydrated + * It stores what it finds in each resource given, unless the parameters leave out default fields or expansions + * A resource of another class raises `ArgumentError` before a request +* Hydrate the stubs of a page of `stubs`, or of a cursor that requests identifiers alone, together, in batch lookups of up to 100 + * Lists, communities, and direct messages, which the API looks up one at a time, hydrate one at a time + * A reference a page did not include, such as the `author` of a post, hydrates one at a time; `hydrate_all` batches any +* Add object methods to `X::Client`, with `find_tweet` aliases for the finders + * `find_user`, `find_post`, `find_list`, `find_space`, `search_posts`, and `search_all_posts` + * `create_post`, `delete_post`, `direct_messages`, and `create_direct_message` + * `follow`, `unfollow`, `like`, `unlike`, `repost`, and `unrepost` + * `follow` returns true once it asks to follow a protected user; `repost` returns true once the user has reposted +* Add `block`, `unblock`, `mute`, `unmute`, `bookmark`, and `unbookmark` to `X::Client` +* Add `follow_list`, `unfollow_list`, `pin_list`, and `unpin_list` to `X::Client` +* Read the authenticated user's posts that others reposted with `X::Post.reposts_of_me` and `client.reposts_of_me` + * Both are aliased as `retweets_of_me` +* Read the authenticated user with `client.current_user` and `current_user!`, or `X::User.current` and `current!` + * `current_user` returns nil, yielding the problems the API reported; `current_user!` raises `X::MissingResource` + * `current_user_id` keeps the ID per credentials, even on a frozen `X::Client`; a frozen client without `memoize` looks it up each time + * With OAuth 1.0a, `current_user_id` reads the ID from the access token, without a request +* Look a user up by ID when given an Integer and by username when given a String + * Say which with `X::User.find_by_id(!)`, `find_all_by_id`, `find_by_username(!)`, and `find_all_by_username` + * On the client: `find_user_by_id(!)`, `find_all_users_by_id`, `find_user_by_username(!)`, `find_all_users_by_username` + * The `by_id` methods look a String of digits up as an ID, as read from a response or an environment variable + * Elsewhere, such as `follow`, `from_id`, or `find_post`, an ID that is not a number raises `ArgumentError` + * So does a resource of another class, as in `client.like(user)`, or another object that answers `id` +* Accept a username with a leading `@` in `find_user`, `find_all_users`, and `X::User.find_all_by_username` +* Validate a username or a non-numeric ID before building a path from it, raising `ArgumentError` without a request + * Such as `find_user("")`, `find_user("../tweets/20")`, `find_user("sferik?expansions=x")`, or `find_user("bad name")` +* Raise `X::MissingResource` from `current_user!` and every `find…!` method when a lookup finds nothing + * Its message names what was looked up, as in "Could not find X::User @sferik" + * It is not `X::NotFound`, since the API answers a lookup of a missing resource with 200 OK and no data + * Data that holds no identifier counts as not found too, rather than raising `X::InvalidAttribute` +* Refer to a resource without a request with `X::User.from_id` and its equivalents, and tell stubs apart with `stub?` + * `X::Resource` itself keeps `new`, `from_id`, and `from_response` private +* Pass a resource class, such as `X::User`, as the `object_class` of any request to build objects from the response + * A list builds an `X::Page`, which holds the response's `meta` and problems, and is empty when there is no `data` + * Any `object_class` that responds to `from_response` is passed the parsed body and `client:` + * A `from_response` of your own must take unknown keywords with `**`, since a 1.x release may pass more +* Include the object methods in a class of your own with `X::Objects::API` + * The class is the client of each request, and answers `get`, `post`, `put`, and `delete` (`X::Objects::_Client`) + * Those methods must take unknown keywords, since a 1.x release may pass any keyword `X::Client` takes + * `X::Objects` names `API`, `Error`, and `VERSION` alone; the modules and helpers behind them are private + * Constants a resource class shares are private, so `X::Post::REPLIED_TO` raises `NameError` + * The signatures the gem ships declare its public interface alone +* Search users with `X::User.search` and `client.search_users` +* Search live and scheduled spaces with `X::Space.search` and `client.search_spaces` +* Request the largest page each search allows + * 500 posts from `search_all_posts`, or 100 when the request asks for context annotations, as the default fields do + * 1,000 users from `search_users` +* Check `list.member?` and `user.follows?` without fetching every page + * `member?` scans the smaller of a public list's members and the user's memberships; a private list scans members + * `follows?` looks up `X::User#connection_status` once when either user is the authenticated user + * A 401 or 403 to the lookup of the authenticated user falls back to a scan; any other failure, such as a rate limit, raises + * `max_pages:` limits the pages a scan reads, raising `X::PageLimitReached` if the API names another + * `max_pages:` defaults to nil, no limit; anything but an Integer of at least 1 or nil raises `ArgumentError` +* Count the posts that match a query with `X::Post.count`, `count_all`, `count_by_period`, and `count_all_by_period` + * On the client: `count_posts`, `count_all_posts`, `count_posts_by_period`, and `count_all_posts_by_period` + * Those have tweet-named aliases, and pass `max_pages:` through + * The by-period counts are in time order, keyed by the `Range` of `Time` each period spans + * A client that signs with OAuth 1.0a counts with a copy that authenticates as the app + * An OAuth 2.0 user client without app credentials counts as the user; the full archive refuses it with `X::Forbidden` + * Every count takes `max_pages:`, raising `X::PageLimitReached` past it, as `follows?` does + * A count by period that is not a String of digits or a non-negative Integer raises `X::InvalidAttribute` +* Report how many posts the app's project has read with `X::PostUsage.current` and `client.post_usage` + * It holds the monthly cap, the day it resets on, and usage by day and by app, each count an Integer + * They return nil, yielding the response's problems to a block, when it holds no usage + * `X::PostUsage.current!` and `client.post_usage!` raise `X::MissingResource` instead + * An OAuth 2.0 user client without app credentials requests as the user, which the API refuses with `X::Forbidden` +* Report the partial errors of a successful response as `X::Problem` objects, which `x-core` declares + * Read them with `problems` on a resource or a page, a block given to a finder, or `X::MissingResource#problems` + * A resource holds the problems about it, or a resource it refers to directly, and those that name no resource +* Look up a direct message with `find_direct_message`, and the conversation with a user with `direct_messages_with` +* Delete a direct message with `X::DirectMessage.delete`, `message.delete`, and `client.delete_direct_message` +* Add `X::DirectMessage#peer(user)`, the other participant of a one-to-one conversation as the user given sees it + * It is nil for a group conversation, as `group?` tells, and for a user not in the conversation +* Start a group conversation with `X::DirectMessage.create_group` and `client.create_group_direct_message` +* Send to and read any conversation with `X::DirectMessage.create_in` and `X::DirectMessage.in` + * On the client: `create_direct_message_in` and `direct_messages_in` + * Each takes a message of the conversation or its identifier +* Add `dm` aliases for the client methods named for direct messages + * `find_dm`, `find_dm!`, `dms`, `dms_with`, `create_dm`, `delete_dm`, `create_group_dm`, `create_dm_in`, and `dms_in` +* Attach uploaded media to a direct message with `media_ids:`, as `create_post` takes it + * Passing both `media_ids:` and `attachments:` raises `ArgumentError` + * An empty `media_ids:` attaches nothing to a post or a message, as nil does +* Post media without text, and send a direct message of attachments alone + * Without text, no `text` field is sent; a call with neither text nor any other field raises `ArgumentError` +* Build a new post's `reply` and `media` from the `reply_to:` and `media_ids:` of `create_post` + * Any other field of a `reply:` or `media:` passed beside them is kept + * A field passed with a String key, such as `"reply"` or `"attachments"`, is read as its Symbol, so it is sent once + * `media_ids:` takes a single value as well as an Array +* Accept what an upload returns, media, or a media key in the `media_ids:` of `create_post` + * An ID is sent only as 1 to 19 digits; anything else, such as a Hash without an `"id"`, raises `ArgumentError` +* Quote a post with the `quote:` of `create_post` and `X::Post.create` +* Raise `X::MissingResource`, holding the response's problems, when a request that creates a resource is answered without it + * From `X::Post.create`, `X::List.create`, `X::DirectMessage.create`, `create_group`, `create_in`, and their client methods + * So `create_post`, `create_list`, `create_dm`, and the rest never return nil + * A response whose data names no identifier raises it too +* Hide a reply to the authenticated user's post, and show it again, with `hide_reply` and `unhide_reply` + * On `X::Post` instances, on the `X::Post` class, and on the client +* Manage lists with `X::List.create`, `X::List.update`, `X::List.delete`, `list.update`, and `list.delete` + * Add and remove members with `list.add_member` and `list.remove_member` + * On the client: `create_list`, `update_list`, `delete_list`, `add_list_member`, and `remove_list_member` + * An update with no field to change raises `ArgumentError` before a request +* Add `X::User#bookmark_folders`, a cursor of `X::BookmarkFolder`, and read a folder's posts with `bookmarks(folder:)` + * Hydrating or refreshing a folder that is not hydrated raises `X::UnsupportedOperation`, since the API has no lookup +* Look up the spaces of many creators with `X::Space.find_all_by_creator` and `client.find_all_spaces_by_creator` + * They take users or their IDs, 100 at a time in parallel batches, and yield each problem the API reports +* Look up, search, and read the posts of spaces whatever the client authenticates with + * A client that signs with OAuth 1.0a makes those requests with its app-only client + * An OAuth 2.0 user client without app credentials makes them itself, and the spaces and posts returned act as that user; one that holds app credentials makes them with its app-only client +* Add `X::Space#topics`, each an `X::Topic` with a `name` and a `description` +* Look up media by media key with `X::Media.find`, `find!`, and `find_all` + * On the client: `find_media`, `find_media!`, and `find_all_media` + * They take a media key, media, or what an upload returned; the numeric media ID raises `ArgumentError` + * `client.find_media(uploaded)` reads what an upload became, with its URL and variants +* Add `X::Media#media_id`, the Integer its media key names, as `X::UploadedMedia#media_id` reads it + * `X::Media#id` is the media key, which the API looks media up by +* Read the trends of a place with `X::Trend.at` and `client.trends`, given its WOEID, such as 1 for the world + * A WOEID that is not a number raises `ArgumentError` before a request + * It returns up to 50 trends unless given `max_trends:`, each with a `name` and a `post_count` + * A client that signs with OAuth 1.0a, which the endpoint refuses, requests as the app +* Read the trends X picks for the authenticated user with `X::PersonalizedTrend.all` and `client.personalized_trends` + * Read `name`, `category`, and `post_count_text` and `trending_since_text`, the text X shows + * Neither trends endpoint has pages, so each returns a frozen Array; trends are equal when their attributes are +* Read the full text of a long post, over 280 characters, with `X::Post#text`, from its `note_post` + * `entities` and `urls` read the note's entities alone, so they lie where `text` holds them +* Add `X::Post#urls` and `expanded_text`, the text with each shortened link replaced by the URL it stands for, in one pass + * `expanded_text` HTML-escapes each URL it puts in, so the whole text reads escaped, as `text` does +* Add `X::Post#matching_rules`, the filtered stream rules a post matched, each an `X::MatchingRule` + * A rule reads the `id` the API gave it, as an Integer, and its `tag`; building one whose `id` is not digits raises `ArgumentError`, and reading one from a post raises `X::InvalidAttribute` + * A post that did not come from the filtered stream matched none +* Add `X::DirectMessage#from?`, `X::Post#coordinates`, and `permalink` and `uri`, the x.com address of a resource + * `permalink` and `uri` are on posts, users, lists, and communities +* Read more fields, which the lookups request + * `X::User#profile_banner_url`, `parody?`, `identity_verified?`, `subscription_type`, `verified_followers_count` + * `X::User#subscriber_count` and `media_count` + * `X::Post#display_text_range`, an exclusive `Range`, nil when absent, and `scopes`, `card_uri`, `article`, `article_title`, `media_metadata`, `paid_partnership?` + * `X::DirectMessage#entities` + * `X::User#affiliation`, `affiliated_with_ids`, and `affiliated_with`; a user included in another resource has none + * Fields only an author, an advertiser, or a program may read are not requested, since asking fails for others +* Add `X::User#receives_your_dm?`, `subscribes_to_you?`, and `subscription`; the predicates are false, and `subscription` nil, unless `user.fields` names them +* Add `X::Post#media_source_posts`, aliased `media_source_tweets`, the posts its attached media was first posted with + * Resolved from the `attachments.media_source_tweet` expansion, which lookups request +* Read the API's `is_` flags as `X::User#identity_verified` and `X::Space#ticketed`, beside their `?` predicates +* Match resources against `case/in` patterns by every attribute they declare, as in `post in {like_count: 100..}` + * Tweet-named aliases match too, as in `user in {pinned_tweet_id: Integer}` + * `X::Trend`, `X::PersonalizedTrend`, and `X::PostUsage` match by their readers, as in `trend in {post_count: 10..}` +* Write resources and value objects as JSON with `as_json` and `to_json`, never with the client's credentials + * On `X::Resource`, `X::Problem`, `X::Trend`, `X::PersonalizedTrend`, `X::PostUsage`, `X::MatchingRule`, and `X::Page` + * A page writes the shape of its response, which the `from_response` of its resource class reads back +* Marshal and YAML-dump them in a format every 1.x release reads + * An unknown format raises `X::UnsupportedFormat`, which `x-core` declares + * A resource keeps its attributes, the included objects it refers to, and its query, but not its client +* Raise from a cursor's `as_json`, `to_json`, and `to_h`, and from `Marshal.dump` and `YAML.dump` of one + * They raise `X::UnsupportedOperation` and `TypeError`, rather than read every page; serialize `first(n)` or `to_a` +* Name the interface after posts rather than tweets, as in `post_count`, `pinned_post_id`, and `repost_count` + * Tweet-named methods, such as `create_tweet`, `tweets`, `quote_tweets`, and `retweet_count`, remain as aliases + * A response that uses tweet names, as a stream does, is read where the post-named field is missing +* Request fields and expansions by the names the X API documentation gives, such as `post.fields` and `referenced_posts` + * The authors of referenced posts are not expanded, since the API reference names no expansion for them +* Leave out the `edit_history_post_ids` and `entities.mentions.username` expansions, whose includes nothing reads +* Read the IDs of users, posts, lists, direct messages, communities, and polls, and references to them, as Integers + * `X::Problem#resource_id` and `value` are Strings, so match a problem to a resource with `problem.about?(user)` + * IDs of spaces, places, and media, media keys, `dm_conversation_id`, and usernames are Strings + * A space or place ID is word characters alone; a `dm_conversation_id` that is not digits, or two numbers joined by a hyphen, raises `X::InvalidAttribute` +* Raise `X::UnsupportedOperation`, which `x-core` declares, for anything the API offers no way to do + * Such as hydrating or refreshing an `X::Poll` or an `X::Place` that is not hydrated + * A lookup the API lacks is not defined: `X::Poll` and `X::Place` answer no finder + * `X::List`, `X::Community`, and `X::DirectMessage` answer `find` and `find!`, but not `find_all` or `hydrate_all` +* Validate the attributes of a resource, a page, a trend, or the usage when it is built, raising `ArgumentError` + * As in `X::User.new({"id" => "abc"})`, `X::Trend.new(nil)`, or a page of items that are not resources +* Raise `X::InvalidAttribute` where a value of a response cannot be read as the API documents it + * Such as a timestamp that is not ISO 8601, a `public_metrics` that is a String, or a link whose `url` is not a String + * A count reads a String of digits as a number, and raises for a negative, signed, or fractional value + * A flag, such as `protected`, reads true, false, or nil, and raises for anything else + * Its cause is the `ArgumentError` that refused the value +* Type-check collections: `X::Cursor` and `X::Page` are generic in their signatures +* Ship a `sig/manifest.yaml` naming `uri` and `json`, so `rbs collection` loads them for code that depends on the gem +* Ship this changelog with the gem, linked from the `changelog_uri` of its gemspec +* Ship a `.yardopts` with the gem, so its documentation on rubydoc.info leaves out the private API + +[1.0.0]: https://github.com/sferik/x-ruby/releases/tag/v1.0.0 diff --git a/x-objects/Gemfile b/x-objects/Gemfile new file mode 100644 index 00000000..1b5bba84 --- /dev/null +++ b/x-objects/Gemfile @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify the gem's dependencies in x-objects.gemspec +gemspec + +# Use the x-core in this repository rather than a released version +gem "x-core", path: "../x-core" + +gem "minitest", ">= 6" +gem "minitest-mock", ">= 5.27" +gem "rake", ">= 13.0.6" +gem "simplecov", ">= 1" +gem "yard", ">= 0.9" +gem "yardstick", ">= 0.9" + +# Mutant and Steep run on CRuby alone, so they are left out of the bundle of any other engine, whose job +# runs the tests alone; the mutant, steep, and docs jobs of CI all run on CRuby +platforms :mri do + gem "mutant-minitest", ">= 0.16" + gem "steep", ">= 2.0" +end diff --git a/x-objects/LICENSE.txt b/x-objects/LICENSE.txt new file mode 100644 index 00000000..0fdc908c --- /dev/null +++ b/x-objects/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 Erik Berlin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/x-objects/README.md b/x-objects/README.md new file mode 100644 index 00000000..c6a9f4c0 --- /dev/null +++ b/x-objects/README.md @@ -0,0 +1,146 @@ +# x-objects + +The object layer of the [`x` gem](https://github.com/sferik/x-ruby): immutable, thread-safe resource classes for the [X API](https://developer.x.com) with identity, references, hydration, cached pagination, and parallel batch lookups. + +It makes no HTTP requests itself: it asks a client to make them. Its one runtime dependency is [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core), for `X::Error`, the base class of every error the X gems raise, `X::UnsupportedOperation`, which it raises for what the API offers no way to do, `X::UnsupportedFormat`, which it raises for what `Marshal` wrote in a format it does not read, and `X::Problem`, which describes the partial errors of a response. + +Most applications should install [`x`](https://rubygems.org/gems/x), which wires this gem to the HTTP client from [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core). + +## Installation + +`x-objects` requires Ruby 3.4 or later. + + bundle add x-objects + +## Resources + +| Class | References | Collections | Class collections | +| --- | --- | --- | --- | +| `X::User` | `pinned_post`, `most_recent_post`, `affiliated_with` | `followers`, `following`, `affiliates`, `blocking`, `muting`, `posts`, `home_timeline`, `mentions`, `liked_posts`, `bookmarks`, `bookmark_folders`, `owned_lists`, `list_memberships`, `followed_lists`, `pinned_lists` | `search` | +| `X::Post` | `author`, `in_reply_to_user`, `community`, `replied_to`, `quoted`, `reposted`, `references`, `media`, `polls`, `place` | `liked_by`, `reposted_by`, `reposts`, `quotes` | `search`, `search_all`, `reposts_of_me` | +| `X::List` | `owner` | `members`, `followers`, `posts` | | +| `X::DirectMessage` | `sender`, `participants`, `references`, `media` | | `all`, `with`, `in` | +| `X::Space` | `creator`, `hosts`, `speakers`, `invited_users`, `topics` | `posts`, `buyers` | `search` | +| `X::Community` | | | `search` | +| `X::Media` | | | | +| `X::Poll`, `X::Place`, `X::Topic`, `X::BookmarkFolder` | | | | + +Every class but `X::Poll`, `X::Place`, `X::Topic`, and `X::BookmarkFolder`, which the API has no lookup for, is looked up with `find`, as in `X::Media.find("3_1880028106020515840", client:)`, which looks media up by its media key. A collection is an `X::Cursor`, read from a resource, as in `user.followers`, and a class collection is one read from the class with a client, as in `X::Post.search("ruby", client:)`. `user.bookmarks(folder:)` reads the posts in one of the `bookmark_folders`, and `X::Space.find_all_by_creator` looks up the spaces of many users by the users who created them. + +Trends are not resources, since they have no identifier: `X::Trend.at(1, client:)` reads the topics trending in a place, named by its Yahoo! Where On Earth identifier, and `X::PersonalizedTrend.all(client:)` the topics X picks for the authenticated user, each returning an Array. A post of the filtered stream reads the rules it matched with `matching_rules`, as `X::MatchingRule` values, and `X::Media#media_id` reads the numeric media ID that the media key of `X::Media#id` names. + +`X::User.find` reads an Integer or a user as an identifier and a String as a username, so a handle of digits needs `X::User.find_by_username` (or `find_by_username!`, and `client.find_user_by_username`) to say which is meant, and an identifier read as a String, as from a response or an environment variable, needs `X::User.find_by_id` (or `find_by_id!`, and `client.find_user_by_id`). + +The space endpoints refuse OAuth 1.0a, so `X::Space.find`, `find_all`, `find_all_by_creator`, `search`, and `space.posts` make their requests with the client's app-only client when it has one, which a client signed in with OAuth 2.0 as a user has when it holds the app's bearer token, or its API key and secret, and with the client itself when it has none, such as one signed in with OAuth 2.0 as a user that holds neither, which the space endpoints take. Bookmarking and unbookmarking a post, and `space.buyers`, take only OAuth 2.0 user context, which the gem cannot route around; reading bookmarks and bookmark folders takes OAuth 1.0a too. + +## The client contract + +Any object that responds to `get`, `post`, `put`, and `delete` can be the client. Each method takes a path relative to the API base URL and keyword options, and returns the parsed JSON body. The path carries the query, which the object layer builds, so a client is never passed a `params:` of its own. `post` and `put` also take the request body as an optional second argument: a Hash, which the client sends as JSON, as `X::Client` does, or a String the client sends as it is. The object layer always passes `array_class: Array, object_class: Hash`, so a client's own parsing defaults can't change what it receives. `X::Client` from `x-core` satisfies this contract, which the `X::Objects::_Client` interface in [`sig/x-objects.rbs`](https://github.com/sferik/x-ruby/blob/main/x-objects/sig/x-objects.rbs) states for a type checker. + +`array_class:` and `object_class:` are the only keywords the object layer passes, but a release within 1.x may pass any other keyword `X::Client` takes for the same method, such as `params:` or `headers:`, so take the keywords with `**options`, as below, rather than name the two alone. + +Five more methods are asked for where they save a request, and a client that answers none of them is asked for none: + +| Method | What it is asked for | Without it | +| --- | --- | --- | +| `app_only` | a client that authenticates as the app, for the endpoints that refuse the OAuth 1.0a of a user, such as the spaces, counts, and usage endpoints | the request is made with the client itself, which the API refuses when it signs with OAuth 1.0a | +| `authenticator` | the `user_id` the authenticator names, since an OAuth 1.0a access token begins with the identifier of the user who authorized it | `current_user_id` looks the user up, which costs a request | +| `current_user_id` | the authenticated user of a check that either user may be, which `X::Objects::API` answers already | `user.follows?(other)` scans the users `user` follows rather than reading `connection_status` in one lookup | +| `memoized` | the value kept under a Symbol key, such as `:x_objects_current_user_id`, for the authenticator the client holds, or nil if none is kept | the value is read from the `@x_objects_current_user_id` instance variable of the client | +| `memoize` | to keep a value under a Symbol key, passed as `memoize(key, value)`, for the authenticator the client holds | the value is kept in the `@x_objects_current_user_id` instance variable of the client, or not at all when the client is frozen | + +The object layer reads the errors of `x-core`, so a client raises them for a request that fails: `X::Unauthorized` or `X::Forbidden` for credentials the API refuses, which `follows?` reads as a client that knows no authenticated user, and scans instead, and `X::UnsupportedOperation` from an `app_only` that holds no credentials of the app, which the space endpoints, the trends of a place, and the count of recent posts read as a client that requests as itself, since they take OAuth 2.0 as a user too, and which the count of the full archive and the usage of the project read so too, though they take the app alone, so the API refuses them with 403 Forbidden, which raises `X::Forbidden`. Any other error ends the call it was raised in. + +```ruby +require "x/objects" + +class MyClient + include X::Objects::API # adds find_user, find_user_by_username, find_user_by_id, find_all_users, current_user, current_user!, find_post, find_all_posts, search_posts, find_list, find_media, find_space, find_community, find_dm, follow, like, ... + + def get(path, **options) + # Send the request and return the response body, parsed into options[:array_class] and options[:object_class] + end + + def post(path, body = nil, **options) + # Send the body as JSON and return the response body, parsed as get parses it + end + + def put(path, body = nil, **options) + # Send the body as JSON and return the response body, parsed as get parses it + end + + def delete(path, **options) + # Send the request and return the response body, parsed as get parses it + end +end +``` + +You can also call the resource classes directly: + +```ruby +X::User.find("sferik", client:) +X::Post.find_all(ids, client:) +X::Post.search("ruby", client:) +``` + +## Building objects from any response + +`from_response` builds a resource from a parsed response, or an `X::Page` of them when its data is a list, which holds the `meta` of the response, such as its `next_token`, and the problems it reported. A lookup of several resources, such as `users?ids=`, that finds none of them builds an empty page of the problems it reported, as one that finds some builds a page of those. `X::Client` from `x-core` calls it when a resource class is the `object_class` of a request, passing the parsed body and itself: + +```ruby +user = client.get("users/by/username/sferik", object_class: X::User) +``` + +An object built this way is not hydrated, because the request may have asked for only some fields, so `hydrate` fetches the full resource. The lookups, batch lookups, and cursors in this gem request every field, so what they return is already hydrated, unless they are given a parameter that overrides a default field or expansion parameter to leave some of its values out, such as `"user.fields": "name"`: what they return then is not hydrated either, so `hydrate` fetches the rest. A parameter that asks for every default value, in any order, and for more besides, such as `"post.fields": [*X::Post::FIELDS, "non_public_metrics"]`, still returns hydrated resources, which keep the fields it added. + +The defaults are the `FIELDS` and `EXPANSIONS` of each class, and a minor release may add to them what the API adds, so that a lookup with the defaults asks for it too. A resource looked up with a list of your own, even one that named every field when it was written, then leaves out what was added, so it stops counting as hydrated, and `hydrate` costs a lookup it did not cost before, as does a resource written with `Marshal` or YAML by a release that asked for less. To ask for more than the defaults, add to what `default_params` gives, as the example above adds to `X::Post::FIELDS`, rather than list every value. + +A resource reports the problems of its response that concern it with `problems`: those whose `resource_id` or `value` is its identifier, or that of a resource it refers to directly, such as the author of a post, and those that name no identifier. A page of a cursor reports every problem of its response, and a finder yields every one to its block. + +## Text + +A resource reads every String as the API sends it, and the API escapes `&`, `<`, and `>` as `&`, `<`, and `>` in the text of a post and of a direct message, so `X::Post#text`, `X::Post#expanded_text`, and `X::DirectMessage#text` hold them, and will throughout 1.x. Unescape the text to display it: + +```ruby +require "cgi/escape" + +post.text # => "Ruby & Rails" +CGI.unescapeHTML(post.text) # => "Ruby & Rails" +``` + +## Nested data + +A reader of an object the API nests in a resource, or of a list of them, returns it as the API sends it: a frozen Hash keyed by String, or an Array of them, as `Hash[String, untyped]` in the signatures. A response that holds anything else in its place, such as a String where the API documents an object, raises `X::InvalidAttribute` from the reader. Among them are the `entities`, `urls`, `public_metrics`, `edit_controls`, `attachments`, and `withheld` of a post, the `variants` of media, the `options` of a poll, and the `subscription` and `affiliation` of a user. Others return objects, as `post.matching_rules` returns `X::MatchingRule`s and `space.topics` returns `X::Topic`s. + +```ruby +post.public_metrics["like_count"] # => 3, which post.like_count reads too +post.urls.map { |url| url["expanded_url"] } +media.variants.max_by { |variant| variant["bit_rate"].to_i }&.fetch("url") +``` + +Each reader of nested data returns a frozen Hash keyed by String, or an Array of them, throughout 1.x. A release within 1.x may add a reader that returns an object for some of that data, but under a new name, never in place of one of these. + +## How it works + +* **Immutability.** Resources, pages, and cursors are frozen, and so are the arrays a lookup returns, those a cursor reads with `to_a`, `entries`, `first`, `take`, and `ids`, and the `items` of a page, which its `to_a` and `entries` return. Their attributes are deep-frozen copies of the response. +* **Identity.** `==`, `eql?`, and `hash` compare class and ID. +* **Identity map.** Each response gets one map. A reference resolves to the included object when the response expanded it, or to a stub otherwise, and every reference to the same resource in that response is the same object. +* **Hydration.** `hydrate` fetches the full resource once per object, under a lock, and memoizes it. `refresh` replaces the memoized value. +* **Cursors.** Pages are fetched lazily under a lock and cached, so concurrent iteration fetches each page once. A page that names the token of a page already fetched as its next raises `X::UnreadableResponse`, rather than page forever. `refresh` returns a cursor with an empty cache. `prefetch` returns a cursor that fetches the next page in a background thread; a page the thread fails to fetch raises the error it failed with when it is reached, rather than being requested again. +* **Parallelism.** `find_all` splits IDs into batches of 100 and fetches the batches on up to 4 threads, or the `concurrency:` it is given, preserving order: it returns a resource for each ID that was found, so an ID given twice comes back twice, though it is asked for once. +* **Reading part of a collection.** `first`, `take`, `any?`, `none?`, `empty?`, and `one?` request pages no larger than they need, and answer from the pages the cursor already holds. A cursor has no `size`, so `each_slice` and `lazy` do not page a whole collection to measure it; `count` reads every page and `published_count` reads the number the API publishes without reading any. +* **Serialization.** Resources, problems, trends, the usage, the rules a post matched, and pages answer `as_json` and `to_json` with their attributes, as plain data, so nothing carries a client or its credentials into a cache or a log. A page answers in the shape of the response it came from, its resources as the `data`, beside its `meta` and, when there are any, its problems as the `errors`, so the `from_response` of its resource class builds it again, though with stubs for the objects the response included. A cursor raises `X::UnsupportedOperation` from both, and `TypeError` from `Marshal.dump` and `YAML.dump`, since serializing it would read, and bill, every page of its collection; serialize `cursor.first(10)`, or `cursor.to_a` for all of it. `Marshal` writes each of the others as plain data led by the number of its format, so that every release of 1.x reads what another wrote, a later one adding only what an earlier one ignores, and raises `X::UnsupportedFormat` for a format it does not read, and each of them writes the same plain data as YAML, each part under its name, since YAML would otherwise write every instance variable, the client among them, and read back unfrozen what was frozen. A resource carries its attributes, whether it is hydrated, which it is read back as only if the query of its request asks for every field and expansion of the release that reads it, and of the response it came from, the included objects it refers to, and those they refer to in turn, the problems about any of them, and the query; a page carries its resources, metadata, and problems, and what the resources of one response refer to once, so a reference they shared is shared again; and each is frozen when it is read back. What it reads back resolves the references its response included, but has no client, so a `hydrate` that would make a request, a collection, or an action of it raises `X::MissingClient`, an `X::Objects::Error`. + +## Development + +This gem has its own `Gemfile`, `Steepfile`, signatures, test suite, and mutation config. It uses the `x-core` in this repository, whose errors and problems it raises and reports, and does not load `x-uploader`: + + bundle install + bundle exec rake test + bundle exec rake mutant + bundle exec rake steep + bundle exec rake yardstick + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/x-objects/Rakefile b/x-objects/Rakefile new file mode 100644 index 00000000..705593c6 --- /dev/null +++ b/x-objects/Rakefile @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.pattern = "test/**/*_test.rb" +end + +desc "Run mutation tests" +task :mutant do + sh "bundle exec mutant run" +end + +# Steep is in the bundle of CRuby alone, where the steep job of CI runs, so the task is defined only where it can run +begin + require "steep/rake_task" +rescue LoadError + task(:steep) { abort "steep is not in the bundle of #{RUBY_ENGINE}; type check with CRuby" } +else + Steep::RakeTask.new(:steep) +end + +require "yardstick/rake/measurement" +require "yardstick/rake/verify" + +# Resource keeps from_id private, since it is no resource itself, and each class of resource makes it public, so it is +# documented as the public API it is there +YARDSTICK_OPTIONS = {"rules" => {"ApiTag::PrivateMethod" => {"exclude" => ["X::Resource.from_id"]}}}.freeze + +Yardstick::Rake::Measurement.new(:yardstick_measure, YARDSTICK_OPTIONS) do |measurement| + measurement.output = "doc/coverage.txt" +end + +Yardstick::Rake::Verify.new(:yardstick, YARDSTICK_OPTIONS) do |verify| + verify.threshold = 100 +end + +task default: %i[test mutant steep yardstick] diff --git a/x-objects/Steepfile b/x-objects/Steepfile new file mode 100644 index 00000000..ea334f20 --- /dev/null +++ b/x-objects/Steepfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Type checks x-objects against the signatures that x-core ships +target :lib do + signature "sig" + signature "../x-core/sig/x-core.rbs" + check "lib" + library "cgi-escape" + library "json" + library "monitor" + library "net-http" + library "openssl" + library "securerandom" + library "simple_oauth" + library "time" + library "uri" + library "zlib" + configure_code_diagnostics(Steep::Diagnostic::Ruby.strict) +end diff --git a/x-objects/lib/x/objects.rb b/x-objects/lib/x/objects.rb new file mode 100644 index 00000000..f51155a0 --- /dev/null +++ b/x-objects/lib/x/objects.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "x/core" +require_relative "objects/version" +require_relative "objects/errors" +require_relative "objects/api" +require_relative "objects/relationships" +require_relative "objects/bookmark_folder" +require_relative "objects/community" +require_relative "objects/cursor" +require_relative "objects/direct_message" +require_relative "objects/list" +require_relative "objects/media" +require_relative "objects/place" +require_relative "objects/poll" +require_relative "objects/post" +require_relative "objects/space" +require_relative "objects/topic" +require_relative "objects/trend" +require_relative "objects/personalized_trend" +require_relative "objects/user" +require_relative "objects/post_usage" diff --git a/x-objects/lib/x/objects/abstract_class.rb b/x-objects/lib/x/objects/abstract_class.rb new file mode 100644 index 00000000..ed8fb903 --- /dev/null +++ b/x-objects/lib/x/objects/abstract_class.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module X + module Objects + # Makes new, from_id, and from_response, which Resource keeps private, public on each class of resource + # + # Resource is the class each resource descends from, and is not a resource itself: one built of it has no endpoint + # to hydrate from, and raises UnsupportedOperation for it, so it builds none. + # + # @api private + module AbstractClass + # The class methods that build a resource, which a class of resource makes public + BUILDERS = %i[new from_id from_response].freeze + + private + + # Make the builders public on each class that descends from this one + # + # @api private + # @param subclass [Class] the class that descends from it + # @return [void] + def inherited(subclass) + super + subclass.public_class_method(BUILDERS) + end + end + private_constant :AbstractClass + end +end diff --git a/x-objects/lib/x/objects/actions.rb b/x-objects/lib/x/objects/actions.rb new file mode 100644 index 00000000..77798dcd --- /dev/null +++ b/x-objects/lib/x/objects/actions.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "actions/direct_messages" +require_relative "actions/engagement" +require_relative "actions/lists" +require_relative "actions/posts" +require_relative "actions/relationships" + +module X + module Objects + # Actions taken as the authenticated user, the modules of which X::Objects::API includes + # + # Internal to x-objects: a namespace of the modules API includes into a client, and not itself included, so that + # the modules it holds are not constants of the client, where the name of one, such as Media, would shadow a + # constant of the same name in a class that inherits from the client. Include API rather than any of them. + # + # @api private + module Actions + end + private_constant :Actions + end +end diff --git a/x-objects/lib/x/objects/actions/direct_messages.rb b/x-objects/lib/x/objects/actions/direct_messages.rb new file mode 100644 index 00000000..4241302c --- /dev/null +++ b/x-objects/lib/x/objects/actions/direct_messages.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative "../direct_message" + +module X + module Objects + module Actions + # Send and delete direct messages as the authenticated user + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module DirectMessages + # Send a direct message to a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the recipient or their identifier + # @param text [String, nil] the text of the message, or nil for a message of attachments alone + # @param params [Hash] additional request body fields, such as media_ids or attachments + # @option params [Array, String, Integer, #fetch, Media] :media_ids the + # identifiers or media keys of uploaded media to attach, what the uploads returned, or media, such as that of + # a post, one or many + # @return [DirectMessage] the sent message, holding only its identifiers + # @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and + # attachments + # @raise [MissingResource] if the API answers without the message + # @example Send a direct message + # client.create_direct_message(user, "Hello!") + # @example Send an image without text + # client.create_direct_message(user, media_ids: media) + def create_direct_message(user, text = nil, **params) # steep:ignore DifferentMethodParameterKind + DirectMessage.create(user, text, client: self, **params) + end + + # Start a group conversation, sending its first message as the authenticated user + # + # @api public + # @param users [Array] the other participants or their identifiers + # @param text [String, nil] the text of the first message, or nil for a message of attachments alone + # @param params [Hash] additional fields of the message, such as media_ids or attachments + # @option params [Array, String, Integer, #fetch, Media] :media_ids the + # identifiers or media keys of uploaded media to attach, what the uploads returned, or media, such as that of + # a post, one or many + # @return [DirectMessage] the sent message, holding only its identifiers, among them the conversation's + # @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and + # attachments + # @raise [MissingResource] if the API answers without the message + # @example Start a group conversation + # client.create_group_direct_message([alice, bob], "Hello, both of you!") + # @example Start a group conversation with an image + # client.create_group_direct_message([alice, bob], media_ids: media) + def create_group_direct_message(users, text = nil, **params) # steep:ignore DifferentMethodParameterKind + DirectMessage.create_group(users, text, client: self, **params) + end + + # Send a direct message to a conversation as the authenticated user + # + # The conversation can be one-to-one or a group. + # + # @api public + # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier + # @param text [String, nil] the text of the message, or nil for a message of attachments alone + # @param params [Hash] additional request body fields, such as media_ids or attachments + # @option params [Array, String, Integer, #fetch, Media] :media_ids the + # identifiers or media keys of uploaded media to attach, what the uploads returned, or media, such as that of + # a post, one or many + # @return [DirectMessage] the sent message, holding only its identifiers + # @raise [ArgumentError] if the conversation identifier is not one, the message has neither text nor any + # other field, or it has both media_ids and attachments + # @raise [MissingResource] if the API answers without the message + # @example Reply to the conversation of a message + # client.create_direct_message_in(message, "Sounds good") + # @example Reply with an image + # client.create_direct_message_in(message, media_ids: media) + def create_direct_message_in(conversation, text = nil, **params) # steep:ignore DifferentMethodParameterKind + DirectMessage.create_in(conversation, text, client: self, **params) + end + + # Delete a direct message event as the authenticated user + # + # @api public + # @param message [DirectMessage, String, Integer] the event or its identifier + # @return [Boolean] true if the event was deleted + # @example Delete a direct message + # client.delete_direct_message("1234567890") + def delete_direct_message(message) + DirectMessage.delete(message, client: self) + end + + alias_method :create_dm, :create_direct_message + alias_method :create_group_dm, :create_group_direct_message + alias_method :create_dm_in, :create_direct_message_in + alias_method :delete_dm, :delete_direct_message + end + end + end +end diff --git a/x-objects/lib/x/objects/actions/engagement.rb b/x-objects/lib/x/objects/actions/engagement.rb new file mode 100644 index 00000000..9330dd39 --- /dev/null +++ b/x-objects/lib/x/objects/actions/engagement.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative "../post" +require_relative "../relation_writes" +require_relative "../utils" + +module X + module Objects + module Actions + # Like, repost, and bookmark posts as the authenticated user + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Engagement + # Like a post as the authenticated user + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the authenticated user now likes the post + # @example Like a post + # client.like("1234567890") + def like(post) + RelationWrites.relate(self, current_user_id, "likes", {"tweet_id" => Utils.id_of(post, Post)}, "liked") + end + + # Unlike a post as the authenticated user + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the authenticated user no longer likes the post + # @example Unlike a post + # client.unlike("1234567890") + def unlike(post) + RelationWrites.unrelate(self, current_user_id, "likes", Utils.id_of(post, Post), "liked") + end + + # Repost a post as the authenticated user + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the authenticated user has reposted the post + # @example Repost a post + # client.repost("1234567890") + def repost(post) + RelationWrites.relate(self, current_user_id, "retweets", {"tweet_id" => Utils.id_of(post, Post)}, "retweeted") + end + + # Undo a repost as the authenticated user + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the authenticated user no longer reposts the post + # @example Undo a repost + # client.unrepost("1234567890") + def unrepost(post) + RelationWrites.unrelate(self, current_user_id, "retweets", Utils.id_of(post, Post), "retweeted") + end + + # Bookmark a post as the authenticated user + # + # Bookmarking a post takes only OAuth 2.0 user context, which the object layer cannot route around, so a + # client that signs with OAuth 1.0a is refused. + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the authenticated user has bookmarked the post + # @example Bookmark a post + # client.bookmark("1234567890") + def bookmark(post) + RelationWrites.relate(self, current_user_id, "bookmarks", {"tweet_id" => Utils.id_of(post, Post)}, "bookmarked") + end + + # Remove a bookmark as the authenticated user + # + # Removing a bookmark takes only OAuth 2.0 user context, which the object layer cannot route around, so a + # client that signs with OAuth 1.0a is refused. + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the authenticated user no longer has the post bookmarked + # @example Remove a bookmark + # client.unbookmark("1234567890") + def unbookmark(post) + RelationWrites.unrelate(self, current_user_id, "bookmarks", Utils.id_of(post, Post), "bookmarked") + end + + alias_method :retweet, :repost + alias_method :unretweet, :unrepost + end + end + end +end diff --git a/x-objects/lib/x/objects/actions/lists.rb b/x-objects/lib/x/objects/actions/lists.rb new file mode 100644 index 00000000..1a3676c5 --- /dev/null +++ b/x-objects/lib/x/objects/actions/lists.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require_relative "../list" +require_relative "../relation_writes" +require_relative "../user" +require_relative "../utils" + +module X + module Objects + module Actions + # Create, update, delete, follow, and pin lists as the authenticated user + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Lists + # Create a list owned by the authenticated user + # + # @api public + # @param name [String] the name of the list + # @param params [Hash] additional request body fields: description and private + # @return [List] the created list, holding only its identifier and name + # @raise [MissingResource] if the API answers without the list + # @example Create a private list + # client.create_list("Rubyists", private: true) + def create_list(name, **params) + List.create(name, client: self, **params) + end + + # Update the name, description, or privacy of a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @param params [Hash] the request body fields to change: name, description, and private + # @return [Boolean] true if the list was updated + # @raise [ArgumentError] if no field is given to change, before any request + # @example Make a list private + # client.update_list("1234567890", private: true) + def update_list(list, **params) + List.update(list, client: self, **params) + end + + # Delete a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @return [Boolean] true if the list was deleted + # @example Delete a list + # client.delete_list("1234567890") + def delete_list(list) + List.delete(list, client: self) + end + + # Add a member to a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the user is now a member + # @example Add a member to a list + # client.add_list_member("1234567890", user) + def add_list_member(list, user) + List.from_id(list, client: self).add_member(user) + end + + # Remove a member from a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the user is no longer a member + # @example Remove a member from a list + # client.remove_list_member("1234567890", user) + def remove_list_member(list, user) + List.from_id(list, client: self).remove_member(user) + end + + # Follow a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @return [Boolean] true if the authenticated user now follows the list + # @example Follow a list + # client.follow_list("1234567890") + def follow_list(list) + RelationWrites.relate(self, current_user_id, "followed_lists", {"list_id" => Utils.id_of(list, List)}, "following") + end + + # Unfollow a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @return [Boolean] true if the authenticated user no longer follows the list + # @example Unfollow a list + # client.unfollow_list("1234567890") + def unfollow_list(list) + RelationWrites.unrelate(self, current_user_id, "followed_lists", Utils.id_of(list, List), "following") + end + + # Pin a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @return [Boolean] true if the authenticated user has pinned the list + # @example Pin a list + # client.pin_list("1234567890") + def pin_list(list) + RelationWrites.relate(self, current_user_id, "pinned_lists", {"list_id" => Utils.id_of(list, List)}, "pinned") + end + + # Unpin a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @return [Boolean] true if the authenticated user no longer has the list pinned + # @example Unpin a list + # client.unpin_list("1234567890") + def unpin_list(list) + RelationWrites.unrelate(self, current_user_id, "pinned_lists", Utils.id_of(list, List), "pinned") + end + end + end + end +end diff --git a/x-objects/lib/x/objects/actions/posts.rb b/x-objects/lib/x/objects/actions/posts.rb new file mode 100644 index 00000000..dcd718b5 --- /dev/null +++ b/x-objects/lib/x/objects/actions/posts.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "../post" + +module X + module Objects + module Actions + # Create and delete posts as the authenticated user + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Posts + # Create a post as the authenticated user + # + # The API bills each post created, and bills a post whose text holds a URL more than ten times as much. + # A post needs no text when it has something else to show, such as media. + # + # @api public + # @param text [String, nil] the text of the post, or nil for a post without text, such as one of media alone + # @param params [Hash] additional request body fields, such as reply_to, quote, media_ids, or poll + # @return [Post] the created post, holding only its identifier and text + # @raise [ArgumentError] if the post has neither text nor any other field + # @raise [MissingResource] if the API answers without the post + # @example Create a post + # client.create_post("Hello, World!") + # @example Post an image without text + # client.create_post(media_ids: [media]) + # @example Reply to a post with an image + # client.create_post("Hello!", reply_to: post, media_ids: [media["id"]]) + # @example Quote a post + # client.create_post("Worth reading", quote: post) + def create_post(text = nil, **params) # steep:ignore DifferentMethodParameterKind + Post.create(text, client: self, **params) + end + + # Delete a post as the authenticated user + # + # @api public + # @param post [Post, String, Integer] the post or its identifier + # @return [Boolean] true if the post was deleted + # @example Delete a post + # client.delete_post("1234567890") + def delete_post(post) + Post.delete(post, client: self) + end + + # Hide a reply to a post of the authenticated user + # + # @api public + # @param post [Post, String, Integer] the reply or its identifier + # @return [Boolean] true if the reply is now hidden + # @example Hide a reply + # client.hide_reply("1234567890") + def hide_reply(post) + Post.hide_reply(post, client: self) + end + + # Show a reply to a post of the authenticated user after hiding it + # + # @api public + # @param post [Post, String, Integer] the reply or its identifier + # @return [Boolean] true if the reply is no longer hidden + # @example Show a hidden reply + # client.unhide_reply("1234567890") + def unhide_reply(post) + Post.unhide_reply(post, client: self) + end + + alias_method :create_tweet, :create_post + alias_method :delete_tweet, :delete_post + end + end + end +end diff --git a/x-objects/lib/x/objects/actions/relationships.rb b/x-objects/lib/x/objects/actions/relationships.rb new file mode 100644 index 00000000..19f5928b --- /dev/null +++ b/x-objects/lib/x/objects/actions/relationships.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "../relation_writes" +require_relative "../user" +require_relative "../utils" + +module X + module Objects + module Actions + # Follow, block, and mute users as the authenticated user + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Relationships + # Follow a user as the authenticated user + # + # A protected user must accept a request to follow them first, so for a protected user true means the follow + # was requested, not that the authenticated user follows them: until they accept it, the follows? of the + # authenticated user, as in client.current_user!.follows?(user), answers false. + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the authenticated user now follows the user, or, for a protected user, has requested + # to follow them + # @example Follow a user + # client.follow("7505382") + def follow(user) + RelationWrites.relate(self, current_user_id, "following", {"target_user_id" => Utils.id_of(user, User)}, "following", "pending_follow") + end + + # Unfollow a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the authenticated user no longer follows the user + # @example Unfollow a user + # client.unfollow("7505382") + def unfollow(user) + RelationWrites.unrelate(self, current_user_id, "following", Utils.id_of(user, User), "following") + end + + # Block a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the authenticated user now blocks the user + # @example Block a user + # client.block("7505382") + def block(user) + RelationWrites.relate(self, current_user_id, "blocking", {"target_user_id" => Utils.id_of(user, User)}, "blocking") + end + + # Unblock a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the authenticated user no longer blocks the user + # @example Unblock a user + # client.unblock("7505382") + def unblock(user) + RelationWrites.unrelate(self, current_user_id, "blocking", Utils.id_of(user, User), "blocking") + end + + # Mute a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the authenticated user now mutes the user + # @example Mute a user + # client.mute("7505382") + def mute(user) + RelationWrites.relate(self, current_user_id, "muting", {"target_user_id" => Utils.id_of(user, User)}, "muting") + end + + # Unmute a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the authenticated user no longer mutes the user + # @example Unmute a user + # client.unmute("7505382") + def unmute(user) + RelationWrites.unrelate(self, current_user_id, "muting", Utils.id_of(user, User), "muting") + end + end + end + end +end diff --git a/x-objects/lib/x/objects/api.rb b/x-objects/lib/x/objects/api.rb new file mode 100644 index 00000000..0f502593 --- /dev/null +++ b/x-objects/lib/x/objects/api.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "actions" +require_relative "lookups" + +module X + module Objects + # The resource methods mixed into a client that responds to get, post, put, and delete + # + # The x gem includes it into X::Client. With x-core and x-objects alone, or with a client of your own, + # include it yourself: X::Client.include(X::Objects::API). + # + # It is the one module of the object layer to include. The modules it includes, those of Lookups and Actions, + # are internal: their methods are public API of the client that includes API, but how they are grouped can change + # within 1.x, and some need the methods of another, such as current_user_id. So are the modules the resource + # classes extend and include, such as Finders and Relationships: the methods they give a resource class are public + # API, the modules are not. + # + # Neither API nor a module it includes holds a constant, as a constant of a module is a constant of each class + # that includes it: a class that inherits from the client and names Media, or Users, reads the constant the name + # reads anywhere else, not a module of the object layer. + # + # @api public + module API + include Lookups::Users + include Lookups::Posts + include Lookups::Lists + include Lookups::Media + include Lookups::Spaces + include Lookups::Communities + include Lookups::DirectMessages + include Lookups::Trends + include Actions::Posts + include Actions::Lists + include Actions::DirectMessages + include Actions::Relationships + include Actions::Engagement + end + end +end diff --git a/x-objects/lib/x/objects/attributes.rb b/x-objects/lib/x/objects/attributes.rb new file mode 100644 index 00000000..e642b072 --- /dev/null +++ b/x-objects/lib/x/objects/attributes.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require_relative "shape" +require_relative "utils" + +module X + module Objects + # Class-level macros for declaring resource attributes and references + # @api private + module Attributes + # The value of a list the response omitted, which reads as empty, as a list of references does, rather than nil + EMPTY_LIST = [] #: Array[untyped] + EMPTY_LIST.freeze + private_constant :EMPTY_LIST + # The values a flag is read from, which the API gives as true or false, and a response that omits it as nil + FLAGS = [true, false, nil].freeze + private_constant :FLAGS + # Converters keyed by attribute type + CONVERTERS = { + raw: ->(value) { value }, + media_key: ->(value) { value }, + conversation_id: ->(value) { Shape.conversation_id(value) }, + boolean: ->(value) { FLAGS.include?(value) ? value : raise(ArgumentError, "#{value.inspect} is not true or false") }, + time: ->(value) { Utils.time(value) }, + integer: ->(value) { Shape.integer(value) }, + integers: ->(value) { (Shape.list(value) || EMPTY_LIST).map { |id| Shape.integer(id) }.freeze }, + list: ->(value) { Shape.list(value) || EMPTY_LIST }, + object: ->(value) { Shape.object(value) }, + objects: ->(value) { Shape.object_list(value) }, + range: ->(value) { Shape.range(value) }, + requested_list: ->(value) { Shape.list(value) } + }.freeze + + # The names of the attributes declared on this class, which pattern matching reads + # + # @api private + # @return [Array] the attribute names + # @example Get the attributes of a user + # X::User.__send__(:attribute_names) + def attribute_names + parent = superclass + @attribute_names ||= parent.respond_to?(:attribute_names, true) ? parent.__send__(:attribute_names).dup : [:id] + end + + # The other names some attributes are read by, which a pattern can ask for + # + # @api private + # @return [Array] the alias names + # @example Get the attribute aliases of a user + # X::User.__send__(:attribute_aliases) # => [:tweet_count, :pinned_tweet_id, :most_recent_tweet_id] + def attribute_aliases + parent = superclass + @attribute_aliases ||= parent.respond_to?(:attribute_aliases, true) ? parent.__send__(:attribute_aliases).dup : [] + end + + # The key paths of the identifiers of the resources this class refers to + # + # The problems of a resource are read by them. A key path holds an identifier, a list of them, or a list of objects that each hold one as id, as the + # referenced_posts of a post do. + # + # @api private + # @return [Array>] the key paths + # @example Get the key paths of the references of a list + # X::List.__send__(:reference_keys) # => [["owner_id"]] + def reference_keys + parent = superclass + @reference_keys ||= parent.respond_to?(:reference_keys, true) ? parent.__send__(:reference_keys).dup : [] + end + + # The identifiers of the resources that attributes of this class refer to + # + # A problem is read to tell which resource it is about, so a key path the attributes hold something other than + # an object along reads as no identifier, rather than raise as the reader of the reference does. + # + # @api private + # @param attrs [Hash{String => Object}] the attributes + # @return [Array] the identifiers, as the attributes hold them + # @example Get the identifiers a post refers to + # X::Post.__send__(:referenced_ids, {"id" => "1", "author_id" => "2"}) # => ["2"] + def referenced_ids(attrs) = reference_keys.flat_map { |path| ids_at(attrs, path) } + + private :attribute_names, :attribute_aliases, :reference_keys, :referenced_ids + + private + + # Define another name for an attribute, which a pattern can ask for + # + # @api private + # @param name [Symbol] the alias name + # @param original [Symbol] the name of the attribute + # @return [void] + def attribute_alias(name, original) + attribute_aliases << name + alias_method name, original + end + + # Define a reader for an attribute + # + # @api private + # @param name [Symbol] the reader name + # @param type [Symbol] the attribute type: raw, boolean, time, integer, integers, list, object, objects, range, + # conversation_id, or requested_list, of which integers, list, and objects read a list the response omitted as empty, and + # requested_list, the type of a field no lookup asks for unless it is requested, reads it as nil, since a + # response that omits it does not say the list is empty; object is the type of an object the API nests in a + # resource, and objects of a list of them + # @param key [Array] the key path + # @param tweet_key [Array, nil] the key path of the name the API gave the attribute before it named + # tweets posts, which it still gives it where it has not renamed it, such as in a stream, read when the + # response holds nothing at the key path + # @return [void] + # @raise [InvalidAttribute] from the reader, if the response holds a value the type cannot be read from + def attribute(name, type = :raw, key: [name.to_s], tweet_key: nil) + attribute_names << name + paths = key_paths(key, tweet_key) + converter = CONVERTERS.fetch(type) + define_method(name) do + # @type self: Resource + Utils.read("#{self.class}##{name}", Shape.dig_first("#{self.class}##{name}", attrs, paths)) { |value| converter.call(value) } + end + return unless type.eql?(:boolean) + + define_method(:"#{name}?") do + # @type self: Resource + public_send(name).eql?(true) + end + end + + # Define a reader that resolves a referenced resource + # + # @api private + # @param name [Symbol] the reader name + # @param klass_name [Symbol] the referenced resource class name under X + # @param key [Array] the key path holding the identifier + # @param tweet_key [Array, nil] the key path of the name the API gave it before it named tweets posts, + # read when the response holds nothing at the key path + # @return [void] + # @raise [InvalidAttribute] from the reader, if the response holds an identifier that is not one, or a key path + # that passes through something other than an object + def reference(name, klass_name, key:, tweet_key: nil) + paths = key_paths(key, tweet_key) + reference_keys.concat(paths) + define_method(name) do + # @type self: Resource + resolve(X.const_get(klass_name), Shape.dig_first("#{self.class}##{name}", attrs, paths)) + end + end + + # Define a reader that resolves a list of referenced resources + # + # @api private + # @param name [Symbol] the reader name + # @param klass_name [Symbol] the referenced resource class name under X + # @param key [Array] the key path holding the identifiers + # @return [void] + # @raise [InvalidAttribute] from the reader, if the response holds something other than a list of identifiers + def references(name, klass_name, key:) + path = key_path(key) + reference_keys << path + define_method(name) do + # @type self: Resource + reader = "#{self.class}##{name}" + ids = Utils.read(reader, Shape.dig(reader, attrs, path)) { |value| Shape.list(value) } || EMPTY_LIST + ids.map { |id| resolve(X.const_get(klass_name), id) }.freeze + end + end + + # Check that a key path is an array of keys + # + # @api private + # @param key [Object] the key path to check + # @return [Array] the key path + # @raise [ArgumentError] if the key path is not an array + def key_path(key) + raise ArgumentError, "key must be an Array of keys, not #{key.inspect}" unless key.is_a?(Array) + + key + end + + # The key paths an attribute is read at, in the order they are tried + # + # @api private + # @param key [Object] the key path + # @param tweet_key [Object, nil] the key path of the name the API gave it before it named tweets posts, if any + # @return [Array>] the key paths + # @raise [ArgumentError] if a key path is not an array + def key_paths(key, tweet_key) = [key, tweet_key].compact.each { |path| key_path(path) } + + # The identifiers at a key path + # + # A key path holds an identifier, a list of them, or a list of objects that each hold one as id. + # + # @api private + # @param attrs [Hash{String => Object}] the attributes + # @param path [Array] the key path + # @return [Array] the identifiers, with nil for a path that holds none + def ids_at(attrs, path) + found = path.reduce(attrs) { |value, key| Hash.try_convert(value)&.[](key) } + (Array.try_convert(found) || [found]).map { |element| Hash.try_convert(element)&.[]("id") || element } + end + end + private_constant :Attributes + end +end diff --git a/x-objects/lib/x/objects/batch.rb b/x-objects/lib/x/objects/batch.rb new file mode 100644 index 00000000..fd8af417 --- /dev/null +++ b/x-objects/lib/x/objects/batch.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "memo" + +module X + module Objects + # A group of stubs, no more than one lookup takes, that hydrate together in one request rather than one each + # @api private + class Batch + # Initialize a batch over some identifiers + # + # @api private + # @param klass [Class] the class of the resources, which BatchFinders extends + # @param ids [Array] the identifiers + # @param client [Object] the client used to make the requests + # @return [Batch] a new batch + def initialize(klass, ids, client:) + @klass = klass + @ids = ids + @client = client + @memo = Memo.new + freeze + end + + # The resource of one identifier, looking up every identifier of the batch at once + # + # @api private + # @param id [String, Integer] the identifier + # @return [Resource, nil] the resource, or nil if it was not found + def fetch(id) = resources[id] + + private + + # The resources of the batch, keyed by identifier + # @api private + # @return [Hash{Object => Resource}] the resources + def resources + @memo.fetch { @klass.find_all(@ids, client: @client).to_h { |resource| [resource.id, resource] } } + end + end + private_constant :Batch + end +end diff --git a/x-objects/lib/x/objects/batch_finders.rb b/x-objects/lib/x/objects/batch_finders.rb new file mode 100644 index 00000000..f7a3ee10 --- /dev/null +++ b/x-objects/lib/x/objects/batch_finders.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require_relative "finders" +require_relative "parallel" +require_relative "utils" + +module X + module Objects + # Class methods that look resources up many at a time, extended into each resource class the API can look up so + # + # A resource the API looks up only one at a time, such as a list, a community, or a direct message event, extends + # Finders alone, and so answers neither find_all nor hydrate_all, rather than answer them only to raise. + # + # Internal to x-objects: the methods it gives a resource class, such as X::Post.find_all, are public API, but the + # module is only how they are shared, and which classes extend or include it can change within 1.x. + # + # @api private + module BatchFinders + include Finders + + # Maximum number of identifiers accepted by a batch lookup endpoint + MAX_BATCH_SIZE = 100 + + # Default number of batch lookups a request makes at once, which matches the chunks an upload sends at once + DEFAULT_CONCURRENCY = 4 + + # The message of the error raised for a concurrency that would look nothing up + INVALID_CONCURRENCY = "concurrency must be an Integer of at least 1, not %s" + private_constant :INVALID_CONCURRENCY + + # The message of the error raised for resources to hydrate that are not of the class hydrating them + FOREIGN_RESOURCE = "%s.hydrate_all hydrates %s resources, not %s" + private_constant :FOREIGN_RESOURCE + + # Replace the resources that are not hydrated with the full resources + # + # A resource that hydrate would look up is looked up, which is a stub and also a resource a response included + # without every field, so what comes back is hydrated throughout. A resource that was not found is dropped, as + # is nil, which a reference to no resource reads as, such as the author of a post whose response named none, + # and a hydrated resource is kept as it is, so resources that are all hydrated need no lookup. What was found is + # stored in each original, so hydrating one of them afterwards costs no request, unless params override a + # default field or expansion parameter: what such a lookup found is not the full resource, so it is returned + # without being stored, and hydrating an original fetches the full resource. A resource that holds what hydrate + # returns, because hydrate or an earlier hydrate_all stored it, is replaced with that, and looked up again by no + # request, since the API bills each resource a lookup returns. + # + # @api public + # @param resources [Array] the resources, some of which may not be hydrated, and some nil + # @param client [Object] the client used to make the requests + # @param concurrency [Integer] the number of batch lookups made at once, which must be at least one + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Array] the resources, in order, with the ones that were not hydrated replaced, frozen + # @raise [ArgumentError] if the concurrency is less than one + # @raise [ArgumentError] if a resource is not of this class, which a lookup of its identifier would find + # another resource for, before a request + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up every field of the authors of posts, the ones a search included among them + # X::User.hydrate_all(posts.map(&:author), client: client) + # @example Expand only the authors a search did not include, which the API bills for alone + # X::User.hydrate_all(posts.filter_map(&:author).select(&:stub?), client: client) + def hydrate_all(resources, client:, concurrency: DEFAULT_CONCURRENCY, **params, &) + resources = resources.compact + validate_class!(resources) + partial = resources.reject { |resource| settled?(resource) } + replace = replacer(find_all(partial, client:, concurrency:, **params, &), params) + resources.filter_map { |resource| settled?(resource) ? resource.hydrate : replace.call(resource) }.freeze + end + + # Look up many resources by identifier, in parallel batches + # + # The resources come back in the order of the identifiers they were asked for by, whatever order the batches + # were answered in, one for each identifier that was found, so an identifier asked for twice comes back twice, + # as hydrate_all keeps a resource it is given twice. Each identifier is asked for once, however often it is + # given. + # + # @api public + # @param ids [Array] the identifiers, or resources of this class + # @param client [Object] the client used to make the requests + # @param concurrency [Integer] the number of batches looked up at once, which must be at least one; each is a + # request of up to MAX_BATCH_SIZE identifiers, so a lower number spends a rate limit more slowly + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Array] the resources that were found, one for each identifier that names one, frozen + # @raise [ArgumentError] if the concurrency is less than one + # @raise [ArgumentError] if an identifier is not one, or is a resource of another class, before a request + # @yieldparam problem [Problem] each problem the API reported, such as an identifier that was not found + # @example Look up many posts by identifier, reporting the ones that were not found + # X::Post.find_all([1234567890, 1234567891], client: client) { |problem| warn problem.detail } + # @example Look up many posts one batch at a time, to spend a rate limit more slowly + # X::Post.find_all(ids, client: client, concurrency: 1) + def find_all(ids, client:, concurrency: DEFAULT_CONCURRENCY, **params, &) + ids = ids.map { |id| Utils.id_of(id, self) } + in_order_of(lookup_in_batches(endpoint!, batch_key, ids, client:, concurrency:, **params, &), ids) + end + + private + + # What replaces each resource that is not hydrated, from what a lookup found + # + # A lookup of every field found the full resource, which is stored in the original, as is a resource it did not + # find. A lookup of some fields found what is returned in its place, and nothing is stored. + # + # @api private + # @param found [Array] the resources the lookup found + # @param params [Hash] the query parameters the lookup was given, merged over the default parameters + # @return [Proc] a callable passed a resource that is not hydrated, which returns what replaces it, or nil + def replacer(found, params) + by_id = found.to_h { |resource| [resource.id, resource] } + return ->(resource) { by_id[resource.id] } unless fully_requested_by?(Utils.merge_params(default_params, params)) + + ->(resource) { resource.__send__(:hydrated_with, by_id[resource.id]) } + end + + # Look up values in parallel batches, asking for each value once + # @api private + # @param path [String] the batch lookup endpoint path + # @param key [Symbol] the query parameter the values go in, such as ids or usernames + # @param values [Array] the values + # @param client [Object] the client used to make the requests + # @param concurrency [Integer] the number of batches looked up at once + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Array] the resources that were found, in the order the batches were answered in + # @raise [ArgumentError] if the concurrency is less than one + # @yieldparam problem [Problem] each problem the responses reported + def lookup_in_batches(path, key, values, client:, concurrency:, **params, &) + validate_concurrency!(concurrency) + query = Utils.merge_params(default_params, params) + bodies = Parallel.map(values.uniq.each_slice(MAX_BATCH_SIZE), concurrency:) { |batch| get(path, client:, query: query.merge(Utils.query(key => batch))) } + bodies.flat_map { |body| collection_built_from(reporting(body, &), client:, hydrated: fully_requested_by?(query), query:) } + end + + # Order resources as the identifiers that name them, one for each identifier + # + # An identifier is read as the resource reads its own, so that one asked for as a String of digits matches the + # Integer of the resource. + # + # @api private + # @param resources [Array] the resources found + # @param ids [Array] the identifiers asked for + # @return [Array] the resource each identifier names, in the order of the identifiers, frozen + def in_order_of(resources, ids) + convert = Attributes::CONVERTERS.fetch(id_type) + by_id = resources.to_h { |resource| [resource.id, resource] } + ids.filter_map { |id| by_id[convert.call(id)] }.freeze + end + + # Check whether hydrating a resource costs no request + # @api private + # @param resource [Resource] the resource + # @return [Boolean] true if the resource is hydrated, or holds what hydrate returns + def settled?(resource) = resource.hydrated? || resource.__send__(:hydration_stored?) + + # Check that a number of batches to look up at once is an Integer of at least one + # + # Anything that is not an Integer, such as a String read from an environment variable, raises ArgumentError too, + # rather than NoMethodError from the check. + # + # @api private + # @param concurrency [Integer] the number of batches looked up at once + # @return [void] + # @raise [ArgumentError] if the concurrency is not an Integer, or is less than one + def validate_concurrency!(concurrency) + raise ArgumentError, format(INVALID_CONCURRENCY, concurrency.inspect) unless concurrency.instance_of?(Integer) && concurrency.positive? + end + + # Check that resources to hydrate are of this class, whose endpoint looks them up + # @api private + # @param resources [Array] the resources + # @return [void] + # @raise [ArgumentError] if a resource is not of this class + def validate_class!(resources) + foreign = resources.grep_v(self).first + raise ArgumentError, format(FOREIGN_RESOURCE, self, self, foreign.class) unless foreign.nil? + end + end + private_constant :BatchFinders + end +end diff --git a/x-objects/lib/x/objects/bookmark_folder.rb b/x-objects/lib/x/objects/bookmark_folder.rb new file mode 100644 index 00000000..3e1dec81 --- /dev/null +++ b/x-objects/lib/x/objects/bookmark_folder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "resource" + +module X + # A folder the authenticated user keeps bookmarks in + # + # The API offers no lookup of a folder, so a folder is read from the bookmark folders of the user it belongs to, + # which X::User#bookmark_folders pages through, and the class answers no finder. from_id builds one from its + # identifier, which X::User#bookmarks takes as the folder to read the posts of, but hydrate and refresh raise + # UnsupportedOperation for one that is not hydrated, since there is nothing to look it up with. + # + # @api public + class BookmarkFolder < Resource + # @!attribute [r] name + # The name of the folder + # @api public + # @return [String, nil] the name + # @example Get the name + # folder.name # => "Ruby" + attribute :name + end +end diff --git a/x-objects/lib/x/objects/community.rb b/x-objects/lib/x/objects/community.rb new file mode 100644 index 00000000..e275fb67 --- /dev/null +++ b/x-objects/lib/x/objects/community.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "uri" +require_relative "cursor" +require_relative "finders" +require_relative "resource" + +module X + module Objects + # A community of users who post to one another + # @api public + class ::X::Community < Resource + extend Finders + + # Every public community field + # + # A minor release may add to it the fields the API adds, so that a lookup asks for them too; see + # {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own. + FIELDS = %w[access created_at description id join_policy member_count name].freeze + # Maximum number of communities per page of a search + MAX_RESULTS = 100 + private_constant :MAX_RESULTS + + class << self + # The API endpoint used to look up communities by identifier + # + # @api private + # @return [String] the endpoint + # @example Get the endpoint + # X::Community.__send__(:endpoint) # => "communities" + def endpoint = "communities" + + # The query parameter that selects community fields + # + # @api private + # @return [String] the fields parameter + # @example Get the fields parameter + # X::Community.__send__(:fields_key) # => "community.fields" + def fields_key = "community.fields" + + private :endpoint, :fields_key + + # The default query parameters requesting every community field + # + # @api public + # @return [Hash{String => Array}] the default query parameters + # @example Get the default parameters + # X::Community.default_params["community.fields"] + def default_params = {"community.fields" => FIELDS} + + # Search communities + # + # @api public + # @param query [String] the search query + # @param client [Object] the client used to make the requests + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the matching communities + # @example Print the communities matching a query + # X::Community.search("ruby", client: client).each { |community| puts community.name } + def search(query, client:, **params) + Cursor.__send__(:build, self, "communities/search", client:, params: {query:, max_results: MAX_RESULTS}.merge(params), + token_param: "next_token", min_results: 10) + end + end + + # @!attribute [r] name + # The name + # @api public + # @return [String, nil] the name + # @example Get the name + # community.name + attribute :name + + # @!attribute [r] description + # The description + # @api public + # @return [String, nil] the description + # @example Get the description + # community.description + attribute :description + + # @!attribute [r] created_at + # The creation time + # @api public + # @return [Time, nil] the creation time + # @example Get the creation time + # community.created_at + attribute :created_at, :time + + # @!attribute [r] member_count + # The number of members + # @api public + # @return [Integer, nil] the member count + # @example Get the member count + # community.member_count + attribute :member_count, :integer + + # @!attribute [r] access + # Who can see the community's posts, as the API names it + # @api public + # @return [String, nil] the access level + # @example Get the access level + # community.access + attribute :access + + # @!attribute [r] join_policy + # How users become members, as the API names it + # @api public + # @return [String, nil] the join policy + # @example Get the join policy + # community.join_policy + attribute :join_policy + + # The permalink of the community + # + # @api public + # @return [String] the x.com address of the community + # @example Get the permalink + # community.permalink # => "https://x.com/i/communities/1234567890" + def permalink = "https://x.com/i/communities/#{id}" + + # The permalink of the community as a URI + # + # @api public + # @return [URI::Generic] the x.com address of the community + # @example Get the address as a URI + # community.uri # => # + def uri = URI(permalink) + end + end +end diff --git a/x-objects/lib/x/objects/cursor.rb b/x-objects/lib/x/objects/cursor.rb new file mode 100644 index 00000000..9ce63645 --- /dev/null +++ b/x-objects/lib/x/objects/cursor.rb @@ -0,0 +1,491 @@ +# frozen_string_literal: true + +require_relative "memo" +require_relative "page" +require_relative "pages" +require_relative "utils" + +module X + module Objects + # A lazily paginated, cached, thread-safe collection of resources + # @api public + class ::X::Cursor + include Enumerable + + # The query parameter most endpoints take the token of the next page in + DEFAULT_TOKEN_PARAM = "pagination_token" + private_constant :DEFAULT_TOKEN_PARAM + # The message raised for a cursor serialized whole, which would read every page of its collection + SERIALIZATION_MESSAGE = "Serializing a cursor would read every page of its collection, a billed request per " \ + "page; serialize cursor.first(n), or cursor.to_a to read every page" + private_constant :SERIALIZATION_MESSAGE + + # The class of the resources in this collection + # @api public + # @return [Class] the resource class + # @example Get the resource class + # user.followers.resource_class # => X::User + attr_reader :resource_class + + # The client the resources hold, which also fetches the pages + # + # The pages of an endpoint that refuses OAuth 1.0a, such as the posts of a space, are fetched with the app-only + # client of a client that signs with it, while the resources hold the client itself. + # + # @api public + # @return [Object] the client + # @example Get the client + # cursor.client + attr_reader :client + + # Build a cursor + # + # Internal to x-objects: a resource builds the cursors of its collections, and the searches and lookups build + # theirs, with it, and new is private, so that the settings a cursor pages with can change within 1.x, as the + # readers of the token parameter, the smallest page, and whether the pages are fetched as the app are private for + # the same reason. A cursor is made from another with refresh, prefetch, and stubs. + # + # @api private + # @param resource_class [Class] the class of the resources in the collection + # @param path [String] the endpoint path + # @param client [Object] the client the resources hold, which fetches the pages unless the endpoint takes + # app-only authentication + # @param params [Hash] query parameters merged over the resource class's default parameters + # @param prefetch [Boolean] whether to fetch the next page in a background thread while the current page is consumed + # @param token_param [String] the query parameter the token of the next page is sent in + # @param min_results [Integer] the smallest page the endpoint accepts, which first never asks below + # @param app_only [Boolean] whether the pages are fetched with the app-only client of the client, for an endpoint + # that refuses the OAuth 1.0a of a user, while the resources hold the client, so that they act as the user + # @param total [Proc, nil] a block returning the number of resources the API publishes for the collection, which + # reads it again when given fresh: true + # @param ids_only [Boolean] whether the endpoint gives the resources by their identifiers alone, and takes none of + # their fields, so the pages ask for none of the default parameters, and read stubs that hydrate together + # @return [Cursor] a new cursor + # @example Build a cursor over the followers of a user, which counts them with followers_count + # X::Cursor.__send__(:build, X::User, "users/7505382/followers", client: client, total: ->(fresh: false) { 42 }) + # @example Build a cursor over an endpoint that pages with next_token + # X::Cursor.__send__(:build, X::User, "users/search", client: client, params: {query: "ruby"}, token_param: "next_token") + def self.build(resource_class, path, client:, params: {}, prefetch: false, token_param: DEFAULT_TOKEN_PARAM, min_results: 1, app_only: false, total: nil, ids_only: false) + allocate.tap { |cursor| cursor.__send__(:setup, resource_class, path, client:, params:, prefetch:, token_param:, min_results:, app_only:, total:, ids_only:) } + end + private_class_method :new, :build + + # Check whether the next page is fetched in the background + # + # @api public + # @return [Boolean] true if pages are prefetched + # @example Check whether a cursor prefetches + # cursor.prefetch? # => false + def prefetch? = @prefetch + + # Iterate over every resource, fetching pages as needed + # + # @api public + # @yield [Resource] each resource + # @return [Enumerator, Cursor] an enumerator without a block, otherwise self + # @example Print every follower + # user.followers.each { |follower| puts follower.username } + def each(&block) + return to_enum unless block + + each_page { |page| page.each(&block) } + end + + # Iterate over every page, fetching pages as needed + # + # The pages are those {#page} reads, as they were fetched, so a page need not hold as many resources as the + # largest page the endpoint allows. + # + # @api public + # @yield [Page] each page + # @return [Enumerator, Cursor] an enumerator without a block, otherwise self + # @example Print the size of every page + # user.followers.each_page { |page| puts page.result_count } + def each_page + return to_enum(:each_page) unless block_given? + + index = 0 + while (current = page(index)) + yield current + index += 1 + end + self + end + + # Fetch a page by index, using the cache when possible + # + # The pages before the one asked for are read first, since the token of each asks for the next. A page is the + # page as it was fetched and kept, whatever fetched it: iterating fetches the largest page the endpoint allows, + # but first, take, any?, and empty? fetch pages no larger than they need, which the cursor keeps too, so that + # an iteration after them does not pay again for what they read. After user.followers.first, the first page + # holds one follower, and the pages an iteration fetches after it as many as the largest page does. The API may + # serve a page with fewer resources than it was asked for, or none, so no page has a size to rely on. + # + # @api public + # @param index [Integer] the zero-based page index + # @return [Page, nil] the page or nil if the collection has fewer pages + # @raise [TypeError] if the index is not an Integer, such as the String "1" or the Float 1.5, which name no page + # @raise [ArgumentError] if the index is negative, since pages are read forward from the first + # @example Fetch the first page + # user.followers.page(0) + def page(index) = @pages.at(index) + + # Return a new cursor over the same collection with an empty page cache + # + # The number the API publishes for the collection is read again too, once, the first time published_count asks + # for it, since the collection it counts may have changed. + # + # @api public + # @return [Cursor] a new cursor + # @example Iterate again with fresh data + # followers = user.followers.refresh + def refresh = self.class.__send__(:build, resource_class, path, client:, params: own_params, prefetch: prefetch?, token_param:, min_results:, app_only: app_only?, total: fresh_total, ids_only: ids_only?) + + # Return a new cursor over the same collection with prefetching enabled + # + # A page the background thread fails to fetch is not requested again when it is reached: the error the thread + # failed with is raised there, once, and a page asked for again after it is requested again. + # + # @api public + # @return [Cursor] a new cursor + # @example Fetch every follower while overlapping requests with processing + # user.followers.prefetch.each { |follower| process(follower) } + def prefetch = self.class.__send__(:build, resource_class, path, client:, params: own_params, prefetch: true, token_param:, min_results:, app_only: app_only?, total: @total, ids_only: ids_only?) + + # Return a new cursor over the same collection that yields stubs + # + # The requests ask for nothing but identifiers, and each resource is a stub holding only its identifier, + # which hydrates on demand, even when the API returns a few default fields alongside it. + # + # @api public + # @return [Cursor] a new cursor + # @raise [UnsupportedOperation] if the resource class has no fields parameter + # @example Check whether a user is among thousands of followers without fetching their fields + # user.followers.stubs.any?(other) + def stubs = self.class.__send__(:build, resource_class, path, client:, params: id_only_params, prefetch: prefetch?, token_param:, min_results:, app_only: app_only?, total: @total, ids_only: true) + + # The first resource, or the first few, requesting pages no larger than needed + # + # Iterating a cursor requests the largest page an endpoint allows, which costs the least in requests. + # The API bills each resource returned, so first asks for a page of the size it needs instead, raised to + # the endpoint's minimum, and each page after the first asks for no more than the pages before it left. + # The API may serve an empty page with the token of the next, having left out what it filters, such as + # suspended users, so first reads on until it finds a resource. The cursor keeps the pages first reads, as it + # keeps every page, so a cursor whose pages already hold what is asked for answers from them, without a request, + # and an iteration after first requests only what first left. + # + # @api public + # @param count [Integer, nil] the number of resources, or nil for the first resource alone; a Float is read as + # the Integer it converts to, as Array#first reads it + # @return [Resource, Array, nil] the first resource, or the first resources, frozen + # @raise [ArgumentError] if the count is negative + # @raise [TypeError] if the count is not a number that converts to an Integer + # @example Read ten followers in one request for ten users + # user.followers.first(10) + def first(count = nil) + resources = @pages.read(count.nil? ? 1 : Utils.count!(count)) #: Array[untyped] + return resources unless count.nil? + + resource, = resources + resource + end + + # Every resource, fetching every page + # + # The array is frozen, as the arrays first and take return are, since a cursor keeps the pages it read and a + # caller that changed what it returned would change nothing the cursor holds. + # + # @api public + # @return [Array] every resource, frozen + # @example Read every follower + # user.followers.to_a + def to_a = super.freeze + + alias_method :entries, :to_a + + # The first few resources, requesting pages no larger than needed, as first does + # + # @api public + # @param count [Integer] the number of resources; a Float is read as the Integer it converts to, as Array#take + # reads it + # @return [Array] the first resources, frozen + # @raise [ArgumentError] if the count is negative + # @raise [TypeError] if the count is not a number that converts to an Integer, such as nil or a String + # @example Read three followers in one request for three users + # user.followers.take(3) + def take(count) = first(Utils.count!(count)) + + # Check whether the collection holds any resource, requesting one + # + # Without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a + # full page. + # + # @api public + # @param pattern [Object] a pattern each resource is matched against + # @yield [Resource] each resource + # @return [Boolean] true if any resource matches + # @example Check whether a user has any followers + # user.followers.any? + def any?(*pattern, &block) + return super unless pattern.empty? && block.nil? + + !first.nil? + end + + # Check whether the collection holds no resource, requesting one + # + # Without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a + # full page. + # + # @api public + # @param pattern [Object] a pattern each resource is matched against + # @yield [Resource] each resource + # @return [Boolean] true if no resource matches + # @example Check whether a user follows nobody + # user.following.none? + def none?(*pattern, &block) + return super unless pattern.empty? && block.nil? + + first.nil? + end + + # Check whether the collection is empty, requesting one resource + # + # Like none? without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, + # rather than a full page. + # + # @api public + # @return [Boolean] true if the collection holds no resource + # @example Check whether a user has no followers + # user.followers.empty? + def empty? = first.nil? + + # Check whether the collection holds one resource, requesting two + # + # Without a pattern or a block, this asks for two resources, raised to the endpoint's minimum, rather than a full + # page. + # + # @api public + # @param pattern [Object] a pattern each resource is matched against + # @yield [Resource] each resource + # @return [Boolean] true if exactly one resource matches + # @example Check whether a list has a single member + # list.members.one? + def one?(*pattern, &block) + return super unless pattern.empty? && block.nil? + + take(2).size.eql?(1) + end + + # The number the API publishes for the collection, without reading any of it + # + # The API publishes a number for a user's followers, followed users, and list memberships, and for a list's + # members and followers. Reading it costs no request when the user or list holds it, and one lookup when it is a + # stub. The number counts what the collection holds, which can differ from what count reads, since the + # endpoint leaves out what the authenticated user cannot see, such as private lists and suspended users. count + # instead reads every page of the collection, a request per page, and the API bills each resource. A cursor + # answers no size, so that Ruby's own methods, such as each_slice and lazy, do not page a collection to size it. + # + # @api public + # @return [Integer, nil] the published number, or nil for a collection the API publishes no number for + # @example Count a user's followers without reading one of them + # user.followers.published_count # => 12345 + def published_count = @total&.call + + # The identifiers of every resource, requesting nothing but identifiers + # + # @api public + # @return [Array] the identifiers, Integers unless the resource's identifiers are not numbers, + # frozen + # @raise [UnsupportedOperation] if the resource class has no fields parameter + # @example Get the identifiers of every follower + # user.followers.ids + def ids = stubs.map(&:id).freeze + + # Refuse to write the collection as JSON, which would read every page of it + # + # Serializing a cursor would read every page of the collection, a request per page, and the API bills each + # resource it returns, from a call that says nothing of it, such as a cursor in a Hash that a log or a render + # writes. It raises instead, as ActiveSupport would otherwise read a cursor as the Enumerable it is. Serialize what + # first(n) or to_a reads instead, each of which says at the call how much it reads. + # + # @api public + # @return [void] + # @raise [UnsupportedOperation] always + # @example Serialize the first ten followers rather than every one of them + # user.followers.first(10).as_json + def as_json(*) = raise(UnsupportedOperation, SERIALIZATION_MESSAGE) + + # A Hash of the pair a block returns for each resource, reading every page + # + # Without a block it raises as {#as_json} does, before it reads a page, since a page of the collection is read as + # a Hash only by its as_json, rather than raise TypeError from the to_h of Enumerable once it has read one. + # + # @api public + # @yieldparam resource [Resource] each resource + # @yieldreturn [Array(Object, Object)] the key and value of the resource + # @return [Hash] the pairs the block returns + # @raise [UnsupportedOperation] if no block is given + # @example Index the members of a list by username + # list.members.to_h { |user| [user.username, user] } + def to_h(&block) = block ? super() : raise(UnsupportedOperation, SERIALIZATION_MESSAGE) + + # Refuse to write the collection as a JSON array, which would read every page + # + # It raises as {#as_json} does, for the reason that says. + # + # @api public + # @param _state [JSON::State, nil] the state a JSON encoder passes + # @return [void] + # @raise [UnsupportedOperation] always + # @example Serialize a whole collection, reading every page of it + # list.members.to_a.to_json # => "[{\"id\":\"7505382\"}]" + def to_json(_state = nil) = raise(UnsupportedOperation, SERIALIZATION_MESSAGE) + + # Refuse to write the cursor with Marshal, as it refuses to write it as JSON + # + # A cursor holds its client, and the threads and locks that fetch its pages, none of which Marshal can write, and + # caching the collection it names would mean reading every page of it, as {#as_json} says. It raises the + # TypeError Marshal raises for what it cannot write, with the message of {#as_json}, rather than the one Marshal + # would raise from within the cursor. Marshal what first(n) or to_a reads, or a page, instead. + # + # @api public + # @return [void] + # @raise [TypeError] always + # @example Cache the first page of the followers of a user rather than the cursor + # Rails.cache.write("followers", user.followers.page(0)) + def marshal_dump = raise(TypeError, SERIALIZATION_MESSAGE) + + # Refuse to write the cursor as YAML, as it refuses Marshal + # + # YAML reads no marshal_dump, and would write every instance variable of the cursor, its client and the + # credentials it holds among them, so it raises as {#marshal_dump} does. Write what first(n) or to_a reads, or a + # page, instead. + # + # @api public + # @param _coder [Psych::Coder] the coder YAML would write the cursor with + # @return [void] + # @raise [TypeError] always + # @example Write the first page of the followers of a user as YAML rather than the cursor + # YAML.dump(user.followers.page(0)) + def encode_with(_coder) = raise(TypeError, SERIALIZATION_MESSAGE) + + # Summarize the cursor for the console + # + # @api public + # @return [String] the class name, resource class, and path + # @example Inspect a cursor + # user.followers.inspect # => # + def inspect = "#<#{self.class} resource_class=#{resource_class} path=#{path.inspect}>" + + private + + # Set the collection, requests, and pages of a new cursor, and freeze it + # @api private + # @return [void] + def setup(resource_class, path, client:, params:, prefetch:, token_param:, min_results:, app_only:, total:, ids_only:) + @resource_class = resource_class + @client = client + @path = path + @params = Utils.deep_freeze(Utils.merge_params(ids_only ? {} : resource_class.default_params, params)) + @prefetch, @app_only, @ids_only = prefetch, app_only, ids_only + @token_param = token_param + @min_results = min_results + @total = total + @pages = Pages.new(self) + freeze + end + + # The endpoint path + # + # Internal to x-objects: the pages of a cursor are requested at it, and the endpoint a collection is read from may + # change within 1.x, as the API moves one. + # + # @api private + # @return [String] the endpoint path + # @example Get the path + # user.followers.__send__(:path) # => "users/7505382/followers" + attr_reader :path + + # The query parameters sent with every page request + # + # Internal to x-objects: the pages of a cursor are requested with them, and they hold the default fields of the + # resource class, which a minor release may add to, and the size of a page, which it may change. + # + # @api private + # @return [Hash{String => Object}] the query parameters + # @example Get the parameters + # user.followers.__send__(:params)["max_results"] # => 1000 + attr_reader :params + + # The smallest page the endpoint accepts + # + # Internal to x-objects: Pages never asks for a smaller page than this. + # + # @api private + # @return [Integer] the minimum page size + attr_reader :min_results + + # The query parameter the token of the next page is sent in + # + # Internal to x-objects: Pages sends the token of each page after the first in it. + # + # @api private + # @return [String] the parameter name + attr_reader :token_param + + # Check whether the pages are fetched with the app-only client of the client + # + # The space endpoints refuse the OAuth 1.0a of a user, so a cursor over one fetches its pages with the app-only + # client, while its resources hold the client, so that they act as the user. Internal to x-objects: Pages asks + # it which client fetches a page. + # + # @api private + # @return [Boolean] true if pages are fetched as the app + def app_only? = @app_only + + # Check whether the endpoint gives the resources by their identifiers alone + # + # Such an endpoint takes none of their fields either. Internal to x-objects: the pages of such a cursor read stubs, which is what Pages asks it for this. + # + # @api private + # @return [Boolean] true if the pages ask for none of the default parameters, and read stubs + def ids_only? = @ids_only + + # The block of a refreshed cursor, which reads the published number again once + # @api private + # @return [Proc, nil] the block, or nil for a collection the API publishes no number for + def fresh_total + total = @total or return + count = Memo.new + lambda do |fresh: false| + # @type var fresh: bool + fresh ? total.call(fresh: true) : count.fetch { total.call(fresh: true) } + end + end + + # The parameters of this cursor, keeping dropped defaults dropped + # @api private + # @return [Hash{String => Object}] the parameters + def own_params + dropped = {} #: Hash[String, nil] + resource_class.default_params.each_key { |key| dropped[key] = nil } + dropped.merge(params) + end + + # The query parameters that select nothing but the identifier + # @api private + # @return [Hash{String => Object}] the query parameters + # @raise [UnsupportedOperation] if the resource class has no fields parameter + def id_only_params + return params if ids_only? + + fields_key = resource_class.__send__(:fields_key) || raise(UnsupportedOperation, "#{resource_class} has no fields parameter") #: String + id_key = resource_class.__send__(:id_key) #: String + dropped = {} #: Hash[String, nil] + resource_class.default_params.each_key { |key| dropped[key] = nil } + params.merge(dropped, fields_key => id_key) + end + end + end +end diff --git a/x-objects/lib/x/objects/direct_message.rb b/x-objects/lib/x/objects/direct_message.rb new file mode 100644 index 00000000..3e6c9bd8 --- /dev/null +++ b/x-objects/lib/x/objects/direct_message.rb @@ -0,0 +1,314 @@ +# frozen_string_literal: true + +require_relative "cursor" +require_relative "direct_message_conversations" +require_relative "finders" +require_relative "resource" + +module X + module Objects + # A direct message event + # @api public + class ::X::DirectMessage < Resource + extend Finders + extend DirectMessageConversations + + # The direct message event fields the object layer requests; the sender, the participants, and the posts a + # message refers to come with their expansions + # + # A minor release may add to it the fields the API adds, so that a lookup asks for them too; see + # {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own. + FIELDS = %w[attachments created_at dm_conversation_id entities event_type id text].freeze + # Every expansion available on direct message endpoints + # + # A minor release may add to it the expansions the API adds, so that a lookup asks for them too; see + # {Resource#hydrated?} for what that means for a resource looked up with a list of expansions of its own. + EXPANSIONS = %w[attachments.media_keys participant_ids referenced_posts sender_id].freeze + # Maximum number of events per page + MAX_RESULTS = 100 + private_constant :MAX_RESULTS + + class << self + # The API endpoint used to look up direct message events by identifier + # + # @api private + # @return [String] the endpoint + # @example Get the endpoint + # X::DirectMessage.__send__(:endpoint) # => "dm_events" + def endpoint + "dm_events" + end + + # The query parameter that selects direct message event fields + # + # @api private + # @return [String] the fields parameter + # @example Get the fields parameter + # X::DirectMessage.__send__(:fields_key) # => "dm_event.fields" + def fields_key = "dm_event.fields" + + private :endpoint, :fields_key + + # The default query parameters requesting every direct message field and expansion + # + # @api public + # @return [Hash{String => Array}] the default query parameters + # @example Get the default parameters + # X::DirectMessage.default_params["dm_event.fields"] + def default_params + {"dm_event.fields" => FIELDS, "user.fields" => User::FIELDS, "post.fields" => Post::FIELDS, + "media.fields" => Media::FIELDS, "expansions" => EXPANSIONS} + end + + # The most recent direct message events across every conversation + # + # @api public + # @param client [Object] the client used to make the requests + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the events + # @example Print the most recent direct messages + # X::DirectMessage.all(client: client).first(10).each { |message| puts message.text } + def all(client:, **params) + Cursor.__send__(:build, self, "dm_events", client:, params: {max_results: MAX_RESULTS}.merge(params)) + end + + # The direct message events in the one-to-one conversation with a user + # + # @api public + # @param user [User, String, Integer] the other participant or their identifier + # @param client [Object] the client used to make the requests + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the events + # @example Print the conversation with a user + # X::DirectMessage.with(user, client: client).each { |message| puts message.text } + def with(user, client:, **params) + path = "dm_conversations/with/#{Utils.id_of(user, User)}/dm_events" + Cursor.__send__(:build, self, path, client:, params: {max_results: MAX_RESULTS}.merge(params)) + end + + # Send a direct message to a user as the authenticated user + # + # @api public + # @param user [User, String, Integer] the recipient or their identifier + # @param text [String, nil] the text of the message, or nil for a message of attachments alone + # @param client [Object] the client used to make the request + # @param media_ids [Array, String, Integer, #fetch, Media, nil] the identifiers or + # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or + # many + # @param params [Hash] additional request body fields, such as attachments + # @return [DirectMessage] the sent message, holding only its identifiers + # @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and + # attachments + # @raise [MissingResource] if the API answers without the message + # @example Send a direct message + # X::DirectMessage.create(user, "Hello!", client: client) + # @example Send an image without text + # X::DirectMessage.create(user, client: client, media_ids: media) + def create(user, text = nil, client:, media_ids: nil, **params) + path = "dm_conversations/with/#{Utils.id_of(user, User)}/messages" + sent(client.post(path, message(text, params, media_ids), **Utils::JSON_CLASSES), path, client:) + end + + # Delete a direct message event as the authenticated user + # + # @api public + # @param message [DirectMessage, String, Integer] the event or its identifier + # @param client [Object] the client used to make the request + # @return [Boolean] true if the event was deleted + # @example Delete a direct message + # X::DirectMessage.delete("1234567890", client: client) + def delete(message, client:) + body = client.delete("dm_events/#{Utils.id_of(message, self)}", **Utils::JSON_CLASSES) + Utils.written(body, "deleted").eql?(true) + end + end + + # @!attribute [r] text + # The text + # + # It is the text as the API sends it, which escapes &, <, and > as &, <, and >, and it stays so + # throughout 1.x, so unescape it to display it. + # + # @api public + # @return [String, nil] the text, HTML-escaped as the API sends it + # @example Get the text + # message.text # => "Ruby & Rails" + # @example Display the text + # CGI.unescapeHTML(message.text) # => "Ruby & Rails" + attribute :text + + # @!attribute [r] event_type + # The event type: MessageCreate, ParticipantsJoin, or ParticipantsLeave + # @api public + # @return [String, nil] the event type + # @example Get the event type + # message.event_type + attribute :event_type + + # @!attribute [r] created_at + # The time when the event occurred + # @api public + # @return [Time, nil] the event time + # @example Get the event time + # message.created_at + attribute :created_at, :time + + # @!attribute [r] sender_id + # The identifier of the sender + # @api public + # @return [Integer, nil] the sender identifier + # @example Get the sender identifier + # message.sender_id + attribute :sender_id, :integer + + # @!attribute [r] dm_conversation_id + # The identifier of the conversation + # @api public + # @return [String, nil] the conversation identifier: the identifiers of the two users of a one-to-one + # conversation joined with a hyphen, or the number of a group conversation + # @raise [InvalidAttribute] if the response holds an identifier that is neither + # @example Get the conversation identifier + # message.dm_conversation_id + attribute :dm_conversation_id, :conversation_id + + # @!attribute [r] participant_ids + # The identifiers of the participants who joined or left + # @api public + # @return [Array] the participant identifiers, empty if there are none + # @example Get the participant identifiers + # message.participant_ids + attribute :participant_ids, :integers + + # @!attribute [r] referenced_posts + # The referenced posts with their identifiers + # @api public + # @return [Array] the referenced posts, empty if there are none + # @example Get the referenced posts + # message.referenced_posts + attribute :referenced_posts, :objects, tweet_key: %w[referenced_tweets] + reference_keys.push(%w[referenced_posts], %w[referenced_tweets]) + + # @!attribute [r] attachments + # The attachment keys + # @api public + # @return [Hash, nil] the attachments + # @example Get the attachments + # message.attachments + attribute :attachments, :object + + # @!attribute [r] entities + # The entities found in the text: its URLs, hashtags, mentions, and cashtags + # @api public + # @return [Hash, nil] the entities + # @example Get the URLs of a message + # message.entities&.dig("urls") + attribute :entities, :object + + # @!method sender + # The sender, resolved from the includes or as a stub holding only its identifier + # @api public + # @return [User, nil] the sender + # @example Get the sender's username + # message.sender.username + reference :sender, :User, key: %w[sender_id] + + # @!method participants + # The participants who joined or left, from the includes or as stubs + # @api public + # @return [Array] the participants + # @example Get the participants + # message.participants + references :participants, :User, key: %w[participant_ids] + + # @!method media + # The attached media, from the includes or as stubs holding only their keys + # @api public + # @return [Array] the media + # @example Get the media URLs + # message.media.map(&:url) + references :media, :Media, key: %w[attachments media_keys] + + # The referenced posts, resolved from the includes or built as stubs + # + # @api public + # @return [Array] the referenced posts + # @raise [InvalidAttribute] if the response holds a referenced post that is not an object + # @example Get the referenced posts + # message.references + def references + referenced_posts.filter_map do |reference| + resolve(Post, reference["id"]) #: Post? + end.freeze + end + + # Check whether a user sent this message + # + # A message that does not name its sender, as the message a new direct message returns does not, or one fetched + # with dm_event.fields that leave sender_id out, cannot say the user sent it, so it answers false; its sender_id + # is nil. + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the user sent the message, false if another user did, or the message does not name + # its sender + # @example Split messages into sent and received + # me = client.current_user_id + # messages.partition { |message| message.from?(me) } + def from?(user) = sender_id.to_s.eql?(Utils.id_of(user, User)) + + # Check whether the message belongs to a group conversation + # + # The identifier of a one-to-one conversation joins the identifiers of its two participants with a hyphen, and + # the identifier of a group conversation is a number of its own. + # + # @api public + # @return [Boolean] true if the message belongs to a group conversation, false if to a one-to-one conversation or + # the message does not say + # @raise [InvalidAttribute] if the response holds a conversation identifier that is not one + # @example Leave out the messages of group conversations + # client.direct_messages.reject(&:group?) + def group? + conversation_id = dm_conversation_id + !conversation_id.nil? && !conversation_id.include?("-") + end + + # The other participant of a one-to-one conversation, as seen by a user + # + # The sender, when the user did not send the message, and otherwise the other member of the + # conversation, from the includes or as a stub holding only its identifier. A message that does not name its + # sender, as the message a new direct message returns does not, is read by its conversation alone, whose other + # member is the peer of a user who is one of its two. A message whose conversation the user is not one of the + # two members of has no peer for that user, even one another user sent, so neither has a message of a group + # conversation, whose identifier names none of its members. + # + # @api public + # @param user [User, String, Integer] the user, usually the authenticated user, or their identifier + # @return [User, nil] the other participant, or nil for a group conversation, one without another participant, + # or one the user is not a member of + # @raise [InvalidAttribute] if the response holds a conversation identifier that is not one + # @example Print the identifier of the user each message was exchanged with + # me = client.current_user_id + # client.direct_messages.reject(&:group?).each { |message| puts message.peer(me)&.id } + def peer(user) + user_id = Utils.id_of(user, User) + members = dm_conversation_id.to_s.split("-") + return unless members.empty? || members.include?(user_id) + return sender unless sender_id.nil? || from?(user_id) + + resolve(User, members.find { |id| !id.eql?(user_id) }) #: User? + end + + # Delete this direct message event as the authenticated user + # + # @api public + # @return [Boolean] true if the event was deleted + # @example Delete a direct message + # message.delete + def delete + self.class.delete(self, client: client!) + end + + attribute_alias :referenced_tweets, :referenced_posts + end + end +end diff --git a/x-objects/lib/x/objects/direct_message_conversations.rb b/x-objects/lib/x/objects/direct_message_conversations.rb new file mode 100644 index 00000000..8db4d2b7 --- /dev/null +++ b/x-objects/lib/x/objects/direct_message_conversations.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require_relative "cursor" +require_relative "media_ids" +require_relative "shape" +require_relative "utils" + +module X + module Objects + # Group conversations of direct messages: starting one, sending to one, and reading one, extended by DirectMessage + # + # Internal to x-objects: the methods it gives DirectMessage, such as X::DirectMessage.create_group, are public API, + # but the module is only how they are shared, and which classes extend or include it can change within 1.x. + # + # @api private + module DirectMessageConversations + # Maximum number of events per page of a conversation + MAX_RESULTS = 100 + private_constant :MAX_RESULTS + + # Start a group conversation, sending its first message as the authenticated user + # + # @api public + # @param users [Array] the other participants or their identifiers + # @param text [String, nil] the text of the first message, or nil for a message of attachments alone + # @param client [Object] the client used to make the request + # @param media_ids [Array, String, Integer, #fetch, Media, nil] the identifiers or + # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or + # many + # @param params [Hash] additional fields of the message, such as attachments + # @return [DirectMessage] the sent message, holding only its identifiers, among them the new conversation's + # @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and + # attachments + # @raise [MissingResource] if the API answers without the message + # @example Start a group conversation + # X::DirectMessage.create_group([alice, bob], "Hello, both of you!", client: client) + # @example Start a group conversation with an image + # X::DirectMessage.create_group([alice, bob], client: client, media_ids: media) + def create_group(users, text = nil, client:, media_ids: nil, **params) + body = {conversation_type: "Group", participant_ids: users.map { |user| Utils.id_of(user, User) }, message: message(text, params, media_ids)} + sent(client.post("dm_conversations", body, **Utils::JSON_CLASSES), "dm_conversations", client:) + end + + # Send a direct message to a conversation as the authenticated user + # + # @api public + # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier + # @param text [String, nil] the text of the message, or nil for a message of attachments alone + # @param client [Object] the client used to make the request + # @param media_ids [Array, String, Integer, #fetch, Media, nil] the identifiers or + # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or + # many + # @param params [Hash] additional request body fields, such as attachments + # @return [DirectMessage] the sent message, holding only its identifiers + # @raise [ArgumentError] if the conversation identifier is not one, the message has neither text nor any other + # field, or it has both media_ids and attachments + # @raise [MissingResource] if the API answers without the message + # @example Reply to the conversation of a message + # X::DirectMessage.create_in(message, "Sounds good", client: client) + # @example Reply with an image + # X::DirectMessage.create_in(message, client: client, media_ids: media) + def create_in(conversation, text = nil, client:, media_ids: nil, **params) + path = "dm_conversations/#{conversation_id_of(conversation)}/messages" + sent(client.post(path, message(text, params, media_ids), **Utils::JSON_CLASSES), path, client:) + end + + # The direct message events of a conversation, one-to-one or group + # + # @api public + # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier + # @param client [Object] the client used to make the requests + # @param params [Hash] query parameters merged over the default parameters, such as event_types + # @return [Cursor] a cursor over the events + # @raise [ArgumentError] if the conversation identifier is not one + # @example Print the conversation a message belongs to + # X::DirectMessage.in(message, client: client).each { |event| puts event.text } + def in(conversation, client:, **params) + path = "dm_conversations/#{conversation_id_of(conversation)}/dm_events" + Cursor.__send__(:build, DirectMessage, path, client:, params: {max_results: MAX_RESULTS}.merge(params)) + end + + private + + # The identifier of a conversation, from a message of it or as given + # @api private + # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier + # @return [String] the conversation identifier + # @raise [ArgumentError] if the identifier is not one + def conversation_id_of(conversation) + id = case conversation + when DirectMessage then conversation.dm_conversation_id.to_s + else conversation.to_s + end + return id if id.match?(Shape::CONVERSATION_ID) + + raise ArgumentError, "#{conversation.inspect} is not a conversation: pass a direct message or a conversation identifier" + end + + # The fields of a message to send, which needs text or attachments + # + # The API takes media as attachments, each an object holding the identifier of one upload as a String, so + # media_ids builds them, and a caller who builds them itself passes attachments instead, by a String or a Symbol. + # + # @api private + # @param text [String, nil] the text of the message + # @param params [Hash] additional fields of the message, such as attachments + # @param media_ids [Array, #fetch, Media, String, Integer, nil] the identifiers of uploaded media to attach, what + # the uploads returned, or media, one or many; an empty list attaches nothing, as nil does + # @return [Hash{Symbol => Object}] the fields, without the text when there is none + # @raise [ArgumentError] if the message has neither text nor any other field, or attaches media by both + # media_ids and attachments + def message(text, params, media_ids) + fields = {text:, **Utils.fields(params)}.compact + attachments = MediaIds.media_ids_of(media_ids).map { |media_id| {media_id:} } + unless attachments.empty? + raise ArgumentError, "pass media_ids or attachments, not both" if fields.key?(:attachments) + + fields[:attachments] = attachments + end + raise ArgumentError, "a direct message needs text, or something else to show, such as media_ids" if fields.empty? + + fields + end + + # The message a send created, from the identifiers the API returned + # + # It is built as the resources of any response are, so a response without the message, or without its event + # identifier, raises, as any request that creates a resource does. + # + # @api private + # @param body [Hash, nil] the response body + # @param path [String] the path the message was sent to + # @param client [Object] the client used to make the request + # @return [DirectMessage] the message + # @raise [MissingResource] if the response holds no data, or no event identifier + # @raise [InvalidAttribute] if the response holds an event identifier that is not one + def sent(body, path, client:) + body = body.to_h + data = body["data"] + data = {"id" => data["dm_event_id"], "dm_conversation_id" => data["dm_conversation_id"]} if data.is_a?(Hash) + created_from_response(body.merge("data" => data), "POST #{path}", client:) + end + end + private_constant :DirectMessageConversations + end +end diff --git a/x-objects/lib/x/objects/errors.rb b/x-objects/lib/x/objects/errors.rb new file mode 100644 index 00000000..0a4cbf77 --- /dev/null +++ b/x-objects/lib/x/objects/errors.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "x/core" + +module X + module Objects + # Base error class for the failures of the object layer, which every error x-objects raises of its own descends from + # + # It descends from X::Error, so that rescuing the errors of the X API catches one of them as well. The errors + # that descend from it are named directly under X, as the errors of x-core are, so that this is the one name + # under X::Objects a rescue reaches for: it catches the failure of the object layer alone. + # + # @api public + class Error < X::Error; end + end + + # Raised when a resource that was asked for by identifier or name does not exist + # + # The API answers a lookup of a resource that is not there with 200 OK and no data, so this is not the NotFound of + # a 404 response, which an endpoint that is not there raises, and which holds the response that named it. + # + # It is raised as well when a request that creates a resource, such as X::Post.create, succeeds without returning + # it, as X::User.current! raises it when users/me returns no user, holding the problems the response reported. + # + # @api public + class MissingResource < Objects::Error + # The problems the API reported about the resource + # @api public + # @return [Array] the problems, empty if the API reported none + # @example Read why a user was not found + # error.problems.first&.detail # => "Could not find user with username: [nobody]." + attr_reader :problems + + # Initialize the error, adding the detail of the first problem to the message + # + # @api public + # @param message [String, nil] the message + # @param problems [Array] the problems the API reported + # @return [MissingResource] a new error + # @example Raise the error + # raise X::MissingResource.new("Could not find X::User nobody", problems: problems) + def initialize(message = nil, problems: []) + explanation = problems.first&.then { |problem| problem.detail || problem.title } + super(([message, explanation].compact.join(": ") if message || explanation)) + @problems = problems.dup.freeze + end + end + + # Raised when a successful response holds what the object layer cannot read as what the API documents + # + # It is the base of InvalidAttribute, for one value of a response, and is raised itself for what a response says + # beside its values, such as a page that names the token of a page before it as the next, which would have the + # pages requested again for good. It is not the InvalidResponse of x-core, which a body that is not JSON raises, + # and which holds the response. + # + # @api public + # @example Rescue what the object layer cannot read of a response + # begin + # user.followers.to_a + # rescue X::UnreadableResponse => e + # logger.warn(e.message) + # end + class UnreadableResponse < Objects::Error; end + + # Raised when a response holds a value that cannot be read as what the API documents it to be + # + # A timestamp that is not ISO 8601, or an identifier that is not one, is read when the attribute or the reference + # that holds it is read, and the identifier of a resource when the resource is built from the response, so that one + # value the object layer cannot read raises where it is read, as this error, which descends from X::Error, rather + # than as the ArgumentError the same value raises when a caller passes it. The cause is the error that refused it. + # + # @api public + class InvalidAttribute < UnreadableResponse; end + + # Raised when a resource that holds no client is asked for what only a request can answer + # + # A resource built without a client, such as one Marshal read back, or one built with from_id and no client, holds + # its attributes, which it reads as any resource does, but cannot hydrate, refresh, page a collection, or act, since + # each of them is a request, and the client is what makes it. Build the resource with the client: of from_id, or + # look it up again with a client. + # + # @api public + # @example Hydrate a resource that has a client + # X::User.from_id(7_505_382, client: client).hydrate + class MissingClient < Objects::Error; end + + # Raised when a scan or a count reads the pages its max_pages allows, and the API names a page after them + # + # A check that pages through a collection, such as List#member? or User#follows?, and a count of posts, such as + # X::Post.count_all, read as many pages as the answer takes, and the API bills each one. Given max_pages, + # each reads no more pages than that, and raises this rather than answer from the pages it read, which would be + # wrong: a member on a page it did not read, or posts counted on one, would go unseen. + # + # @api public + # @example Give up on a check that would read more than ten pages + # begin + # list.member?(user, max_pages: 10) + # rescue X::PageLimitReached + # nil + # end + class PageLimitReached < Objects::Error; end +end diff --git a/x-objects/lib/x/objects/finders.rb b/x-objects/lib/x/objects/finders.rb new file mode 100644 index 00000000..14fe1bf2 --- /dev/null +++ b/x-objects/lib/x/objects/finders.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "x/core" +require_relative "errors" +require_relative "utils" + +module X + module Objects + # Class methods that look resources up one at a time, extended into each resource class the API can look up + # + # A resource the API offers no lookup of, such as a poll or a place, does not extend it, and so answers none of + # its methods, rather than answer them only to raise. BatchFinders includes it for a resource the API can also + # look up many at a time. + # + # Internal to x-objects: the methods it gives a resource class, such as X::Post.find, are public API, but the module + # is only how they are shared, and which classes extend or include it can change within 1.x. + # + # @api private + module Finders + # Look up a resource by identifier + # + # @api public + # @param id [String, Integer, Resource] the identifier, or a resource of this class + # @param client [Object] the client used to make the request + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Resource, nil] the resource or nil if it was not found + # @raise [ArgumentError] if the identifier is not one, or is a resource of another class, before a request + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up a post by identifier + # X::Post.find(1234567890, client: client) + def find(id, client:, **params, &) = lookup("#{endpoint!}/#{Utils.id_of(id, self)}", client:, **params, &) + + # Look up a resource by identifier, which must exist + # + # The error it raises names the identifier looked up, whether the identifier or a resource was given. + # + # @api public + # @param id [String, Integer, Resource] the identifier, or a resource of this class + # @param client [Object] the client used to make the request + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Resource] the resource + # @raise [ArgumentError] if the identifier is not one, or is a resource of another class, before a request + # @raise [MissingResource] if the resource was not found + # @example Look up a post by identifier + # X::Post.find!(1234567890, client: client) + def find!(id, client:, **params) + problems = [] #: Array[Problem] + find(id, client:, **params) { |problem| problems << problem } || raise(MissingResource.new("Could not find #{self} #{Utils.id_from(id, self)}", problems:)) + end + + # Fetch a single resource from an endpoint + # + # Internal to x-objects: the finders and hydrate call it with the path of an endpoint, which names the API's + # own resources and can change within 1.x as the API does. + # + # Data that holds no identifier is no resource: X answers the lookup of a user that does not exist, when it asks + # for a field the client may not read, such as parody for a client that authenticates as the app, with data that + # holds only the defaults of the fields it may, and the errors of those it may not, but no identifier. + # + # @api private + # @param path [String] the endpoint path + # @param client [Object] the client used to make the request + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Resource, nil] the resource or nil if the response has no data, or data that holds no identifier + # @yieldparam problem [Problem] each problem the API reported + # @example Fetch the authenticated user + # X::User.__send__(:lookup, "users/me", client: client) + def lookup(path, client:, **params, &) + query = Utils.merge_params(default_params, params) + body = reporting(get(path, client:, query:), &) + resource_built_from(body, client:, hydrated: fully_requested_by?(query), query:) unless resourceless?(body) + end + + # Fetch a list of resources from an endpoint without paginating + # + # Internal to x-objects: the batch lookups call it with the path of an endpoint, which names the API's own + # resources and can change within 1.x as the API does. + # + # @api private + # @param path [String] the endpoint path + # @param client [Object] the client used to make the request + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest + # @return [Array] the resources + # @yieldparam problem [Problem] each problem the API reported + # @example Fetch users by username + # X::User.__send__(:lookup_all, "users/by", client: client, usernames: ["sferik", "gem"]) + def lookup_all(path, client:, **params, &) + query = Utils.merge_params(default_params, params) + collection_built_from(reporting(get(path, client:, query:), &), client:, hydrated: fully_requested_by?(query), query:) + end + + # The client a lookup of this resource makes its requests with + # + # Most endpoints take the client as it is, and one that refuses the credentials a client signs with, such as the + # space endpoints, which refuse OAuth 1.0a, replaces it with a client that authenticates as the app. + # + # @api private + # @param client [Object] the client the lookup was given + # @return [Object] the client the request is made with + # @example Get the client a user lookup requests with + # X::User.__send__(:client_for, client) # => client + def client_for(client) = client + + private :lookup, :lookup_all, :client_for + + private + + # Request an endpoint + # @api private + # @param path [String] the endpoint path + # @param client [Object] the client used to make the request + # @param query [Hash] the query parameters, merged over the default parameters + # @return [Hash, nil] the parsed response body + def get(path, client:, query:) + client_for(client).get(Utils.path(path, query), **Utils::JSON_CLASSES) + end + + # Build the resource a request that creates one returned, which must hold it + # + # The API answers a request that creates a resource with the resource, so a successful response without one + # created nothing the caller can read, and raises, holding the problems the response reported, as current! + # raises for a users/me that returns no user, rather than return nil, which the caller would read as the + # resource. A response whose data holds no identifier holds no resource, as find reads it, so it raises too. + # + # @api private + # @param body [Hash, nil] the parsed response body + # @param request [String] the method and path of the request, which the message names + # @param client [Object] the client used to make the request + # @return [Resource] the resource + # @raise [MissingResource] if the response holds no resource, or data without an identifier + # @raise [InvalidAttribute] if the response holds a resource with an identifier that is not one + # @example Build the post a request created + # X::Post.__send__(:created_from_response, {"data" => {"id" => "1"}}, "POST tweets", client: client) + def created_from_response(body, request, client:) + raise MissingResource.new("#{request} returned no #{self}", problems: Problem.all_from(body)) if resourceless?(body) + + resource_built_from(body, client:, hydrated: false, query: nil) #: Resource + end + + # Whether a response body holds no resource + # + # It holds none when it holds no data, data that is no object, or an object with no identifier. + # + # @api private + # @param body [Hash, nil] the parsed response body + # @return [Boolean] true if the body holds no object with an identifier as its data + def resourceless?(body) = Hash.try_convert(body.to_h["data"]).to_h[id_key].nil? + + # Pass the problems a response body reports to a block, if there is one + # @api private + # @param body [Hash, nil] the parsed response body + # @return [Hash, nil] the body + # @yieldparam problem [Problem] each problem the body reports + def reporting(body) + Problem.all_from(body).each { |problem| yield problem } if block_given? + body + end + end + private_constant :Finders + end +end diff --git a/x-objects/lib/x/objects/identity.rb b/x-objects/lib/x/objects/identity.rb new file mode 100644 index 00000000..6c9c555b --- /dev/null +++ b/x-objects/lib/x/objects/identity.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module X + module Objects + # Equality and hashing by class and identifier, so the same resource fetched twice compares equal + # + # Internal to x-objects: the methods it gives a resource, such as ==, are public API, but the module is only how + # they are shared, and which classes extend or include it can change within 1.x. + # + # @api private + module Identity + # Compare resources by class and identifier + # + # @api public + # @param other [Object] the object to compare with + # @return [Boolean] true if the other object is the same kind of resource with the same identifier + # @example Compare users fetched in different requests + # post.author == client.find_user("sferik") + def ==(other) + self.class.equal?(other.class) && id.eql?(other.id) + end + + # @!method eql?(other) + # Alias for ==, compares resources by class and identifier + # @api public + # @param other [Object] the object to compare with + # @return [Boolean] true if the other object is the same kind of resource with the same identifier + # @example Deduplicate resources + # [user, client.find_user("sferik")].uniq + alias_method :eql?, :== + + # Hash resources by class and identifier + # + # @api public + # @return [Integer] the hash code + # @example Use resources as hash keys + # {user => 1}[client.find_user("sferik")] + def hash + [self.class, id].hash + end + + # Deconstruct the resource into its attributes, so it matches a hash pattern + # + # Every attribute the resource declares is read as its own method reads it, so a pattern sees the + # identifier as a number, a timestamp as a Time, and a metric by the name it is read by. A pattern can ask + # for an attribute by another name it is read by, such as retweet_count for repost_count, and a pattern + # that asks for every attribute, with a double splat, gets each once, by the name the resource declares. + # + # @api public + # @param keys [Array, nil] the keys the pattern asks for, or nil for every attribute + # @return [Hash{Symbol => Object}] the attributes + # @example Match a post by its author + # puts "by sferik" if post in {author_id: 7505382} + # @example Match a post by a name from before posts were posts + # puts "widely reposted" if post in {retweet_count: 100..} + def deconstruct_keys(keys) + klass = self.class + names = klass.__send__(:attribute_names) + names = (names + klass.__send__(:attribute_aliases)) & keys unless keys.nil? + names.to_h { |name| [name, public_send(name)] } + end + end + private_constant :Identity + end +end diff --git a/x-objects/lib/x/objects/includes.rb b/x-objects/lib/x/objects/includes.rb new file mode 100644 index 00000000..2df3a5a8 --- /dev/null +++ b/x-objects/lib/x/objects/includes.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "monitor" +require_relative "utils" + +module X + module Objects + # The context of one API response: its identity map of expanded objects and stubs, and the problems it reported + # @api private + class Includes + # The keys the API gave the includes of a class before it named tweets posts, which it still gives them where it + # has not renamed them, such as in a stream + TWEET_KEYS = {"posts" => "tweets"}.freeze + private_constant :TWEET_KEYS + + # Initialize a new identity map + # + # @api private + # @param data [Hash, nil] the includes hash from an API response + # @param problems [Array] the problems the response reported + # @param query [Hash{String => Object}, nil] the query parameters of the request, merged over the defaults, or + # nil if they are not known + # @return [Includes] a new identity map + def initialize(data = nil, problems: [], query: nil) + @data = Utils.deep_freeze(data.to_h) + @problems = problems.freeze + @query = Utils.deep_freeze(query) + @monitor = Monitor.new + @index = {} + @resources = {} + freeze + end + + # The problems the response reported, such as missing expanded resources + # @api private + # @return [Array] the problems + attr_reader :problems + + # What some resources built over the identity map refer to of it, as plain data + # + # A resource that Marshal writes holds it, and a page holds it once for the resources that share it. It is the + # included objects the resources refer to, and the ones those refer to in turn, so that every reference that + # resolves to an included object still does, with none of the rest of the response; the problems about any of + # them, or about none; and the query, which tells whether an included object is hydrated. The problems are + # written as themselves, which Marshal writes as their attributes. + # + # @api private + # @param resources [Array] the resources, each built over this identity map + # @return [Array(Hash, Array, Hash, nil)] the included objects, the problems, and the query + def state_of(resources) + kept = {} #: Hash[String, Array[attrs]] + ids = resources.map(&:id) + pending = resources.map { |resource| [resource.class, resource.attrs] } #: Array[[singleton(Resource), attrs]] + # Each object kept joins the objects whose references are read, which each reaches once it comes to them + pending.each do |klass, attrs| + referenced = referenced_by(klass, attrs) + ids.concat(referenced) + pending.concat(keep(kept, referenced)) + end + [kept, problems_about(ids), @query] + end + + # Check whether a resource of the response is hydrated as it is read back + # + # A resource written with the query of its request is hydrated only if that query asks for every field and + # expansion this release requests of its class, since a minor release may add to them, so that a resource an + # earlier release wrote as hydrated is not, once it lacks what was added, and hydrate fetches it. One written + # without a query, as one a caller built from a response is, is hydrated as it was written. + # + # @api private + # @param klass [Class] the resource class + # @param hydrated [Boolean] whether the resource was hydrated as it was written + # @return [Boolean] true if the resource holds every field this release requests + def hydrated_as_read?(klass, hydrated) + query = @query + hydrated && (query.nil? || klass.__send__(:fully_requested_by?, query)) + end + + # The problems the response reported about any of some identifiers + # + # A problem that names no resource is about any of them, too. A problem names the resource it is about by its resource_id, or by its value, and one that names neither + # could be about any resource of the response. + # + # @api private + # @param ids [Array] the identifiers of a resource and of the resources it refers to + # @return [Array] the problems, frozen + def problems_about(ids) + ids = ids.map(&:to_s) + problems.select { |problem| about?(problem, ids) }.freeze + end + + # Resolve a reference to the included resource or a stub holding its identifier + # + # Every reference to the same resource within one response resolves to the same object, + # so hydrating it once hydrates it everywhere it is referenced. + # + # An included resource is hydrated when the request asked for every field of its class, and its class expands + # nothing of its own, as a poll, a place, and media expand nothing, since the API applies the expansions of a + # request to its data alone: a post or a user included in a response lacks the resources it would expand, which + # hydrate looks up. A stub, of a resource the response did not include, is never hydrated. + # + # @api private + # @param klass [Class] the resource class + # @param id [String] the identifier + # @param client [Object, nil] the client used to fetch the response + # @return [Resource] the resource + def resolve(klass, id, client:) + @monitor.synchronize do + @resources[[klass, id]] ||= build(klass, id, client) + end + end + + private + + # Build the included resource an identifier names, or a stub of it + # @api private + # @param klass [Class] the resource class + # @param id [String] the identifier + # @param client [Object, nil] the client used to fetch the response + # @return [Resource] the resource + def build(klass, id, client) + attrs = index(klass)[id] + return klass.__send__(:build, {klass.__send__(:id_key) => id}, client:, includes: self) if attrs.nil? + + klass.__send__(:build, attrs, client:, includes: self, hydrated: fully_requested?(klass)) + end + + # Check whether a problem names one of some identifiers, or names none + # @api private + # @param problem [Problem] the problem + # @param ids [Array] the identifiers + # @return [Boolean] true if the problem names one of the identifiers, or names no resource + def about?(problem, ids) + named = [problem.resource_id, problem.value].compact + named.empty? || named.any? { |value| ids.include?(value.to_s) } + end + + # Check whether the request asked for every field of a class that expands nothing + # @api private + # @param klass [Class] the resource class + # @return [Boolean] true if a resource of the class that the response included holds every field + def fully_requested?(klass) + query = @query + return false if query.nil? || klass.default_params.key?("expansions") + + klass.__send__(:fully_requested_by?, query) + end + + # Keep the included objects some identifiers name that are not kept yet + # + # An object is kept under the key the response included it by, in the order the response included it, so that + # what is kept resolves as the response did. + # + # @api private + # @param kept [Hash{String => Array}] the included objects kept so far, which this adds to + # @param ids [Array] the identifiers, as the objects that refer to them hold them + # @return [Array(Class, Hash)] the class and attributes of each object this kept, whose references are kept next + def keep(kept, ids) + collections.flat_map do |klass, key| + found = named(klass, key, ids) - kept[key].to_a + kept[key] = @data.fetch(key) & (kept[key].to_a + found) unless found.empty? + found.map { |attrs| [klass, attrs] } + end + end + + # The included objects of a resource class that some identifiers name + # @api private + # @param klass [Class] the resource class + # @param key [String] the key the response included the objects of the class under + # @param ids [Array] the identifiers + # @return [Array] the objects, in the order the response included them + def named(klass, key, ids) = @data.fetch(key).select { |attrs| ids.include?(attrs[klass.__send__(:id_key)]) } + + # The identifiers of what an object refers to, as the object holds them + # + # A reference resolves an identifier as the object holds it, so it is kept as that too. + # + # @api private + # @param klass [Class] the resource class of the object + # @param attrs [Hash{String => Object}] the attributes of the object + # @return [Array] the identifiers + def referenced_by(klass, attrs) = klass.__send__(:referenced_ids, attrs).compact + + # The key the response included each resource class under, of those it included + # @api private + # @return [Array] each class, and the key the response included it under + def collections + classes = Resource.subclasses #: Array[singleton(Resource)] + classes.filter_map do |klass| + name = klass.__send__(:includes_key) + key = [name, TWEET_KEYS[name]].find { |candidate| @data.key?(candidate) } #: String? + [klass, key] unless key.nil? + end + end + + # The included objects of one resource class, under the key the API gave them + # @api private + # @param klass [Class] the resource class + # @return [Array] the included objects, empty if the response included none + def entries_of(klass) + key = klass.__send__(:includes_key) + @data.fetch(key) { @data.fetch(TWEET_KEYS[key], []) } + end + + # Build or fetch the identifier index for one type of expanded object + # + # @api private + # @param klass [Class] the resource class + # @return [Hash{String => Hash}] the expanded objects keyed by identifier + def index(klass) + @index[klass.__send__(:includes_key)] ||= entries_of(klass).group_by { |attrs| attrs[klass.__send__(:id_key)] }.transform_values(&:first) + end + end + private_constant :Includes + end +end diff --git a/x-objects/lib/x/objects/list.rb b/x-objects/lib/x/objects/list.rb new file mode 100644 index 00000000..ebb30100 --- /dev/null +++ b/x-objects/lib/x/objects/list.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true + +require "uri" +require_relative "cursor" +require_relative "finders" +require_relative "page_limit" +require_relative "resource" + +module X + module Objects + # A curated list of users + # @api public + class ::X::List < Resource + extend Finders + + # Every public list field + # + # A minor release may add to it the fields the API adds, so that a lookup asks for them too; see + # {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own. + FIELDS = %w[created_at description follower_count id member_count name private].freeze + # Every expansion available on list endpoints + # + # A minor release may add to it the expansions the API adds, so that a lookup asks for them too; see + # {Resource#hydrated?} for what that means for a resource looked up with a list of expansions of its own. + EXPANSIONS = %w[owner_id].freeze + # Maximum number of users or posts per page + MAX_RESULTS = 100 + private_constant :MAX_RESULTS + + class << self + # The API endpoint used to look up lists by identifier + # + # @api private + # @return [String] the endpoint + # @example Get the endpoint + # X::List.__send__(:endpoint) # => "lists" + def endpoint + "lists" + end + + # The query parameter that selects list fields + # + # @api private + # @return [String] the fields parameter + # @example Get the fields parameter + # X::List.__send__(:fields_key) # => "list.fields" + def fields_key = "list.fields" + + private :endpoint, :fields_key + + # The default query parameters requesting every list field and expansion + # + # @api public + # @return [Hash{String => Array}] the default query parameters + # @example Get the default parameters + # X::List.default_params["list.fields"] + def default_params + {"list.fields" => FIELDS, "user.fields" => User::FIELDS, "expansions" => EXPANSIONS} + end + + # Create a list owned by the authenticated user + # + # @api public + # @param name [String] the name of the list + # @param client [Object] the client used to make the request + # @param params [Hash] additional request body fields: description and private + # @return [List] the created list, holding only its identifier and name + # @raise [MissingResource] if the API answers without the list + # @example Create a private list + # X::List.create("Rubyists", client: client, description: "People who write Ruby", private: true) + def create(name, client:, **params) + body = client.post("lists", {name:, **params}, **Utils::JSON_CLASSES) + created_from_response(body, "POST lists", client:) + end + + # Update the name, description, or privacy of a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @param client [Object] the client used to make the request + # @param params [Hash] the request body fields to change: name, description, and private + # @return [Boolean] true if the list was updated + # @raise [ArgumentError] if no field is given to change, before any request + # @example Rename a list and make it private + # X::List.update("1234567890", client: client, name: "Rubyists", private: true) + def update(list, client:, **params) + raise ArgumentError, "a list update needs a field to change, such as name, description, or private" if params.empty? + + body = client.put("lists/#{Utils.id_of(list, self)}", params, **Utils::JSON_CLASSES) + Utils.written(body, "updated").eql?(true) + end + + # Delete a list as the authenticated user + # + # @api public + # @param list [List, String, Integer] the list or its identifier + # @param client [Object] the client used to make the request + # @return [Boolean] true if the list was deleted + # @example Delete a list + # X::List.delete("1234567890", client: client) + def delete(list, client:) + body = client.delete("lists/#{Utils.id_of(list, self)}", **Utils::JSON_CLASSES) + Utils.written(body, "deleted").eql?(true) + end + end + + # @!attribute [r] name + # The name + # @api public + # @return [String, nil] the name + # @example Get the name + # list.name + attribute :name + + # @!attribute [r] description + # The description + # @api public + # @return [String, nil] the description + # @example Get the description + # list.description + attribute :description + + # @!attribute [r] created_at + # The time when the list was created + # @api public + # @return [Time, nil] the creation time + # @example Get the creation time + # list.created_at + attribute :created_at, :time + + # @!attribute [r] follower_count + # The number of followers + # @api public + # @return [Integer, nil] the follower count + # @example Get the follower count + # list.follower_count + attribute :follower_count, :integer + + # @!attribute [r] member_count + # The number of members + # @api public + # @return [Integer, nil] the member count + # @example Get the member count + # list.member_count + attribute :member_count, :integer + + # @!attribute [r] owner_id + # The identifier of the owner + # @api public + # @return [Integer, nil] the owner identifier + # @example Get the owner identifier + # list.owner_id + attribute :owner_id, :integer + + # @!attribute [r] private + # Whether the list is private + # @api public + # @return [Boolean, nil] true if the list is private + # @example Check whether a list is private + # list.private? + attribute :private, :boolean + + # @!method private? + # Check whether the list is private + # @api public + # @return [Boolean] true if the list is private + # @example Check whether the list is private + # list.private? + + # @!method owner + # The owner, resolved from the includes or as a stub holding only its identifier + # @api public + # @return [User, nil] the owner + # @example Get the owner's username + # list.owner.username + reference :owner, :User, key: %w[owner_id] + + # The members of this list + # + # @api public + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the members + # @example Print every member + # list.members.each { |user| puts user.username } + def members(**params) + cursor(User, "lists/#{id}/members", max_results: MAX_RESULTS, total: :member_count, **params) + end + + # The followers of this list + # + # @api public + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the followers + # @example Print every follower + # list.followers.each { |user| puts user.username } + def followers(**params) + cursor(User, "lists/#{id}/followers", max_results: MAX_RESULTS, total: :follower_count, **params) + end + + # The posts by members of this list + # + # @api public + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the posts + # @example Print the most recent posts + # list.posts.first(10).each { |post| puts post.text } + def posts(**params) + cursor(Post, "lists/#{id}/tweets", max_results: MAX_RESULTS, **params) + end + + # Check whether a user is a member of this list, scanning until one matches + # + # The API has no lookup for a membership, so this scans either the members of the list or the lists + # the user is on. A private list scans its members, since a user's memberships leave private lists + # out. A public list scans the lists the user is on when there are fewer of them than members, as its + # member_count and the user's listed_count tell, looking up the list or the user first when either is + # a stub. The API bills every resource a scan returns, so max_pages limits the pages it reads, raising + # PageLimitReached rather than read past them. + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @param max_pages [Integer, nil] the most pages of members or memberships to read, or nil for no limit + # @return [Boolean] true if the user is a member + # @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request + # @raise [PageLimitReached] if the scan reads max_pages pages without the user, and the API names another + # @example Check whether a user is on a list + # list.member?(user) + # @example Read no more than ten pages to tell + # list.member?(user, max_pages: 10) + def member?(user, max_pages: nil) + member, max_pages = User.from_id(user), PageLimit.check!(max_pages) + return PageLimit.scan(members.stubs, member, what: "List#member?", max_pages:) unless fewer_memberships?(user) + + PageLimit.scan(User.from_id(member, client: client!).list_memberships.stubs, self, what: "List#member?", max_pages:) + end + + # The permalink of the list + # + # @api public + # @return [String] the x.com address of the list + # @example Get the permalink + # list.permalink # => "https://x.com/i/lists/1234567890" + def permalink = "https://x.com/i/lists/#{id}" + + # The permalink of the list as a URI + # + # @api public + # @return [URI::Generic] the x.com address of the list + # @example Get the address as a URI + # list.uri # => # + def uri = URI(permalink) + + # Add a member to this list as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the user is now a member + # @example Add a member + # list.add_member(user) + def add_member(user) + body = client!.post("lists/#{id}/members", {user_id: Utils.id_of(user, User)}, **Utils::JSON_CLASSES) + Utils.written(body, "is_member").eql?(true) + end + + # Remove a member from this list as the authenticated user + # + # @api public + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the user is no longer a member + # @example Remove a member + # list.remove_member(user) + def remove_member(user) + body = client!.delete("lists/#{id}/members/#{Utils.id_of(user, User)}", **Utils::JSON_CLASSES) + Utils.written(body, "is_member").eql?(false) + end + + # Update the name, description, or privacy of this list as the authenticated user + # + # The list keeps the attributes it was built with, so refresh it to read the new ones. + # + # @api public + # @param params [Hash] the request body fields to change: name, description, and private + # @return [Boolean] true if the list was updated + # @raise [ArgumentError] if no field is given to change, before any request + # @example Change the description of a list + # list.update(description: "People who write Ruby") + def update(**params) + self.class.update(self, client: client!, **params) + end + + # Delete this list as the authenticated user + # + # @api public + # @return [Boolean] true if the list was deleted + # @example Delete a list + # list.delete + def delete + self.class.delete(self, client: client!) + end + + alias_method :tweets, :posts + + private + + # Check whether the user is on fewer lists than this public list has members + # @api private + # @param user [User, String, Integer] the user or their identifier + # @return [Boolean] true if the lists the user is on are fewer than the members of this public list + def fewer_memberships?(user) + list = hydrate + return false if list.nil? || list.private? + + member_count = list.member_count + return false if member_count.nil? + + listed_count = listed_count_of(user) + !listed_count.nil? && listed_count < member_count + end + + # The number of lists a user is on, looking up a user that is not hydrated + # @api private + # @param user [User, String, Integer] the user or their identifier + # @return [Integer, nil] the listed count, or nil if the user was not found + def listed_count_of(user) + known = user if user.is_a?(User) && user.hydrated? + (known || User.find(User.from_id(user), client: client!))&.listed_count + end + end + end +end diff --git a/x-objects/lib/x/objects/lookups.rb b/x-objects/lib/x/objects/lookups.rb new file mode 100644 index 00000000..4a475e67 --- /dev/null +++ b/x-objects/lib/x/objects/lookups.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "lookups/communities" +require_relative "lookups/direct_messages" +require_relative "lookups/lists" +require_relative "lookups/media" +require_relative "lookups/posts" +require_relative "lookups/spaces" +require_relative "lookups/trends" +require_relative "lookups/users" + +module X + module Objects + # Lookups, searches, and collections, the modules of which X::Objects::API includes + # + # Internal to x-objects: a namespace of the modules API includes into a client, and not itself included, so that + # the modules it holds are not constants of the client, where the name of one, such as Media, would shadow a + # constant of the same name in a class that inherits from the client. Include API rather than any of them. + # + # @api private + module Lookups + end + private_constant :Lookups + end +end diff --git a/x-objects/lib/x/objects/lookups/communities.rb b/x-objects/lib/x/objects/lookups/communities.rb new file mode 100644 index 00000000..7266a02d --- /dev/null +++ b/x-objects/lib/x/objects/lookups/communities.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative "../community" + +module X + module Objects + module Lookups + # Look up and search communities, mixed into a client through API + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Communities + # Look up a community by identifier + # + # @api public + # @param id [String, Integer, Community] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [Community, nil] the community or nil if the community was not found + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up a community + # client.find_community(1234567890).name + def find_community(id, **params, &) + Community.find(id, client: self, **params, &) + end + + # Look up a community by identifier, which must exist + # + # @api public + # @param id [String, Integer, Community] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [Community] the community + # @raise [MissingResource] if the community was not found + # @example Look up a community + # client.find_community!(1234567890).name + def find_community!(id, **params) + Community.find!(id, client: self, **params) + end + + # Search communities + # + # @api public + # @param query [String] the search query + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the matching communities + # @example Print the communities matching a query + # client.search_communities("ruby").each { |community| puts community.name } + def search_communities(query, **params) + Community.search(query, client: self, **params) + end + end + end + end +end diff --git a/x-objects/lib/x/objects/lookups/direct_messages.rb b/x-objects/lib/x/objects/lookups/direct_messages.rb new file mode 100644 index 00000000..e3ef1e5d --- /dev/null +++ b/x-objects/lib/x/objects/lookups/direct_messages.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative "../direct_message" + +module X + module Objects + module Lookups + # Look up direct messages, and the conversations they belong to, mixed into a client through API + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module DirectMessages + # Look up a direct message event by identifier + # + # @api public + # @param id [String, Integer, DirectMessage] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [DirectMessage, nil] the event or nil if the event was not found + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up a direct message + # client.find_direct_message(1234567890).text + def find_direct_message(id, **params, &) + DirectMessage.find(id, client: self, **params, &) + end + + # Look up a direct message event by identifier, which must exist + # + # @api public + # @param id [String, Integer, DirectMessage] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [DirectMessage] the event + # @raise [MissingResource] if the event was not found + # @example Look up a direct message + # client.find_direct_message!(1234567890).text + def find_direct_message!(id, **params) + DirectMessage.find!(id, client: self, **params) + end + + # The most recent direct message events across every conversation + # + # @api public + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the events + # @example Print the most recent direct messages + # client.direct_messages.first(10).each { |message| puts message.text } + def direct_messages(**params) + DirectMessage.all(client: self, **params) + end + + # The direct message events in the one-to-one conversation with a user + # + # @api public + # @param user [User, String, Integer] the other participant or their identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the events + # @example Print the conversation with a user + # client.direct_messages_with(user).each { |message| puts message.text } + def direct_messages_with(user, **params) + DirectMessage.with(user, client: self, **params) + end + + # The direct message events of a conversation, one-to-one or group + # + # @api public + # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier + # @param params [Hash] query parameters merged over the default parameters, such as event_types + # @return [Cursor] a cursor over the events + # @raise [ArgumentError] if the conversation identifier is not one + # @example Print the conversation a message belongs to + # client.direct_messages_in(message).each { |event| puts event.text } + def direct_messages_in(conversation, **params) + DirectMessage.in(conversation, client: self, **params) + end + + alias_method :find_dm, :find_direct_message + alias_method :find_dm!, :find_direct_message! + alias_method :dms, :direct_messages + alias_method :dms_with, :direct_messages_with + alias_method :dms_in, :direct_messages_in + end + end + end +end diff --git a/x-objects/lib/x/objects/lookups/lists.rb b/x-objects/lib/x/objects/lookups/lists.rb new file mode 100644 index 00000000..7fd5ffeb --- /dev/null +++ b/x-objects/lib/x/objects/lookups/lists.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../list" + +module X + module Objects + module Lookups + # Look up lists, mixed into a client through API + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Lists + # Look up a list by identifier + # + # @api public + # @param id [String, Integer, List] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [List, nil] the list or nil if the list was not found + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up a list + # client.find_list(1234567890).name + def find_list(id, **params, &) + List.find(id, client: self, **params, &) + end + + # Look up a list by identifier, which must exist + # + # @api public + # @param id [String, Integer, List] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [List] the list + # @raise [MissingResource] if the list was not found + # @example Look up a list + # client.find_list!(1234567890).name + def find_list!(id, **params) + List.find!(id, client: self, **params) + end + end + end + end +end diff --git a/x-objects/lib/x/objects/lookups/media.rb b/x-objects/lib/x/objects/lookups/media.rb new file mode 100644 index 00000000..9c7ed2aa --- /dev/null +++ b/x-objects/lib/x/objects/lookups/media.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "../batch_finders" +require_relative "../media" + +module X + module Objects + module Lookups + # Look up media by media key + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Media + # Look up media by media key + # + # A post refers to its media by media key, and so does what an upload returns, so this reads the photo, + # video, or animated GIF that was uploaded, with its URL and variants. + # + # @api public + # @param media_key [String, X::Media, #media_key] the media key, such as 3_1880028106020515840, media, or what + # an upload returned + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field + # parameter to leave some out builds media that is not hydrated, so hydrate fetches the rest + # @return [X::Media, nil] the media, or nil if it was not found + # @yieldparam problem [Problem] each problem the API reported + # @example Look up media by media key + # client.find_media("3_1880028106020515840") + # @example Look up media that was uploaded + # client.find_media(uploaded) + def find_media(media_key, **params, &) + X::Media.find(media_key, client: self, **params, &) + end + + # Look up media by media key, in parallel batches + # + # @api public + # @param media [Array] the media keys, or the media, or what the uploads + # returned, whose keys are taken + # @param concurrency [Integer] the number of batches looked up at once, which must be at least one; each is + # a request of up to 100 media keys, so a lower number spends a rate limit more slowly + # @param params [Hash] query parameters merged over the default parameters; one that overrides a default + # field parameter builds media that is not hydrated, so hydrate fetches the rest + # @return [Array] the media that was found + # @raise [ArgumentError] if the concurrency is less than one + # @yieldparam problem [Problem] each problem the API reported, such as a media key that was not found + # @example Look up the media of a post + # client.find_all_media(post.media) + # @example Look up media by media key + # client.find_all_media(%w[3_1880028106020515840 3_1880028106020515841]) + def find_all_media(media, concurrency: BatchFinders::DEFAULT_CONCURRENCY, **params, &) + X::Media.find_all(media, client: self, concurrency:, **params, &) + end + + # Look up media by media key, raising if it is not found + # + # @api public + # @param media_key [String, X::Media, #media_key] the media key, media, or what an upload returned + # @param params [Hash] query parameters merged over the default parameters + # @return [X::Media] the media + # @raise [MissingResource] if the media was not found + # @example Look up media that must exist + # client.find_media!("3_1880028106020515840") + def find_media!(media_key, **params) + X::Media.find!(media_key, client: self, **params) + end + end + end + end +end diff --git a/x-objects/lib/x/objects/lookups/posts.rb b/x-objects/lib/x/objects/lookups/posts.rb new file mode 100644 index 00000000..ef741a26 --- /dev/null +++ b/x-objects/lib/x/objects/lookups/posts.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require_relative "../post" +require_relative "../post_usage" + +module X + module Objects + module Lookups + # Look up, search, and count posts, and report how many posts the app has read, mixed into a client through API + # + # Internal to x-objects: X::Objects::API includes it, and its methods are public API of the client that + # includes API, but the module is only how they are grouped, and some of them need the methods of another, + # so include API rather than this module alone. + # + # @api semipublic + module Posts + # Look up a post by identifier + # + # @api public + # @param id [String, Integer, Post] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [Post, nil] the post or nil if the post was not found + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up a post + # client.find_post(1234567890).text + def find_post(id, **params, &) + Post.find(id, client: self, **params, &) + end + + # Look up a post by identifier, which must exist + # + # @api public + # @param id [String, Integer, Post] the identifier + # @param params [Hash] query parameters merged over the default parameters + # @return [Post] the post + # @raise [MissingResource] if the post was not found + # @example Look up a post + # client.find_post!(1234567890).text + def find_post!(id, **params) + Post.find!(id, client: self, **params) + end + + # Look up many posts by identifier, in parallel batches + # + # @api public + # @param ids [Array] the identifiers + # @param concurrency [Integer] the number of batches looked up at once, which must be at least one; each is + # a request of up to 100 posts, so a lower number spends a rate limit more slowly + # @param params [Hash] query parameters merged over the default parameters + # @return [Array] the posts that were found + # @raise [ArgumentError] if the concurrency is less than one + # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found + # @example Look up many posts + # client.find_all_posts([1234567890, 1234567891]) + # @example Look up many posts one batch at a time + # client.find_all_posts(ids, concurrency: 1) + def find_all_posts(ids, concurrency: BatchFinders::DEFAULT_CONCURRENCY, **params, &) + Post.find_all(ids, client: self, concurrency:, **params, &) + end + + # Search recent posts + # + # @api public + # @param query [String] the search query + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the matching posts + # @example Print posts about Ruby + # client.search_posts("ruby -is:retweet").each { |post| puts post.text } + def search_posts(query, **params) + Post.search(query, client: self, **params) + end + + # The posts of the authenticated user that other users have reposted + # + # @api public + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the reposted posts + # @example Print the reposted posts + # client.reposts_of_me.each { |post| puts post.text } + def reposts_of_me(**params) + Post.reposts_of_me(client: self, **params) + end + + # Search the full archive of posts + # + # @api public + # @param query [String] the search query + # @param params [Hash] query parameters merged over the default parameters + # @return [Cursor] a cursor over the matching posts + # @example Print every post about Ruby + # client.search_all_posts("ruby -is:retweet").each { |post| puts post.text } + def search_all_posts(query, **params) + Post.search_all(query, client: self, **params) + end + + # Count the recent posts that match a query, without reading them + # + # The API bills a count by the request, not by the post, and refuses OAuth 1.0a for it, so a client that signs + # with OAuth 1.0a counts with a copy that authenticates as the app. + # + # @api public + # @param query [String] the search query + # @param params [Hash] query parameters, such as start_time and end_time, and max_pages, the most pages of + # counts to request + # @return [Integer] the number of matching posts + # @example Count the recent posts about Ruby with an app-only client + # client.count_posts("ruby") + def count_posts(query, **params) = Post.count(query, client: self, **params) # steep:ignore DifferentMethodParameterKind + + # Count the posts from the full archive that match a query, without reading them + # + # The API bills a count by the request, not by the post, and refuses OAuth 1.0a for it, so a client that signs + # with OAuth 1.0a counts with a copy that authenticates as the app. A client signed in with OAuth 2.0 as a user + # that holds no credentials of the app counts as the user, which the full archive refuses with X::Forbidden. + # + # @api public + # @param query [String] the search query + # @param params [Hash] query parameters, such as start_time and end_time, and the max_pages of + # X::Post.count_all, which limits the pages of counts requested + # @return [Integer] the number of matching posts + # @example Count every post about Ruby from 2024 + # client.count_all_posts("ruby", start_time: "2024-01-01T00:00:00Z", end_time: "2025-01-01T00:00:00Z") + def count_all_posts(query, **params) = Post.count_all(query, client: self, **params) # steep:ignore DifferentMethodParameterKind + + # Count the posts from the last seven days that match a query, by period + # + # The API bills a count by the request, not by the post, and refuses OAuth 1.0a for it, so a client that signs + # with OAuth 1.0a counts with a copy that authenticates as the app. + # + # @api public + # @param query [String] the search query + # @param params [Hash] query parameters, such as granularity, which is day by default, and max_pages, the most + # pages of counts to request + # @return [Hash{Range