diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdb9526 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +vendor diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..56a0cd9 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,4 @@ +AllCops: + TargetRubyVersion: 2.2 +Metrics/LineLength: + Enabled: false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..bed6e27 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: ruby +rvm: + - 2.3.0 +before_install: gem install bundler -v 1.11.2 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c22404e --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in http_api_builder.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..621ec44 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Jeff Sandberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..05bceef --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# HttpApiBuilder +A simple tool for building API clients that use HTTP. + +This is for **clients** as in *consumers*, not for servers. Look into things like Rails-api or Grape for those. + +[![Code Climate](https://codeclimate.com/github/paradox460/http_api_builder/badges/gpa.svg)](https://codeclimate.com/github/paradox460/http_api_builder) + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'http_api_builder' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install http_api_builder + +## Usage + +```ruby +require 'http_api_builder/client/http_rb' + +class ElGoog < HttpApiBuilder::BaseClient + include HttpApiBuilder::Client::HttpRb + + base_url 'https://google.com' + + get '/', as: :search, params: {required: :q} +end +``` + +You can then use the API as such: + +```ruby +g = ElGoog.new + +g.search(q: 'ruby') +``` + +See the wiki for more details. + +## License + +The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). + + +``` +The MIT License (MIT) + +Copyright (c) 2016 Jeff Sandberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4c774a2 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..68e68f1 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'http_api_builder' + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require 'pry' +Pry.start diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/http_api_builder.gemspec b/http_api_builder.gemspec new file mode 100644 index 0000000..d3677c0 --- /dev/null +++ b/http_api_builder.gemspec @@ -0,0 +1,31 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'http_api_builder/version' + +Gem::Specification.new do |spec| + spec.name = 'http_api_builder' + spec.version = HttpApiBuilder::VERSION + spec.authors = ['Jeff Sandberg'] + spec.email = ['paradox460@gmail.com'] + + spec.summary = 'A utility gem providing a DSL for building HTTP api wrappers.' + spec.description = 'A gem providing a nice DSL for building HTTP api wrappers.' + spec.homepage = 'https://github.com/paradox460/http_api_builder' + spec.license = 'MIT' + + spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.required_ruby_version = ">= 2.2" + + spec.add_development_dependency 'bundler', '~> 1.11' + spec.add_development_dependency 'rake', '~> 10.0' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'pry' + spec.add_development_dependency 'pry-stack_explorer' + spec.add_development_dependency 'pry-byebug' + spec.add_development_dependency 'http' +end diff --git a/lib/http_api_builder.rb b/lib/http_api_builder.rb new file mode 100644 index 0000000..7cc9ac9 --- /dev/null +++ b/lib/http_api_builder.rb @@ -0,0 +1,34 @@ +require 'http_api_builder/version' +require 'http_api_builder/dsl' +require 'http_api_builder/helpers' + +module HttpApiBuilder + # A basic HTTP client. Meant to be extended from. + class BaseClient + extend Dsl + include Helpers + + def initialize(); end + + # Perform the request, post processors, and return the result + def perform(method, path, form: nil, query: nil, body: nil, json: nil, &_block) # rubocop:disable Metrics/ParameterLists + response = request(method, path, form: form, query: query, body: body, json: json) + status = response.status + resource = response.body + block_given? ? yield(resource, status, response) : resource + end + + # Placeholder for your request method. + # Accepts these params, for you to do whatever you like with. See the HTTPrb_client implementation + # + # @param [Symbol] method The HTTP VERB to use + # @param [String, URI] path The path, excluding base_url, which should be prepended inside your implementation + # @param [Hash] form: nil Form data, for encoding into HTTP form encoding + # @param [Hash] query: nil Query key/value pairs + # @param [String] body: nil A raw body + # @param [Hash, Array] json: nil Hash/Array data to be encoded as JSON. + def request(*) + raise 'HttpApiBuilder::BaseClient#request must be implemented, see documentation' + end + end +end diff --git a/lib/http_api_builder/client/http_rb.rb b/lib/http_api_builder/client/http_rb.rb new file mode 100644 index 0000000..c6f1c5f --- /dev/null +++ b/lib/http_api_builder/client/http_rb.rb @@ -0,0 +1,15 @@ +require 'http' + +module HttpApiBuilder + module Client + # A demonstration implementation, using HTTP.rb + # This is functional and pretty much production ready, but you can + # easily rewrite it to use curb or typhoeus or anything else really + module HttpRb + def request(verb, path, form:, query:, body:, json:) # rubocop:disable Metrics/ParameterLists + url = URI.join(self.class.base_url || '', path) + HTTP.send(verb, url, form: form, params: query, body: body, json: json) + end + end + end +end diff --git a/lib/http_api_builder/dsl.rb b/lib/http_api_builder/dsl.rb new file mode 100644 index 0000000..0c6d09a --- /dev/null +++ b/lib/http_api_builder/dsl.rb @@ -0,0 +1,95 @@ +require 'forwardable' + +module HttpApiBuilder + # Module for restful api dsl commands + module Dsl + VERBS = + %i(get head post put delete trace options connect) + # HTTP 1.1 + %i(propfind proppatch mkcol copy move lock unlock) + # WebDAV + %i(orderpatch) + # WebDAV Ordered Collections protocol + %i(acl) + # WebDAV Access Control protocol + %i(patch) + # PATCH method for HTTP + %i(search) # WebDAV search + + # Set the initial URL used by the gem + def base_url(value = nil) + value.nil? ? @base_url : (@base_url = value) + end + + protected + + # Generate whiny and quiet API consumer methods. + # + # Whiny methods have a bang suffix and raise errors if they fail + # Quiet methods do not have the bang suffix and return nil if they fail + # + # eg: + # endpoint '/path', as: :mymethod + # results in: + # mymethod! <-- whiny + # mymethod <-- quiet + def endpoint(path, as:, using: :get, params: nil, form: nil, body: nil, processors: nil, json: nil) # rubocop:disable Metrics/ParameterLists + def_whiny_method as, path, using, processors, params, form, body, json + def_quiet_method as + end + + VERBS.each do |v| + define_method v do |path, **opts| + consume path, using: v, **opts + end + end + + private + + # Generate a consumer method that raises exceptions when requests raise an error + # + def def_whiny_method(name, path, using, processors, params, form, body, json) # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize, Metrics/MethodLength + required, optional = requirements(path, params) + + define_method :"#{name}!" do |opts = {}| + validate_args! opts.keys, required, optional + + reqpath = interpolate_path(path, opts) + query = query_params(path, opts, required, optional) + + form = Hash(form).merge(Hash(opts[:form])) + json = opts[:json] || json + body = opts[:body] || body + perform(using, reqpath, form: form, query: query, body: body, json: json) do |resource, *_| + run_processors resource, processors + end + end + end + + # Generate a consumer method that returns nil when requests raise errors + # + def def_quiet_method(name) + define_method name do |opts = {}| + begin + send(:"#{name}!", opts) + rescue StandardError + nil + end + end + end + + # Extract param segments from a path, paperclip style. Returns an array of symbols matching the names of the param segments. + # + # Param segments are sections of the path that begin with `:`, and run to the next / + def interpolated_params(path) + path.split('/').reject { |i| i.length.zero? || i !~ /^:/ }.uniq.map { |i| i[1..-1].to_sym } + end + + # Parse out and return the required and optional arguments + # + # Required are any that are in the URL or in the required hash + # Optional are any other arguments. + def requirements(path, params) + required, optional = Hash(params).values_at(*%i(required optional)).map { |list| Array(list) } + + required += interpolated_params(path) + + [required, optional] + end + end +end diff --git a/lib/http_api_builder/helpers.rb b/lib/http_api_builder/helpers.rb new file mode 100644 index 0000000..6a11a29 --- /dev/null +++ b/lib/http_api_builder/helpers.rb @@ -0,0 +1,75 @@ +module HttpApiBuilder + # Helper methods + module Helpers + protected + + # Validates arguments and ensures that required and optional are present. + # Also validates that all args are expected + # + # @param [Array] actual The args (or keys of args) we've received + # @param [Array] required Args we are required to have, will raise error if not present + # @param [Array] optional Optional args. + def validate_args!(actual, required, optional) + validate_required_arguments(actual, required) + validate_valid_arguments(actual, required, optional) + end + + private + + # Start the processing chain for the response object + # Processors work in a chain, meaning order of operations is important. + # The first processor gets the raw result, the second processor the first's + # output and so forth. + # The last processor's output is the return of the method + # processors: [processor1, processor2] yields this pipeline: + # request -> processor1 -> processor2 -> output + def run_processors(resource, processors) + Array(processors).reduce(resource) do |data, processor| + if processor.respond_to?(:call) + processor.call(data) + else + send(processor, data) + end + end + end + + # Build the path, filling in any interpolation tokens + # + # @param [String] path + # @param [Hash] values Key-value {interpolation: value} + # @return [String] interpolated string + def interpolate_path(path, values) + result = path.dup + values.each do |token, value| + token = ":#{token}" + result.gsub!(token, value) + end + result + end + + # Get the non-interpolating query parameters + def query_params(path, params, required, optional) + path_params = self.class.send(:interpolated_params, path) + query_keys = (required + (optional - [:data])) - path_params + params.select { |k, _| query_keys.include? k } + end + + # Raise an ArgumentError if not everything listed as required is not listed in supplied. + # + def validate_required_arguments(supplied, required) + missing = supplied & required + return if missing.sort == required.sort + + raise ArgumentError, "missing required arguments: #{missing.join(', ')}" + end + + # Raise an ArgumentError if not everything listed in supplied appear in either the required or optional list. + # + def validate_valid_arguments(supplied, required, optional) + unrecognized = supplied - (required + optional).uniq + return if unrecognized.empty? + + raise ArgumentError, "unrecognized arguments: #{unrecognized.join(', ')}" + end + end +end diff --git a/lib/http_api_builder/version.rb b/lib/http_api_builder/version.rb new file mode 100644 index 0000000..e29c302 --- /dev/null +++ b/lib/http_api_builder/version.rb @@ -0,0 +1,3 @@ +module HttpApiBuilder + VERSION = '0.2.0'.freeze +end diff --git a/spec/http_api_builder_spec.rb b/spec/http_api_builder_spec.rb new file mode 100644 index 0000000..113f53b --- /dev/null +++ b/spec/http_api_builder_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe HttpApiBuilder do + it 'has a version number' do + expect(HttpApiBuilder::VERSION).not_to be nil + end + + it 'does something useful' do + expect(false).to eq(true) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..d272410 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,2 @@ +$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) +require 'http_api_builder'