All threads / Pointing a method to a namespace, class, or something (ruby question)

Ask A Question

Notifications

You’re not receiving notifications from this thread.

Pointing a method to a namespace, class, or something (ruby question)

Chris Zempel asked in General

I'm building an API wrapper and struggling with how to set it up the way I'd like. Here's an example of what I'd ultimately like this to look like:

ServiceName::Client.new(api_key).record_type.all
# or
ServiceName::Client.new(api_key).record_type.download(args)

Gonna have to swap out that api key a lot based on the user, so can't take care of that by configuring in an initializer or something.

Figure, given the domain, it would be really intuitive just to kind of direct people down specific endpoints as methods like that. Where I'm stuck is, how do I set up the ruby?

module ServiceName # everything will be in this namespace, but gonna ignore it from here on
  class Client
    def initialize(api_key)
       @api_key = api_key
    end
  end
end

# Now...I'd like to be able to call it like this:

ServiceName::Client.new(api_key).private_records.search("query")

# So I'm not sure how this might look:

class Client
  def initialize(api_key)
     @api_key = api_key
  end

  def private_records
    # ?
  end  
end

I could do this, just passing that down through various new classes:

class PrivateRecord
  def initialize(api_client)
    @api_client = api_client
  end

  def search(q)
    # fire off http request
  end
end

class Client
  def private_records
    PrivateRecord.new(api_client)
  end
end

but I'm generally wondering if there's a way I could just have this Client class and then pull in modules intelligently so I just initialize necessary state like the api key one time in one place.

The link in this thread of my thinking stops at something like this:

class Client
  def private_records
    # Refer to an already-included module?
    PrivateRecord
  end
end

# now I can call Client.new(api_key).private_records.search("query"), but how do I also pass in the @api_key ?

# do I have to veer into something like this:

class Client
  def private_records(method, *args)
    PrivateRecord.send(method, api_key, *args)
  end
end

module PrivateRecord
  def search(api_key, *args)
    # do the request
  end
end

Or is there a simpler way? I'm not really sure how to phrase the question, so if you know of any examples of this (esp in the rails codebase) would be mucho appreciated too.

Little update - this is pretty close to what I'm going for: https://github.com/digitalocean/droplet_kit

and it looks like they deal with it via method_missing

module DropletKit
  class Client

    def self.resources
      {
        actions: ActionResource,
        droplets: DropletResource,
        domains: DomainResource,
        domain_records: DomainRecordResource,
        droplet_actions: DropletActionResource,
        images: ImageResource,
        image_actions: ImageActionResource,
        regions: RegionResource,
        sizes: SizeResource,
        ssh_keys: SSHKeyResource,
        account: AccountResource,
        floating_ips: FloatingIpResource,
        floating_ip_actions: FloatingIpActionResource,
        tags: TagResource
      }
    end

    def method_missing(name, *args, &block)
      if self.class.resources.keys.include?(name)
        resources[name] ||= self.class.resources[name].new(connection: connection)
        resources[name]
      else
        super
      end
    end

    def resources
      @resources ||= {}
    end
  end
end

the confusing part in here for me is the Connection stuff, but that looks like it relates to Faraday...

edit/update: Aha! Yeah. I can make the client responsible for all the info needed to send off the requests. I'll need to figure out how that works with HTTParty, but this is exactly the kind of behavior I'd like to achieve so I consider this question answered, unless anyone has any other thoughts on how to set this up.

Here's what I ended up doing, for posterity, and also because I'm gonna have to write this documentation again but for real:

Context

Writing a custom API wrapper that will be used in more than one app. Can't explicitly discuss the code itself due to project restrictions, so here's a contrived/structurally similar situation:

We're using the "LemonConfidential" API. It's a custom, private api for one company, and they needs some Rails apps build on top to serve all their Lemon needs.

The API serves up functionality about a couple major resources:

  • Farms: the variety of farms the lemons can be at, these records contain name, address, contact info, etc
  • Lemons: information about each individual lemon.
  • LemonDownload: choose some lemons to download.

All the responses are JSON, and except for creating LemonDownloads, mostly read-only.

Goal

Here's how we'd like to be able to ultimately use our client:

client = LemonConfidential::Client.new(current_user.api_key)

farms = client.farms.all
search = client.lemons.search
lemon = client.lemons.get(lemon_id)

Building it

I'll be using HTTParty. Since we know we're gonna wanna pull this out of just one app eventually, best to keep everything in a namespace. Furthermore, since the API is confidential, every user has their own api key tied to their info so all the requests can be tracked and scoped properly. We'll need to throw that in the header. So to start, we know we're going to need this:

module LemonConfidential
  class Client

    HOSTNAME = Rails.application.secrets.lemons_confidential_hostname
    API_URL = "https://#{HOSTNAME}/api/"

    def initialize(api_key=nil)
      @api_key = api_key
    end

    def headers
      { "Authorization" => @api_key, "Content-Type" => "application/json" }
    end
  end
end

Now, what I was stuck on, is: how do we pass info from this client to different classes for each resource to actually go and make the requests? Inspired by the DO api wrapper linked above:

module LemonConfidential
  class Client

    HOSTNAME = Rails.application.secrets.lemons_confidential_hostname
    API_URL = "https://#{HOSTNAME}/api"

    def initialize(api_key=nil)
      @api_key = api_key
    end

    def headers
      { "Authorization" => @api_key, "Content-Type" => "application/json" }
    end

    def self.resources
      {
        lemons: LemonConfidential::Lemon,
        farms: LemonConfidential::Farm,
        lemon_downloads: LemonConfidential::LemonDownload,
      }
    end

    def method_missing(name, *args, &block)
      if self.class.resources.keys.include?(name)
        resources[name] ||= self.class.resources[name].new(API_URL, headers)
        resources[name]
      else
        super
      end
    end

    def resources
      @resources ||= {}
    end
  end
end

So when we call

client = LemonConfidential::Client.new(current_user.api_key)
search = client.lemons.search("sour")

When we call lemons on our client, it will hit our method_missing and run it against the hash. There, it will find LemonConfidential::Lemon

So, we'll need to build out that class to receive the info, get our hash ready to throw the api_key in the headers of our request.

class LemonConfidential::Lemon
  include HTTParty

  def initialize(api_url, headers)
    @api_url = api_url
    @headers = headers
  end

  def set_options
    options = {}
    options.merge! headers: @headers
    options
  end
end

Of course, now, we have to actually process the request:

class LemonConfidential::Lemon
  include HTTParty

  def initialize(api_url, headers)
    @api_url = api_url
    @headers = headers
  end

  def search(query)
    options = set_options
    options.merge! { query: query } 
    # httpary will append query parameters to the url. {query: {page: 2, q: "sour"} } will produce: ?page=2&q=sour

    # now, time to make the request:
    url = [@api_url, "/lemons/search"]
    self.class.get(url, options)
  end

  def set_options
    options = {}
    options.merge! { headers: @headers }
    options
  end
end

But, as we get through building the resources, it becomes apparent we're duplicating quite a bit:

class LemonConfidential::Farm
  include HTTParty

  def initialize(api_url, headers)
    @api_url = api_url
    @headers = headers
  end

  def search(query)
    options = set_options
    options.merge! { query: query } 

    url = [@api_url, "/farms/search"]
    self.class.get(url, options)
  end

  def set_options
    options = {}
    options.merge! { headers: @headers }
    options
  end
end

So, there's an underlying idea in here. What these all have in common is that they're a "Resource" of the api, and will need similar credentials/setups to make requests. How about we pull that out like:

class LemonConfidential::Resource
  include HTTParty

  def initialize(api_url, headers)
    @api_url = api_url
    @headers = headers
  end

  def set_options
    options = {}
    options.merge! { headers: @headers }
    options
  end
end

and then we can just inherit from that and have our respective Lemon and Farm classes do what they do best - define endpoints, get the right parameters, and make the request:

class LemonConfidential::Farm < LemonConfidential::Resource
  def all
    options = set_options

    url = [@api_url, "/farms"]
    self.class.get(url, options)
  end
  # ex: client.farms.all

  def get(farm_id)
    options = set_options

    url = [@api_url, "/farms/", farm_id]
    self.class.get(url, options)
  end
  # ex: client.farms.get(3)
end

This seems pretty clean, and isolates a lot of configuration to one place that would make changes easy. Of course, adding this layer of abstraction over the raw request-making means if you change your endpoints, you gotta change your methods. But I think in this case, its a pretty good tradeoff.

This feels a lot like a "BattleReport" I used to write about Starcraft games. If you took the time to look in this badly-worded question, I hope you got something out of it!

Join the discussion

Want to stay up-to-date with Ruby on Rails?

Join 33,665+ developers who get early access to new tutorials, screencasts, articles, and more.

    We care about the protection of your data. Read our Privacy Policy.

    logo Created with Sketch.

    Ruby on Rails tutorials, guides, and screencasts for web developers learning Ruby, Rails, Javascript, Turbolinks, Stimulus.js, Vue.js, and more. Icons by Icons8

    © 2020 GoRails, LLC. All rights reserved.