Skip to content

Commit

Permalink
refactor(endpoint): declarative API
Browse files Browse the repository at this point in the history
  • Loading branch information
ksol committed Feb 12, 2024
1 parent 85cab62 commit 62cba8b
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 19 deletions.
73 changes: 73 additions & 0 deletions lib/scalingo/api/endpoint.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "addressable/template"
require "forwardable"

module Scalingo
Expand All @@ -6,13 +7,85 @@ class Endpoint
extend Forwardable
attr_reader :client

# Add a handler for a given endpoint
%i[get post put patch delete].each do |method|
# @example
# class Example < API::Endpoint
# get :all, "some-endpoint/{id}/subthings{?query*}", optional: [:query]
# post :create, "some-endpoint", root_key: :subthing
# end
define_singleton_method(method) do |name, path, **default_attrs, &default_block|
# @example
# endpoint = Example.new
# endpoint.all(id: "1", query: {page: 1})
# endpoint.create(name: "thing")
define_method(name) do |**runtime_attrs, &runtime_block|
params = {**default_attrs, **runtime_attrs}

request(method, path, **params) do |req|
default_block&.call(req, params)
runtime_block&.call(req, params)
end
end
end

# Those methods are not meant to be used outside of a class definition
private_class_method method
end

def initialize(client)
@client = client
end

def_delegator :client, :connection
def_delegator :client, :database_connection

# Perform a request to the API
def request(method, path, body: nil, root_key: nil, connected: true, basic: nil, dry_run: false, **params, &block)
# path can be an URI template
# see https://github.com/sporkmonger/addressable?tab=readme-ov-file#uri-templates
# see https://www.rfc-editor.org/rfc/rfc6570.txt
template = Addressable::Template.new(path)

# If the template has keys, we need to expand it with the params
if template.keys.present?
# We assume every variable in the template is required
expected_keys = Set.new(template.keys.map(&:to_sym))
# ... but we can opt out by specifying :optional when performing the request or in the endpoint definition
expected_keys -= params[:optional] if params[:optional].present?

# if any required key is missing, raise an error with the missing keys,
# as if it was a regular keyword argument that was not supplied
if expected_keys.present?
received_keys = Set.new(params.keys.map(&:to_sym))

unless received_keys.superset?(expected_keys)
missings = (expected_keys - received_keys).map { |item| sprintf("%p", item) }.join(" ")
raise ArgumentError, "missing keyword: #{missings}"
end
end

# Now, we can expand the template with the supplied params
actual_path = template.expand(params).to_s
else
# Otherwise, it's not a template but a string to be used as it is
actual_path = path
end

# we nest the given body under the root_key if it's present
request_body = body
request_body = {root_key => body} if request_body && root_key

# We can use the client in either connected or unconnected mode
conn = connected ? client.authenticated_connection : client.unauthenticated_connection

# We can specify basic auth credentials if needed
conn.request :authorization, :basic, basic[:user], basic[:password] if basic.present?

# Finally, perform the request
conn.public_send(method, actual_path, request_body, &block)
end

def inspect
str = %(<#{self.class}:0x#{object_id.to_s(16)} base_url:"#{@client.url}" endpoints:)

Expand Down
17 changes: 14 additions & 3 deletions spec/support/scalingo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,27 @@ def describe_method(method_name, &block)
raise NameError, "No method named `#{method_name}` for class #{described_class}"
end

# Helper method to quickly define a context for a method with default let values
context(method_name) do
let(:method_name) { method_name }
let(:basic) { nil }
let(:params) { {} }
let(:body) { nil }
let(:arguments) { nil }

let(:response) {
args = [method_name]

# A few methods use positional arguments
if arguments.is_a?(Array)
subject.public_send(*[method_name, *arguments].compact)
else
subject.public_send(*[method_name, arguments].compact)
args += arguments
elsif arguments
args << arguments
end

args.compact!

subject.public_send(*args, body: body, basic: basic, **params)
}

instance_exec(&block)
Expand Down
25 changes: 9 additions & 16 deletions spec/support/shared.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
RSpec.shared_examples "a successful response" do |code = 200|
let(:expected_code) { code }
let(:custom_headers) { {"X-Custom-Header" => "custom"} }

it "is successful" do
expect(response).to be_success
Expand All @@ -10,25 +9,19 @@
# Checking that the method accepts a block and passes the faraday object
it "can configure the request via a block" do
expect { |block|
args = [method_name]

# A few methods use positional arguments
if arguments.is_a?(Array)
subject.public_send(*[method_name, *arguments].compact, &block)
else
subject.public_send(*[method_name, arguments].compact, &block)
args += arguments
elsif arguments
args << arguments
end
}.to yield_with_args(Faraday::Request)
end

# Leverages the block version to check that the headers can also be set with an argument
it "can configure headers via the last argument" do
checker = proc { |conn|
expect(conn.headers["X-Custom-Header"]).to eq "custom"
}
args.compact!

if arguments.is_a?(Array)
subject.public_send(*[method_name, *arguments, custom_headers].compact, &checker)
else
subject.public_send(*[method_name, arguments, custom_headers].compact, &checker)
end
subject.public_send(*args, **params, body: body, &block)
}.to yield_with_args(Faraday::Request, Hash)
end
end

Expand Down

0 comments on commit 62cba8b

Please sign in to comment.