npolar/argos-ruby

View on GitHub
lib/argos/soap.rb

Summary

Maintainability
C
1 day
Test Coverage
require "savon"

module Argos
  class Soap

    # A [simple](http://wanderingbarque.com/nonintersecting/2006/11/15/the-s-stands-for-simple/) Soap client for the Argos satellite tracking webservice operated by CLS
    
    # client [Savon] (version 3)
    # request [String] Soap:Envelope (XML request body)
    # response [Savon::Response]
    # operation [Savon::Operation]
    # log [Logger]
    # xml [String] (Extracted, inner) XML
    # filter 
    # platformId [String] Comma-separated list of platforms
    # programNumber [String] Comma-separated list of programs
    # nbDaysFromNow
    # period
    attr_accessor :client, :request, :response, :operation, :log, :xml, :filter,
      :platformId, :programNumber, :nbDaysFromNow, :period
    # username [String]
    # password [String]
    attr_writer :username, :password
    
    URI = "http://ws-argos.cls.fr/argosDws/services/DixService"
    # Alternative: "http://ws-argos.clsamerica.com/argosDws/services/DixService"
    
    WSDL = "#{URI}?wsdl"       
    
    ARGOS_NS = "http://service.dataxmldistribution.argos.cls.fr/types"
    
    SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
    
    NAMESPACES = {
      "soap" => SOAP_NS,
      "argos" => ARGOS_NS
    }
    
    # Constructor
    # soap = Argos::Soap.new({username: "argos-system-user", password: "argos-system-pw"})
    def initialize(config={})
      config.each do |k,v|
        case k.to_sym
        when :username
          @username=v
        when :password
          @password=v
        when :wsdl
          @wsdl=v
        when :programNumber
          @programNumber = v
        when :platformId
          @platformId = v
        when :nbDaysFromNow
          @nbDaysFromNow = v.to_i
        when :period
          @period = v
        when :filter
          @filter = v
        else
          #raise ArgumentError, "Unkown config key: #{k}"
        end
      end
    end
    
    # Build baseRequest Hash
    # The service requires programNumber or PlatformId, but if you do not provide any,
    # this method will call the service (@see #programs) and get the current user's programs
    # @return [Hash]
    def baseRequest
      # if override key is platformId... delete programNumber...
      # if override key is period... delete nbDaysFromNow...
      baseRequest = { username: _username, password: _password }
      
      # First choice (program or platform)
      if @programNumber.nil? and @platformId.nil?
        # Fetch all programs if neither is provided
        baseRequest[:programNumber] = programs.map {|p|p.to_s}.join(",")
      elsif @programNumber.to_s =~ /\d+/ and @platformId.to_s =~ /\d+/
        baseRequest[:platformId] = @platformId # ignores programNumber
      elsif @programNumber.to_s =~ /\d+/ 
        baseRequest[:programNumber] = @programNumber
      elsif @platformId.to_s =~ /\d+/
        baseRequest[:platformId] = @platformId
      end
      
      # 2nd choice (time)
      if @nbDaysFromNow.nil? and @period.nil?
        # Default to 20 days of data (the maximum)
        baseRequest[:nbDaysFromNow] = 20
      elsif @nbDaysFromNow =~ /\d+/ and not @period.nil?
        raise "Cannot provide both nbDaysFromNow and period"
      elsif @nbDaysFromNow.to_s =~ /\d+/ 
        baseRequest[:nbDaysFromNow] = @nbDaysFromNow.to_i
      else
        baseRequest[:period] = @period
      end
      
      #baseRequest = baseRequest.merge({
        # @todo 
        #<xs:element minOccurs="0" name="referenceDate" type="tns:referenceDateType"/>
        #<xs:element minOccurs="0" name="locClass" type="xs:string"/>
        #<xs:element minOccurs="0" name="geographicArea" type="xs:string"/>
        #<xs:element minOccurs="0" name="compression" type="xs:int"/>
        #<xs:element minOccurs="0" name="mostRecentPassages" type="xs:boolean"/>
      #})
      baseRequest
      
    end
        
    # @return [Savon]
    def client
      @client ||= begin  
        
        tmpwsdl = "/tmp/argos-soap.wsdl"        
        if File.exists?(tmpwsdl)
          uri = tmpwsdl
        else
          uri = @wsdl||=WSDL
        end
        
        client = ::Savon.new(uri)
        if not File.exists?(tmpwsdl)
          response = ::Net::HTTP.get_response(::URI.parse(WSDL))
          File.open(tmpwsdl, "wb") { |file| file.write(response.body)}
        end
        client
      end
    end
    
    def filter?
      not @filter.nil? and filter.respond_to?(:call)
    end

    # @return [String]
    def getCsv
      o = _operation(:getCsv)
      o.body = { csvRequest: baseRequest.merge(
        showHeader: true).merge(xmlRequest)
      }
      @response = o.call
      @request = o.build
      
      # Handle faults (before extracting data)
      _envelope.xpath("soap:Body/soap:Fault", namespaces).each do | fault |
        raise fault.to_s
      end
      
      @text = _extract_escaped_xml("csvResponse").call(response)
    end
    
    # @return [Hash]
    def getKml
      _call_xml_operation(:getKml, { kmlRequest: baseRequest.merge(xmlRequest)}, _extract_escaped_xml("kmlResponse"))
    end
    
    # @return [Hash]
    # {"data":{"program":[{"programNumber":"9660","platform":[{ .. },{ .. }]}],"@version":"1.0"}}
    # Each platform Hash (.. above): {"platformId":"129990","lastLocationClass":"3","lastCollectDate":"2013-10-03T08:32:24.000Z","lastLocationDate":"2013-05-22T04:55:15.000Z","lastLatitude":"47.67801","lastLongitude":"-122.13419"}
    def getPlatformList
      platformList = _call_xml_operation(:getPlatformList, { platformListRequest:
        # Cannot use #baseRequest here because that methods calls #programs which also calls #getPlatformList...
        { username: _username, password: _password },
      }, _extract_escaped_xml("platformListResponse"))
      
      # Raise error if no programs
      if platformList["data"]["program"].nil?
        raise platformList.to_json
      end
      # Force Arrays
      if not platformList["data"]["program"].is_a? Array
        platformList["data"]["program"] = [platformList["data"]["program"]]
      end
      
      platformList["data"]["program"].map! {|program|
        if program["platform"].is_a? Hash
          program["platform"] = [program["platform"]]
        end
        program 
      }
      
      platformList
    end
    
    # @return [Hash]
    def getXml
      _call_xml_operation(:getXml,
        { xmlRequest: baseRequest.merge(xmlRequest)},
        _extract_escaped_xml("xmlResponse"))
    end
    
    # @return [Hash]
    def getStreamXml
      _call_xml_operation(:getStreamXml,
        { streamXmlRequest: baseRequest.merge(xmlRequest)},
        _extract_motm)
    end 
    
    # @return [Hash]
    def getXsd  
      _call_xml_operation(:getXsd, { xsdRequest: {} }, _extract_escaped_xml("xsdResponse"))
    end
    
    # @return [Hash]
    # choice: programNumber | platformId | wmo*
    # nbMaxObs
    def getObsXml
      _call_xml_operation(:getObsXml,
        { observationRequest: baseRequest.merge(xmlRequest)},
        _extract_escaped_xml("observationResponse"))
    end
    
    # @return [Text]
    # choice: programNumber | platformId | wmo*
    # nbMaxObs
    def getObsCsv
      o = _operation(:getObsCsv)
      o.body = { observationRequest: baseRequest.merge(xmlRequest)
      }
      @response = o.call
      @request = o.build
      @text = _extract_escaped_xml("observationResponse").call(response)
    end
    
    # Platforms: array of platformId integers
    # @return [Array] of [Integer]
    def platforms
      platforms = []

      platformListPrograms = getPlatformList["data"]["program"]

      if @programNumber.to_s =~ /\d+/
        platformListPrograms.select! {|p| p["programNumber"].to_i == @programNumber.to_i }
      end
      
      platformListPrograms.each do |program|
        if program.key?("platform") and not program["platform"].is_a?(Array)
          platforms << program["platform"]["platformId"].to_i
        else
          platforms  += program["platform"].map {|p| p["platformId"].to_i}
        end
      end
      platforms
    end
    
    # Period request
    def period(startDate, endDate)  
      { startDate: startDate, endDate: endDate }
    end
    
    # Programs: Array of programNumber integers
    # @return [Array]
    def programs
      platformList = getPlatformList
      if platformList.key?("data") and platformList["data"].key?("program")
        
        platformList_data_program = platformList["data"]["program"].is_a?(Array) ? platformList["data"]["program"] : [platformList["data"]["program"]]
        
        platformList_data_program.map {|p| p["programNumber"].to_i }
        
        
      else
        raise platformList
      end
    end

    # @return [String]
    def raw
      response.raw
    end
    
    # @return [String]
    def request
      if operation.nil?
        nil
      else
        operation.build
      end
    end
    
    # @return [Hash] {"DixService":{"ports":{"DixServicePort":{"type":"http://schemas.xmlsoap.org/wsdl/soap12/","location":"http://ws-argos.cls.fr/argosDws/services/DixService"}}}}
    def services
      client.services
    end
    
    def schema
      Nokogiri::XML::Schema(File.read("#{__dir__}/_xsd/argos-data.xsd"))
    end
    
    def validate(xml)
      if xml.is_a? String
        xml = Nokogiri.XML(xml)
      end
      schema.validate(xml)
      
    end
    
    # @return [String]
    def text
      @text||=""
    end

    # @return [Array] [:getCsv, :getStreamXml, :getKml, :getXml, :getXsd, :getPlatformList, :getObsCsv, :getObsXml] 
    def operations
      @response = client.operations(:DixService, :DixServicePort)
    end
    
    def namespaces
      NAMESPACES
    end
    
    def xmlRequest
      {
        displayLocation: true,
        displayDiagnostic: true,
        displayMessage: true,
        displayCollect: true,
        displayRawData: true,
        displaySensor: true,
        #argDistrib: "",
        displayImageLocation: true,
        displayHexId: true  
      }
    end
    
    protected
    
    # Build and call @operation, set @response, @request, and @xml
    # @raise on faults
    # @return [Hash]
    def _call_xml_operation(op_sym, body, extract=nil)
      @operation = _operation(op_sym)
      @operation.body = body
      @response = operation.call

      # Check for http errors?

      # Handle faults (before extracting data)
      _envelope.xpath("soap:Body/soap:Fault", namespaces).each do | fault |
        raise Exception, fault.to_s
      end
      
      # Extract data
      if extract.respond_to?(:call)
        @xml = extract.call(response)
      else
        @xml = response.raw
      end
      
      # Handle errors

      ng = Nokogiri.XML(xml)
      ng.xpath("/data/errors/error").each do | error |
        if error.key?("code")
          case error["code"].to_i
          when 4
            raise NodataException
          end
          #<error code="2">max response reached</error>
          #<error code="3">authentification error</error>
          #<error code="9">start date upper than end date</error>
        else
          raise Exception, error
        end
      end
      
      # Validation - only :getXml
      if [:getXml].include? op_sym
        # Validation against getXSD schema does not work: ["Element 'data': No matching global declaration available for the validation root."]
        # See https://github.com/npolar/argos-ruby/commit/219e4b3761e5265f8f9e8b924bcfc23607902428 for the fix
        schema = Nokogiri::XML::Schema(File.read("#{__dir__}/_xsd/argos-data.xsd"))
        v = schema.validate(ng)
        if v.any?
          log.debug "#{v.size} errors: #{v.map{|v|v.to_s}.uniq.to_json}"
        end
      end
      

      # Convert XML to Hash
      nori = Nori.new
      nori.parse(xml)
    end
    
    # This is a shame, but who's to blame when there's multiple XML prologs (the second is escaped) and even mix of (declared) encodings (UTF-8 in soap envelope, ISO-8859-1 inside)
    # Note: the inner data elements are non-namespaced (see da), so that recreating as proper XML would need to set xmlns=""
    def _extract_escaped_xml(responseElement)
      lambda {|response| CGI.unescapeHTML(response.raw.split("<#{responseElement} xmlns=\"http://service.dataxmldistribution.argos.cls.fr/types\"><return>")[1].split("</return>")[0])}
    end
    
    # This is proof-of-concept quality code.
    # @todo Need to extract boundary and start markers from Content-Type header:
    # Content-Type: multipart/related; type="application/xop+xml"; boundary="uuid:14b8db9f-a393-4786-be3f-f0f7b12e14a2"; start="<root.message@cxf.apache.org>"; start-info="application/soap+xml"

    # @return [String]
    
    def _extract_motm
      lambda {|response|
        # Scan for MOTM signature --uuid:*
        if response.raw =~ (/^(--[\w:-]+)--$/)
          # Get the last message, which is -2 because of the trailing --
          xml = response.raw.split($1)[-2].strip

          # Get rid of HTTP headers
          if xml =~ /\r\n\r\n[<]/
            xml = xml.split(/\r\n\r\n/)[-1]
          end
          
        else
          raise "Cannot parse MOTM"
        end
        }
    end
    
    # @return [Nokogiri:*]
    def _envelope
      ng = Nokogiri.XML(response.raw).xpath("/soap:Envelope", namespaces)
      if not ng.any?
        # Again, this is a shame...
        envstr = '<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
        extracted = envstr + response.raw.split(envstr)[1].split("</soap:Envelope>")[0] + "</soap:Envelope>"
        ng = Nokogiri.XML(extracted).xpath("/soap:Envelope", namespaces)
      end
      ng
    end
        
    # @return [Savon::Operation]
    def _operation(operation_name)
      client.operation(:DixService, :DixServicePort, operation_name)
    end
    
    # @return [String]
    def _username
      @username||=ENV["ARGOS_SOAP_USERNAME"]
    end
    
    # @return [String]
    def _password
      @password||=ENV["ARGOS_SOAP_PASSWORD"]
    end
    
  end  
end