Skip to content

Commit

Permalink
Merge pull request #187 from samvera/ld_refactor_iri_template
Browse files Browse the repository at this point in the history
Create models for IRI templates and use IRI template service to build URLs
  • Loading branch information
carolyncole committed Dec 3, 2018
2 parents e5f1c0e + e105c55 commit 915b7ab
Show file tree
Hide file tree
Showing 11 changed files with 652 additions and 2 deletions.
56 changes: 56 additions & 0 deletions app/models/qa/iri_template/url_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Provide access to iri template configuration.
# See https://www.hydra-cg.com/spec/latest/core/#templated-links for information on IRI Templated Links.
# TODO: It would be good to find a more complete resource describing templated links.
module Qa
module IriTemplate
class UrlConfig
TYPE = "IriTemplate".freeze
CONTEXT = "http://www.w3.org/ns/hydra/context.jsonld".freeze
attr_reader :template # [String] the URL template with variables for substitution (required)
attr_reader :variable_representation # [String] always "BasicRepresentation" # TODO what other values are supported and what do they mean
attr_reader :mapping # [Array<Qa::IriTempalte::VariableMap>] array of maps for use with a template (required)

# @param [Hash] url_config configuration hash for the iri template
# @option url_config [String] :template the URL template with variables for substitution (required)
# @option url_config [String] :variable_representation always "BasicRepresentation" # TODO what other values are supported and what do they mean
# @option url_config [Array<Hash>] :mapping array of maps for use with a template (required)
def initialize(url_config)
@template = extract_template(config: url_config)
@mapping = extract_mapping(config: url_config)
@variable_representation = url_config.fetch(:variable_representation, 'BasicRepresentation')
end

# Selective extract substitution variable-value pairs from the provided substitutions.
# @param [Hash, ActionController::Parameters] full set of passed in substitution values
# @returns [HashWithIndifferentAccess] Only variable-value pairs for variables defined in the variable mapping.
def extract_substitutions(substitutions)
selected_substitutions = HashWithIndifferentAccess.new
mapping.each do |m|
selected_substitutions[m.variable] = substitutions[m.variable] if substitutions.key? m.variable
end
selected_substitutions
end

private

# Extract the url template from the config
# @param config [Hash] configuration holding the template to be extracted
# @return [String] url template for accessing the authority
def extract_template(config:)
template = config.fetch(:template, nil)
raise Qa::InvalidConfiguration, "template is required" unless template
template
end

# Initialize the variable maps
# @param config [Hash] configuration holding the variable maps to be extracted
# @return [Array<IriTemplate::Map>] array of the variable maps
def extract_mapping(config:)
mapping = config.fetch(:mapping, nil)
raise Qa::InvalidConfiguration, "mapping is required" unless mapping
raise Qa::InvalidConfiguration, "mapping must include at least one map" if mapping.empty?
mapping.collect { |m| Qa::IriTemplate::VariableMap.new(m) }
end
end
end
end
77 changes: 77 additions & 0 deletions app/models/qa/iri_template/variable_map.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Provide access to iri template variable map configuration.
# See https://www.hydra-cg.com/spec/latest/core/#templated-links for information on IRI Templated Links - Variable Mapping.
# TODO: It would be good to find a more complete resource describing templated links.
module Qa
module IriTemplate
class VariableMap
TYPE = "IriTemplateMapping".freeze
attr_reader :variable
attr_reader :default

# @param [Hash] map configuration hash for the variable map
# @option map [String] :variable name of the variable in the template (e.g. {?query} has the name 'query')
# @option map [String] :property always "hydra:freetextQuery" # TODO what other values are supported and what do they mean
# @option map [True | False] :required is this variable required
# @option map [String] :default value to use if a value is not provided in the request
def initialize(map)
@variable = extract_variable(config: map)
@required = extract_required(config: map)
@default = extract_default(config: map)
@property = map.fetch(:property, 'hydra:freetextQuery')
end

# Is this variable required?
# @returns true if required; otherwise, false
def required?
@required
end

# TODO: When implementing more complex query substitution, simple_value is used when template url specifies variable as {var_name}.
# Value to use in substitution, using default if one isn't passed in
# @param [Object] value to use if it exists
# @returns the value to use (e.g. 'fr')
def simple_value(sub_value = nil)
return sub_value.to_s if sub_value.present?
raise Qa::IriTemplate::MissingParameter, "#{variable} is required, but missing" if required?
default
end

# TODO: When implementing more complex query substitution, parameter_value is used when template url specifies variable as {?var_name}.
# # Parameter and value to use in substitution, using default is one isn't passed in
# # @param [Object] value to use if it exists
# # @returns the parameter and value to use (e.g. 'language=fr')
# def parameter_value(sub_value = nil)
# simple_value = simple_value(sub_value)
# return '' if simple_value.blank?
# param_value = "#{variable}=#{simple_value}"
# end

private

# Extract the variable name from the config
# @param config [Hash] configuration (json) holding the variable map
# @return [String] variable for substitution in the url tmeplate
def extract_variable(config:)
varname = config.fetch(:variable, nil)
raise Qa::InvalidConfiguration, 'variable is required' unless varname
varname
end

# Extract the variable name from the config
# @param config [Hash] configuration (json) holding the variable map
# @return [True | False] required as true or false
def extract_required(config:)
required = config.fetch(:required, nil)
raise Qa::InvalidConfiguration, 'required must be true or false' unless required == true || required == false
required
end

# Extract the default value from the config
# @param config [Hash] configuration (json) holding the variable map
# @return [String] default value to use for the variable; defaults to empty string
def extract_default(config:)
config.fetch(:default, '').to_s
end
end
end
end
34 changes: 34 additions & 0 deletions app/services/qa/iri_template_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Provide service for building a URL based on an IRI Templated Link and its variable mappings based on provided substitutions.
module Qa
class IriTemplateService
# Construct an url from an IriTemplate making identified substitutions
# @param url_config [Qa::IriTemplate::UrlConfig] configuration (json) holding the template and variable mappings
# @param substitutions [HashWithIndifferentAccess] name-value pairs to substitute into the url template
# @return [String] url with substitutions
def self.build_url(url_config:, substitutions:)
# TODO: This is a very simple approach using direct substitution into the template string.
# Better would be to...
# * pattern {var_name} = simple value substitution in place of pattern produces 'value'
# * pattern {?var_name} = parameter substitution in place of pattern produces 'var_name=value'
# * patterns without a substitution are not included in the resulting URL
# * appropriately adds '?' or '&'
# * ensure proper escaping of values (e.g. value="A simple string" which is encoded as A%20simple%20string)
# Even more advanced would be to...
# * support BasicRepresentation (which is what it does now)
# * support ExplicitRepresentation
# * literal encoding for values (e.g. value="A simple string" becomes %22A%20simple%20string%22)
# * language encoding for values (e.g. value="A simple string" becomes value="A simple string"@en which is encoded as %22A%20simple%20string%22%40en)
# * type encoding for values (e.g. value=5.5 becomes value="5.5"^^http://www.w3.org/2001/XMLSchema#decimal which is encoded
# as %225.5%22%5E%5Ehttp%3A%2F%2Fwww.w3.org%2F2001%2FXMLSchema%23decimal)
# Fuller implementations parse the template into component parts and then build the URL by adding parts in as applicable.
url = url_config.template
url_config.mapping.each do |m|
key = m.variable
url = url.gsub("{?#{key}}", m.simple_value(substitutions[key])) # Incorrectly applies pattern {?var_name} to produce substitution 'value'
# url.gsub("{#{key}}", m.simple_value(substitutions[key])) # TODO: pattern {var_name} should produce substitution 'value'
# url.gsub("{?#{key}}", m.parameter_value(substitutions[key])) # TODO: pattern {?var_name} should produce substitution 'var_name=value'
end
url
end
end
end
60 changes: 60 additions & 0 deletions app/services/qa/linked_data/authority_url_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Provide service for constructing the external access URL for an authority.
module Qa
module LinkedData
class AuthorityUrlService
# Build a url for an authority/subauthority for the specified action.
# @param authority [Symbol] name of a registered authority
# @param subauthority [String] name of a subauthority
# @param action [Symbol] action with valid values :search or :term
# @param action_request [String] the request the user is making of the authority (e.g. query text or term id/uri)
# @param substitutions [Hash] variable-value pairs to substitute into the URL template
# @returns a valid URL the submits the action request to the external authority
def self.build_url(action_config:, action:, action_request:, substitutions: {}, subauthority: nil)
action_validation(action)
url_config = Qa::IriTemplate::UrlConfig.new(action_url(action_config, action))
selected_substitutions = url_config.extract_substitutions(substitutions)
selected_substitutions[action_request_variable(action_config, action)] = action_request
selected_substitutions[action_subauth_variable(action_config, action)] = action_subauth_variable_value(action_config, subauthority, action) if subauthority.present?

Qa::IriTemplateService.build_url(url_config: url_config, substitutions: selected_substitutions)
end

def self.action_validation(action)
return if [:search, :term].include? action
raise Qa::UnsupportedAction, "#{action} Not Supported - Action must be one of the supported actions (e.g. :term, :search)"
end
private_class_method :action_validation

# TODO: elr - rename search and term config methods to be the same to avoid all the ternary checks
def self.action_url(auth_config, action)
action == :search ? auth_config.url : auth_config.term_url
end
private_class_method :action_url

def self.action_request_variable(action_config, action)
key = action == :search ? :query : :term_id
action == :search ? action_config.qa_replacement_patterns[key] : action_config.term_qa_replacement_patterns[key]
end
private_class_method :action_request_variable

def self.action_subauth_variable(action_config, action)
action == :search ? action_config.qa_replacement_patterns[:subauth] : action_config.term_qa_replacement_patterns[:subauth]
end
private_class_method :action_subauth_variable

def self.action_subauth_variable_value(action_config, subauthority, action)
case action
when :search
pattern = action_subauth_variable(action_config, action)
default = action_config.url_mappings[pattern.to_sym][:default]
action_config.subauthorities[subauthority.to_sym] || default
when :term
pattern = action_subauth_variable(action_config, action)
default = action_config.term_url_mappings[pattern.to_sym][:default]
action_config.term_subauthorities[subauthority.to_sym] || default
end
end
private_class_method :action_subauth_variable_value
end
end
end
8 changes: 8 additions & 0 deletions lib/qa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ class UnsupportedFormat < ArgumentError; end
# Raised when a configuration parameter is incorrect or is required and missing
class InvalidConfiguration < ArgumentError; end

# Raised when a request is made for an unsupported action (e.g. :search, :term are supported)
class UnsupportedAction < ArgumentError; end

# Raised when a linked data request to a server returns a 503 error
class ServiceUnavailable < ArgumentError; end

Expand All @@ -50,4 +53,9 @@ class ServiceError < ArgumentError; end

# Raised when the server returns 404 for a find term request
class TermNotFound < ArgumentError; end

# Raised when a required mapping parameter is missing while building an IRI Template
module IriTemplate
class MissingParameter < StandardError; end
end
end
2 changes: 1 addition & 1 deletion lib/qa/authorities/linked_data/find_term.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def initialize(term_config)
def find(id, language: nil, replacements: {}, subauth: nil, jsonld: false)
raise Qa::InvalidLinkedDataAuthority, "Unable to initialize linked data term sub-authority #{subauth}" unless subauth.nil? || term_subauthority?(subauth)
language ||= term_config.term_language
url = term_config.term_url_with_replacements(id, subauth, replacements)
url = Qa::LinkedData::AuthorityUrlService.build_url(action_config: term_config, action: :term, action_request: id, substitutions: replacements, subauthority: subauth)
Rails.logger.info "QA Linked Data term url: #{url}"
graph = load_graph(url: url, language: language)
return "{}" unless graph.size.positive?
Expand Down
2 changes: 1 addition & 1 deletion lib/qa/authorities/linked_data/search_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def initialize(search_config)
def search(query, language: nil, replacements: {}, subauth: nil)
raise Qa::InvalidLinkedDataAuthority, "Unable to initialize linked data search sub-authority #{subauth}" unless subauth.nil? || subauthority?(subauth)
language ||= search_config.language
url = search_config.url_with_replacements(query, subauth, replacements)
url = Qa::LinkedData::AuthorityUrlService.build_url(action_config: search_config, action: :search, action_request: query, substitutions: replacements, subauthority: subauth)
Rails.logger.info "QA Linked Data search url: #{url}"
graph = load_graph(url: url, language: language)
parse_search_authority_response(graph)
Expand Down
102 changes: 102 additions & 0 deletions spec/models/iri_template/url_config_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
require 'spec_helper'

RSpec.describe Qa::IriTemplate::UrlConfig do
let(:url_template) do
{
:"@context" => "http://www.w3.org/ns/hydra/context.jsonld",
:"@type" => "IriTemplate",
template: "http://localhost/test_default/search?subauth={?subauth}&query={?query}&param1={?param1}&param2={?param2}",
variableRepresentation: "BasicRepresentation",
mapping: [
{
:"@type" => "IriTemplateMapping",
variable: "query",
property: "hydra:freetextQuery",
required: true
},
{
:"@type" => "IriTemplateMapping",
variable: "subauth",
property: "hydra:freetextQuery",
required: false,
default: "search_sub1_name"
},
{
:"@type" => "IriTemplateMapping",
variable: "param1",
property: "hydra:freetextQuery",
required: false,
default: "delta"
},
{
:"@type" => "IriTemplateMapping",
variable: "param2",
property: "hydra:freetextQuery",
required: false,
default: "echo"
}
]
}
end

describe 'model attributes' do
subject { described_class.new(url_template) }

it { is_expected.to respond_to :template }
it { is_expected.to respond_to :variable_representation }
it { is_expected.to respond_to :mapping }
end

describe '#initialize' do
context 'when missing template' do
before do
allow(url_template).to receive(:fetch).with(:template, nil).and_return(nil)
end

it 'raises an error' do
expect { described_class.new(url_template) }.to raise_error(Qa::InvalidConfiguration, 'template is required')
end
end

context 'when missing mapping' do
before do
allow(url_template).to receive(:fetch).with(:template, nil).and_return("http://localhost/test_default/search?subauth={?subauth}&query={?query}&param1={?param1}&param2={?param2}")
allow(url_template).to receive(:fetch).with(:mapping, nil).and_return(nil)
end

it 'raises an error' do
expect { described_class.new(url_template) }.to raise_error(Qa::InvalidConfiguration, 'mapping is required')
end
end

context 'when no maps defined' do
before do
allow(url_template).to receive(:fetch).with(:template, nil).and_return("http://localhost/test_default/search?subauth={?subauth}&query={?query}&param1={?param1}&param2={?param2}")
allow(url_template).to receive(:fetch).with(:mapping, nil).and_return([])
end

it 'raises an error' do
expect { described_class.new(url_template) }.to raise_error(Qa::InvalidConfiguration, 'mapping must include at least one map')
end
end
end

describe '#template' do
subject { described_class.new(url_template) }

it 'returns the configured url template' do
expect(subject.template).to eq 'http://localhost/test_default/search?subauth={?subauth}&query={?query}&param1={?param1}&param2={?param2}'
end
end

describe '#mapping' do
subject { described_class.new(url_template) }

it 'returns an array of variable maps' do
mapping = subject.mapping
expect(mapping).to be_kind_of Array
expect(mapping.size).to eq 4
expect(mapping.first).to be_kind_of Qa::IriTemplate::VariableMap
end
end
end
Loading

0 comments on commit 915b7ab

Please sign in to comment.