diff --git a/app/controllers/qa/linked_data_terms_controller.rb b/app/controllers/qa/linked_data_terms_controller.rb index 089389e0..2fe59fe3 100644 --- a/app/controllers/qa/linked_data_terms_controller.rb +++ b/app/controllers/qa/linked_data_terms_controller.rb @@ -35,7 +35,7 @@ def reload # get "/search/linked_data/:vocab(/:subauthority)" # @see Qa::Authorities::LinkedData::SearchQuery#search def search - terms = @authority.search(query, subauth: subauthority, language: language, replacements: replacement_params) + terms = @authority.search(query, subauth: subauthority, language: language, replacements: replacement_params, context: context?) cors_allow_origin_header(response) render json: terms rescue Qa::ServiceUnavailable @@ -189,6 +189,11 @@ def jsonld? format == 'jsonld' end + def context? + context = params.fetch(:context, false) + context == 'true' ? true : false + end + def validate_auth_reload_token token = params.key?(:auth_token) ? params[:auth_token] : nil valid = Qa.config.valid_authority_reload_token?(token) diff --git a/app/models/qa/linked_data/config/context_map.rb b/app/models/qa/linked_data/config/context_map.rb index 7cbe5758..e482638e 100644 --- a/app/models/qa/linked_data/config/context_map.rb +++ b/app/models/qa/linked_data/config/context_map.rb @@ -5,14 +5,15 @@ module Config class ContextMap attr_reader :properties # [Array] set of property map models - attr_reader :context_map, :groups - private :context_map, :groups + attr_reader :context_map, :groups, :prefixes + private :context_map, :groups, :prefixes # @param [Hash] context_map that defines groups and properties for additional context to display during the selection process # @option context_map [Hash] :groups (optional) predefine group ids and labels to be used in the properties section to group properties # @option groups [Hash] key=group_id; value=[Hash] with group_label_i18n and/or group_label_default # @option context_map [Array] :properties (optional) property maps defining how to get and display the additional context (if none, context will not be added) - # @example + # @param [Hash] shortcut names for URI prefixes with key = part of predicate that is the same for all terms (e.g. { "madsrdf": "http://www.loc.gov/mads/rdf/v1#" }) + # @example context_map example # { # "groups": { # "dates": { @@ -38,8 +39,9 @@ class ContextMap # } # ] # } - def initialize(context_map = {}) + def initialize(context_map = {}, prefixes = {}) @context_map = context_map + @prefixes = prefixes extract_groups extract_properties end @@ -53,7 +55,7 @@ def group_label(group_id) def extract_properties @properties = [] properties_map = Qa::LinkedData::Config::Helper.fetch(context_map, :properties, {}) - properties_map.each { |property_map| @properties << ContextPropertyMap.new(property_map) } + properties_map.each { |property_map| @properties << ContextPropertyMap.new(property_map, prefixes) } end def extract_groups diff --git a/app/models/qa/linked_data/config/context_property_map.rb b/app/models/qa/linked_data/config/context_property_map.rb index da4fd62a..8314f502 100644 --- a/app/models/qa/linked_data/config/context_property_map.rb +++ b/app/models/qa/linked_data/config/context_property_map.rb @@ -1,4 +1,6 @@ # Defines the external authority predicates used to extract additional context from the graph. +require 'ldpath' + module Qa module LinkedData module Config @@ -6,8 +8,8 @@ class ContextPropertyMap attr_reader :group_id, # id that identifies which group the property should be in :label # plain text label extracted from locales or using the default - attr_reader :property_map, :ldpath - private :property_map, :ldpath + attr_reader :property_map, :ldpath, :prefixes + private :property_map, :ldpath, :prefixes # @param [Hash] property_map defining information to return to provide context # @option property_map [String] :group_id (optional) default label to use for a property (default: no label) @@ -25,13 +27,14 @@ class ContextPropertyMap # "selectable": false, # "drillable": false # } - def initialize(property_map = {}) + def initialize(property_map = {}, prefixes = {}) @property_map = property_map @group_id = Qa::LinkedData::Config::Helper.fetch_symbol(property_map, :group_id, nil) @label = extract_label @ldpath = Qa::LinkedData::Config::Helper.fetch_required(property_map, :ldpath, false) @selectable = Qa::LinkedData::Config::Helper.fetch_boolean(property_map, :selectable, false) @drillable = Qa::LinkedData::Config::Helper.fetch_boolean(property_map, :drillable, false) + @prefixes = prefixes end # Can this property be the selected value? @@ -46,6 +49,14 @@ def drillable? @drillable end + def ldpath_program + return @program if @program.present? + program_code = "" + prefixes.each { |key, url| program_code << "@prefix #{key} : <#{url}> \;\n" } + program_code << "property = #{ldpath} \;" + @program = Ldpath::Program.parse program_code + end + def group? group_id.present? end diff --git a/app/services/qa/linked_data/mapper/context_mapper_service.rb b/app/services/qa/linked_data/mapper/context_mapper_service.rb new file mode 100644 index 00000000..b20c1b0e --- /dev/null +++ b/app/services/qa/linked_data/mapper/context_mapper_service.rb @@ -0,0 +1,53 @@ +# Provide service for mapping predicates to object values. +module Qa + module LinkedData + module Mapper + class ContextMapperService + class_attribute :graph_service + self.graph_service = Qa::LinkedData::GraphService + + class << self + # Extract predicates specified in the predicate_map from the graph and return as a value map for a single subject URI. + # @param graph [RDF::Graph] the graph from which to extract result values + # @param context_map [Qa::LinkedData::Config::ContextMap] defines properties to extract from the graph to provide additional context + # @param subject_uri [RDF::URI] the subject within the graph for which the values are being extracted + # @return [>] mapped context values and information with hash of map key = array of object values for predicates identified in predicate_map. + # @example value map for a single result + # {:uri=>[#], + # :id=>[#], + # :label=>[#], + # :altlabel=>[], + # :sort=>[#]} + def map_context(graph:, context_map:, subject_uri:) + context = [] + context_map.properties.each do |property_map| + values = fetch_values(property_map, graph, subject_uri) + next if values.blank? + context << construct_context(property_map, values) + end + context + end + + private + + def fetch_values(property_map, graph, subject_uri) + output = property_map.ldpath_program.evaluate subject_uri, graph + output.present? ? output['property'].uniq : nil + rescue + 'PARSE ERROR' + end + + def construct_context(property_map, values) + property_info = {} + property_info["group"] = property_map.group_id if property_map.group? + property_info["property"] = property_map.label + property_info["values"] = values + property_info["selectable"] = property_map.selectable? + property_info["drillable"] = property_map.drillable? + property_info + end + end + end + end + end +end diff --git a/app/services/qa/linked_data/mapper/graph_mapper_service.rb b/app/services/qa/linked_data/mapper/graph_mapper_service.rb index bfed18c1..4a316705 100644 --- a/app/services/qa/linked_data/mapper/graph_mapper_service.rb +++ b/app/services/qa/linked_data/mapper/graph_mapper_service.rb @@ -29,9 +29,9 @@ def self.map_values(graph:, predicate_map:, subject_uri:) value_map = {} predicate_map.each do |key, predicate| values = predicate == :subject_uri ? [subject_uri] : graph_service.object_values(graph: graph, subject: subject_uri, predicate: predicate) - values = yield values if block_given? value_map[key] = values end + value_map = yield value_map if block_given? value_map end end diff --git a/app/services/qa/linked_data/mapper/search_results_mapper_service.rb b/app/services/qa/linked_data/mapper/search_results_mapper_service.rb index 84e584fb..8cbda965 100644 --- a/app/services/qa/linked_data/mapper/search_results_mapper_service.rb +++ b/app/services/qa/linked_data/mapper/search_results_mapper_service.rb @@ -3,9 +3,10 @@ module Qa module LinkedData module Mapper class SearchResultsMapperService - class_attribute :graph_mapper_service, :deep_sort_service + class_attribute :graph_mapper_service, :deep_sort_service, :context_mapper_service self.graph_mapper_service = Qa::LinkedData::Mapper::GraphMapperService self.deep_sort_service = Qa::LinkedData::DeepSortService + self.context_mapper_service = Qa::LinkedData::Mapper::ContextMapperService class << self # Extract predicates specified in the predicate_map from the graph and return as an array of value maps for each search result subject URI. @@ -31,11 +32,17 @@ class << self # :altlabel=>[], # :sort=>[#]} # ] - def map_values(graph:, predicate_map:, sort_key:, preferred_language: nil) + def map_values(graph:, predicate_map:, sort_key:, preferred_language: nil, context_map: nil) search_matches = [] graph.subjects.each do |subject| next if subject.anonymous? # skip blank nodes - values = graph_mapper_service.map_values(graph: graph, predicate_map: predicate_map, subject_uri: subject) + values = graph_mapper_service.map_values(graph: graph, predicate_map: predicate_map, subject_uri: subject) do |value_map| + next value_map if context_map.blank? + context = {} + context = context_mapper_service.map_context(graph: graph, context_map: context_map, subject_uri: subject) if context_map.present? + value_map[:context] = context + value_map + end search_matches << values unless sort_key.present? && values[sort_key].blank? end search_matches = deep_sort_service.new(search_matches, sort_key, preferred_language).sort diff --git a/lib/qa/authorities/linked_data/search_query.rb b/lib/qa/authorities/linked_data/search_query.rb index bb257238..bb9cee4f 100644 --- a/lib/qa/authorities/linked_data/search_query.rb +++ b/lib/qa/authorities/linked_data/search_query.rb @@ -25,13 +25,15 @@ def initialize(search_config) # @param language [Symbol] (optional) language used to select literals when multi-language is supported (e.g. :en, :fr, etc.) # @param replacements [Hash] (optional) replacement values with { pattern_name (defined in YAML config) => value } # @param subauth [String] (optional) the subauthority to query + # @param context [Boolean] (optional) true if context should be returned with the results; otherwise, false (default: false) # @return [String] json results # @example Json Results for Linked Data Search # [ {"uri":"http://id.worldcat.org/fast/5140","id":"5140","label":"Cornell, Joseph"}, # {"uri":"http://id.worldcat.org/fast/72456","id":"72456","label":"Cornell, Sarah Maria, 1802-1832"}, # {"uri":"http://id.worldcat.org/fast/409667","id":"409667","label":"Cornell, Ezra, 1807-1874"} ] - def search(query, language: nil, replacements: {}, subauth: nil) + def search(query, language: nil, replacements: {}, subauth: nil, context: false) raise Qa::InvalidLinkedDataAuthority, "Unable to initialize linked data search sub-authority #{subauth}" unless subauth.nil? || subauthority?(subauth) + @context = context @language = language_service.preferred_language(user_language: language, authority_language: search_config.language) url = authority_service.build_url(action_config: search_config, action: :search, action_request: query, substitutions: replacements, subauthority: subauth) Rails.logger.info "QA Linked Data search url: #{url}" @@ -48,10 +50,18 @@ def load_graph(url:) def parse_search_authority_response results = results_mapper_service.map_values(graph: @graph, predicate_map: preds_for_search, sort_key: :sort, - preferred_language: @language) + preferred_language: @language, context_map: context_map) convert_results_to_json(results) end + def context_map + context? ? search_config.context_map : nil + end + + def context? + @context == true + end + def preds_for_search label_pred_uri = search_config.results_label_predicate raise Qa::InvalidConfiguration, "required label_predicate is missing in search configuration for LOD authority #{auth_name}" if label_pred_uri.nil? @@ -78,6 +88,7 @@ def convert_result_to_json(result) json_result[:uri] = result[:uri].first.to_s json_result[:id] = result[:id].first.to_s json_result[:label] = full_label(result[:label], result[:altlabel]) + json_result[:context] = result[:context] if context? json_result end diff --git a/qa.gemspec b/qa.gemspec index 10a748c5..06c12750 100644 --- a/qa.gemspec +++ b/qa.gemspec @@ -19,6 +19,7 @@ Gem::Specification.new do |s| s.add_dependency 'activerecord-import' s.add_dependency 'deprecation' s.add_dependency 'faraday' + s.add_dependency 'ldpath' s.add_dependency 'nokogiri', '~> 1.6' s.add_dependency 'rails', '~> 5.0' s.add_dependency 'rdf' diff --git a/spec/controllers/linked_data_terms_controller_spec.rb b/spec/controllers/linked_data_terms_controller_spec.rb index d1d202dd..e39b746a 100644 --- a/spec/controllers/linked_data_terms_controller_spec.rb +++ b/spec/controllers/linked_data_terms_controller_spec.rb @@ -231,6 +231,10 @@ end end end + + it 'returns basic data + context when context=true' do + skip 'Pending: Need to write #search test with and without the context parameter' + end end describe '#show' do diff --git a/spec/models/linked_data/config/context_map_spec.rb b/spec/models/linked_data/config/context_map_spec.rb index edbb5de7..f709e813 100644 --- a/spec/models/linked_data/config/context_map_spec.rb +++ b/spec/models/linked_data/config/context_map_spec.rb @@ -48,6 +48,12 @@ } end + describe '#new' do + it 'tests required context_map parameter and optional prefixes parameter' do + skip 'Pending: Need to write test for #initialize method' + end + end + describe '#properties' do it 'returns the configured url template' do expect(subject.properties.size).to eq 3 diff --git a/spec/models/linked_data/config/context_property_map_spec.rb b/spec/models/linked_data/config/context_property_map_spec.rb index 5145e829..ecd84ca1 100644 --- a/spec/models/linked_data/config/context_property_map_spec.rb +++ b/spec/models/linked_data/config/context_property_map_spec.rb @@ -38,6 +38,10 @@ expect { subject }.to raise_error(Qa::InvalidConfiguration, 'drillable must be true or false') end end + + it 'processes prefixes parameter' do + skip 'Pending: Need to test passing prefixes parameter to #initialize' + end end describe '#selectable?' do @@ -178,4 +182,10 @@ end end end + + describe '#ldpath_program' do + it 'returns the ldpath program for this property map' do + skip 'Pending: Need to write tests for #ldpath_program' + end + end end diff --git a/spec/services/linked_data/mapper/context_mapper_service_spec.rb b/spec/services/linked_data/mapper/context_mapper_service_spec.rb new file mode 100644 index 00000000..2b66f783 --- /dev/null +++ b/spec/services/linked_data/mapper/context_mapper_service_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +RSpec.describe Qa::LinkedData::Mapper::ContextMapperService do + describe '.map_context' do + it 'needs tests' do + skip 'Pending: Need to write tests for the ContextMapperService class' + end + end +end diff --git a/spec/services/linked_data/mapper/graph_mapper_service_spec.rb b/spec/services/linked_data/mapper/graph_mapper_service_spec.rb index 2fcfe5ca..d19d2fca 100644 --- a/spec/services/linked_data/mapper/graph_mapper_service_spec.rb +++ b/spec/services/linked_data/mapper/graph_mapper_service_spec.rb @@ -71,6 +71,10 @@ validate_entry(subject, :sort, ['3'], RDF::Literal) end end + + it 'yields to passed in block' do + skip 'Pending: Need to write test to confirm passed in block can modify value_map' + end end def validate_entry(results, key, values, entry_kind) diff --git a/spec/services/linked_data/mapper/search_results_mapper_service_spec.rb b/spec/services/linked_data/mapper/search_results_mapper_service_spec.rb index 1de2bdaf..843ccd97 100644 --- a/spec/services/linked_data/mapper/search_results_mapper_service_spec.rb +++ b/spec/services/linked_data/mapper/search_results_mapper_service_spec.rb @@ -59,5 +59,9 @@ expect(subjects).not_to include "http://id.worldcat.org/fast/510103" expect(subjects).not_to include "_:b0" end + + it 'adds context if requested' do + skip 'Pending: Need to write tests checking that the context_mapper_service is called only when context is requested and exists' + end end end