# Copyright 2013 CDN Connect
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require 'faraday'
require 'faraday/utils'
require 'signet/oauth_2/client'
require 'cdnconnect_api/response'
module CDNConnect
class APIClient
@@application_name = 'cdnconnect-api-ruby'
@@application_version = '0.1.0'
@@api_host = 'https://api.cdnconnect.com'
@@user_agent = @@application_name + ' v' + @@application_version
##
# Creates a client to authorize interactions with the API using the OAuth 2.0 protocol.
# This can be given a known API Key (access_token) or can use OAuth 2.0
# web application flow designed for 3rd party web sites (clients).
#
# @param [Hash] options
# The configuration parameters for the client.
# - :api_key
-
# An API Key (commonly known as an access_token) which was previously
# created within CDN Connect's account for a specific app.
# - :response_format
-
# How data should be formatted on the response. Possible values for
# include application/json, application/xml. JSON is the default.
# - :client_id
-
# A unique identifier issued to the client to identify itself to CDN Connect's
# authorization server. This is issued by CDN Connect to external clients.
# This is only needed if an API Key isn't already known.
# - :client_secret
-
# A secret issued by the CDN Connect's authorization server,
# which is used to authenticate the client. Do not confuse this is an access_token
# or an api_key. This is only required if an API Key
# isn't already known. A client secret should not be shared.
# - :scope
-
# The scope of the access request, expressed either as an Array
# or as a space-delimited String.
# - :state
-
# An unguessable random string designed to allow the client to maintain state
# to protect against cross-site request forgery attacks.
# - :code
-
# The authorization code received from the authorization server.
# - :redirect_uri
-
# The redirection URI used in the initial request.
# - :access_token
-
# The current access token for this client, also known as the API Key.
def initialize(options={})
# Normalize key to String to allow indifferent access.
options = options.inject({}) { |accu, (k, v)| accu[k.to_s] = v; accu }
# Initialize all of the options
@client_id = options["client_id"]
@client_secret = options["client_secret"]
@scope = options["scope"]
@state = options["state"]
@code = options["code"]
@redirect_uri = options["redirect_uri"]
options["access_token"] = options["access_token"] || options["api_key"] # both work
@access_token = options["access_token"]
@response_format = options["response_format"] || 'application/json'
@prefetched_upload_urls = {}
# Create the OAuth2 client which will be used to authorize the requests
@client = Signet::OAuth2::Client.new(:client_id => client_id,
:client_secret => @client_secret,
:scope => @scope,
:state => @state,
:code => @code,
:redirect_uri => @redirect_uri,
:access_token => @access_token)
return self
end
##
# Executes a GET request to an API URL and returns a response object.
# GET requests are used when reading data.
#
# @param url [String] The API URL to send a GET request to.
# @return [APIResponse] A response object with helper methods to read the response.
def get(url)
options[:path] = url
options[:method] = 'GET'
return self.fetch(options)
end
##
# Executes a POST request to an API URL and returns a response object.
# POST requests are used when creating data.
#
# @param url [String] The API URL to send a POST request to.
# @return [APIResponse] A response object with helper methods to read the response.
def post(url)
options[:path] = url
options[:method] = 'POST'
return self.fetch(options)
end
##
# Executes a PUT request to an API URL and returns a response object.
# PUT requests are used when updating data.
#
# @param url [String] The API URL to send a GET request to.
# @return [APIResponse] A response object with helper methods to read the response.
def post(url)
options[:path] = url
options[:method] = 'POST'
return self.fetch(options)
end
##
# Executes a DELETE request to an API URL and returns a response object.
# DELETE requests are used when (you guessed it) deleting data.
#
# @param url [String] The API URL to send a DELETE request to.
# @return [APIResponse] A response object with helper methods to read the response.
def delete(url)
options[:path] = url
options[:method] = 'DELETE'
return self.fetch(options)
end
##
# Used to upload a file or files within a folder to a destination folder within
# a CDN Connect app. This method requires either a CDN Connect URL, or both an app_id
# and obj_id. If you are uploading many files be sure to use the same client instance.
#
# @param [Hash] options
# The configuration parameters for the client.
# - :destination_folder_url
-
# The URL of the folder to upload to. If the destination folder URL option
# is not provided then you must use the app_id and obj_id options.
# - :app_id
-
# The app_id of the app to upload to. If the app_id or obj_id options
# are not provided then you must use the url option.
# - :obj_id
-
# The obj_id of the folder to upload to. If the app_id or obj_id options
# are not provided then you must use the url option.
# - :source_file_local_path
-
# A string of a source file's local paths to upload.
# @return [APIResponse] A response object with helper methods to read the response.
def upload(options={})
# Make sure we've got good data before starting the upload
prepare_upload(options)
i = 1
begin
# Check if we have a prefetched upload url before requesting a new one
upload_url = get_prefetched_upload_url(options[:destination_folder_url],
options[:app_id],
options[:obj_id])
if upload_url == nil
# We do not already have an upload url created. The first upload request
# will need to make a request for an upload url. After the first upload
# each upload response will also include a new upload url which can be used
# for the next upload when uploading to the same folder.
upload_url_response = self.get_upload_url(options)
if upload_url_response.is_error
return upload_url_response
end
upload_url = upload_url_response.get_result('upload_url')
end
# Create the POST data that gets sent in the request
post_data = {}
post_data[:create_upload_url] = 'true' # have the API also create the next upload url
post_data[:file] = Faraday::UploadIO.new(options[:source_file_local_path], options[:mime_type])
# Build the request to the API
conn = Faraday.new() do |req|
# https://github.com/lostisland/faraday
req.headers['User-Agent'] = @@user_agent
req.headers['Authorization'] = 'Bearer ' + @access_token
req.request :multipart
req.adapter :net_http
end
# Kick it off!
api_response = conn.post upload_url, post_data
# Woot! Convert the response to our model and see what's up
response = APIResponse.new(api_response)
# an upload response also contains a new upload url. Save it for the next upload.
set_prefetched_upload_url(options[:destination_folder_url],
options[:app_id],
options[:obj_id],
response.get_result('upload_url'))
# Rettempt the upload a max of two times if there was a server error
# Otherwise return the response data
if not response.is_server_error or i > 2
return response
end
i += 1
end while i <= 3
end
##
# This method should not be called directly, but is used by the upload method
# to get the options all ready to go and validated before uploading a file(s).
# @!visibility private
def prepare_upload(options={})
# Validate we've got a source file
source_file_local_path = options[:source_file_local_path]
if source_file_local_path == nil
raise ArgumentError, 'source_file_local_path required'
end
# Validate we've got a destination folder to upload to
destination_folder_url = options[:destination_folder_url]
app_id = options[:app_id]
obj_id = options[:obj_id]
if destination_folder_url == nil and (app_id == nil or obj_id == nil)
raise ArgumentError, 'destination_folder_url or app_id/obj_id required'
end
# Ideally it'd be awesome to already set what the mime type is, but getting that
# info accurately is a pain. If you do not send in the mime_type we will
# figure it out for you by the file extension (so ALWAYS have an extension)
# This will only work when using the source_file option, and will not
# work with the source_files or source_folder option.
if options[:mime_type] == nil
options[:mime_type] = 'application/octet-stream'
end
options
end
##
# This method should not be called directly, but is used to check if we
# already have an upload url ready to go for the folder we're uploading to.
# @!visibility private
def get_prefetched_upload_url(destination_url, app_id, obj_id)
# Build a unique key for the folder which was used to save an new upload url
key = destination_url || ''
key += app_id || ''
key += obj_id || ''
rtn_url = @prefetched_upload_urls[key]
@prefetched_upload_urls[key] = nil
return rtn_url
end
##
# This method should not be called directly, but is used to remember an upload url
# for the next upload to this folder.
# @!visibility private
def set_prefetched_upload_url(destination_url, app_id, obj_id, upload_url)
# Build a unique key for the folder to save an new upload url value to
key = destination_url || ''
key += app_id || ''
key += obj_id || ''
@prefetched_upload_urls[key] = upload_url
end
##
# An upload url must be optained first before uploading a file. After the first
# upload url is received, all upload responses contain another upload which can be
# used to eliminate the need to do seperate requests for an upload url.
# @!visibility private
def get_upload_url(options={})
destination_folder_url = options[:destination_folder_url]
path = nil
if destination_folder_url != nil
path = '/v1/' + destination_folder_url + '/upload'
else
path = generate_obj_path(options) + '/upload'
end
i = 1
begin
response = self.fetch(:path => path)
if not response.is_server_error or i > 2
return response
end
i += 1
end while i <= 3
end
##
# This method should not be called directly, but is used to build the api
# path common needed by a few methods.
# @!visibility private
def generate_obj_path(options={})
path = options[:path]
if path != nil
return path
end
app_id = options[:app_id]
obj_id = options[:obj_id]
uri = options[:uri] || options[:url]
path = nil
# An object's path can either be made up of an app_id and an obj_id
# Or it can be made up of the entire URI
if app_id != nil and obj_id != nil
path = 'apps/' + app_id + '/objects/' + obj_id
elsif uri != nil
path = uri
end
if path == nil
raise ArgumentError, "missing url or both app_id and obj_id"
end
return '/v1/' + path
end
##
# This method should not be called directly, but is used to validate data
# and make it all pretty before firing off the request to the API.
# @!visibility private
def prepare(options={})
if options[:path] == nil
raise ArgumentError, 'missing api path'
end
options[:response_format] = options[:response_format] || @response_format
headers = { 'User-Agent' => @@user_agent }
# There are three possible response content-types: JSON, XML
# Default Content-Type is application/json with a .json extension
if options[:response_format] == 'application/xml'
headers['Content-Type'] = 'application/xml'
response_extension = 'xml'
else
options[:response_format] = 'application/json'
headers['Content-Type'] = 'application/json'
response_extension = 'json'
end
options[:headers] = headers
options[:uri] = @@api_host + options[:path] + '.' + response_extension
options[:url] = options[:uri]
options[:method] = options[:method] || 'GET'
return options
end
##
# Guts of an authorized request. Do not call this directly.
# @!visibility private
def fetch(options={})
# Prepare the data to be shipped in the request
options = self.prepare(options)
begin
# Send the request and get the response
response = @client.fetch_protected_resource(options)
# Return the API response
return APIResponse.new(response)
rescue Signet::AuthorizationError => detail
return APIResponse.new(detail.response)
end
end
# - :code
-
# The authorization code received from the authorization server.
# - :redirect_uri
-
# The redirection URI used in the initial request.
# - :access_token
-
# The current access token for this client, also known as the API Key.
##
# A unique identifier issued to the client to identify itself to CDN Connect's
# authorization server. This is issued by CDN Connect to external clients.
# This is only needed if an API Key isn't already known.
#
# @return [String]
def client_id
@client_id
end
##
# A secret issued by the CDN Connect's authorization server,
# which is used to authenticate the client. Do not confuse this is an access_token
# or an api_key. This is only required if an API Key
# isn't already known. A client secret should not be shared.
#
# @return [String]
def client_secret
@client_secret
end
##
# The scope of the access request, expressed either as an Array
# or as a space-delimited String. This is only required if an API Key
# isn't already known.
#
# @return [String]
def scope
@scope
end
##
# An unguessable random string designed to allow the client to maintain state
# to protect against cross-site request forgery attacks.
# This is only required if an API Key isn't already known.
#
# @return [String]
def state
@state
end
# @return [String]
def code
@code
end
# @return [String]
def redirect_uri
@redirect_uri
end
# @return [String]
def access_token
@access_token
end
# @return [String]
def api_key
@access_token
end
# @return [String]
def response_format
@response_format
end
end
end