diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cb6eeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ 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..58de5fb --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,41 @@ +inherit_from: .rubocop_todo.yml + +Metrics/LineLength: + Max: 120 + +Metrics/MethodLength: + Max: 25 + +Metrics/AbcSize: + Max: 31 + +Metrics/CyclomaticComplexity: + Max: 20 + +Metrics/PerceivedComplexity: + Max: 10 + +Style/GuardClause: + Enabled: false + +Style/SignalException: + Enabled: false + +Style/Documentation: + Enabled: false + +Lint/AssignmentInCondition: + Enabled: false + +Style/PerlBackrefs: + Enabled: false + +Style/SpaceInsideHashLiteralBraces: + EnforcedStyle: no_space + +Style/TrailingCommaInLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma + diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..e69de29 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..9082ba3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +sudo: false +before_install: gem install bundler +rvm: + - '2.1' + - '2.2' + diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..aa11452 --- /dev/null +++ b/Gemfile @@ -0,0 +1,13 @@ +source 'https://rubygems.org' + +gem 'rake' +gem 'byebug' +gem 'rubocop' +gem 'yard' + +group :test do + gem 'rspec', '~> 3.2' + gem 'vcr', '~> 2.9', github: 'vcr/vcr', branch: 'master', ref: '480304be6d73803e6c4a0eb21a4ab4091da558d8' +end + +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0babeeb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 graemej + +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..f342ec6 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# oktakit + +Ruby toolkit for the [Okta API](http://developer.okta.com/docs/api/getting_started/design_principles.html). + +[![Build Status](https://secure.travis-ci.org/Shopify/oktakit.png)](http://travis-ci.org/Shopify/oktakit) +[![Gem Version](https://badge.fury.io/rb/oktakit.png)](http://badge.fury.io/rb/oktakit) + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'oktakit' +``` + +And then execute: + + $ bundle + +## Usage + +`Oktakit` follow the same patterns as [`Octokit`](https://github.com/octokit/octokit.rb), if you are familiar with it you should feel right at home. + +```ruby +client = Oktakit.new(token: 't0k3n') +organization = client.organization('my-great-org') +agents = organization.rels[:agents].get.data +``` + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +1. Fork it ( https://github.com/shopify/oktakit/fork ) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..2e3e4e6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,19 @@ +require 'bundler/gem_tasks' + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:spec) + +require 'rubocop/rake_task' +RuboCop::RakeTask.new + +task test: :spec +task default: [:spec, :rubocop] + +namespace :doc do + require 'yard' + YARD::Rake::YardocTask.new do |task| + task.files = %w(LICENSE.md lib/**/*.rb) + task.options = %w(--output-dir doc/yard --markup markdown) + end + task default: :yard +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..d19f772 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'oktakit' + +# 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 'irb' +IRB.start diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..b65ed50 --- /dev/null +++ b/bin/setup @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/oktakit.rb b/lib/oktakit.rb new file mode 100644 index 0000000..24f2865 --- /dev/null +++ b/lib/oktakit.rb @@ -0,0 +1,8 @@ +require 'oktakit/version' +require 'oktakit/client' + +module Oktakit + def self.new(*args) + Client.new(*args) + end +end diff --git a/lib/oktakit/client.rb b/lib/oktakit/client.rb new file mode 100644 index 0000000..4c22d0b --- /dev/null +++ b/lib/oktakit/client.rb @@ -0,0 +1,136 @@ +require 'sawyer' +require 'oktakit/response/raise_error' + +module Oktakit + class Client + # Header keys that can be passed in options hash to {#get},{#head} + CONVENIENCE_HEADERS = Set.new([:accept, :content_type]) + + # In Faraday 0.9, Faraday::Builder was renamed to Faraday::RackBuilder + RACK_BUILDER_CLASS = defined?(Faraday::RackBuilder) ? Faraday::RackBuilder : Faraday::Builder + + # Default Faraday middleware stack + MIDDLEWARE = RACK_BUILDER_CLASS.new do |builder| + builder.use Oktakit::Response::RaiseError + builder.adapter Faraday.default_adapter + end + + def initialize(token) + @token = token + @organization = organization + end + + # Make a HTTP GET request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @return [Sawyer::Resource] + def get(url, options = {}) + request :get, url, parse_query_and_convenience_headers(options) + end + + # Make a HTTP POST request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Body and header params for request + # @return [Sawyer::Resource] + def post(url, options = {}) + request :post, url, options + end + + # Make a HTTP PUT request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Body and header params for request + # @return [Sawyer::Resource] + def put(url, options = {}) + request :put, url, options + end + + # Make a HTTP PATCH request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Body and header params for request + # @return [Sawyer::Resource] + def patch(url, options = {}) + request :patch, url, options + end + + # Make a HTTP DELETE request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @return [Sawyer::Resource] + def delete(url, options = {}) + request :delete, url, options + end + + # Make a HTTP HEAD request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @return [Sawyer::Resource] + def head(url, options = {}) + request :head, url, parse_query_and_convenience_headers(options) + end + + attr_reader :last_response + + # Fetch the root resource for the API + # + # @return [Sawyer::Resource] + def root + get('/') + end + + private + + def request(method, path, data, options = {}) + if data.is_a?(Hash) + options[:query] = data.delete(:query) || {} + options[:headers] = data.delete(:headers) || {} + if accept = data.delete(:accept) + options[:headers][:accept] = accept + end + end + + @last_response = response = sawyer_agent.call(method, URI::Parser.new.escape(path.to_s), data, options) + response.data + end + + def sawyer_agent + @agent ||= Sawyer::Agent.new(api_endpoint, sawyer_options) do |http| + http.headers[:accept] = 'application/json' + http.headers[:content_type] = 'application/json' + http.headers[:user_agent] = "Oktakit v#{Oktakit::VERSION}" + http.authorization 'SSWS ', @token + end + end + + def sawyer_options + { + links_parser: Sawyer::LinkParsers::Simple.new, + faraday: Faraday.new(builder: MIDDLEWARE), + } + end + + def api_endpoint + "https://#{organization}.okta.com/api/v1/" + end + + def parse_query_and_convenience_headers(options) + headers = options.fetch(:headers, {}) + CONVENIENCE_HEADERS.each do |h| + if header = options.delete(h) + headers[h] = header + end + end + query = options.delete(:query) + opts = {query: options} + opts[:query].merge!(query) if query && query.is_a?(Hash) + opts[:headers] = headers unless headers.empty? + + opts + end + end +end diff --git a/lib/oktakit/error.rb b/lib/oktakit/error.rb new file mode 100644 index 0000000..337f5b6 --- /dev/null +++ b/lib/oktakit/error.rb @@ -0,0 +1,166 @@ +module Oktakit + # Custom error class for rescuing from all Okta errors + class Error < StandardError + # Returns the appropriate Oktakit::Error subclass based + # on status and response message + # + # @param [Hash] response HTTP response + # @return [Oktakit::Error] + def self.from_response(response) + status = response[:status].to_i + if klass = case status + when 400 then Oktakit::BadRequest + when 401 then Oktakit::Unauthorized + when 403 then Oktakit::Forbidden + when 404 then Oktakit::NotFound + when 405 then Oktakit::MethodNotAllowed + when 406 then Oktakit::NotAcceptable + when 409 then Oktakit::Conflict + when 415 then Oktakit::UnsupportedMediaType + when 422 then Oktakit::UnprocessableEntity + when 400..499 then Oktakit::ClientError + when 500 then Oktakit::InternalServerError + when 501 then Oktakit::NotImplemented + when 502 then Oktakit::BadGateway + when 503 then Oktakit::ServiceUnavailable + when 500..599 then Oktakit::ServerError + end + klass.new(response) + end + end + + def initialize(response = nil) + @response = response + super(build_error_message) + end + + # Documentation URL returned by the API for some errors + # + # @return [String] + def documentation_url + data[:documentation_url] if data.is_a? Hash + end + + # Array of validation errors + # @return [Array] Error info + def errors + if data && data.is_a?(Hash) + data[:errors] || [] + else + [] + end + end + + private + + def data + @data ||= parse_data + end + + def parse_data + body = @response[:body] + return if body.empty? + return body unless body.is_a?(String) + + headers = @response[:response_headers] + content_type = headers && headers[:content_type] || '' + if content_type =~ /json/ + Sawyer::Agent.serializer.decode(body) + else + body + end + end + + def response_message + case data + when Hash + data[:message] + when String + data + end + end + + def response_error + "Error: #{data[:error]}" if data.is_a?(Hash) && data[:error] + end + + def response_error_summary + return nil unless data.is_a?(Hash) && !Array(data[:errors]).empty? + + summary = "\nError summary:\n" + summary << data[:errors].map do |hash| + hash.map { |k, v| " #{k}: #{v}" } + end.join("\n") + + summary + end + + def build_error_message + return nil if @response.nil? + + message = "#{@response[:method].to_s.upcase} " + message << redact_url(@response[:url].to_s) + ': ' + message << "#{@response[:status]} - " + message << response_message.to_s unless response_message.nil? + message << response_error.to_s unless response_error.nil? + message << response_error_summary.to_s unless response_error_summary.nil? + message << " // See: #{documentation_url}" unless documentation_url.nil? + message + end + + def redact_url(url_string) + %w(client_secret access_token).each do |token| + url_string.gsub!(/#{token}=\S+/, "#{token}=(redacted)") if url_string.include? token + end + url_string + end + end + + # Raised on errors in the 400-499 range + class ClientError < Error; end + + # Raised when Okta returns a 400 HTTP status code + class BadRequest < ClientError; end + + # Raised when Okta returns a 401 HTTP status code + class Unauthorized < ClientError; end + + # Raised when Okta returns a 403 HTTP status code + class Forbidden < ClientError; end + + # Raised when Okta returns a 404 HTTP status code + class NotFound < ClientError; end + + # Raised when Okta returns a 405 HTTP status code + class MethodNotAllowed < ClientError; end + + # Raised when Okta returns a 406 HTTP status code + class NotAcceptable < ClientError; end + + # Raised when Okta returns a 409 HTTP status code + class Conflict < ClientError; end + + # Raised when Okta returns a 414 HTTP status code + class UnsupportedMediaType < ClientError; end + + # Raised when Okta returns a 422 HTTP status code + class UnprocessableEntity < ClientError; end + + # Raised on errors in the 500-599 range + class ServerError < Error; end + + # Raised when Okta returns a 500 HTTP status code + class InternalServerError < ServerError; end + + # Raised when Okta returns a 501 HTTP status code + class NotImplemented < ServerError; end + + # Raised when Okta returns a 502 HTTP status code + class BadGateway < ServerError; end + + # Raised when Okta returns a 503 HTTP status code + class ServiceUnavailable < ServerError; end + + # Raised when client fails to provide valid Content-Type + class MissingContentType < ArgumentError; end +end diff --git a/lib/oktakit/response/raise_error.rb b/lib/oktakit/response/raise_error.rb new file mode 100644 index 0000000..100d6e8 --- /dev/null +++ b/lib/oktakit/response/raise_error.rb @@ -0,0 +1,19 @@ +require 'faraday' +require 'oktakit/error' + +module Oktakit + # Faraday response middleware + module Response + # This class raises an Oktakit-flavored exception based + # HTTP status codes returned by the API + class RaiseError < Faraday::Response::Middleware + private + + def on_complete(response) + if error = Oktakit::Error.from_response(response) + raise error + end + end + end + end +end diff --git a/lib/oktakit/version.rb b/lib/oktakit/version.rb new file mode 100644 index 0000000..87aedf3 --- /dev/null +++ b/lib/oktakit/version.rb @@ -0,0 +1,3 @@ +module Oktakit + VERSION = '0.1.0'.freeze +end diff --git a/oktakit.gemspec b/oktakit.gemspec new file mode 100644 index 0000000..c9324fc --- /dev/null +++ b/oktakit.gemspec @@ -0,0 +1,25 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'oktakit/version' + +Gem::Specification.new do |spec| + spec.name = 'oktakit' + spec.version = Oktakit::VERSION + spec.authors = ['Graeme Johnson'] + spec.email = ['graeme.johnson@shopify.com'] + + spec.summary = 'Ruby toolkit for working with the Okta API' + spec.homepage = 'https://github.com/shopify/oktakit' + 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.0' + + spec.add_dependency 'sawyer', '~> 0.6.0' + spec.add_development_dependency 'bundler' +end diff --git a/spec/oktakit_spec.rb b/spec/oktakit_spec.rb new file mode 100644 index 0000000..a5fd91d --- /dev/null +++ b/spec/oktakit_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Oktakit do + it 'has a version number' do + expect(Oktakit::VERSION).not_to be nil + end + + describe '#root' do + it 'fetches the API root' do + VCR.use_cassette 'root' do + root = client.root + expect(root.response).to be == 'Hello World' + end + end + end + + describe 'errors' do + it 'raises a Oktakit::NotFound on 404 responses' do + VCR.use_cassette '404' do + expect { client.get('/404-not-found') }.to raise_error(Oktakit::NotFound) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..9a262f5 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,38 @@ +require 'vcr' + +module AgentsAccessTokenFilter + private + + def serializable_body(*) + body = super + body['string'].gsub!(/"access_token":\s*"\w+"/, '"access_token": "<>"') + body['string'].gsub!(/"ip_address":\s*"[\d\.]+"/, '"ip_address": "127.0.0.1"') + body + end +end + +VCR::Response.include(AgentsAccessTokenFilter) +VCR.configure do |config| + config.configure_rspec_metadata! + config.cassette_library_dir = 'spec/cassettes/' + config.hook_into :faraday + config.filter_sensitive_data('<>') do + test_okta_token + end +end + +$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) +require 'oktakit' + +module TestClient + extend RSpec::SharedContext + let(:client) { Oktakit::Client.new(token: test_oktakit_token) } +end + +RSpec.configure do |config| + config.include TestClient +end + +def test_okta_token + ENV.fetch 'OKTA_TEST_TOKEN', 'x' * 40 +end