From 62cba8b50eb93a16cdb8caed940df3d3afa2fd6d Mon Sep 17 00:00:00 2001 From: Kevin Soltysiak Date: Mon, 12 Feb 2024 00:23:15 +0100 Subject: [PATCH] refactor(endpoint): declarative API --- lib/scalingo/api/endpoint.rb | 73 ++++++++++++++++++++++++++++++++++++ spec/support/scalingo.rb | 17 +++++++-- spec/support/shared.rb | 25 +++++------- 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/lib/scalingo/api/endpoint.rb b/lib/scalingo/api/endpoint.rb index f0d5824..ca729af 100644 --- a/lib/scalingo/api/endpoint.rb +++ b/lib/scalingo/api/endpoint.rb @@ -1,3 +1,4 @@ +require "addressable/template" require "forwardable" module Scalingo @@ -6,6 +7,32 @@ 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 @@ -13,6 +40,52 @@ def initialize(client) 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:) diff --git a/spec/support/scalingo.rb b/spec/support/scalingo.rb index e8aa5dc..54f7d92 100644 --- a/spec/support/scalingo.rb +++ b/spec/support/scalingo.rb @@ -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) diff --git a/spec/support/shared.rb b/spec/support/shared.rb index b652db7..8e37f79 100644 --- a/spec/support/shared.rb +++ b/spec/support/shared.rb @@ -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 @@ -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