Skip to content

Replace rest-client with Faraday, require Ruby 3.3, fix spec mismatches - #92

Closed
posiczko-ferret wants to merge 18 commits into
procore-oss:mainfrom
posiczko-ferret:faraday-migration
Closed

posiczko-ferret wants to merge 18 commits into
procore-oss:mainfrom
posiczko-ferret:faraday-migration

Conversation

@posiczko-ferret

Copy link
Copy Markdown

Summary

Replaces rest-client with Faraday, requires Ruby 3.3+, and fixes five bugs found reviewing the gem against Procore's OpenAPI spec. Reasoning for the HTTP client switch is in docs/adr/0001-replace-rest-client-with-faraday.md.

 lib/procore/
 ├── requestable.rb        # Faraday + faraday-retry + faraday-multipart
-│                         # rest-client
+│                         # Accept header (was "Accepts"), versioned paths pass through
+├── request.rb            # Procore::Request struct replaces RestClient::Request
 ├── response.rb
+│                         # empty body -> {}, pagination keeps rest/vX.Y or vapid
 ├── errors.rb             # Struct stand-ins replace OpenStruct (Ruby 4.0)
 ├── util.rb               # bearer tokens redacted in every log line
 └── auth/
     ├── token.rb
+    │                     # explicit to_h / to_json for the Session, Redis and Dalli stores
     ├── access_token_credentials.rb
+    │                     # revoke failures raise Procore::OAuthError
     └── stores/file.rb    # YAML.safe_load with permitted classes (Ruby 3.4+)

Error mapping for callers:

on(request)
  2xx            -> Response
  401/403/404    -> AuthorizationError / ForbiddenError / NotFoundError
  400/422        -> InvalidRequestError
  429            -> RateLimitError
  other          -> ServerError
  timeout, refused, reset, closed   -> retry with 1.5s * 1.5^n backoff,
                                       then APIConnectionError
  DNS, unreachable, TLS             -> APIConnectionError   (was a raw exception)

Bug fixes on top of the migration:

  • Gem failed outside Rails: three ActiveSupport core extensions were used but never required. Session/Redis/Dalli stores serialized a token as "#<Procore::Auth::Token:0x...>", sync raised on deep_merge, OAuthError raised on presence.
  • Empty bodies (204) parsed to the String "{}".
  • pagination[:next] dropped the API version, so following a v1.1, v2.0 or vapid link requested v1.0.
  • README showed e.response.errors and rescue Procore:Error, neither of which works.
  • Header sent as Accepts instead of Accept.
  • Pre-existing: delete ignored company_id; DELETE requests logged the Authorization header.

Evidence

  • Before: rake test on main passes, but a bare ruby -e 'require "procore"' process shows the breakage the suite hides because test_helper loads ActiveRecord:

    Procore::Auth::Token.new(...).to_json  #=> "#<Procore::Auth::Token:0x...>"
    {}.deep_merge({})                      #=> NoMethodError
    Procore::Response.new(body: "", ...).body  #=> "{}" (String)
    

    After: test/procore/standalone_load_test.rb runs those scenarios in a subprocess with only require "procore".

    bundle exec rake test
    125 runs, 203 assertions, 0 failures, 3 errors, 0 skips
    

    The 3 errors are the Dalli store tests, which need the memcached from docker-compose.yaml; they pass in CI where memcached is a service.

  • test/procore/rest_client_leakage_test.rb is the migration's acceptance check: nine of its tests failed before the switch and pass now.

Merge Danger

Door: two-way until 2.0.0 is pushed to RubyGems. Reverting the merge restores rest-client with no data or external state to unwind.

Blast Radius: consumers

This is the 2.0.0 major. Breaking for gem users:

  • Ruby 3.3+ required.
  • Response#request is a Procore::Request, not a RestClient::Request.
  • APIConnectionError#cause and Requestable::HTTP_EXCEPTIONS are Faraday classes; TLS, DNS and unreachable-host failures now raise APIConnectionError.
  • Response#pagination paths include the version segment ("rest/v1.0/projects?page=2").
  • activesupport >= 7.0.
  • Apps that got RestClient transitively through this gem must add it themselves.

Merging triggers the Release workflow, since lib/procore/version.rb changes in this PR. On this fork the RubyGems push step has no credentials and will fail without side effects.

Parts generated with Claude Code

Ensure pstore is a explicit dependency when ruby used is >= 4. Ruby 4 removes pstore from stdlib. Ruby 3 uses pstore from stdlib.
Replaced the OpenStruct objects with Structs on OAuthError, DuckResponse and DuckRequest. They use keyword_init: true because the gem still supports Ruby 3
…o require Ruby >= 3.3

Drop EOL-ed rubies <= 3.2
YAML in ruby up to 3.3 reads the file with unsafe_load, which accepts any class, while from 3.4 on, it uses safe_load and only allows Symbol.

One way to fix this mess would be to save tokens as hashes and rebuild Token on read. This would break token files written by old gems & require migrating.

Instead, subclass YAML::Store, allow old style loading of expires_at (Time) and Procore::Auth::Token for backward compatibility.
- Add `redact` method to replace Bearer tokens with `###` in logs for security.
- Introduce tests to verify Bearer token redaction for `log_info`, `log_error`, and token handling in requests.
- Fix `delete` request to include dynamic headers from `options`.
- Add tests for `delete` with `company_id` and response request matching RestClient values.
- Refactor constants and logging methods for maintainability.
- Migrated HTTP logic in `Requestable` from RestClient to Faraday with multipart and retry middleware.
- Introduced `Procore::Request` to expose request details while maintaining compatibility with existing behavior.
- Updated error handling and connection retries to utilize Faraday's features.
- Expanded test coverage, including response metadata, redirects, and retry scenarios.
- Adjusted dependencies in `procore.gemspec` to remove RestClient and add Faraday-related gems.
- Set `TargetRubyVersion` to 3.3 and disable `NewCops`.
- Replace deprecated cops with their updated equivalents (e.g., `Style/FlipFlop` to `Lint/FlipFlop`).
- Adjust options like `ExcludedMethods` to `AllowedMethods`.
- Disable Rails-specific cop `Rails/RakeEnvironment`, as the project is a gem.
- Refine layout and naming configurations for consistency.
- **Breaking:** Drop support for Ruby < 3.3 (EOL). Require Ruby 3.3+.
- **Breaking:** Replace `rest-client` with `Faraday` (`Procore::Request`, improved error handling, TLS/dns failure reporting).
- Fix token revoke errors raising `OAuth2::Error` instead of `Procore::OAuthError`.
- Fix request logging to include `X-Request-Id` and redact sensitive tokens.
- Fix DELETE requests ignoring `options` (e.g., `Procore-Company-Id`).
- Add Ruby 3.4/4.0 support (replace `OpenStruct` with `Struct`, add `pstore`, YAML compatibility in `File` store).
    The gem only required active_support/hash_with_indifferent_access but
    used three core extensions it never loaded. Without a full ActiveSupport
    load, the Session, Redis and Dalli stores serialized a token as
    "#<Procore::Auth::Token:0x...>" so every request raised
    MissingTokenError, sync raised NoMethodError on deep_merge, and
    OAuthError raised NoMethodError on presence.

    Give Token an explicit to_h/to_json, require the two remaining
    extensions where they are used, and require activesupport >= 7.0.
    Covered by subprocess tests that load the gem with nothing else.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
  The raw body defaulted to "{}".to_json, which is the JSON string
  literal "\"{}\"", so a 204 No Content response gave a String body.

  Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
  Response#pagination stripped the rest/vX.Y or vapid prefix from Link
  header URLs, so client.get(pagination[:next]) always used
  default_version. Following a next link from a v1.1, v2.0 or vapid
  response silently requested v1.0.

  Pagination paths now keep the version segment, and get/delete pass a
  path that already names its version through without prefixing it.

  Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
  e.response.errors does not exist; Response exposes the parsed body as
  e.response.body. Also fix the single-colon Procore:Error rescue.

  Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
  Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@posiczko-ferret
posiczko-ferret requested review from a team as code owners October 27, 2026 00:45
@posiczko-ferret
posiczko-ferret marked this pull request as draft October 27, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant