Pointing a method to a namespace, class, or something (ruby question)
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!