require 'ebsco/eds/version' require 'ebsco/eds/info' require 'ebsco/eds/results' require 'faraday' require 'faraday_middleware' require 'logger' require 'json' require 'active_support' require 'faraday_eds_middleware' require 'ebsco/eds/configuration' module EBSCO module EDS # Sessions are used to query and retrieve information from the EDS API. class Session # Contains search Info available in the session profile. Includes the sort options, search fields, limiters and expanders available to the profile. attr_accessor :info # Contains the search Options sent as part of each request to the EDS API such as limiters, expanders, search modes, sort order, etc. attr_accessor :search_options # The authentication token. This is passed along in the x-authenticationToken HTTP header. attr_accessor :auth_token # :nodoc: # The session token. This is passed along in the x-sessionToken HTTP header. attr_accessor :session_token # :nodoc: # The session configuration. attr_reader :config # Creates a new session. # # This can be done in one of two ways: # # === 1. Environment variables # * +EDS_AUTH+ - authentication method: 'ip' or 'user' # * +EDS_PROFILE+ - profile ID for the EDS API # * +EDS_USER+ - user id attached to the profile # * +EDS_PASS+ - user password # * +EDS_GUEST+ - allow guest access: 'y' or 'n' # * +EDS_ORG+ - name of your institution or company # # ==== Example # Once you have environment variables set, simply create a session like this: # session = EBSCO::EDS::Session.new # # === 2. \Options # * +:auth+ # * +:profile+ # * +:user+ # * +:pass+ # * +:guest+ # * +:org+ # # ==== Example # session = EBSCO::EDS::Session.new { # :auth => 'user', # :profile => 'edsapi', # :user => 'joe' # :pass => 'secret', # :guest => false, # :org => 'Acme University' # } def initialize(options = {}) @session_token = '' @auth_token = '' @config = {} @guest = true eds_config = EBSCO::EDS::Configuration.new if options[:config] @config = eds_config.configure_with(options[:config]) # return default if there is some problem with the yaml file (bad syntax, not found, etc.) @config = eds_config.configure if @config.nil? else @config = eds_config.configure(options) end # these properties aren't in the config if options.has_key? :user @user = options[:user] elsif ENV.has_key? 'EDS_USER' @user = ENV['EDS_USER'] end if options.has_key? :pass @pass = options[:pass] elsif ENV.has_key? 'EDS_PASS' @pass = ENV['EDS_PASS'] end if options.has_key? :profile @profile = options[:profile] elsif ENV.has_key? 'EDS_PROFILE' @profile = ENV['EDS_PROFILE'] end raise EBSCO::EDS::InvalidParameter, 'Session must specify a valid api profile.' if blank?(@profile) # these config options can be overridden by environment vars @auth_type = (ENV.has_key? 'EDS_AUTH') ? ENV['EDS_AUTH'] : @config[:auth] @org = (ENV.has_key? 'EDS_ORG') ? ENV['EDS_ORG'] : @config[:org] if ENV.has_key? 'EDS_GUEST' if ['n', 'N', 'no', 'No'].include?(ENV['EDS_GUEST']) @guest = false else @guest = true end else @guest = @config[:guest] end # use cache for auth token and info calls? if @config[:use_cache] cache_dir = File.join(@config[:eds_cache_dir], 'faraday_eds_cache') @cache_store = ActiveSupport::Cache::FileStore.new cache_dir end @max_retries = @config[:max_attempts] if options.has_key? :auth_token @auth_token = options[:auth_token] else @auth_token = create_auth_token end if options.key? :session_token @session_token = options[:session_token] else @session_token = create_session_token end @info = EBSCO::EDS::Info.new(do_request(:get, path: @config[:info_url]), @config) @current_page = 0 @search_options = nil if @config[:debug] if options.key? :caller puts 'CREATE SESSION CALLER: ' + options[:caller].inspect puts 'CALLER OPTIONS: ' + options.inspect end puts 'AUTH TOKEN: ' + @auth_token.inspect puts 'SESSION TOKEN: ' + @session_token.inspect end end # :category: Search & Retrieve Methods # Performs a search. # # Returns search Results. # # ==== \Options # # * +:query+ - Required. The search terms. Format: {booleanOperator},{fieldCode}:{term}. Example: SU:Hiking # * +:mode+ - Search mode to be used. Either: all (default), any, bool, smart # * +:results_per_page+ - The number of records retrieved with the search results (between 1-100, default is 20). # * +:page+ - Starting page number for the result set returned from a search (if results per page = 10, and page number = 3 , this implies: I am expecting 10 records starting at page 3). # * +:sort+ - The sort order for the search results. Either: relevance (default), oldest, newest # * +:highlight+ - Specifies whether or not the search term is highlighted using tags. Either true or false. # * +:include_facets+ - Specifies whether or not the search term is highlighted using tags. Either true (default) or false. # * +:facet_filters+ - Facets to apply to the search. Facets are used to refine previous search results. Format: \{filterID},{facetID}:{value}[,{facetID}:{value}]* Example: 1,SubjectEDS:food,SubjectEDS:fiction # * +:view+ - Specifies the amount of data to return with the response. Either 'title': title only; 'brief' (default): Title + Source, Subjects; 'detailed': Brief + full abstract # * +:actions+ - Actions to take on the existing query specification. Example: addfacetfilter(SubjectGeographic:massachusetts) # * +:limiters+ - Criteria to limit the search results by. Example: LA99:English,French,German # * +:expanders+ - Expanders that can be applied to the search. Either: thesaurus, fulltext, relatedsubjects # * +:publication_id+ - Publication to search within. # * +:related_content+ - Comma separated list of related content types to return with the search results. Either 'rs' (Research Starters) or 'emp' (Exact Publication Match) # * +:auto_suggest+ - Specifies whether or not to return search suggestions along with the search results. Either true or false (default). # # ==== Examples # # results = session.search({query: 'abraham lincoln', results_per_page: 5, related_content: ['rs','emp']}) # results = session.search({query: 'volcano', results_per_page: 1, publication_id: 'eric', include_facets: false}) def search(options = {}, add_actions = false) # use existing/updated SearchOptions if options.empty? if @search_options.nil? @search_results = EBSCO::EDS::Results.new(empty_results) else _response = do_request(:post, path: @config[:search_url], payload: @search_options) @search_results = EBSCO::EDS::Results.new(_response, @info.available_limiters, options) @current_page = @search_results.page_number @search_results end else # Only perform a search when there are query terms since certain EDS profiles will throw errors when # given empty queries if (options.keys & %w[query q]).any? || options.has_key?(:query) # create/recreate the search options if nil or not passing actions if @search_options.nil? || !add_actions @search_options = EBSCO::EDS::Options.new(options, @info) end _response = do_request(:post, path: @config[:search_url], payload: @search_options) @search_results = EBSCO::EDS::Results.new(_response, @info.available_limiters, options) @current_page = @search_results.page_number @search_results else @search_results = EBSCO::EDS::Results.new(empty_results) end end end # :category: Search & Retrieve Methods # Performs a simple search. All other search options assume default values. # # Returns search Results. # # ==== Attributes # # * +query+ - the search query. # # ==== Examples # # results = session.simple_search('volcanoes') # def simple_search(query) search({:query => query}) end # :category: Search & Retrieve Methods # Returns a Record based a particular result based on a database ID and accession number. # # ==== Attributes # # * +:dbid+ - The database ID (required). # * +:an+ - The accession number (required). # * +highlight+ - Comma separated list of terms to highlight in the data records (optional). # * +ebook+ - Preferred format to return ebook content in. Either ebook-pdf (default) or ebook-pdf. # # ==== Examples # record = session.retrieve({dbid: 'asn', an: '108974507'}) # def retrieve(dbid:, an:, highlight: nil, ebook: 'ebook-pdf') payload = { DbId: dbid, An: an, HighlightTerms: highlight, EbookPreferredFormat: ebook } retrieve_response = do_request(:post, path: @config[:retrieve_url], payload: payload) record = EBSCO::EDS::Record.new(retrieve_response) # puts 'RECORD: ' + record.pretty_inspect record end # Create a result set with just the record before and after then index item def solr_retrieve_previous_next(options = {}) records = [] hits = search(options).stat_total_hits # don't try to return a previous result if it's the first record if options['previous-next-index'].to_i > 1 options.update(:results_per_page => 1, 'page' => (options['previous-next-index'].to_i) - 1) records.push search(options).records.first end options.update(:results_per_page => 1, 'page' => (options['previous-next-index'].to_i) + 1) records.push search(options).records.first r = empty_results(hits) results = EBSCO::EDS::Results.new(r) results.records = records results.to_solr end def solr_retrieve_list(list: [], highlight: nil) records = [] if list.any? list.each { |id| dbid = id.split('__').first accession = id.split('__').last accession.gsub!(/_/, '.') records.push retrieve(dbid: dbid, an: accession, highlight: highlight, ebook: @config[:ebook_preferred_format]) } end r = empty_results(records.length) results = EBSCO::EDS::Results.new(r) results.records = records results.to_solr end # :category: Search & Retrieve Methods # Invalidates the session token. End Session should be called when you know a user has logged out. def end # todo: catch when there is no valid session? do_request(:post, path: @config[:end_session_url], payload: {:SessionToken => @session_token}) connection.headers['x-sessionToken'] = '' @session_token = '' end # :category: Search & Retrieve Methods # Clear all specified query expressions, facet filters, limiters and expanders, and set the page number back to 1. # Returns search Results. def clear_search add_actions 'ClearSearch()' end # :category: Search & Retrieve Methods # Clears all queries and facet filters, and set the page number back to 1; limiters and expanders are not modified. # Returns search Results. def clear_queries add_actions 'ClearQueries()' end # :category: Search & Retrieve Methods # Add a query to the search request. When a query is added, it will be assigned an ordinal, which will be exposed # in the search response message. It also removes any specified facet filters and sets the page number to 1. # Returns search Results. # ==== Examples # results = session.add_query('AND,California') def add_query(query) add_actions "AddQuery(#{query})" end # :category: Search & Retrieve Methods # Removes query from the currently specified search. It also removes any specified facet filters and sets the # page number to 1. # Returns search Results. # ==== Examples # results = session.remove_query(1) def remove_query(query_id) add_actions "removequery(#{query_id})" end # :category: Search & Retrieve Methods # Add actions to an existing search session # Returns search Results. # ==== Examples # results = session.add_actions('addfacetfilter(SubjectGeographic:massachusetts)') def add_actions(actions) @search_options.add_actions(actions, @info) search() end # :category: Setter Methods # Sets the sort for the search. The available sorts for the specified databases can be obtained from the session’s # info attribute. Sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.set_sort('newest') def set_sort(val) add_actions "SetSort(#{val})" end # :category: Setter Methods # Sets the search mode. The available search modes are returned from the info method. # Returns search Results. # ==== Examples # results = session.set_search_mode('bool') def set_search_mode(mode) add_actions "SetSearchMode(#{mode})" end # :category: Setter Methods # Specifies the view parameter. The view representes the amount of data to return with the search. # Returns search Results. # ==== Examples # results = session.set_view('detailed') def set_view(view) add_actions "SetView(#{view})" end # :category: Setter Methods # Sets whether or not to turn highlighting on or off (y|n). # Returns search Results. # ==== Examples # results = session.set_highlight('n') def set_highlight(val) add_actions "SetHighlight(#{val})" end # :category: Setter Methods # Sets the page size on the search request. # Returns search Results. # ==== Examples # results = session.results_per_page(50) def results_per_page(num) add_actions "SetResultsPerPage(#{num})" end # :category: Setter Methods # A related content type to additionally search for and include with the search results. # Returns search Results. # ==== Examples # results = session.include_related_content('rs') def include_related_content(val) add_actions "includerelatedcontent(#{val})" end # :category: Setter Methods # Specify to include facets in the results or not. Either 'y' or 'n'. # Returns search Results. # ==== Examples # results = session.set_include_facets('n') def set_include_facets(val) add_actions "SetIncludeFacets(#{val})" end # -- # ==================================================================================== # PAGINATION # ==================================================================================== # ++ # :category: Pagination Methods # Get the next page of results. # Returns search Results. def next_page page = @current_page + 1 get_page(page) end # :category: Pagination Methods # Get the previous page of results. # Returns search Results. def prev_page get_page([1, @current_page - 1].sort.last) end # :category: Pagination Methods # Get a specified page of results # Returns search Results. def get_page(page = 1) add_actions "GoToPage(#{page})" end # :category: Pagination Methods # Increments the current results page number by the value specified. If the current page was 5 and the specified value # was 2, the page number would be set to 7. # Returns search Results. def move_page(num) add_actions "MovePage(#{num})" end # :category: Pagination Methods # Get the first page of results. # Returns search Results. def reset_page add_actions 'ResetPaging()' end # -- # ==================================================================================== # FACETS # ==================================================================================== # ++ # :category: Facet Methods # Removes all specified facet filters. Sets the page number back to 1. # Returns search Results. def clear_facets add_actions 'ClearFacetFilters()' end # :category: Facet Methods # Adds a facet filter to the search request. Sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.add_facet('Publisher', 'wiley-blackwell') # results = session.add_facet('SubjectEDS', 'water quality') # def add_facet(facet_id, facet_val) add_actions "AddFacetFilter(#{facet_id}:#{facet_val})" end # :category: Facet Methods # Removes a specified facet filter id. Sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.remove_facet(45) def remove_facet(group_id) add_actions "RemoveFacetFilter(#{group_id})" end # :category: Facet Methods # Removes a specific facet filter value from a group. Sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.remove_facet_value(2, 'DE', 'Psychology') def remove_facet_value(group_id, facet_id, facet_val) add_actions "RemoveFacetFilterValue(#{group_id},#{facet_id}:#{facet_val})" end # -- # ==================================================================================== # LIMITERS # ==================================================================================== # ++ # :category: Limiter Methods # Clears all currently specified limiters and sets the page number back to 1. # Returns search Results. def clear_limiters add_actions 'ClearLimiters()' end # :category: Limiter Methods # Adds a limiter to the currently defined search and sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.add_limiter('FT','y') def add_limiter(id, val) add_actions "AddLimiter(#{id}:#{val})" end # :category: Limiter Methods # Removes the specified limiter and sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.remove_limiter('FT') def remove_limiter(id) add_actions "RemoveLimiter(#{id})" end # :category: Limiter Methods # Removes a specified limiter value and sets the page number back to 1. # Returns search Results. # ==== Examples # results = session.remove_limiter_value('LA99','French') def remove_limiter_value(id, val) add_actions "RemoveLimiterValue(#{id}:#{val})" end # -- # ==================================================================================== # EXPANDERS # ==================================================================================== # ++ # :category: Expander Methods # Removes all specified expanders and sets the page number back to 1. # Returns search Results. def clear_expanders add_actions 'ClearExpanders()' end # :category: Expander Methods # Adds expanders and sets the page number back to 1. Multiple expanders should be comma separated. # Returns search Results. # ==== Examples # results = session.add_expander('thesaurus,fulltext') def add_expander(val) add_actions "AddExpander(#{val})" end # :category: Expander Methods # Removes a specified expander. Sets the page number to 1. # Returns search Results. # ==== Examples # results = session.remove_expander('fulltext') def remove_expander(val) add_actions "RemoveExpander(#{val})" end # -- # ==================================================================================== # PUBLICATION (this is only used for profiles configured for publication searching) # ==================================================================================== # ++ # :category: Publication Methods # Specifies a publication to search within. Sets the pages number back to 1. # Returns search Results. # ==== Examples # results = session.add_publication('eric') def add_publication(pub_id) add_actions "AddPublication(#{pub_id})" end # :category: Publication Methods # Removes a publication from the search. Sets the page number back to 1. # Returns search Results. def remove_publication(pub_id) add_actions "RemovePublication(#{pub_id})" end # :category: Publication Methods # Removes all publications from the search. Sets the page number back to 1. # Returns search Results. def remove_all_publications add_actions 'RemovePublication()' end # -- # ==================================================================================== # INTERNAL METHODS # ==================================================================================== # ++ def do_request(method, path:, payload: nil, attempt: 0) # :nodoc: if attempt > @config[:max_attempts] raise EBSCO::EDS::ApiError, 'EBSCO API error: Multiple attempts to perform request failed.' end begin resp = connection.send(method) do |req| case method when :get req.url path when :post req.url path unless payload.nil? req.body = JSON.generate(payload) end else raise EBSCO::EDS::ApiError, "EBSCO API error: Method #{method} not supported for endpoint #{path}" end end resp.body rescue Error => e if e.respond_to? 'fault' error_code = e.fault[:error_body]['ErrorNumber'] || e.fault[:error_body]['ErrorCode'] unless error_code.nil? case error_code # session token missing when '108', '109' @session_token = create_session_token do_request(method, path: path, payload: payload, attempt: attempt+1) # auth token invalid when '104', '107' @auth_token = nil @auth_token = create_auth_token do_request(method, path: path, payload: payload, attempt: attempt+1) else raise e end end else raise e end end end # -- # attempts to query profile capabilities # dummy search just to get the list of available databases # ++ def get_available_databases # :nodoc: search({query: 'supercalifragilisticexpialidocious-supercalifragilisticexpialidocious', results_per_page: 1, mode: 'all', include_facets: false}).database_stats end # :category: Profile Settings Methods # Get a list of all available database IDs. # Returns Array of IDs. def get_available_database_ids get_available_databases.map{|item| item[:id]} end # :category: Profile Settings Methods # Determine if a database ID is available in the profile. # Returns Boolean. def dbid_in_profile(dbid) get_available_database_ids.include? dbid end # :category: Profile Settings Methods # Determine if publication matching is available in the profile. # Returns Boolean. def publication_match_in_profile @info.available_related_content_types.include? 'emp' end # :category: Profile Settings Methods # Determine if research starters are available in the profile. # Returns Boolean. def research_starters_match_in_profile @info.available_related_content_types.include? 'rs' end private def connection logger = Logger.new(@config[:log]) logger.level = Logger::DEBUG Faraday.new(url: @config[:eds_api_base]) do |conn| conn.headers['Content-Type'] = 'application/json;charset=UTF-8' conn.headers['Accept'] = 'application/json' conn.headers['x-sessionToken'] = @session_token ? @session_token : '' conn.headers['x-authenticationToken'] = @auth_token ? @auth_token : '' conn.headers['User-Agent'] = @config[:user_agent] conn.request :url_encoded conn.use :eds_caching_middleware, store: @cache_store if @config[:use_cache] conn.use :eds_exception_middleware conn.response :json, content_type: /\bjson$/ conn.response :logger, logger if @config[:debug] conn.adapter Faraday.default_adapter end end def create_auth_token if blank?(@auth_token) # ip auth if (blank?(@user) && blank?(@pass)) || @auth_type.casecmp('ip').zero? resp = do_request(:post, path: @config[:ip_auth_url]) # user auth else resp = do_request(:post, path: @config[:uid_auth_url], payload: { UserId: @user, Password: @pass, InterfaceId: @config[:interface_id] }) end end @auth_token = resp['AuthToken'] @auth_token end def create_session_token guest_string = @guest ? 'y' : 'n' resp = do_request(:get, path: @config[:create_session_url] + '?profile=' + @profile + '&guest=' + guest_string + '&displaydatabasename=y') @session_token = resp['SessionToken'] end # helper methods def blank?(var) var.nil? || var.respond_to?(:length) && var.empty? end # used to reliably create empty results when there are no search terms provided def empty_results(hits = 0) { 'SearchRequest'=> { 'SearchCriteria'=> { 'Queries'=>nil, 'SearchMode'=>'', 'IncludeFacets'=>'y', 'Sort'=>'relevance', 'AutoSuggest'=>'n' }, 'RetrievalCriteria'=> { 'View'=>'brief', 'ResultsPerPage'=>20, 'Highlight'=>'y' }, 'SearchCriteriaWithActions'=> { 'QueriesWithAction'=>nil } }, 'SearchResult'=> { 'Statistics'=> { 'TotalHits'=>hits, 'TotalSearchTime'=>0, 'Databases'=>[] }, 'Data'=> {'RecordFormat'=>'EP Display'}, 'AvailableCriteria'=>{'DateRange'=>{'MinDate'=>'0001-01', 'MaxDate'=>'0001-01'}} } } end end end end