DEV Community

loading...

API and Provider methodology

thermatix profile image Martin Becker Updated on ・3 min read

Introduction

So, One of the biggest issues when designing a library/framework Is when you need to make a call to an external dependency.

Specifically in the context of this post, when that dependency is no longer usable for any reason, what do you do? Most would then need to implement a new interface for a new dependency, and then as a result potentially make breaking changes to your API to accommodate which isn't good.

I bring this up because I see all to often libraries that directly call the external dependency in there API methods (I'm guilty of this too -_-), then have to completly change there API when the dependency changes that requires data to be structured differently.

This then breaks things for many people.

This is why I advocate separating the API and the external calls into separate classes, it does add complexity and increases the amount of code but the advantage is that when you're external dependency changes, your API doesn't, and nor does it for anyone using you're libraries.

When I think to do this (ahem) I like to separate the logic into:

  • API: The API of your library

  • Provider: The logic that calls your an external dependency

  • Formatter: If needed, the logic that handles data formatting between the API and the Provider

Basic Provider Logic

Now you can do this any way you like, so long as there is a clear separation of concerns. Using ruby as an example, I like to take advantage of the fact you can define def self.[]() to make my provider API easy to use so I can do somthing like this:

Module Providers
    class << self
        attr_writer :listing
        def [](provider)
            @listing[provider]       
        end
    end
    class Provider_Base
        def self.inherited(child, name = child.class.to_s.downcase.to_sym)
            # Normally I'd add logic to ensure that only this method can add to
            # `Providers.listing`, but I can't be arsed for this example
            Providers.listing[name] = child
        end
    end
end

The reason I do name = child.class.to_s.downcase.to_sym in the function declaration is so that if you want you can easily change the way the name is set by doing:

Module Providers
    class OtherProviderBase < Provider_Base
        def self.inherited(child)
            super(child, :some_other_naming_mechaism)
        end
    end
end

Now you can inherit from OtherProviderBase instead.

Using Your provider

Using the providers in you're api becomes super easy now:

class API

    PROVIDER_NAME = :provider_name

    def some_method
        Providers[PROVIDER_NAME].some_method
    end

end

Why do it like this? Lets say you build a new dependency provider, you can just change the provider to this:

class API

    PROVIDER_NAME = :other_provider_name

    def some_method
        Providers[PROVIDER_NAME].some_method
    end

end

End result is that the API doesn't change and won't break for you of your users.

And using the above providers example, you can add formatters like this:

class API
    PROVIDER_NAME = :provider_name

    def some_method
        Formatters[PROVIDER_NAME].some_method(
            Providers[PROVIDER_NAME].some_method)
    end

end

You can even go further and do this:

class API

    PROVIDER_NAME = :provider_name

    def some_method(*args)
        method = :some_method
        output = Providers[PROVIDER_NAME].send(method, (Formatters::Input[PROVIDER_NAME].send(method,*args))
        Formatters::Output[PROVIDER_NAME].send(method, output)
    end

end

Provider Piplines

You could even provide a mechanism (where appropriate of course) to automatically pipeline, like this maybe:

module Pipelines
    attr_reader :listing

    def pipline_for(action, *pipes)
        @listing[action] = pipes
        define_method(action, do) |*args|
            run_pipline_for(action, *args)
        end
    end

    def self.extended(child)
        child.include Instance_Methods
    end

    module Instance_Methods
       def run_pipline_for(action, *args)
           Pipelines.listing[action].inject(args) do |result, (item, custom_action)|
              item[PROVIDER_NAME].send(custom_action || action, *result)
           end
       end
    end
end


class API
    extend Pipeline

    PROVIDER_NAME = :provider_name

    pipline_for :action, Formatters::Input, [Providers, :get_remote_data], Formatters::Output
    pipline_for :other_action, Formatters::Input, Providers, [Processors, :differing_name], [Formatters::Output, :custom_output]

end

Sorry, this probably out of scope for this post but couldn't help myself when I saw somthing interesting to code. Also I'm not sure if PROVIDER_NAME will be picked up in the local context of the API class when Pipeline is included so you might need to code a mechanism for setting and retrieval that does but you get the idea.

conclusion

By separating your external dependency into it's logic that is then called by your API, you can then keep your API constant between versions and then the only difference between versions becomes the dependencies, without any breaking changes between them.

p.s. I completly admit to not having checked or tested any of this code as it was just examples to get the ball rolling.

Discussion

pic
Editor guide
Collapse
thermatix profile image
Martin Becker Author

If someone is interested in a functional pipline example:

module Pipeline
  def pipline_for(action, *pipes)
    (@listing ||= {})[action] = pipes
    define_singleton_method(action) do |*args|
      run_pipline_for(action, *args)
    end
  end

  def run_pipline_for(action, *args)
    @listing[action].inject(args) do |result, (item, custom_action)|
      item.send(custom_action || action, *result)
    end
  end
end

module Formatters
  class Input
    class << self
      def action(*args)
        puts "Formatters::Input.action"

        args
      end

      def other_action(*args)
        puts "Formatters::Input.other_action"

        args
      end
    end
  end

  class Output
    class << self
      def action(*args)
        puts "Formatters::Output.action"

        args
      end
    end
  end
end

class Provider
  class << self
    def get_remote_data(*args)
      puts "Provider.get_remote_data"

      args
    end

    def other_action(*args)
      puts "Provider.other_action"

      args
    end
  end
end

class Processor
  class << self
    def action(*args)
      puts "Processor.action"

      args
    end

    def custom_processor(*args)
      puts "Processor.custom_processor"

      args
    end
  end
end

class API
  extend Pipeline

  pipline_for :action, Formatters::Input, [Provider, :get_remote_data], Processor, Formatters::Output
  pipline_for :other_action, Formatters::Input, Provider, [Processor, :custom_processor], [Formatters::Output, :action]
end

puts API.action('Hello World')
puts API.other_action('Goodbye World')
Collapse
thermatix profile image
Martin Becker Author

You're not wrong, I would normally add insurance to insure that the output or the next output is returned but as I said at the bottom of my post, I never actually tested the code and was never intended to be properly used, merely ideas and examples to get the ball rolling, which is why I made it simple!