module Scrivito # Provides a simple wrapper for the CMS Rest API. # # @example Request the published workspace: # Scrivito::CmsRestApi.get('workspaces/published') # # @example Create a new Obj: # Scrivito::CmsRestApi.post('workspaces/001384beff9e5845/objs', # {'obj' => {'_path' => '/new_obj', '_obj_class' => 'Publication'}}) # # @example Update an Obj: # Scrivito::CmsRestApi.put('workspaces/001384beff9e5845/objs/9e432077f0412a63', # {'obj' => {'title' => 'new title'}}) # # @example Delete an Obj: # Scrivito::CmsRestApi.delete('workspaces/001384beff9e5845/objs/f4123622ff07b70b') # # @example Specify a poll interval (in seconds; default: 2) to use in case the response # is a task reference response and the final response is polled for: # Scrivito::CmsRestApi.put('workspace/001384beff9e5845/publish', nil, :interval => 10) # # @example Return immediately with the first response (without polling in case it is a # task reference response): # Scrivito::CmsRestApi.task_unaware_request(:put, 'workspace/001384beff9e5845/publish', nil) # class CmsRestApi METHOD_TO_NET_HTTP_CLASS = { :get => Net::HTTP::Get, :put => Net::HTTP::Put, :post => Net::HTTP::Post, :delete => Net::HTTP::Delete, }.freeze DEFAULT_TIMEOUT = 25.seconds.freeze MAX_REQUEST_TIME = 10.seconds.freeze def self.get(resource_path, payload = nil, options = {}) request_cms_api(:get, resource_path, payload, options) end def self.put(resource_path, payload, options = {}) request_cms_api(:put, resource_path, payload, options) end def self.post(resource_path, payload, options = {}) request_cms_api(:post, resource_path, payload, options) end def self.delete(resource_path, payload = nil, options = {}) request_cms_api(:delete, resource_path, payload, options) end def self.task_unaware_request(method, resource_path, payload = nil) raise "Unexpected method #{method}" unless [:delete, :get, :post, :put].include?(method) response_for_request_cms_api(method, resource_path, payload, build_timer) end def self.count_requests(path) @count_requests = path @number_of_requests = 0 yield @count_requests = nil @number_of_requests end def self.upload_file(file, obj_id) upload_permission = get('blobs/upload_permission') upload = perform_file_upload(file, upload_permission) activate_upload(upload: upload, obj_id: obj_id) end def self.activate_upload(params) put('blobs/activate_upload', params) end def self.normalize_path_component(component) Addressable::URI.normalize_component(component, Addressable::URI::CharacterClasses::UNRESERVED) end class << self private def request_cms_api(action, resource_path, payload, options) log_api_request(action, resource_path, payload) do @number_of_requests += 1 if resource_path == @count_requests timer = build_timer(options) response = response_for_request_cms_api(action, resource_path, payload, timer) if task_response?(response) task_path = "tasks/#{response['task']['id']}" poll_interval = options[:interval].presence.try(:to_f) || 2 wait_for_tasks_final_response(task_path, poll_interval) else response end end end def log_api_request(action, resource_path, payload, &block) ActiveSupport::Notifications.instrumenter.instrument( "backend_request.scrivito", { :path => resource_path, :verb => action, :params => action == :get ? payload : nil }, &block ) end def task_response?(response) response.is_a?(Hash) && response.keys == ['task'] && response['task'].is_a?(Hash) end def response_for_request_cms_api(method, resource_path, payload, timer) request = method_to_net_http_class(method).new(path(resource_path)) set_headers(request) request.body = MultiJson.encode(payload) if payload.present? response = retry_once_on_network_error(method, timer) do CmsRestApi::RateLimit.retry_on_rate_limit(timer) do request_timeout = [timer.remaining_time, MAX_REQUEST_TIME].min connection_manager.request(request, request_timeout) end end handle_response(resource_path, response) end def handle_response(resource_path, response) http_code = response.code.to_i if response.code.start_with?('2') MultiJson.load(response.body) elsif response.code == '403' raise AccessDenied.new(response.body) else begin error_body = MultiJson.decode(response.body) specific_output = error_body['error'] if response.code.start_with?('4') backend_code = error_body['code'] message = "'#{specific_output}' on '#{resource_path}'" raise ClientError.new(specific_output, http_code, backend_code) elsif response.code == '500' && specific_output raise BackendError.new(specific_output, http_code) else # 3xx and >500 are treated as NetworkErrors raise NetworkError.new(response.body, http_code) end rescue MultiJson::DecodeError raise NetworkError.new(response.body, http_code) end end end def wait_for_tasks_final_response(task_path, poll_interval) task_data = fetch_final_response(task_path, poll_interval) if task_data['status'] == 'success' task_data['result'] else message = task_data["message"] || "Missing error message in task response #{task_data}" backend_code = task_data['code'] raise ClientError.new(message, 400, backend_code) end end def perform_file_upload(file, upload_permission) uri = URI.parse(upload_permission['url']) File.open(file) do |open_file| content_type = MIME::Types.type_for(file.path).first.content_type upload_io = UploadIO.new(open_file, content_type, File.basename(file)) params = upload_permission['fields'].merge('file' => upload_io) request = Net::HTTP::Post::Multipart.new(uri.path, params) response = ConnectionManager.request(uri, request) if response.code.starts_with?('2') upload_permission['blob'].merge('filename' => File.basename(file.path)) else raise ScrivitoError, "File upload failed with code #{response.code}" end end end def fetch_final_response(task_path, poll_interval) loop do sleep poll_interval task_data = response_for_request_cms_api(:get, task_path, nil, build_timer) return task_data if task_data['status'] != 'open' end end def retry_once_on_network_error(method, timer) return yield if method == :post begin yield rescue NetworkError => e if timer.finished? raise e else yield end end end def set_headers(request) request.basic_auth('api_token', Configuration.api_key) request['Content-type'] = 'application/json' request['Accept'] = 'application/json' end def path(path) "/#{Configuration.endpoint_uri.path}/#{path}".squeeze('/') end def connection_manager ConnectionManager.instance end def build_timer(options={}) CmsRestApi::RequestTimer.new(options.fetch(:timeout, DEFAULT_TIMEOUT)) end def method_to_net_http_class(method) METHOD_TO_NET_HTTP_CLASS.fetch(method) end end end end