lib/fastly/client.rb



# frozen_string_literal: true

require 'json'
require 'cgi'
require 'net/http' # also requires uri
require 'openssl'

class Fastly
  # The UserAgent to communicate with the API
  class Client #:nodoc: all

    DEFAULT_URL = 'https://api.fastly.com'.freeze

    attr_accessor :api_key, :base_url, :debug, :user, :password, :cookie, :customer

    def initialize(opts)
      @api_key            = opts.fetch(:api_key, nil)
      @base_url           = opts.fetch(:base_url, DEFAULT_URL)
      @customer           = opts.fetch(:customer, nil)
      @oldpurge           = opts.fetch(:use_old_purge_method, false)
      @password           = opts.fetch(:password, nil)
      @user               = opts.fetch(:user, nil)
      @debug              = opts.fetch(:debug, nil)
      @thread_http_client = if defined?(Concurrent::ThreadLocalVar)
                              Concurrent::ThreadLocalVar.new { build_http_client }
                            end

      return self unless fully_authed?

      warn("DEPRECATION WARNING: Username/password authentication is deprecated
      and will not be available starting September 2020;
      please migrate to API tokens as soon as possible.")

      # If full auth creds (user/pass) then log in and set a cookie
      resp = http.post(
        '/login', 
        make_params(user: user, password: password), 
        {'Content-Type' =>  'application/x-www-form-urlencoded'}
      )
      if resp.kind_of?(Net::HTTPSuccess)
        @cookie = resp['Set-Cookie']
      else
        fail Unauthorized, "Invalid auth credentials. Check username/password."
      end

      self
    end

    def require_key!
      raise Fastly::KeyAuthRequired.new("This request requires an API key") if api_key.nil?
      @require_key = true
    end

    def require_key?
      !!@require_key
    end

    def authed?
      !api_key.nil? || fully_authed?
    end

    # Some methods require full username and password rather than just auth token
    def fully_authed?
      !(user.nil? || password.nil?)
    end

    def get(path, params = {})
      extras = params.delete(:headers) || {}
      include_auth = params.key?(:include_auth) ? params.delete(:include_auth) : true
      path += "?#{make_params(params)}" unless params.empty?
      resp  = http.get(path, headers(extras, include_auth))
      fail Error, resp.body unless resp.kind_of?(Net::HTTPSuccess)
      JSON.parse(resp.body)
    end

    def get_stats(path, params = {})
      resp = get(path, params)

      # return meta data, not just the actual stats data
      if resp['status'] == 'success'
        resp
      else
        fail Error, resp['msg']
      end
    end

    def post(path, params = {})
      post_and_put(:post, path, params)
    end

    def put(path, params = {})
      post_and_put(:put, path, params)
    end

    def delete(path, params = {})
      extras = params.delete(:headers) || {}
      include_auth = params.key?(:include_auth) ? params.delete(:include_auth) : true
      resp  = http.delete(path, headers(extras, include_auth))
      resp.kind_of?(Net::HTTPSuccess)
    end

    def purge(url, params = {})
      return post("/purge/#{url}", params) if @oldpurge

      extras = params.delete(:headers) || {}
      uri    = URI.parse(url)
      http   = Net::HTTP.new(uri.host, uri.port)

      if uri.is_a? URI::HTTPS
        http.use_ssl = true
      end

      resp   = http.request Net::HTTP::Purge.new(uri.request_uri, headers(extras))

      fail Error, resp.body unless resp.kind_of?(Net::HTTPSuccess)
      JSON.parse(resp.body)
    end

    def http
      return @thread_http_client.value if @thread_http_client
      return Thread.current[:fastly_net_http] if Thread.current[:fastly_net_http]

      Thread.current[:fastly_net_http] = build_http_client
    end

    private

    def build_http_client
      uri      = URI.parse(base_url)
      net_http = Net::HTTP.new(uri.host, uri.port, :ENV, nil, nil, nil)

      # handle TLS connections outside of development
      net_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      net_http.use_ssl     = uri.scheme.downcase == 'https'

      # debug http interactions if specified
      net_http.set_debug_output(debug) if debug

      net_http
    end

    def post_and_put(method, path, params = {})
      extras = params.delete(:headers) || {}
      include_auth = params.key?(:include_auth) ? params.delete(:include_auth) : true
      query = make_params(params)
      resp  = http.send(method, path, query, headers(extras, include_auth).merge('Content-Type' =>  'application/x-www-form-urlencoded'))
      fail Error, resp.body unless resp.kind_of?(Net::HTTPSuccess)
      JSON.parse(resp.body)
    end

    def headers(extras={}, include_auth=true)
      headers = {}
      # Some endpoints (POST /tokens) break if any auth headers including cookies are sent
      if include_auth
        headers['Cookie'] = cookie if fully_authed?
        headers['Fastly-Key'] = api_key if api_key
      end
      headers.merge('Content-Accept' => 'application/json', 'User-Agent' => "fastly-ruby-v#{Fastly::VERSION}").merge(extras.keep_if {|k,v| !v.nil? })
    end

    def make_params(params)
      param_ary = params.map do |key, value|
        next if value.nil?
        key = key.to_s

        if value.is_a?(Hash)
          value.map do |sub_key, sub_value|
            "#{CGI.escape("#{key}[#{sub_key}]")}=#{CGI.escape(sub_value.to_s)}"
          end
        else
          "#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"
        end
      end

      param_ary.flatten.delete_if { |v| v.nil? }.join('&')
    end
  end
end

# See Net::HTTPGenericRequest for attributes and methods.
class Net::HTTP::Purge < Net::HTTPRequest
  METHOD = 'PURGE'
  REQUEST_HAS_BODY = false
  RESPONSE_HAS_BODY = true
end