lib/util/vsac_api.rb in cqm-parsers-0.2.4 vs lib/util/vsac_api.rb in cqm-parsers-2.0.0

- old
+ new

@@ -1,6 +1,6 @@ -require 'rest_client' +require 'typhoeus' require 'uri' module Util module VSAC # Generic VSAC related exception. @@ -14,10 +14,17 @@ super("Value Set (#{oid}) was not found.") @oid = oid end end + # When VSAC responds with a 404 + class VSACNotFoundError < VSACError + def initialize + super('Resource not found.') + end + end + # Error represnting a program not found response from the API. class VSACProgramNotFoundError < VSACError attr_reader :oid def initialize(program) super("VSAC Program #{program} does not exist.") @@ -78,30 +85,25 @@ # Options for the API are passed in as a hash. # * config - def initialize(options) # check that :config exists and has needed fields raise VSACArgumentError.new("Required param :config is missing or empty.") if options[:config].nil? - symbolized_config = options[:config].symbolize_keys - unless check_config symbolized_config + @config = options[:config].symbolize_keys + unless check_config @config raise VSACArgumentError.new("Required param :config is missing required URLs.") end - @config = symbolized_config # if a ticket_granting_ticket was passed in, check it and raise errors if found # username and password will be ignored if !options[:ticket_granting_ticket].nil? provided_ticket_granting_ticket = options[:ticket_granting_ticket] if provided_ticket_granting_ticket[:ticket].nil? || provided_ticket_granting_ticket[:expires].nil? raise VSACArgumentError.new("Optional param :ticket_granting_ticket is missing :ticket or :expires") end - # check if it has expired - if Time.now > provided_ticket_granting_ticket[:expires] - raise VSACTicketExpiredError.new - end + raise VSACTicketExpiredError.new if Time.now > provided_ticket_granting_ticket[:expires] - # ticket granting ticket looks good @ticket_granting_ticket = { ticket: provided_ticket_granting_ticket[:ticket], expires: provided_ticket_granting_ticket[:expires] } # if username and password were provided use them to get a ticket granting ticket elsif !options[:username].nil? && !options[:password].nil? @@ -112,38 +114,26 @@ ## # Gets the list of profiles. This may be used without credentials. # # Returns a list of profile names. These are kept in the order that VSAC provides them in. def get_profile_names - profiles_response = RestClient.get("#{@config[:utility_url]}/profiles") - profiles = [] + profiles_response = http_get("#{@config[:utility_url]}/profiles") # parse xml response and get text content of each profile element doc = Nokogiri::XML(profiles_response) profile_list = doc.at_xpath("/ProfileList") - profile_list.xpath("//profile").each do |profile| - profiles << profile.text - end - - return profiles + return profile_list.xpath("//profile").map(&:text) end ## # Gets the list of programs. This may be used without credentials. # # Returns a list of program names. These are kept in the order that VSAC provides them in. def get_program_names - programs_response = RestClient.get("#{@config[:utility_url]}/programs") - program_names = [] - - # parse json response and return the names of the programs - programs_info = JSON.parse(programs_response)['Program'] - programs_info.each do |program| - program_names << program['name'] - end - - return program_names + programs_response = http_get_json("#{@config[:utility_url]}/programs") + programs_info = programs_response['Program'] + return programs_info.map { |program| program['name'] } end ## # Gets the details for a program. This may be used without credentials. # @@ -152,20 +142,14 @@ # the DEFAULT_PROGRAM constant for the program. # # Returns the JSON parsed response for program details. def get_program_details(program = nil) # if no program was provided use the one in the config or default in constant - if program.nil? - program = @config.fetch(:program, DEFAULT_PROGRAM) - end - - begin - # parse json response and return it - return JSON.parse(RestClient.get("#{@config[:utility_url]}/program/#{ERB::Util.url_encode(program)}")) - rescue RestClient::ResourceNotFound - raise VSACProgramNotFoundError.new(program) - end + program = @config.fetch(:program, DEFAULT_PROGRAM) if program.nil? + return http_get_json("#{@config[:utility_url]}/program/#{ERB::Util.url_encode(program)}") + rescue VSACNotFoundError + raise VSACProgramNotFoundError.new(program) end ## # Gets the latest profile for a program. This is a separate call from the program details call. It returns JSON # with only the name of the latest profile and the timestamp of the request. ex: @@ -179,27 +163,19 @@ # the DEFAULT_PROGRAM constant for the program. # # Returns the name of the latest profile for the given program. def get_latest_profile_for_program(program = nil) # if no program was provided use the one in the config or default in constant - if program.nil? - program = @config.fetch(:program, DEFAULT_PROGRAM) - end + program = @config.fetch(:program, DEFAULT_PROGRAM) if program.nil? - begin - # parse json response and return it - parsed_response = JSON.parse(RestClient.get("#{@config[:utility_url]}/program/#{ERB::Util.url_encode(program)}/latest%20profile")) + # parse json response and return it + parsed_response = http_get_json("#{@config[:utility_url]}/program/#{ERB::Util.url_encode(program)}/latest%20profile") - # As of 5/17/18 VSAC does not return 404 when an invalid profile is provided. It just doesnt fill the name - # attribute in the 200 response. We need to check this. - raise VSACProgramNotFoundError.new(program) if parsed_response['name'].nil? - return parsed_response['name'] - - # keeping this rescue block in case the API is changed to return 404 for invalid profile - rescue RestClient::ResourceNotFound - raise VSACProgramNotFoundError.new(program) - end + # As of 5/17/18 VSAC does not return 404 when an invalid profile is provided. It just doesnt fill the name + # attribute in the 200 response. We need to check this. + raise VSACProgramNotFoundError.new(program) if parsed_response['name'].nil? + return parsed_response['name'] end ## # Gets the releases for a program. This may be used without credentials. # @@ -208,84 +184,171 @@ # the DEFAULT_PROGRAM constant for the program. # # Returns a list of release names in a program. These are kept in the order that VSAC provides them in. def get_program_release_names(program = nil) program_details = get_program_details(program) - releases = [] - - # pull just the release names out - program_details['release'].each do |release| - releases << release['name'] - end - - return releases + return program_details['release'].map { |release| release['name'] } end ## # Gets a valueset. This requires credentials. # def get_valueset(oid, options = {}) - # base parameter oid is always needed - params = { id: oid } + needed_vs = {value_set: {oid: oid}, vs_vsac_options: options} + return get_multiple_valuesets([needed_vs])[0] + end - # release parameter, should be used moving forward - unless options[:release].nil? - params[:release] = options[:release] - end + ## + # Get multiple valuesets (executed in parallel). Requires credentials. + # + # Parameter needed_value_sets is an array of hashes, each hash should have at least: + # hash = {vs_vsac_options: ___, value_set: {oid: ___} } + # + def get_multiple_valuesets(needed_value_sets) + raise VSACNoCredentialsError.new unless @ticket_granting_ticket + raise VSACTicketExpiredError.new if Time.now > @ticket_granting_ticket[:expires] - # profile parameter, may be needed for getting draft value sets - if !options[:profile].nil? - params[:profile] = options[:profile] - unless options[:include_draft].nil? - params[:includeDraft] = !options[:include_draft].nil? ? 'yes' : 'no' - end - else - unless options[:include_draft].nil? - raise VSACArgumentError.new("Option :include_draft requires :profile to be provided.") - end + vs_responses = get_multiple_valueset_raw_responses(needed_value_sets) + vs_datas = [needed_value_sets,vs_responses].transpose.map do |needed_vs,vs_response| + expected_oid = needed_vs[:value_set][:oid] + process_and_validate_vsac_response(vs_response, expected_oid) end - # version parameter, rarely used - unless options[:version].nil? - params[:version] = options[:version] - end + return vs_datas + end - # get a new service ticket - params[:ticket] = get_ticket + private - # run request + # Given a raw valueset response, process and validate + def process_and_validate_vsac_response(vs_response, expected_oid) + raise VSNotFoundError.new(expected_oid) if vs_response.response_code == 404 + validate_http_status_for_ticket_based_request(vs_response.response_code) + + vs_data = vs_response.body.force_encoding("utf-8") begin - return RestClient.get("#{@config[:content_url]}/RetrieveMultipleValueSets", params: params) - rescue RestClient::ResourceNotFound - raise VSNotFoundError.new(oid) - rescue RestClient::InternalServerError - raise VSACError.new("Server error response from VSAC for (#{oid}).") + doc = Nokogiri::XML(vs_data) + doc.root.add_namespace_definition("vs","urn:ihe:iti:svs:2008") + vs_element = doc.at_xpath("/vs:RetrieveValueSetResponse/vs:ValueSet|/vs:RetrieveMultipleValueSetsResponse/vs:DescribedValueSet") + concepts = vs_element.xpath("//vs:Concept") + rescue StandardError + raise VSACError.new("Could not parse VSAC response for #{expected_oid}. Body: #{vs_data}") end + + raise Util::VSAC::VSNotFoundError.new(expected_oid) unless (vs_element && vs_element['ID'] == expected_oid) + raise Util::VSAC::VSEmptyError.new(expected_oid) if concepts.empty? + return vs_data end - private + # Execute bulk requests for valuesets, return the raw Typheous responses (requests executed in parallel) + def get_multiple_valueset_raw_responses(needed_value_sets) + service_tickets = get_service_tickets(needed_value_sets.size) - def get_ticket - # if there is no ticket granting ticket then we should raise an error + hydra = Typhoeus::Hydra.new # Hydra executes multiple HTTP requests at once + requests = needed_value_sets.map do |n| + request = create_valueset_request(n[:value_set][:oid], service_tickets.pop, n[:vs_vsac_options]) + hydra.queue(request) + request + end + + hydra.run + responses = requests.map(&:response) + return responses + end + + # Bulk get an amount of service tickets (requests executed in parallel) + def get_service_tickets(amount) raise VSACNoCredentialsError.new unless @ticket_granting_ticket - # if the ticket granting ticket has expired, throw an error raise VSACTicketExpiredError.new if Time.now > @ticket_granting_ticket[:expires] + + hydra = Typhoeus::Hydra.new # Hydra executes multiple HTTP requests at once + requests = amount.times.map do + request = create_service_ticket_request + hydra.queue(request) + request + end - # attempt to get a ticket - begin - ticket = RestClient.post("#{@config[:auth_url]}/Ticket/#{@ticket_granting_ticket[:ticket]}", service: TICKET_SERVICE_PARAM) - return ticket.to_s - rescue RestClient::Unauthorized + hydra.run + tickets = requests.map do |request| + validate_http_status_for_ticket_based_request(request.response.response_code) + request.response.body + end + return tickets + end + + # Create a typheous request for a valueset (this must be executed later) + def create_valueset_request(oid, ticket, options = {}) + # base parameter oid is always needed + params = { id: oid } + # release parameter, should be used moving forward + params[:release] = options[:release] unless options[:release].nil? + + # profile parameter, may be needed for getting draft value sets + if options[:profile].present? + params[:profile] = options[:profile] + params[:includeDraft] = options[:include_draft] ? 'yes' : 'no' unless options[:include_draft].nil? + end + if !options[:include_draft].nil? && options[:profile].nil? + raise VSACArgumentError.new("Option :include_draft requires :profile to be provided.") + end + + # version parameter, rarely used + params[:version] = options[:version] unless options[:version].nil? + params[:ticket] = ticket + + return Typhoeus::Request.new("#{@config[:content_url]}/RetrieveMultipleValueSets", params: params) + end + + # Create a typheous request for a service ticket (this must be executed later) + def create_service_ticket_request + return Typhoeus::Request.new("#{@config[:auth_url]}/Ticket/#{@ticket_granting_ticket[:ticket]}", + method: :post, + params: { service: TICKET_SERVICE_PARAM}) + end + + # Use your username and password to retrive a ticket granting ticket from VSAC + def get_ticket_granting_ticket(username, password) + response = Typhoeus.post( + "#{@config[:auth_url]}/Ticket", + # looks like typheous sometimes switches the order of username/password when encoding + # which vsac cant handle (!?), so encode first + body: URI.encode_www_form(username: username, password: password) + ) + raise VSACInvalidCredentialsError.new if response.response_code == 401 + validate_http_status(response.response_code) + return { ticket: String.new(response.body), expires: Time.now + 8.hours } + end + + # Raise errors if http_status is not OK (200), and expire TGT if auth fails + def validate_http_status_for_ticket_based_request(http_status) + if http_status == 401 @ticket_granting_ticket[:expires] = Time.now raise VSACTicketExpiredError.new end + validate_http_status(http_status) end - def get_ticket_granting_ticket(username, password) - ticket = RestClient.post("#{@config[:auth_url]}/Ticket", username: username, password: password) - return { ticket: String.new(ticket), expires: Time.now + 8.hours } - rescue RestClient::Unauthorized - raise VSACInvalidCredentialsError.new + # Raise errors if http_status is not OK (200) + def validate_http_status(http_status) + return if http_status == 200 + if http_status == 0 + raise VSACError.new("Error communicating with VSAC.") + elsif http_status == 404 + raise VSACNotFoundError.new + else + raise VSACError.new("HTTP Error code #{http_status} received from VSAC.") + end + end + + # Convenience function to perform an http get request (raises errors on failure) + def http_get(url) + response = Typhoeus.get(url) + validate_http_status(response.response_code) + return response.body + end + + # Convenience function to perform an http get request and convert to JSON (raises errors on failure) + def http_get_json(url) + return JSON.parse(http_get(url)) end # Checks to ensure the API config has all necessary fields def check_config(config) return !config.nil? &&