- Installation
- Upgrading to 2.0
- Configuration
- Making Requests
- Usage
- Error Handling
- Pagination
- Sync Actions
- Stores
- Full Example
- Contributing
Add this line to your application's Gemfile:
gem "procore"The gem requires Ruby 3.3 or newer.
Version 2.0 requires Ruby 3.3 or newer, and sends requests with
Faraday instead of rest-client. The
request methods (get, post, put, patch, delete, sync), their
arguments, Procore::Response and the configuration options are unchanged.
What changes is mostly which exceptions you see. The reasoning is recorded in
docs/adr/0001-replace-rest-client-with-faraday.md.
Every failure now raises a Procore::Error subclass, so
rescue Procore::Error catches anything the client raises for a failed request.
| Failure | 1.x raises | 2.0 raises | cause in 1.x |
cause in 2.0 |
|---|---|---|---|---|
| HTTP 401 | AuthorizationError |
unchanged | – | – |
| HTTP 403 | ForbiddenError |
unchanged | – | – |
| HTTP 404 | NotFoundError |
unchanged | – | – |
| HTTP 400, 422 | InvalidRequestError |
unchanged | – | – |
| HTTP 429 | RateLimitError |
unchanged | – | – |
| Any other non-2xx status | ServerError |
unchanged | – | – |
| Connect timeout | APIConnectionError |
unchanged | RestClient::Exceptions::OpenTimeout |
Faraday::ConnectionFailed |
| Read timeout | APIConnectionError |
unchanged | RestClient::Exceptions::ReadTimeout |
Faraday::TimeoutError |
| Connection refused | APIConnectionError |
unchanged | Errno::ECONNREFUSED |
Faraday::ConnectionFailed |
| Connection reset | APIConnectionError |
unchanged | Errno::ECONNRESET |
Faraday::ConnectionFailed |
| Server closed the connection | APIConnectionError |
unchanged | RestClient::ServerBrokeConnection |
Faraday::ConnectionFailed |
| Access token could not be fetched | APIConnectionError |
unchanged | Procore::OAuthError |
unchanged |
| TLS certificate error | RestClient::SSLCertificateNotVerified |
APIConnectionError |
– | Faraday::SSLError |
| DNS lookup failed | SocketError |
APIConnectionError |
– | Faraday::ConnectionFailed |
| Host unreachable | Errno::EHOSTUNREACH |
APIConnectionError |
– | Faraday::ConnectionFailed |
Retries work as before: timeouts, refused and reset connections, and token
fetch failures are retried max_retries times with backoff before
APIConnectionError is raised. HTTP error statuses, including 429 and 5xx, are
not retried. Every request method is retried, including post and patch.
To upgrade:
-
If you rescue rest-client or socket errors, rescue
Procore::APIConnectionErrorinstead.# 1.x rescue RestClient::SSLCertificateNotVerified, SocketError, Errno::EHOSTUNREACH => e # 2.0 rescue Procore::APIConnectionError => e
-
If you inspect
cause, check for the Faraday class from the table. Faraday keeps the underlying error, so the originalErrnoorSocketErroris still reachable ase.cause.wrapped_exception.# 1.x retry_later if e.cause.is_a?(Errno::ECONNREFUSED) # 2.0 retry_later if e.cause.wrapped_exception.is_a?(Errno::ECONNREFUSED)
-
If you read
Procore::Requestable::HTTP_EXCEPTIONS, it now lists Faraday exception classes instead ofRestClient::Exceptions::TimeoutandRestClient::ServerBrokeConnection.
-
Response#requestis no longer aRestClient::Request. It is aProcore::Request.url,method(a lowercase string such as"get") andheadersreturn the same values as before.payloadnow returns the request body as a string; in 1.x, reading it raisedIOError. -
The gem no longer loads rest-client. If your application uses
RestClientdirectly but only got it through this gem, add it to your Gemfile:gem "rest-client"
The Procore Gem exposes a configuration with several options.
For example, when using this gem in a Rails application, the configuration can be done like below:
# config/initializes/procore.rb
require "procore"
Procore.configure do |config|
# Base API host name. Alter this depending on your environment - in a
# staging or test environment you may want to point this at a sandbox
# instead of production.
config.host = ENV.fetch("PROCORE_BASE_API_PATH", "https://api.procore.com")
# Base Login host name used by the OAuth client to generate API tokens.
# Alter this depending on your environment - in a staging or test environment
# you may want to point this at a sandbox instead of production.
config.login_host = ENV.fetch("PROCORE_BASE_LOGIN_PATH", "https://login.procore.com")
# When using #sync action, sets the default batch size to use for chunking
# up a request body. Example: if the size is set to 500, and 2,000 updates
# are desired, 4 requests will be made. Note, the maximum size is 1000.
config.default_batch_size = 500
# The default API version to use if none is specified in the request.
# Should be either "v1.0" (recommended) or "vapid" (legacy).
config.default_version = "v1.0"
# Integer: Number of times to retry a failed API call. Reasons an API call
# could potentially fail:
# 1. Service is briefly down or unreachable
# 2. Timeout hit - service is experiencing immense load or mid restart
# 3. Because computers
#
# Defaults to 1 retry. Would recommend 3-5 for production use.
# Has exponential backoff - first request waits a 1.5s after a failure,
# next one 2.25s, next one 3.375s, 5.0, etc.
config.max_retries = 3
# Float: Threshold for canceling an API request. If a request takes longer
# than this value it will automatically cancel.
config.timeout = 5.0
# Instance of a Logger. This gem will log information about requests,
# responses and other things it might be doing. In a Rails application it
# should be set to Rails.logger
config.logger = Rails.logger
# String: User Agent sent with each API request. API requests must have a user
# agent set. It is recomended to set the user agent to the name of your
# application.
config.user_agent = "MyAppName"
endAt the core of the gem is the Client class. Clients are initialized with a
client_id and client_secret which can be obtained by signing up for
Procore's Developer Program.
A client requires a store. A store manages a particular user's access token. Stores automatically manage tokens for you - refreshing, revoking and storage are abstracted away to make your code as simple as possible. There are several different types of stores available to you.
The Client class exposes #get, #post, #put, #patch, #sync and
#delete methods to you.
get(path, version: "", query: {}, options: {})
post(path, version: "", body: {}, options: {})
put(path, version: "", body: {}, options: {})
patch(path, version: "", body: {}, options: {})
delete(path, version: "", query: {}, body: nil, options: {})
sync(path, version: "", body: {}, options: {})delete sends a JSON body only when body: is given, for the endpoints that
take one (bulk destroys, for example):
client.delete("projects/1/items/bulk_destroy", body: { ids: [1, 2] }, options: { company_id: 1 })All paths are relative, the gem will handle expanding them. An API version can be specified in the version: argument, otherwise the default_version in the configuration will be used.
| Example | Requested URL |
|---|---|
client.get("me") |
https://api.procore.com/rest/v1.0/me |
client.get("me", version: "v1.1") |
https://api.procore.com/rest/v1.1/me |
client.get("me", version: "vapid") |
https://api.procore.com/vapid/me |
In order for your application to work with Multiple Procore Zones (MPZ), each call your application makes to the Procore API must contain a request header that includes the Procore-Company-Id field. This request header field specifies the id of the company into which you are making the call.
This can be achieved by setting the company_id in the request options. (And this gem will convert it into a Procore-Company-Id header automatically.)
For example:
client.get("companies/12345/users", options: { company_id: 12345 })Note: Only the following Procore API endpoints do not require a request header containing the Procore-Company-Id field:
- Show User Info - (GET /rest/v1.0/me)
- List Companies - (GET /rest/v1.0/companies)
store = Procore::Auth::Stores::Session.new(session: session)
client = Procore::Client.new(
client_id: "client id",
client_secret: "client secret",
store: store
)
# Get the current user's companies
companies = client.get("companies").body
companies.first[:name] #=> "Procore Company 1"
# Get a company's projects (note the company_id value in options)
projects = client.get("projects", query: {company_id: <company_id>}, options: {company_id: <company_id>}).body
projects.first[:name] #=> "Project 1"The first step is to place the user's token into the store. For this example, the tokens will be stored in the Rails session. In the controller action that handles the end of the OAuth 2.0 Flow add the following code:
def handle_callback
if auth_hash.blank?
redirect_to '/auth/procore'
else
auth_hash = request.env['omniauth.auth']
# Create a new token to save into a store
token = Procore::Auth::Token.new(
access_token: auth_hash["credentials"]["token"],
refresh_token: auth_hash["credentials"]["refresh_token"],
expires_at: auth_hash["credentials"]["expires_at"]
)
store = Procore::Auth::Stores::Session.new(session: session)
store.save(token)
redirect_to root_path
end
endWith the user's token stored, requests can be made from anywhere in the application that has access to the Rails session.
client = Procore::Client.new(
client_id: Rails.application.secrets.procore_app_id,
client_secret: Rails.application.secrets.procore_app_secret,
store: Procore::Auth::Stores::Session.new(session: session)
)
client.get("me")Expired tokens will automatically be refreshed, but can also be refreshed manually:
client.refreshTokens may also be manually revoked, forcing the client to refresh its token on the next request:
client.revokeAn integration that runs without a signed-in user authenticates with the
client credentials grant
instead. Pass grant_type: :client_credentials and the client requests its own
token whenever the store is empty or the token has expired, so nothing has to be
put in the store first. A Memory store is enough when one process owns the
token; use a shared store when several processes or servers do.
client = Procore::Client.new(
client_id: ENV["PROCORE_CLIENT_ID"],
client_secret: ENV["PROCORE_CLIENT_SECRET"],
store: Procore::Auth::Stores::Memory.new(key: "service-account"),
grant_type: :client_credentials,
)
client.get("companies")client.refresh and client.revoke work the same way as for a user token.
The Procore Gem raises errors whenever a request returns a non 2xx response.
Errors return both a message detailing the error and an instance of a
Response.
begin
# Use Procore Gem to make requests
client.get("projects")
rescue Procore::RateLimitError => e
# Raised when a token reaches its request limit for the current time period.
# If you are receiving this error then you are making too many requests
# against the Procore API.
rescue Procore::NotFoundError => e
# Raised when the request 404's
rescue Procore::InvalidRequestError => e
# Raised when the request is incorrectly formatted. Possible causes: missing
# required parameters or sending a request to access a non-existent resource.
rescue Procore::MissingTokenError => e
# Raised whenever an access token is nil or invalid.
rescue Procore::AuthorizationError => e
# Raised when the request is attempting to access a resource the token's
# owner does not have access to.
rescue Procore::ForbiddenError => e
# Raised when the request failed because you lack the required permissions.
rescue Procore::APIConnectionError => e
# Raised when the gem cannot connect to the Procore API. Possible causes:
# Procore is down or the network is doing something funny.
rescue Procore::ServerError => e
# Raised when a Procore endpoint returns a 5xx response code.
rescue Procore::Error => e
# Generic catch all error.
rescue => e
# Something unrelated to Procore errored.
endNote that all errors inherit from Procore::Error, so if you do not need to
handle each error uniquely you can just rescue from this base class to catch all
errors generated by this gem.
begin
client.get("projects")
rescue Procore::Error => e
# Something went wrong.
# Print the error class
puts e.class
# Print the error message
puts e.message
# Print the HTTP code
puts e.response.code
# Print the parsed error response body
puts e.response.body
# Print the headers
puts e.response.headers
endWhen client is calling #refresh or #revoke to refresh or revoke an access token, Procore::OAuthError will be raised if an exception happens.
begin
# Use Procore Gem to make refresh or revoke access token
client.refresh
rescue Procore::OAuthError => e
# Raised whenever there is a problem with OAuth. Possible causes: required
# credentials are missing or an access token failed to refresh.
endEndpoints which return multiple objects (a collection) will include pagination
information. The Response object has a #pagination method that will return
a hash which may conditionally contain the following keys:
:next URL for the immediate next page of results.
:last URL for the last page of results.
:first URL for the first page of results.
:prev URL for the immediate previous page of results.
For example, on the first page of results the #pagination method will look
like:
response.pagination
{
next: "rest/v1.0/projects?per_page=100&page=2",
last: "rest/v1.0/projects?per_page=100&page=5"
}The next value will return the second page of results - which is expected as
all paginated responses start on page 1. The last value ends on page 5, so
there are another 4 pages to consume in order to get all the possible results.
Each path carries the API version the page was requested with, so passing it
back to client#get requests the same version without a version: argument.
To get the next page of results, just pass the url into client#get. You may
want to guard against the next page being potentially empty.
first_page = client.get("projects")
if first_page.pagination[:next]
next_page = client.get(first_page.pagination[:next])
end
puts next_page.pagination
{
first: "rest/v1.0/projects?per_page=100&page=1",
next: "rest/v1.0/projects?per_page=100&page=3",
prev: "rest/v1.0/projects?per_page=100&page=1",
last: "rest/v1.0/projects?per_page=100&page=5"
}You can pass a per_page query parameter in your request to change the page
size. The pagination links will update to reflect that change.
first_page = client.get("projects", query: { per_page: 250 })
puts first_page.pagination
{
next: "rest/v1.0/projects?per_page=250&page=2",
last: "rest/v1.0/projects?per_page=250&page=2"
}
Notice that because per_page has been set to 250, there are only two pages of
results (500 resources / 250 page size = 2 pages).
The Sync action enables batch creation or updates to resources using a single
call. When using a Sync action, the resources to be created or updated can be
specified by supplying either an id or an origin_id in the request body.
Utilizing the origin_id attribute for batch operations is often preferable as
it allows you to easily link to external systems by maintaining your own list of
unique resource identifiers outside of Procore.
The caller provides an array of hashes, each hash containing the attributes for a single resource. The attribute names in each hash match those used by the Create and Update actions for the resource. Attributes for a maximum of 1000 resources within a collection may be passed with each call. The API will always return an HTTP status of 200.
The response body contains two attributes - entities and errors. The
attributes for each successfully created or updated resource will appear in the
entities list. The attributes for each resource will match those returned by the
Show action. For each resource which could not be created or updated, the
attributes supplied by the caller are present in the errors list, along with an
additional errors attribute which provides reasons for the failure.
Example Usage:
client.sync(
"projects/sync",
body: {
updates: [
{ id: 1, name: "Update 1" },
{ id: 2, name: "Update 2" },
{ id: 3, name: "Update 3" },
...
...
{ id: 5055, name: "Update 5055" },
]
},
options: { batch_size: 500, company_id: 1 },
)Stores contain logic for accessing, storing, and managing access tokens. The Procore API uses expiring tokens - this gem abstracts away the need to manually refresh tokens.
How the client uses its store:
- Before every request, the client reads the token from the store.
- When the access token has expired, the client refreshes it with the refresh token and saves the new access and refresh tokens back to the store.
- If the store has no token, the request raises
Procore::MissingTokenErrorand the user has to sign in again.
So a store must keep the token for as long as you want the user to stay signed in, and must be writable from everywhere the client runs, including background jobs.
| Store | Where the token lives | Survives a restart | Shared between processes and servers | Best for |
|---|---|---|---|---|
| ActiveRecord | A row in your database, usually the user | Yes | Yes | Rails apps with a user model, including background jobs |
| Redis | A Redis key | Yes, if Redis persistence is on | Yes | Apps without a suitable model, or tokens not tied to a user |
| Dalli | A memcached key | No; memcached may also evict it | Yes | Only when an occasional forced sign-in is acceptable |
| Session | The user's session | As long as the session lasts | Only within that user's requests | Not recommended; see the warning below |
| File | A YAML file on disk | Yes | Same machine only | Command-line tools and scripts |
| Memory | A Ruby object | No | No, not even between store instances | Tests |
Which to use:
- Rails app where users connect their Procore account: use the
ActiveRecord store on the user model. The token lives
next to the user, survives deploys and is available to background jobs. Use
Active Record encryption
(
encrypts :access_token, :refresh_token) so tokens aren't stored in plain text. - Background jobs or several app servers, without a model to hold the token (for example, one integration-wide token): use the Redis store with persistence turned on.
- Command-line tool or script: use the File store, so users don't sign in for every command.
- Tests: use the Memory store.
- Avoid the Session store: the token is unavailable to background jobs, and depending on your session store it may be logged or kept client-side. Use the Dalli store only if losing a token now and then is acceptable, because memcached forgets data on restart and evicts it under memory pressure.
Every built-in store keeps tokens in plain text: as JSON in Redis, memcached and the session, as YAML in the File store, and in your table's columns for ActiveRecord unless you encrypt them. Protect the storage the way you would a password.
The gem doesn't lock around token refresh. If several processes share a token and it expires, more than one may refresh it at the same time, and the last save wins.
Available stores:
Options: session: Instance of a Rails session
For applications that want to keep access tokens in the user's session.
store = Procore::Auth::Stores::Session.new(session: session)- Use it when: prototyping a web app where every API call happens inside the signed-in user's own requests.
- Watch out for: background jobs can't reach the token. With Rails' default cookie session store, the token travels to and from the browser on every request.
Options: redis: Instance of Redis
Options: key: Unique identifier to an access token
For applications which want to store access tokens in Redis. There's two
required options, redis which is an instance of a Redis connection, and key
which is a unique key which will be used to save / retrieve an access token.
The key will usually be the id of the current user.
store = Procore::Auth::Stores::Redis.new(redis: Redis.new, key: current_user.id)- Use it when: several processes or servers need the token, for example web servers and background jobs, and there's no model to hold it.
- Watch out for: the token is stored under
procore-redis-{key}with no expiry. It only survives a Redis restart if persistence (RDB or AOF) is on, and an eviction policy such asallkeys-lrucan remove it.
Options: dalli: Instance of Dalli
Options: key: Unique identifier to an access token
For applications which want to store access tokens in memcached using Dalli.
There's two required options, dalli which is an instance of a Dalli client,
and key which is a unique key which will be used to save / retrieve an access
token. The key will usually be the id of the current user.
The store saves tokens as JSON strings, so the client doesn't need Marshal.
With Dalli 5 or newer, pass serializer: Dalli::JSONSerializer to avoid
deserializing with Marshal and to silence Dalli's security warning.
dalli = Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)
store = Procore::Auth::Stores::Dalli.new(dalli: dalli, key: current_user.id)- Use it when: you already run memcached and users signing in again after a cache restart is acceptable.
- Watch out for: memcached is a cache. A restart clears every token, and under memory pressure it evicts keys, so users are asked to sign in again without warning.
Options: object: Instance of an ActiveRecord model.
For applications that store access token information on some user object.
The following columns must exist on the model you pass in:
access_token, refresh_token and expires_at.
store = Procore::Auth::Stores::ActiveRecord.new(object: current_user)- Use it when: tokens belong to a record you already have, usually the user. This is the best default for Rails apps.
- Watch out for:
expires_atholds a Unix timestamp, so make it an integer column. Encryptaccess_tokenandrefresh_token, for example withencrypts :access_token, :refresh_token. Each save runsupdate, so the model's validations and callbacks run too, and if validation fails the new token is silently not saved.
Options: path: Path to a file to store access tokens
Options: key: Unique identifier to an access token
Intended for command line applications, the File Store saves access token information to disk. This way a user can run a CLI without needing to authenticate every single command.
store = Procore::Auth::Stores::File.new(path: "./tokens.yml", key: current_user.id)- Use it when: building a command-line tool or script that runs on one machine. One file can hold tokens for several keys, and file locking keeps concurrent processes on that machine from corrupting it.
- Watch out for: the file is plain YAML, created with your default
permissions, which usually lets every user on the machine read it. Restrict
it (
chmod 600 tokens.yml) and keep it out of version control.
Options: key: Unique identifier to an access token
The most basic store - a token is kept in memory for the duration of a request. This store type is not recommended for application usage - it is meant to be used in tests.
store = Procore::Auth::Stores::Memory.new(key: current_user.id)- Use it when: writing tests.
- Watch out for: each store instance keeps its own token. A new
Memory.new(key: ...)with the same key starts empty, so reuse the same instance.
Any object can be a store if it responds to three methods:
save(token): persist aProcore::Auth::Token.fetch: return the savedProcore::Auth::Token, ornilif there is none.delete: remove the saved token.
The client also logs the store with to_s, so define it to identify the store
without printing the token.
This example keeps tokens in HashiCorp Vault with the vault gem:
class VaultStore
def initialize(key:)
@key = key
end
def save(token)
Vault.logical.write("secret/procore/#{@key}", **token_attributes(token))
end
def fetch
secret = Vault.logical.read("secret/procore/#{@key}")
if secret
Procore::Auth::Token.new(**secret.data.slice(:access_token, :refresh_token, :expires_at))
end
end
def delete
Vault.logical.delete("secret/procore/#{@key}")
end
def to_s
"Vault, Key: #{@key}"
end
private
def token_attributes(token)
{
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at: token.expires_at,
}
end
end# In controller, callback from oauth
def handle_callback
if auth_hash.blank?
redirect_to '/auth/procore'
else
auth_hash = request.env['omniauth.auth']
# Create a new token to save into a store
token = Procore::Auth::Token.new(
access_token: auth_hash["token"]
refresh_token: auth_hash["refresh_token"],
expires_at: auth_hash["expires_at"]
)
store = Procore::Auth::Stores::Session.new(session: session)
store.save(token)
redirect_to root_path
end
end
# Somewhere else in the application
class ProjectsController
def index
@projects = client.get("projects", query: { company_id: params[:company_id] })
end
private
def client
@client ||= Procore::Client.new(
client_id: Rails.application.secrets.procore_client_id,
client_secret: Rails.application.secrets.procore_client_secret,
store: Procore::Auth::Stores::Session.new(session: session)
)
end
endTo contribute to the gem, please clone the repo and cut a new branch. In the PR, update the changelog with a short explanation of what you've changed, and your name under the "Unreleased" section. Example changelog update:
## Unreleased
* Short sentence of what has changed
*Your Name*Please do not bump the gem version in your PR. This will be done in a follow up by the gem maintainers.
The repository has pre-commit hooks for secret scanning (Gitleaks), a
vulnerability audit of Gemfile.lock (bundler-audit), and a guard against
committing directly to main. Install them with
prek (or pre-commit):
prek install --hook-type pre-commitGem maintainer should follow these steps to release a new version:
- Merge all PRs into the
mainbranch that you want included in the release. - Create a release branch from
main.git switch -c release-{version} - Update
version.rbto a new semantic version, and move unreleased items inCHANGELOG.mdinto a section for that version. - Stage and commit changes.
git commit -a - Open a PR for the release branch and merge it into
main. Direct commits tomainare blocked by the pre-commit hook. - Once the Test workflow passes on
main, the Release workflow sees the change toversion.rband publishes the new version to RubyGems. - Tag the merge commit for the new version.
git switch main && git pull && git tag -a {version} && git push --tags
To run the specs run the following command:
$ bundle exec rake testThe gem is available as open source under the terms of the MIT License.
The Procore Gem is maintained by Procore Technologies.
Procore - building the software that builds the world.
Learn more about the #1 most widely used construction management software at procore.com