lib/sul_orcid_client.rb
# frozen_string_literal: true
require 'active_support'
require 'active_support/core_ext'
require 'faraday'
require 'faraday/retry'
require 'oauth2'
require 'singleton'
require 'zeitwerk'
# Load the gem's internal dependencies: use Zeitwerk instead of needing to manually require classes
Zeitwerk::Loader.for_gem.setup
# Client for interacting with ORCID API
# rubocop:disable Metrics/ClassLength
class SulOrcidClient
include Singleton
class InvalidTokenError < StandardError; end
class << self
# @param client_id [String] the client identifier registered with Orcid
# @param client_secret [String] the client secret to authenticate with Orcid
# @param base_url [String] the base URL for the API
# @param base_public_url [String] the base public URL for the API
# @param base_auth_url [String] the base authorization URL for the API
def configure(client_id:, client_secret:, base_url:, base_public_url:, base_auth_url:)
instance.base_url = base_url
instance.base_public_url = base_public_url
instance.client_id = client_id
instance.client_secret = client_secret
instance.base_auth_url = base_auth_url
self
end
delegate :fetch_works, :fetch_work, :fetch_name, :search, :add_work, :update_work, :delete_work, to: :instance
end
attr_accessor :base_url, :base_public_url, :base_auth_url, :client_id, :client_secret
# Fetch the works for a researcher.
# Model for the response: https://pub.orcid.org/v3.0/#!/Development_Public_API_v3.0/viewWorksv3
# @param [string] ORCID ID for the researcher
# @return [Hash]
def fetch_works(orcidid:)
get("/v3.0/#{base_orcidid(orcidid)}/works")
end
# Fetch the details for a work
def fetch_work(orcidid:, put_code:)
get("/v3.0/#{base_orcidid(orcidid)}/work/#{put_code}")
end
# Fetches the name for a user given an orcidid
# rubocop:disable Metrics/MethodLength
def fetch_name(orcidid:)
match = /[0-9xX]{4}-[0-9xX]{4}-[0-9xX]{4}-[0-9xX]{4}/.match(orcidid)
raise 'invalid orcidid provided' unless match
response = public_conn.get("/v3.0/#{match[0]&.upcase}/personal-details")
case response.status
when 200
resp_json = JSON.parse(response.body)
[resp_json.dig('name', 'given-names', 'value'),
resp_json.dig('name', 'family-name', 'value')]
else
raise "ORCID.org API returned #{response.status} (#{response.body}) for: #{orcidid}"
end
end
# Run a generalized search query against ORCID
# see https://info.orcid.org/documentation/api-tutorials/api-tutorial-searching-the-orcid-registry
# @param [query] query to pass to ORCID
# @param [expanded] set to true or false (defaults to false) to indicate an expanded query results (see ORCID docs)
# rubocop:disable Metrics/AbcSize
def search(query:, expanded: false)
if expanded
search_method = 'expanded-search'
response_name = 'expanded-result'
else
search_method = 'search'
response_name = 'result'
end
# this is the maximum number of rows ORCID allows in their response currently
max_num_returned = 1000
total_response = get("/v3.0/#{search_method}/?q=#{query}&rows=#{max_num_returned}")
num_results = total_response['num-found']
return total_response if num_results <= max_num_returned
num_pages = (num_results / max_num_returned.to_f).ceil
# we already have page 1 of the results
(1..num_pages - 1).each do |page_num|
response = get("/v3.0/#{search_method}/?q=#{query}&start=#{(page_num * max_num_returned) + 1}&rows=#{max_num_returned}")
total_response[response_name] += response[response_name]
end
total_response
end
# Add a new work for a researcher.
# @param [string] ORCID ID for the researcher
# @param [Hash] work in correct data structure for ORCID work
# @param [string] access token
# @return [string] put-code
def add_work(orcidid:, work:, token:)
response = conn_with_token(token).post("/v3.0/#{base_orcidid(orcidid)}/work",
work.to_json,
'Content-Type' => 'application/json')
case response.status
when 201
response['Location'].match(%r{work/(\d+)})[1]
when 401
raise InvalidTokenError,
"Invalid token for #{orcidid} - ORCID.org API returned #{response.status} (#{response.body})"
when 409
match = response.body.match(/put-code (\d+)\./)
raise 'ORCID.org API returned a 409, but could not find put-code' unless match
match[1]
else
raise "ORCID.org API returned #{response.status} (#{response.body}) for: #{work.to_json}"
end
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize
# Update an existing work for a researcher.
# @param [String] orcidid an ORCiD ID for the researcher
# @param [Hash] work a work in correct data structure for ORCID work
# @param [String] token an ORCiD API access token
# @param [String] put_code the PUT code
# @return [Boolean] true if update succeeded
# @raise [RuntimeError] if the API response status is not successful
def update_work(orcidid:, work:, token:, put_code:)
response = conn_with_token(token).put("/v3.0/#{base_orcidid(orcidid)}/work/#{put_code}",
work.merge({ 'put-code' => put_code }).to_json,
'Content-Type' => 'application/vnd.orcid+json')
if response.status == 404
raise "ORCID.org API returned #{response.status} when updating #{put_code} for #{orcidid}. The author may have previously deleted " \
'this work from their ORCID profile.'
end
raise "ORCID.org API returned #{response.status} when updating #{put_code} for #{orcidid}" unless response.status == 200
true
end
# Delete a work
# @param [string] ORCID ID for the researcher
# @param [string] put-code
# @param [string] access token
# @return [boolean] true if delete succeeded
def delete_work(orcidid:, put_code:, token:)
response = conn_with_token(token).delete("/v3.0/#{base_orcidid(orcidid)}/work/#{put_code}")
case response.status
when 204
true
when 404
false
else
raise "ORCID.org API returned #{response.status} when deleting #{put_code} for #{orcidid}"
end
end
private
def get(url)
response = conn.get(url)
raise "ORCID.org API returned #{response.status}" if response.status != 200
JSON.parse(response.body).with_indifferent_access
end
def client_token
client = OAuth2::Client.new(client_id, client_secret, site: base_auth_url)
token = client.client_credentials.get_token({ scope: '/read-public' })
token.token
end
# @return [Faraday::Connection]
def conn
@conn ||= conn_with_token(client_token)
end
# @return [Faraday::Connection]
def public_conn
conn = Faraday.new(url: base_public_url) do |faraday|
faraday.request :retry, max: 5,
interval: 0.5,
interval_randomness: 0.5,
backoff_factor: 2
end
conn.options.timeout = 500
conn.options.open_timeout = 10
conn.headers = headers
conn
end
# @return [Faraday::Connection]
# rubocop:disable Metrics/MethodLength
def conn_with_token(token)
conn = Faraday.new(url: base_url) do |faraday|
faraday.request :retry, max: 3,
interval: 0.5,
interval_randomness: 0.5,
backoff_factor: 2
end
conn.options.timeout = 500
conn.options.open_timeout = 10
conn.headers = headers
conn.headers[:authorization] = "Bearer #{token}"
conn
end
# rubocop:enable Metrics/MethodLength
def headers
{
'Accept' => 'application/json',
'User-Agent' => 'stanford-library-sul-pub'
}
end
# Extract the ID part from an ORCID ID.
# For example, 0000-0003-3437-349X from https://sandbox.orcid.org/0000-0003-3437-349X.
# @param [string] orcidid
# @return [string] base of ORCID ID
def base_orcidid(orcidid)
orcidid[-19, 19]
end
end
# rubocop:enable Metrics/ClassLength