Skip to content

Commit

Permalink
Merge pull request #379 from samvera/jeremyf---extracting-logic-for-d…
Browse files Browse the repository at this point in the history
…etermining-qa-authority

Extracting `Qa.authority_for`
  • Loading branch information
jrgriffiniii authored Oct 15, 2024
2 parents 2d95176 + 79a13f9 commit bf88588
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 26 deletions.
15 changes: 10 additions & 5 deletions app/controllers/qa/linked_data_terms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def reload
# get "/search/linked_data/:vocab(/:subauthority)"
# @see Qa::Authorities::LinkedData::SearchQuery#search
def search # rubocop:disable Metrics/MethodLength
terms = @authority.search(query, request_header: request_header_service.search_header)
terms = @authority.search(query)
cors_allow_origin_header(response)
render json: terms
rescue Qa::ServiceUnavailable
Expand All @@ -65,7 +65,7 @@ def search # rubocop:disable Metrics/MethodLength
# get "/show/linked_data/:vocab/:subauthority/:id
# @see Qa::Authorities::LinkedData::FindTerm#find
def show # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
term = @authority.find(id, request_header: request_header_service.fetch_header)
term = @authority.find(id)
cors_allow_origin_header(response)
render json: term, content_type: request_header_service.content_type_for_format
rescue Qa::TermNotFound
Expand Down Expand Up @@ -95,7 +95,7 @@ def show # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
# get "/fetch/linked_data/:vocab"
# @see Qa::Authorities::LinkedData::FindTerm#find
def fetch # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
term = @authority.find(uri, request_header: request_header_service.fetch_header)
term = @authority.find(uri)
cors_allow_origin_header(response)
render json: term, content_type: request_header_service.content_type_for_format
rescue Qa::TermNotFound
Expand Down Expand Up @@ -157,9 +157,14 @@ def create_request_header_service
@request_header_service = request_header_service_class.new(request: request, params: params)
end

# @see Qa::AuthorityWrapper for these methods
delegate :search_header, :fetch_header, to: :request_header_service

def init_authority
@authority = Qa::Authorities::LinkedData::GenericAuthority.new(vocab_param)
rescue Qa::InvalidLinkedDataAuthority => e
@authority = Qa.authority_for(vocab: params[:vocab],
subauthority: params[:subauthority],
context: self)
rescue Qa::InvalidAuthorityError, Qa::InvalidLinkedDataAuthority => e
msg = e.message
logger.warn msg
render json: { errors: msg }, status: :bad_request
Expand Down
29 changes: 9 additions & 20 deletions app/controllers/qa/terms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,26 +69,15 @@ def check_vocab_param
end

def init_authority # rubocop:disable Metrics/MethodLength
begin
mod = authority_class.camelize.constantize
rescue NameError
msg = "Unable to initialize authority #{authority_class}"
logger.warn msg
render json: { errors: msg }, status: :bad_request
return
end
begin
@authority = if mod.is_a? Class
mod.new
else
raise Qa::MissingSubAuthority, "No sub-authority provided" if params[:subauthority].blank?
mod.subauthority_for(params[:subauthority])
end
rescue Qa::InvalidSubAuthority, Qa::MissingSubAuthority => e
msg = e.message
logger.warn msg
render json: { errors: msg }, status: :bad_request
end
@authority = Qa.authority_for(vocab: params[:vocab],
subauthority: params[:subauthority],
# Included to preserve error message text
try_linked_data_config: false,
context: self)
rescue Qa::InvalidAuthorityError, Qa::InvalidSubAuthority, Qa::MissingSubAuthority => e
msg = e.message
logger.warn msg
render json: { errors: msg }, status: :bad_request
end

def check_query_param
Expand Down
50 changes: 50 additions & 0 deletions lib/qa.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require "qa/engine"
require "active_record"
require "activerecord-import"
require "qa/authority_wrapper"

module Qa
extend ActiveSupport::Autoload
Expand Down Expand Up @@ -30,6 +31,13 @@ def self.deprecation_warning(in_msg: nil, msg:)
warn "[DEPRECATED] #{in_msg}#{msg} It will be removed in the next major release."
end

# Raised when the authority is not valid
class InvalidAuthorityError < RuntimeError
def initialize(authority_class)
super "Unable to initialize authority #{authority_class}"
end
end

# Raised when the configuration directory for local authorities doesn't exist
class ConfigDirectoryNotFound < StandardError; end

Expand Down Expand Up @@ -67,4 +75,46 @@ class MissingParameter < StandardError; end

# Raised when data is returned but cannot be normalized
class DataNormalizationError < StandardError; end

# @api public
# @since 5.11.0
#
# @param vocab [String]
# @param subauthority [String]
# @param context [#params, #search_header, #fetch_header]
# @param try_linked_data_config [Boolean] when true attempt to check for a linked data authority;
# this is included as an option to help preserve error messaging from the 5.10.0 branch.
# Unless you want to mirror the error messages of `Qa::TermsController#init_authority` then
# use the default value.
#
# @note :try_linked_data_config is included to preserve error message text; something which is
# extensively tested in this gem.
#
# @return [#search, #find] an authority that will respond to #search and #find; and in some cases
# #fetch. This is provided as a means of normalizing how we initialize an authority.
# And to provide a means to request an authority both within a controller request cycle as
# well as outside of that cycle.
def self.authority_for(vocab:, context:, subauthority: nil, try_linked_data_config: true)
authority = build_authority_for(vocab: vocab,
subauthority: subauthority,
try_linked_data_config: try_linked_data_config)
AuthorityWrapper.new(authority: authority, subauthority: subauthority, context: context)
end

# @api private
def self.build_authority_for(vocab:, subauthority: nil, try_linked_data_config: true)
authority_constant_name = "Qa::Authorities::#{vocab.to_s.camelcase}"
authority_constant = authority_constant_name.safe_constantize
if authority_constant.nil?
return Qa::Authorities::LinkedData::GenericAuthority.new(vocab.upcase.to_sym) if try_linked_data_config

raise InvalidAuthorityError, authority_constant_name
end

return authority_constant.new if authority_constant.is_a?(Class)
return authority_constant.subauthority_for(subauthority) if subauthority.present?

raise Qa::MissingSubAuthority, "No sub-authority provided"
end
private_class_method :build_authority_for
end
3 changes: 3 additions & 0 deletions lib/qa/authorities/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@ def all
def find(_id)
raise NotImplementedError, "#{self.class}#find is unimplemented."
end

class_attribute :linked_data, instance_writer: false
self.linked_data = false
end
end
2 changes: 2 additions & 0 deletions lib/qa/authorities/linked_data/generic_authority.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class GenericAuthority < Base
attr_reader :authority_config
private :authority_config

self.linked_data = true

delegate :supports_term?, :term_subauthorities?, :term_subauthority?,
:term_id_expects_uri?, :term_id_expects_id?, to: :term_config

Expand Down
3 changes: 2 additions & 1 deletion lib/qa/authorities/loc/generic_authority.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def find(id)
end

def find_url(id)
"https://id.loc.gov/authorities/#{@subauthority}/#{id}.json"
root_fetch_slug = Loc.root_fetch_slug_for(@subauthority)
File.join("https://id.loc.gov/", root_fetch_slug, "/#{@subauthority}/#{id}.json")
end

private
Expand Down
18 changes: 18 additions & 0 deletions lib/qa/authorities/loc_subauthority.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module Qa::Authorities::LocSubauthority
# @todo Rename to reflect that this is a URI encoded url fragement used only for searching.
def get_url_for_authority(authority)
if authorities.include?(authority) then authority_base_url
elsif vocabularies.include?(authority) then vocab_base_url
Expand All @@ -7,6 +8,23 @@ def get_url_for_authority(authority)
end
end

# @note The returned value is the root directory of the URL. The graphicMaterials sub-authority
# has a "type" of vocabulary. https://id.loc.gov/vocabulary/graphicMaterials/tgm008083.html
# In some cases, this is plural and in others this is singular.
#
# @param authority [String] the LOC authority that matches one of the types
# @return [String]
#
# @note there is a relationship between the returned value and the encoded URLs returned by
# {#get_url_for_authority}.
def root_fetch_slug_for(authority)
validate_subauthority!(authority)
return "authorities" if authorities.include?(authority)
return "vocabulary" if vocabularies.include?(authority)
return "datatype" if datatypes.include?(authority)
return "vocabulary/preservation" if preservation.include?(authority)
end

def authorities
[
"subjects",
Expand Down
49 changes: 49 additions & 0 deletions lib/qa/authority_request_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
module Qa
# @note THIS IS NOT TESTED NOR EXERCISED CODE IT IS PROVIDED AS CONJECTURE. FUTURE CHANGES MIGHT
# BUILD AND REFACTOR UPON THIS.
#
# @api private
# @abstract
#
# This class is responsible for exposing methods that are required by both linked data and
# non-linked data authorities. As of v5.10.0, those three methods are: params, search_header,
# fetch_header. Those are the methods that are used in {Qa::LinkedData::RequestHeaderService} and
# in {Qa::Authorities::Discogs::GenericAuthority}.
#
# The intention is to provide a class that can behave like a controller object without being that
# entire controller object.
#
# @see Qa::LinkedData::RequestHeaderService
# @see Qa::Authorities::Discogs::GenericAuthority
class AuthorityRequestContext
def self.fallback
new
end

def initialize(params: {}, headers: {}, **kwargs)
@params = params
@headers = headers
(SEARCH_HEADER_KEYS + FETCH_HEADER_KEYS).uniq.each do |key|
send("#{key}=", kwargs[key]) if kwargs.key?(key)
end
end

SEARCH_HEADER_KEYS = %i[request request_id subauthority user_language performance_data context response_header replacements].freeze
FETCH_HEADER_KEYS = %i[request request_id subauthority user_language performance_data format response_header replacements].freeze

attr_accessor :params, :headers
attr_accessor(*(SEARCH_HEADER_KEYS + FETCH_HEADER_KEYS).uniq)

def search_header
SEARCH_HEADER_KEYS.each_with_object(headers.deep_dup) do |key, header|
header[key] = send(key) if send(key).present?
end.compact
end

def fetch_header
FETCH_HEADER_KEYS.each_with_object(headers.deep_dup) do |key, header|
header[key] = send(key) if send(key).present?
end.compact
end
end
end
63 changes: 63 additions & 0 deletions lib/qa/authority_wrapper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
module Qa
# @api public
# @since v5.11.0
#
# The intention of this wrapper is to provide a common interface that both linked and non-linked
# data can use. There are implementation differences between the two, but with this wrapper, the
# goal is to draw attention to those differences and insulate the end user from those issues.
#
# One benefit in introducing this class is that when interacting with a questioning authority
# implementation you don't need to consider "Hey when I instantiate an authority, is this linked
# data or not?" And what specifically are the parameter differences. You will need to perhaps
# include some additional values in the context if you don't call this from a controller.
class AuthorityWrapper
require 'qa/authority_request_context.rb'
# @param authority [#find, #search]
# @param subauthority [#to_s]
# @param context [#params, #search_header, #fetch_header]
def initialize(authority:, subauthority:, context:)
@authority = authority
@subauthority = subauthority
@context = context
configure!
end
attr_reader :authority, :context, :subauthority

def search(value)
if linked_data?
# should respond to search_header
authority.search(value, request_header: context.search_header)
elsif authority.method(:search).arity == 2
# This context should respond to params; see lib/qa/authorities/discogs/generic_authority.rb
authority.search(value, context)
else
authority.search(value)
end
end

# context has params
def find(value)
if linked_data?
# should respond to fetch_header
authority.find(value, request_header: context.fetch_header)
elsif authority.method(:find).arity == 2
authority.find(value, context)
else
authority.find(value)
end
end
alias fetch find

def method_missing(method_name, *arguments, &block)
authority.send(method_name, *arguments, &block)
end

def respond_to_missing?(method_name, include_private = false)
authority.respond_to?(method_name, include_private)
end

def configure!
@context.subauthority = @subauthority if @context.respond_to?(:subauthority)
end
end
end
12 changes: 12 additions & 0 deletions spec/lib/authorities/loc_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
end
end

describe ".root_fetch_slug_for" do
it "raises an error for an invalid subauthority" do
expect do
described_class.root_fetch_slug_for("no-one-would-ever-have-this-one")
end.to raise_error Qa::InvalidSubAuthority
end

it "returns the corresponding type for the given subauthority" do
expect(described_class.root_fetch_slug_for("graphicMaterials")).to eq("vocabulary")
end
end

describe "#response" do
subject { authority.response(url) }
let :authority do
Expand Down

0 comments on commit bf88588

Please sign in to comment.