Jesus / dropbox_api

Ruby client library for Dropbox API v2
MIT License
171 stars 113 forks source link

Support for the Dropbox-API-Select-User header (for Dropbox Business) #29

Closed Aupajo closed 2 years ago

Aupajo commented 7 years ago

I've written an app that uses the dropbox_api gem to list and upload files. It works great, but now I need to make it work with the Dropbox Business API.

The Dropbox Business API has a set if its own endpoints, plus a change in request behaviour for the normal Dropbox API endpoint. At this stage, I don't need the additional endpoints, just the altered request behaviour.

The change needed is to add a header – Dropbox-API-Select-User (or Dropbox-API-Select-Admin) –  which contains a team member ID (this is separate from a user ID). Using this header, I can request any of the standard API endpoints, performing each action on behalf of a user in a team.

There's a couple of approaches I can take to implementing this, and ideally I'd like to submit the work and get it accepted back to this gem, so I'm opening this issue for discussion now before I start work on it. I'd love to hear your thoughts.

Proposed solution: Expose ConnectionBuilder's connection middleware

One option would be to add a method to the client which would allow you to have access to the ConnectionBuilder's Faraday connection:

client = DropboxApi::Client.new(...)

client.middleware do |connection|
  connection.headers['Dropbox-API-Select-User'] = '...'
end

client.list_folder('/gifs')

This would be my preferred solution, as exposing the middleware would allow a user to arbitrarily add, for example, custom logging or error handling.

def build(url)
  Faraday.new(url) do |c|
    @middleware_callback.call(c) # <---

    c.authorization :Bearer, @oauth_bearer

    yield c

    c.adapter Faraday.default_adapter
  end
end

Given that some endpoints manipulate the headers, this may cause some headaches. There's also the classic problem of Faraday's middleware ordering – the ability to customise the connection will depend on where the callback gets called inside the block.

Alternative option: Pass parameter to ConnectionBuilder

Another option to solve this problem would be to pass an optional parameter to the connection builder, through the client:

client = DropboxApi::Client.new(token, select_user: '...')
client.list_folder('/gifs')

This would work by passing the select_user to the ConnectionBuilder, which would insert it something like this:

def build(url)
  Faraday.new(url) do |c|
    c.authorization :Bearer, @oauth_bearer

    if @team_member_id
      c.headers['Dropbox-API-Select-User'] = @select_user
    end

    yield c

    c.adapter Faraday.default_adapter
  end
end

Alternative option: support a parameter on each endpoint

This would basically involving patching a bunch of endpoints to allow a select_user option.

client = DropboxApi::Client.new(token)
client.list_folder('/gifs', select_user: '...')
Jesus commented 7 years ago

I like the flexibility of the first approach, this would be useful not only for your purposes but maybe for some other stuff, such as something totally unrelated to Dropbox like a proxy or whatever.

However, after reading a bit of the Business Endpoints documentation, looks like you only need two options: "select user" & "select admin". Having this in mind, it seems like the second proposal is much simpler, specially from the point of view of the library user.

Really, in order to make this decision I would need to know the use cases of whoever is going to be using this. Since you're the first one to get there and I don't have a strong opinion, I think you should go for your preferred solution.

Aupajo commented 7 years ago

@Jesus Thanks for taking the time to respond!

I'm tempted to implement the first option for now, and see where that gets me. The first and second solutions will work side-by-side, and implementing the first would give me a chance to experiment with the Business API and see how viable the second approach is (which seems like the better long-term approach) as well as opening the door to other use-cases (logging, proxying, etc.).

sumonmg commented 4 years ago

@Jesus @Aupajo I'm in a similar need, need to access files under Dropbox Business account team folder by passing header --header 'Dropbox-API-Path-Root: {".tag": "namespace_id", "namespace_id": "10"}', official documentation here

I tried following with no luck:

client = DropboxApi::Client.new(...)

client.middleware do |connection|
  connection.headers['Dropbox-API-Path-Root'] = {".tag": "namespace_id", "namespace_id": "10"}
end

client.list_folder('/gifs')

Any idea how this can be achieved?

Nerian commented 3 years ago

@sumonmg Did you figure out how to do it? I am trying to do the same.

sumonmg commented 3 years ago

@Nerian No, it was painful to resolve and also I find these gems are always behind or something endpoints missing, so I decided to write a simple API client which allows me to add new endpoint very easily whenever I need.

smenor commented 2 years ago

Ugh I'm stuck on this too‥ seems like I shouldn't have bothered with the gem :-/

smenor commented 2 years ago

@sumonmg do you have the full auth flow and file / revision access built for that ? Am about to re-implement the same but would love a working shortcut if you've already built it

sumonmg commented 2 years ago

@smenor Yes I have a nice working structure, let me give you those here:

app/services/path_dbx/client.rb:

# frozen_string_literal: true

# Path Dropbox Client to work with Dropbox Business account
module PathDbx
  class Client
    include HTTParty
    base_uri 'https://api.dropboxapi.com'
    include Endpoints

    def initialize(oauth_bearer = ENV['DROPBOX_OAUTH_BEARER'],
                   namespace_id = ENV['DROPBOX_ROOT_NAMESPACE_ID'],
                   admin_user_id = ENV['DROPBOX_ADMIN_USER_ID'])
      @oauth_bearer   = oauth_bearer
      @namespace_id   = namespace_id
      @admin_user_id  = admin_user_id
    end

    private

    def headers
      # When using business account for production and regular account for staging
      if Rails.env.production?
        common_headers.merge(business_headers)
      else
        common_headers
      end
    end

    def headers_for_content
      # When using business account for production and regular account for staging
      if Rails.env.production?
        common_headers_for_content.merge(business_headers)
      else
        common_headers_for_content
      end
    end

    def common_headers
      @common_headers ||= { 'Authorization': "Bearer #{@oauth_bearer}",
                            'Content-Type': 'application/json' }
    end

    def common_headers_for_content
      @common_headers_for_content ||= { 'Authorization': "Bearer #{@oauth_bearer}" }
    end

    def business_headers
      { 'Dropbox-API-Select-User': @admin_user_id,
        'Dropbox-API-Path-Root': root_path.to_json }
    end

    def root_path
      { '.tag': 'namespace_id',
        'namespace_id': @namespace_id }
    end

    def handle_response(response)
      code = response.code
      return response if code == 200

      raise(StandardError, response)
    end
  end
end

app/services/path_dbx/endpoints.rb:

# frozen_string_literal: true

module PathDbx
  module Endpoints
    CONTENT_URI = 'https://content.dropboxapi.com'

    def get_metadata(path, options = {})
      params = { path: path }.merge(options)
      send_request(:post, '/2/files/get_metadata', params)
    end

    def add_folder_member(shared_folder_id, emails)
      params = { shared_folder_id: shared_folder_id }.merge(members(emails))
      send_request(:post, '/2/sharing/add_folder_member', params)
    end

    def share_folder(path, options = {})
      params = { path: path }.merge(options)
      send_request(:post, '/2/sharing/share_folder', params)
    end

    def check_share_job_status(async_job_id)
      params = { async_job_id: async_job_id }
      send_request(:post, '/2/sharing/check_share_job_status', params)
    end

    def create_folder(path, options = {})
      params = { path: path }.merge(options)
      send_request(:post, '/2/files/create_folder_v2', params)
    end

    def delete(path)
      params = { path: path }
      send_request(:post, '/2/files/delete_v2', params)
    end

    def permanently_delete(path)
      params = { path: path }
      send_request(:post, '/2/files/permanently_delete', params)
    end

    def list_folder(path, options = {})
      params = { path: path }.merge(options)
      send_request(:post, '/2/files/list_folder', params)
    end

    def list_folder_continue(cursor)
      params = { cursor: cursor }
      send_request(:post, '/2/files/list_folder/continue', params)
    end

    def search(query, options = {})
      params = { query: query, options: options }
      send_request(:post, '/2/files/search_v2', params)
    end

    def move(from_path, to_path, options = {})
      params = { from_path: from_path, to_path: to_path }.merge(options)
      send_request(:post, '/2/files/move_v2', params)
    end

    def get_thumbnail(path, options = {})
      params = { resource: { '.tag': 'path', path: path } }.merge(options)
      send_request_for_content(:get, '/2/files/get_thumbnail_v2', params)
    end

    def get_temporary_link(path)
      params = { path: path }
      send_request(:post, '/2/files/get_temporary_link', params)
    end

    private

    def send_request(action, path, params)
      response = self.class.send(action,
                                 path,
                                 body: params.to_json,
                                 headers: headers)
      handle_response(response)
    end

    def send_request_for_content(action, path, params)
      headers = headers_for_content.merge(content_headers(params))
      response = HTTParty.send(action,
                               CONTENT_URI + path,
                               headers: headers)
      handle_response(response)
    end

    def content_headers(params)
      { 'Dropbox-API-Arg': params.to_json }
    end

    def members(emails)
      member_params = []
      emails.each do |email|
        member = { '.tag': 'email', 'email': email }
        member_params << { member: member,
                           access_level: 'editor' }
      end
      { members: member_params }
    end
  end
end

How to use:

dropbox = PathDbx::Client.new
dropbox.create_folder(path)
smenor commented 2 years ago

Thanks / much appreciated @sumonmg !

Jesus commented 2 years ago

Oops, seems like we need to improve the docs. Achieving that should be quite simple, take your example:

client = DropboxApi::Client.new(...)

client.middleware do |connection|
  connection.headers['Dropbox-API-Path-Root'] = {".tag": "namespace_id", "namespace_id": "10"}
end

client.list_folder('/gifs')

Would need to be rewritten as:

client = DropboxApi::Client.new(...)
client.namespace_id = 10

client.list_folder('/gifs')
Jesus commented 2 years ago

I'll leave this open for a few days in case someone has further questions.