diff --git a/app/controllers/qa/linked_data_terms_controller.rb b/app/controllers/qa/linked_data_terms_controller.rb index b8218039..10ff5124 100644 --- a/app/controllers/qa/linked_data_terms_controller.rb +++ b/app/controllers/qa/linked_data_terms_controller.rb @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/controllers/qa/terms_controller.rb b/app/controllers/qa/terms_controller.rb index 05ba7ce9..24d3caae 100644 --- a/app/controllers/qa/terms_controller.rb +++ b/app/controllers/qa/terms_controller.rb @@ -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 diff --git a/lib/qa.rb b/lib/qa.rb index eaad8823..5db3af34 100644 --- a/lib/qa.rb +++ b/lib/qa.rb @@ -1,6 +1,7 @@ require "qa/engine" require "active_record" require "activerecord-import" +require "qa/authority_wrapper" module Qa extend ActiveSupport::Autoload @@ -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 @@ -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 diff --git a/lib/qa/authorities/base.rb b/lib/qa/authorities/base.rb index b3f0d9a9..3eeb1c60 100644 --- a/lib/qa/authorities/base.rb +++ b/lib/qa/authorities/base.rb @@ -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 diff --git a/lib/qa/authorities/linked_data/generic_authority.rb b/lib/qa/authorities/linked_data/generic_authority.rb index d0f89962..9e4709bb 100644 --- a/lib/qa/authorities/linked_data/generic_authority.rb +++ b/lib/qa/authorities/linked_data/generic_authority.rb @@ -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 diff --git a/lib/qa/authorities/loc/generic_authority.rb b/lib/qa/authorities/loc/generic_authority.rb index 21b3b3b5..877200cc 100644 --- a/lib/qa/authorities/loc/generic_authority.rb +++ b/lib/qa/authorities/loc/generic_authority.rb @@ -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 diff --git a/lib/qa/authorities/loc_subauthority.rb b/lib/qa/authorities/loc_subauthority.rb index 97e0c408..e213bb85 100644 --- a/lib/qa/authorities/loc_subauthority.rb +++ b/lib/qa/authorities/loc_subauthority.rb @@ -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 @@ -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", diff --git a/lib/qa/authority_request_context.rb b/lib/qa/authority_request_context.rb new file mode 100644 index 00000000..1be4f71f --- /dev/null +++ b/lib/qa/authority_request_context.rb @@ -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 diff --git a/lib/qa/authority_wrapper.rb b/lib/qa/authority_wrapper.rb new file mode 100644 index 00000000..d81f1415 --- /dev/null +++ b/lib/qa/authority_wrapper.rb @@ -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 diff --git a/spec/lib/authorities/loc_spec.rb b/spec/lib/authorities/loc_spec.rb index 147241ef..e324a362 100644 --- a/spec/lib/authorities/loc_spec.rb +++ b/spec/lib/authorities/loc_spec.rb @@ -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