sul-dlss/orcid_client

View on GitHub
lib/sul_orcid_client.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
95%
# 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