lib/figo.rb in figo-1.0 vs lib/figo.rb in figo-1.1

- old
+ new

@@ -22,52 +22,69 @@ require "json" require "logger" require 'net/http/persistent' require "digest/sha1" -require "./lib/models.rb" +require_relative "models.rb" -$logger = Logger.new(STDOUT) +# Ruby bindings for the figo Connect API: http://developer.figo.me module Figo - API_ENDPOINT = "api.leanbank.com" + $api_endpoint = "api.leanbank.com" - VALID_FINGERPRINTS = ["A6:FE:08:F4:A8:86:F9:C1:BF:4E:70:0A:BD:72:AE:B8:8E:B7:78:52", - "AD:A0:E3:2B:1F:CE:E8:44:F2:83:BA:AE:E4:7D:F2:AD:44:48:7F:1E"] + $valid_fingerprints = ["A6:FE:08:F4:A8:86:F9:C1:BF:4E:70:0A:BD:72:AE:B8:8E:B7:78:52", + "AD:A0:E3:2B:1F:CE:E8:44:F2:83:BA:AE:E4:7D:F2:AD:44:48:7F:1E"] + $logger = Logger.new(STDOUT) + + # Base class for all errors transported via the figo Connect API. class Error < RuntimeError - def initialize(error, error_description) # :nodoc: + # Initialize error object. + # + # @param error [String] the error code + # @param error_description [String] the error description + def initialize(error, error_description) @error = error @error_description = error_description end - def to_s # :nodoc: + # Convert error object to string. + # + # @return [String] the error description + def to_s return @error_description end end - class HTTPS < Net::HTTP::Persistent # :nodoc: + # HTTPS class with certificate authentication and enhanced error handling. + class HTTPS < Net::HTTP::Persistent + # Overwrite `initialize` method from `Net::HTTP::Persistent`. + # + # Verify fingerprints of server SSL/TLS certificates. def initialize(name = nil, proxy = nil) super(name, proxy) # Attribute ca_file must be set, otherwise verify_callback would never be called. - @ca_file = "" + @ca_file = "lib/cacert.pem" @verify_callback = proc do |preverify_ok, store_context| if preverify_ok and store_context.error == 0 certificate = OpenSSL::X509::Certificate.new(store_context.chain[0]) fingerprint = Digest::SHA1.hexdigest(certificate.to_der).upcase.scan(/../).join(":") - VALID_FINGERPRINTS.include?(fingerprint) + $valid_fingerprints.include?(fingerprint) else false end end end + # Overwrite `request` method from `Net::HTTP::Persistent`. + # + # Raise error when a REST API error is returned. def request(uri, req = nil, &block) response = super(uri, req, &block) # Evaluate HTTP response. case response @@ -79,11 +96,11 @@ when Net::HTTPUnauthorized raise Error.new("unauthorized", "Missing, invalid or expired access token.") when Net::HTTPForbidden raise Error.new("forbidden", "Insufficient permission.") when Net::HTTPNotFound - raise Error.new("not_found", "Requested object does not exist.") + return nil when Net::HTTPMethodNotAllowed raise Error.new("method_not_allowed", "Unexpected request method.") when Net::HTTPServiceUnavailable raise Error.new("service_unavailable", "Exceeded rate limit.") else @@ -93,47 +110,78 @@ end end # Represents a non user-bound connection to the figo Connect API. + # + # It's main purpose is to let user login via OAuth 2.0. class Connection - # Create connection object with your client ID and client secret. + # Create connection object with client credentials. + # + # @param client_id [String] the client ID + # @param client_secret [String] the client secret + # @param redirect_uri [String] optional redirect URI def initialize(client_id, client_secret, redirect_uri = nil) @client_id = client_id @client_secret = client_secret @redirect_uri = redirect_uri @https = HTTPS.new("figo-#{client_id}") end - def query_api(path, data = nil) # :nodoc: - uri = URI("https://#{API_ENDPOINT}#{path}") - puts uri + # Helper method for making a OAuth 2.0 request. + # + # @param path [String] the URL path on the server + # @param data [Hash] this optional object will be used as url-encoded POST content. + # @return [Hash] JSON response + def query_api(path, data = nil) + uri = URI("https://#{$api_endpoint}#{path}") - # Setup HTTP request. - request = Net::HTTP::Post.new(path) - request.basic_auth(@client_id, @client_secret) - request["Content-Type"] = "application/x-www-form-urlencoded" - request['User-Agent'] = "ruby-figo" - request.body = URI.encode_www_form(data) unless data.nil? + # Setup HTTP request. + request = Net::HTTP::Post.new(path) + request.basic_auth(@client_id, @client_secret) + request["Accept"] = "application/json" + request["Content-Type"] = "application/x-www-form-urlencoded" + request['User-Agent'] = "ruby-figo" + request.body = URI.encode_www_form(data) unless data.nil? - # Send HTTP request. - response = @https.request(uri, request) + # Send HTTP request. + response = @https.request(uri, request) - # Evaluate HTTP response. - return response.body == "" ? {} : JSON.parse(response.body) + # Evaluate HTTP response. + return response.body == "" ? {} : JSON.parse(response.body) end + # Get the URL a user should open in the web browser to start the login process. + # + # When the process is completed, the user is redirected to the URL provided to + # the constructor and passes on an authentication code. This code can be converted + # into an access token for data access. + # + # @param state [String] this string will be passed on through the complete login + # process and to the redirect target at the end. It should be used to + # validated the authenticity of the call to the redirect URL + # @param scope [String] optional scope of data access to ask the user for, + # e.g. `accounts=ro` def login_url(state, scope = nil) data = { "response_type" => "code", "client_id" => @client_id, "state" => state } data["redirect_uri"] = @redirect_uri unless @redirect_uri.nil? data["scope"] = scope unless scope.nil? - return "https://#{API_ENDPOINT}/auth/code?" + URI.encode_www_form(data) + return "https://#{$api_endpoint}/auth/code?" + URI.encode_www_form(data) end + # Exchange authorization code or refresh token for access token. + # + # @param authorization_code_or_refresh_token [String] either the authorization + # code received as part of the call to the redirect URL at the end of the + # logon process, or a refresh token + # @param scope [String] optional scope of data access to ask the user for, + # e.g. `accounts=ro` + # @return [Hash] object with the keys `access_token`, `refresh_token` and + # `expires,` as documented in the figo Connect API specification. def obtain_access_token(authorization_code_or_refresh_token, scope = nil) # Authorization codes always start with "O" and refresh tokens always start with "R". if authorization_code_or_refresh_token[0] == "O" data = { "grant_type" => "authorization_code", "code" => authorization_code_or_refresh_token } data["redirect_uri"] = @redirect_uri unless @redirect_uri.nil? @@ -143,10 +191,15 @@ end return query_api("/auth/token", data) end # Revoke refresh token or access token. + # + # @note this action has immediate effect, i.e. you will not be able use that token anymore after this call. + # + # @param token [String] access or refresh token to be revoked + # @return [nil] def revoke_token(refresh_token_or_access_token) data = { "token" => refresh_token_or_access_token } query_api("/auth/revoke?" + URI.encode_www_form(data)) return nil end @@ -155,17 +208,24 @@ # Represents a user-bound connection to the figo Connect API and allows access to the user's data. class Session # Create session object with access token. + # + # @param access_token [String] the access token def initialize(access_token) @access_token = access_token @https = HTTPS.new("figo-#{access_token}") end + # Helper method for making a REST request. + # + # @param path [String] the URL path on the server + # @param data [hash] this optional object will be used as JSON-encoded POST content. + # @return [Hash] JSON response def query_api(path, data=nil, method="GET") # :nodoc: - uri = URI("https://#{API_ENDPOINT}#{path}") + uri = URI("https://#{$api_endpoint}#{path}") # Setup HTTP request. request = case method when "POST" Net::HTTP::Post.new(path) @@ -176,34 +236,54 @@ else Net::HTTP::Get.new(path) end request["Authorization"] = "Bearer #{@access_token}" + request["Accept"] = "application/json" request["Content-Type"] = "application/json" request['User-Agent'] = "ruby-figo" request.body = JSON.generate(data) unless data.nil? # Send HTTP request. response = @https.request(uri, request) # Evaluate HTTP response. - return response.body == "" ? {} : JSON.parse(response.body) + if response.nil? + return nil + elsif response.body.nil? + return nil + else + return response.body == "" ? nil : JSON.parse(response.body) + end end # Request list of accounts. + # + # @return [Array] an array of `Account` objects, one for each account the user has granted the app access def accounts response = query_api("/rest/accounts") return response["accounts"].map {|account| Account.new(self, account)} end # Request specific account. + # + # @param account_id [String] ID of the account to be retrieved. + # @return [Account] account object def get_account(account_id) response = query_api("/rest/accounts/#{account_id}") return Account.new(self, response) end # Request list of transactions. + # + # @param since [String] this parameter can either be a transaction ID or a date + # @param start_id [String] do only return transactions which were booked after the start transaction ID + # @param count [Intger] limit the number of returned transactions + # @param include_pending [Boolean] this flag indicates whether pending transactions should be included + # in the response; pending transactions are always included as a complete set, regardless of + # the `since` parameter + # @return [Array] an array of `Transaction` objects, one for each transaction of the user def transactions(since = nil, start_id = nil, count = 1000, include_pending = false) data = {} data["since"] = (since.is_a?(Date) ? since.to_s : since) unless since.nil? data["start_id"] = start_id unless start_id.nil? data["count"] = count.to_s @@ -211,31 +291,70 @@ response = query_api("/rest/transactions?" + URI.encode_www_form(data)) return response["transactions"].map {|transaction| Transaction.new(self, transaction)} end # Request the URL a user should open in the web browser to start the synchronization process. + # + # @param redirect_uri [String] URI the user is redirected to after the process completes + # @param state [String] this string will be passed on through the complete synchronization process + # and to the redirect target at the end. It should be used to validated the authenticity of + # the call to the redirect URL + # @param disable_notifications [Booleon] this flag indicates whether notifications should be sent + # @param if_not_synced_since [Integer] if this parameter is set, only those accounts will be + # synchronized, which have not been synchronized within the specified number of minutes. + # @return [String] the URL to be opened by the user. def sync_url(redirect_uri, state, disable_notifications = false, if_not_synced_since = 0) data = { "redirect_uri" => redirect_uri, "state" => state, "disable_notifications" => disable_notifications, "if_not_synced_since" => if_not_synced_since } response = query_api("/rest/sync", data, "POST") - return "https://#{API_ENDPOINT}/task/start?id=#{response["task_token"]}" + return "https://#{$api_endpoint}/task/start?id=#{response["task_token"]}" end # Request list of registered notifications. + # + # @return [Notification] an array of `Notification` objects, one for each registered notification def notifications response = query_api("/rest/notifications") return response["notifications"].map {|notification| Notification.new(self, notification)} end + # Request specific notification. + # + # @param notification_id [String] ID of the notification to be retrieved + # @return [Notification] `Notification` object for the respective notification + def get_notification(notification_id) + response = query_api("/rest/notifications/#{notification_id}") + return response.nil? ? nil : Notification.new(self, response) + end + # Register notification. + # + # @param observe_key [String] one of the notification keys specified in the figo Connect API + # specification + # @param notify_uri [String] notification messages will be sent to this URL + # @param state [String] any kind of string that will be forwarded in the notification message + # @return [Notification] newly created `Notification` object def add_notification(observe_key, notify_uri, state) data = { "observe_key" => observe_key, "notify_uri" => notify_uri, "state" => state } response = query_api("/rest/notifications", data, "POST") - return response["notification_id"] + return Notification.new(self, response) end + # Modify a notification. + # + # @param notification [Notification] modified notification object + # @return [nil] + def modify_notification(notification) + data = { "observe_key" => notification.observe_key, "notify_uri" => notification.notify_uri, "state" => notification.state } + response = query_api("/rest/notifications/#{notification.notification_id}", data, "PUT") + return nil + end + # Unregister notification. - def remove_notification(notification_id) - query_api("/rest/notifications/#{notification_id}", nil, "DELETE") + # + # @param notification [Notification] notification object which should be deleted + # @return [nil] + def remove_notification(notification) + query_api("/rest/notifications/#{notification.notification_id}", nil, "DELETE") return nil end end