├── Gemfile ├── spec ├── spec_helper.rb └── acceptance │ ├── lambda_api_spec.rb │ └── class_api_spec.rb ├── lib ├── sliver │ ├── response.rb │ ├── hook.rb │ ├── endpoints.rb │ ├── api.rb │ ├── action.rb │ ├── runner.rb │ └── path.rb └── sliver.rb ├── .gitignore ├── Rakefile ├── .rubocop.yml ├── .github └── workflows │ └── ci.yml ├── LICENSE.txt ├── sliver.gemspec └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" 4 | require "bundler" 5 | 6 | Bundler.require :default, :development 7 | -------------------------------------------------------------------------------- /lib/sliver/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sliver::Response 4 | attr_accessor :status, :headers, :body 5 | 6 | def initialize 7 | @headers = {} 8 | end 9 | 10 | def to_a 11 | [status, headers, body] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .rubocop-*-yml 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | *.bundle 20 | *.so 21 | *.o 22 | *.a 23 | mkmf.log 24 | -------------------------------------------------------------------------------- /lib/sliver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sliver 4 | PATH_KEY = "sliver.path".freeze 5 | end 6 | 7 | require "sliver/action" 8 | require "sliver/api" 9 | require "sliver/endpoints" 10 | require "sliver/hook" 11 | require "sliver/path" 12 | require "sliver/response" 13 | require "sliver/runner" 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new(:rubocop) 9 | 10 | Rake::Task["default"].clear if Rake::Task.task_defined?("default") 11 | task :default => %i[ rubocop spec ] 12 | -------------------------------------------------------------------------------- /lib/sliver/hook.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sliver::Hook 4 | def self.call(action, response) 5 | new(action, response).call 6 | end 7 | 8 | def initialize(action, response) 9 | @action = action 10 | @response = response 11 | end 12 | 13 | private 14 | 15 | attr_reader :action, :response 16 | end 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://gist.githubusercontent.com/pat/ba3b8ffb1901bfe5439b460943b6b019/raw/.rubocop.yml 3 | 4 | require: rubocop-performance 5 | 6 | Layout/BlockAlignment: 7 | Exclude: 8 | - sliver.gemspec 9 | 10 | Performance/RedundantBlockCall: 11 | Enabled: false 12 | 13 | Style/ClassAndModuleChildren: 14 | Enabled: false 15 | 16 | Style/Documentation: 17 | Enabled: false 18 | 19 | Style/RedundantFreeze: 20 | Enabled: false 21 | -------------------------------------------------------------------------------- /lib/sliver/endpoints.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sliver::Endpoints 4 | def initialize 5 | @paths = {} 6 | end 7 | 8 | def append(path, action) 9 | paths[path] = action 10 | end 11 | 12 | def find(environment) 13 | path = paths.keys.detect { |key| key.matches?(environment) } 14 | return nil unless path 15 | 16 | environment[Sliver::PATH_KEY] = path 17 | paths[path] 18 | end 19 | 20 | private 21 | 22 | attr_reader :paths 23 | end 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | ruby: 13 | - 2.7.8 14 | - 3.0.6 15 | - 3.1.4 16 | - 3.2.3 17 | - 3.3.0 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | 28 | - name: Rubocop 29 | run: bundle exec rubocop 30 | 31 | - name: RSpec 32 | run: bundle exec rspec 33 | -------------------------------------------------------------------------------- /lib/sliver/api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sliver::API 4 | def initialize(&block) 5 | @endpoints = Sliver::Endpoints.new 6 | 7 | block.call self 8 | end 9 | 10 | def call(environment) 11 | endpoint = endpoints.find environment 12 | 13 | endpoint.nil? ? not_found : invoke(endpoint, environment) 14 | end 15 | 16 | def invoke(endpoint, environment) 17 | endpoint.call environment 18 | end 19 | 20 | def connect(method, path, action) 21 | endpoints.append Sliver::Path.new(method, path), action 22 | end 23 | 24 | private 25 | 26 | attr_reader :endpoints 27 | 28 | def not_found 29 | [404, {"X-Cascade" => "pass"}, ["Not Found"]] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/sliver/action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sliver::Action 4 | def self.included(base) 5 | base.extend Sliver::Action::ClassMethods 6 | end 7 | 8 | module ClassMethods 9 | def call(environment) 10 | Sliver::Runner.new(self, environment).call 11 | end 12 | 13 | def guards 14 | [] 15 | end 16 | 17 | def processors 18 | [] 19 | end 20 | end 21 | 22 | def initialize(environment, response) 23 | @environment = environment 24 | @response = response 25 | end 26 | 27 | def request 28 | @request ||= Rack::Request.new environment 29 | end 30 | 31 | def skip? 32 | false 33 | end 34 | 35 | private 36 | 37 | attr_reader :environment, :response 38 | 39 | def path_params 40 | @path_params ||= environment[Sliver::PATH_KEY].to_params environment 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Pat Allan 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/sliver/runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sliver::Runner 4 | def initialize(klass, environment) 5 | @klass = klass 6 | @environment = environment 7 | 8 | @guarded = false 9 | end 10 | 11 | def call 12 | pass_guards 13 | action.call unless skip_action? 14 | post_process 15 | 16 | response.to_a 17 | end 18 | 19 | private 20 | 21 | attr_reader :klass, :environment 22 | 23 | def action 24 | @action ||= klass.new environment, response 25 | end 26 | 27 | def guard_classes 28 | klass.guards 29 | end 30 | 31 | def guarded? 32 | @guarded 33 | end 34 | 35 | def guarded! 36 | @guarded = true 37 | end 38 | 39 | def pass_guards 40 | guard_classes.each do |guard_class| 41 | guard = guard_class.new action, response 42 | next if guard.continue? 43 | 44 | guard.respond 45 | guarded! 46 | break 47 | end 48 | end 49 | 50 | def post_process 51 | klass.processors.each { |processor| processor.call action, response } 52 | end 53 | 54 | def response 55 | @response ||= Sliver::Response.new 56 | end 57 | 58 | def skip_action? 59 | guarded? || action.skip? 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /sliver.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "sliver" 5 | spec.version = "0.2.5" 6 | spec.authors = ["Pat Allan"] 7 | spec.email = ["pat@freelancing-gods.com"] 8 | spec.summary = "Lightweight, simple Rack APIs" 9 | spec.description = "A super simple, object-focused extendable Rack API." 10 | spec.homepage = "https://github.com/pat/sliver" 11 | spec.license = "MIT" 12 | 13 | spec.metadata["homepage_uri"] = spec.homepage 14 | spec.metadata["source_code_uri"] = spec.homepage 15 | spec.metadata["rubygems_mfa_required"] = "true" 16 | 17 | spec.files = Dir["lib/**/*"] + %w[LICENSE.txt README.md] 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_runtime_dependency "rack", ">= 1.5.2" 23 | 24 | spec.add_development_dependency "rack-test", ">= 0.6.2" 25 | spec.add_development_dependency "rake" 26 | spec.add_development_dependency "rspec", ">= 3.6.0" 27 | spec.add_development_dependency "rubocop", "~> 0.78.0" 28 | spec.add_development_dependency "rubocop-performance", "~> 1.5" 29 | end 30 | -------------------------------------------------------------------------------- /lib/sliver/path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Sliver::Path 4 | METHOD_KEY = "REQUEST_METHOD".freeze 5 | PATH_KEY = "PATH_INFO".freeze 6 | EMPTY_PATH = "".freeze 7 | ROOT_PATH = "/".freeze 8 | 9 | attr_reader :http_method, :string 10 | 11 | def initialize(http_method, string) 12 | @http_method = http_method.to_s.upcase 13 | @string = normalised_path string 14 | end 15 | 16 | def eql?(other) 17 | http_method == other.http_method && string == other.string 18 | end 19 | 20 | def hash 21 | @hash ||= "#{http_method} #{string}".hash 22 | end 23 | 24 | def matches?(environment) 25 | method = environment[METHOD_KEY] 26 | path = normalised_path environment[PATH_KEY] 27 | 28 | http_method == method && path[string_to_regexp] 29 | end 30 | 31 | def to_params(environment) 32 | return {} unless matches?(environment) 33 | 34 | path = normalised_path environment[PATH_KEY] 35 | values = path.scan(string_to_regexp).flatten 36 | 37 | string_keys.each_with_index.inject({}) do |hash, (key, index)| 38 | hash[key] = values[index] 39 | hash 40 | end 41 | end 42 | 43 | private 44 | 45 | def normalised_path(string) 46 | string == EMPTY_PATH ? ROOT_PATH : string 47 | end 48 | 49 | def string_keys 50 | @string_keys ||= string.to_s.scan(/:([\w-]+)/i).flatten 51 | end 52 | 53 | def string_to_regexp 54 | @string_to_regexp ||= Regexp.new( 55 | "\\A" + string.to_s.gsub(/:[\w-]+/, "([\\w\\-\\.\\\\+]+)") + "\\z" 56 | ) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/acceptance/lambda_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe "Basic Sliver API" do 6 | include Rack::Test::Methods 7 | 8 | let(:app) do 9 | Sliver::API.new do |api| 10 | api.connect :get, "/", lambda { |_environment| 11 | [200, {"Content-Type" => "text/plain"}, ["foo"]] 12 | } 13 | 14 | api.connect :get, "/bar", lambda { |_environment| 15 | [200, {"Content-Type" => "text/plain"}, ["baz"]] 16 | } 17 | 18 | api.connect :post, "/", lambda { |_environment| 19 | [200, {"Content-Type" => "text/plain"}, ["qux"]] 20 | } 21 | 22 | api.connect :delete, %r{/remove/\d+}, lambda { |_environment| 23 | [200, {}, ["removed"]] 24 | } 25 | end 26 | end 27 | 28 | it "responds to GET requests" do 29 | get "/" 30 | 31 | expect(last_response.status).to eq(200) 32 | expect(last_response.headers["Content-Type"]).to eq("text/plain") 33 | expect(last_response.body).to eq("foo") 34 | end 35 | 36 | it "matches empty paths as /" do 37 | get "" 38 | 39 | expect(last_response.body).to eq("foo") 40 | end 41 | 42 | it "delegates to the appropriate endpoint" do 43 | get "/bar" 44 | 45 | expect(last_response.body).to eq("baz") 46 | end 47 | 48 | it "responds to POST requests" do 49 | post "/" 50 | 51 | expect(last_response.body).to eq("qux") 52 | end 53 | 54 | it "responds to unknown endpoints with a 404" do 55 | get "/missing" 56 | 57 | expect(last_response.status).to eq(404) 58 | expect(last_response.body).to eq("Not Found") 59 | expect(last_response.headers["X-Cascade"]).to eq("pass") 60 | end 61 | 62 | it "matches against regular expressions" do 63 | delete "/remove/141" 64 | 65 | expect(last_response.status).to eq(200) 66 | expect(last_response.body).to eq("removed") 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/acceptance/class_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class GetAction 6 | include Sliver::Action 7 | 8 | def call 9 | response.status = 200 10 | response.headers = {"Content-Type" => "text/plain"} 11 | response.body = ["foo"] 12 | end 13 | end 14 | 15 | class EchoAction 16 | include Sliver::Action 17 | 18 | def call 19 | response.status = 200 20 | response.body = environment["rack.input"].read 21 | end 22 | end 23 | 24 | class AdditionAction 25 | include Sliver::Action 26 | 27 | def call 28 | response.status = 200 29 | response.body = [ 30 | (request.params["a"].to_i + request.params["b"].to_i).to_s 31 | ] 32 | end 33 | end 34 | 35 | class SkippedAction 36 | include Sliver::Action 37 | 38 | def skip? 39 | response.status = 400 40 | response.body = ["Invalid"] 41 | end 42 | 43 | def call 44 | response.status = 200 45 | response.body = ["Success"] 46 | end 47 | end 48 | 49 | class MyParamGuard < Sliver::Hook 50 | def continue? 51 | action.request.params["hello"] == "world" 52 | end 53 | 54 | def respond 55 | response.status = 404 56 | response.body = ["Not Found"] 57 | end 58 | end 59 | 60 | class GuardedAction 61 | include Sliver::Action 62 | 63 | def self.guards 64 | [MyParamGuard] 65 | end 66 | 67 | def call 68 | response.status = 200 69 | response.body = ["Welcome"] 70 | end 71 | end 72 | 73 | class UnguardedAction < GuardedAction 74 | def self.guards 75 | super - [MyParamGuard] 76 | end 77 | end 78 | 79 | class IdAction 80 | include Sliver::Action 81 | 82 | def call 83 | response.status = 200 84 | response.body = [path_params["id"]] 85 | end 86 | end 87 | 88 | class MultiPathPartAction 89 | include Sliver::Action 90 | 91 | def call 92 | response.status = 200 93 | response.body = ["#{path_params["first"]}:#{path_params["second"]}"] 94 | end 95 | end 96 | 97 | class JsonProcessor < Sliver::Hook 98 | def call 99 | response.headers["Content-Type"] = "application/json" 100 | end 101 | end 102 | 103 | class ProcessedAction 104 | include Sliver::Action 105 | 106 | def self.processors 107 | [JsonProcessor] 108 | end 109 | 110 | def call 111 | response.status = 200 112 | response.body = ["[]"] 113 | end 114 | end 115 | 116 | describe "Class-based Sliver API" do 117 | include Rack::Test::Methods 118 | 119 | let(:app) do 120 | Sliver::API.new do |api| 121 | api.connect :get, "/", GetAction 122 | api.connect :put, "/echo", EchoAction 123 | api.connect :get, "/addition", AdditionAction 124 | api.connect :get, "/skip", SkippedAction 125 | api.connect :get, "/guard", GuardedAction 126 | api.connect :get, "/unguard", UnguardedAction 127 | api.connect :get, "/my/:id", IdAction 128 | api.connect :get, "/my/:first/:second", MultiPathPartAction 129 | api.connect :get, "/processed", ProcessedAction 130 | end 131 | end 132 | 133 | it "constructs responses" do 134 | get "/" 135 | 136 | expect(last_response.status).to eq(200) 137 | expect(last_response.headers["Content-Type"]).to eq("text/plain") 138 | expect(last_response.body).to eq("foo") 139 | end 140 | 141 | it "allows use of environment" do 142 | put "/echo", "baz" 143 | 144 | expect(last_response.body).to eq("baz") 145 | end 146 | 147 | it "allows use of request" do 148 | get "/addition", "a" => "5", "b" => "3" 149 | 150 | expect(last_response.body).to eq("8") 151 | end 152 | 153 | it "allows standard responses to be skipped" do 154 | get "/skip" 155 | 156 | expect(last_response.status).to eq(400) 157 | expect(last_response.body).to eq("Invalid") 158 | end 159 | 160 | it "blocks guarded actions if they cannot continue" do 161 | get "/guard" 162 | 163 | expect(last_response.status).to eq(404) 164 | expect(last_response.body).to eq("Not Found") 165 | end 166 | 167 | it "accepts guarded actions that meet criteria" do 168 | get "/guard", "hello" => "world" 169 | 170 | expect(last_response.status).to eq(200) 171 | expect(last_response.body).to eq("Welcome") 172 | end 173 | 174 | it "respects subclass guard changes" do 175 | get "/unguard" 176 | 177 | expect(last_response.status).to eq(200) 178 | expect(last_response.body).to eq("Welcome") 179 | end 180 | 181 | it "handles path parameter markers" do 182 | get "/my/10" 183 | 184 | expect(last_response.status).to eq(200) 185 | expect(last_response.body).to eq("10") 186 | end 187 | 188 | it "handles path parameters with full stops" do 189 | get "/my/10.1" 190 | 191 | expect(last_response.status).to eq(200) 192 | expect(last_response.body).to eq("10.1") 193 | end 194 | 195 | it "handles path parameters with pluses" do 196 | get "/my/10+1" 197 | 198 | expect(last_response.status).to eq(200) 199 | expect(last_response.body).to eq("10+1") 200 | end 201 | 202 | it "handles multiple path parameter markers" do 203 | get "/my/10/foo" 204 | 205 | expect(last_response.status).to eq(200) 206 | expect(last_response.body).to eq("10:foo") 207 | end 208 | 209 | it "handles processors" do 210 | get "/processed" 211 | 212 | expect(last_response.status).to eq(200) 213 | expect(last_response.headers["Content-Type"]).to eq("application/json") 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sliver 2 | 3 | A super simple, extendable Rack API. 4 | 5 | [![Build Status](https://travis-ci.org/pat/sliver.svg?branch=master)](https://travis-ci.org/pat/sliver) 6 | [![Code Climate](https://codeclimate.com/github/pat/sliver.svg)](https://codeclimate.com/github/pat/sliver) 7 | [![Gem Version](https://badge.fury.io/rb/sliver.svg)](http://badge.fury.io/rb/sliver) 8 | 9 | ## Why? 10 | 11 | Ruby doesn't lack for web frameworks, especially ones focused on APIs, but Sliver is a little different from others I've come across. 12 | 13 | * It focuses on one class per endpoint, for increased Single Responsibility Principle friendliness. 14 | * Separate classes allows for better code organisation, instead of everything in one file. 15 | * It's a pretty small layer on top of Rack, which is the only dependency, which keeps things light and fast. 16 | * Guards and processors provide some structures for managing authentication, JSON responses and other common behaviours across actions (similar to Rails before_action filters). 17 | 18 | ## Installation 19 | 20 | Add it to your Gemfile like any other gem, or install it manually. 21 | 22 | ```ruby 23 | gem 'sliver', '~> 0.2.5' 24 | ``` 25 | 26 | ## Usage 27 | 28 | At its most basic level, Sliver is a simple routing engine to other Rack endpoints. You can map out a bunch of routes (with the HTTP method and the path), and the corresponding endpoints for requests that come in on those routes. 29 | 30 | ### Lambda Endpoints 31 | 32 | Here's an example using lambdas, where the responses must match Rack's expected output (an array with three items: status code, headers, and body). 33 | 34 | ```ruby 35 | app = Sliver::API.new do |api| 36 | api.connect :get, '/', lambda { |environment| 37 | [200, {}, ['How dare the Premier ignore my invitations?']] 38 | } 39 | 40 | api.connect :post '/hits', lambda { |environment| 41 | HitMachine.create! Rack::Request.new(environment).params[:hit] 42 | 43 | [200, {}, ["He'll have to go!"]] 44 | } 45 | end 46 | ``` 47 | 48 | ### Sliver::Action Endpoints 49 | 50 | However, it can be nice to encapsulate each endpoint in its own class - to keep responsibilities clean and concise. Sliver provides a module `Sliver::Action` which makes this approach reasonably simple, with helper methods to the Rack environment and a `response` object, which can have `status`, `headers` and `body` set (which is automatically translated into the Rack response). 51 | 52 | ```ruby 53 | app = Sliver::API.new do |api| 54 | api.connect :get, '/changes', ChangesAction 55 | end 56 | 57 | class ChangesAction 58 | include Sliver::Action 59 | 60 | def call 61 | # This isn't a realistic implementation - just examples of how 62 | # to interact with the provided variables. 63 | 64 | # Change the status: 65 | response.status = 404 66 | 67 | # Add to the response headers: 68 | response.headers['Content-Type'] = 'text/plain' 69 | 70 | # Add a response body - let's provide an array, like Rack expects: 71 | response.body = [ 72 | "How dare the Premier ignore my invitations?", 73 | "He'll have to go", 74 | "So too the bunch he luncheons with", 75 | "It's second on my list of things to do" 76 | ] 77 | 78 | # Access the request environment: 79 | self.environment 80 | 81 | # Access to a Rack::Request object built from that environment: 82 | self.request 83 | end 84 | end 85 | ``` 86 | 87 | ### Path Parameters 88 | 89 | Much like Rails, you can have named parameters in your paths, which are available via `path_params` within your endpoint behaviour: 90 | 91 | ```ruby 92 | app = Sliver::API.new do |api| 93 | api.connect :get, '/changes/:id', ChangeAction 94 | end 95 | 96 | class ChangeAction 97 | include Sliver::Action 98 | 99 | def call 100 | change = Change.find path_params['id'] 101 | 102 | response.status = 200 103 | response.body = ["Change: #{change.name}"] 104 | end 105 | end 106 | ``` 107 | 108 | It's worth noting that unlike Rails, these values are not mixed into the standard `params` hash. 109 | 110 | ### Guards 111 | 112 | Sometimes you're going to have endpoints where you want to check certain things before getting into the core implementation: one example could be to check whether the request is made by an authenticated user. In Sliver, you can do this via Guards: 113 | 114 | ```ruby 115 | app = Sliver::API.new do |api| 116 | api.connect :get, '/changes/:id', ChangeAction 117 | end 118 | 119 | class ChangeAction 120 | include Sliver::Action 121 | 122 | def self.guards 123 | [AuthenticatedUserGuard] 124 | end 125 | 126 | def call 127 | change = Change.find path_params['id'] 128 | 129 | response.status = 200 130 | response.body = ["Change: #{change.name}"] 131 | end 132 | 133 | def user 134 | @user ||= User.find_by :key => request.env['Authentication'] 135 | end 136 | end 137 | 138 | class AuthenticatedUserGuard < Sliver::Hook 139 | def continue? 140 | action.user.present? 141 | end 142 | 143 | def respond 144 | response.status = 401 145 | response.body = ['Unauthorised: valid session is required'] 146 | end 147 | end 148 | ``` 149 | 150 | Guards inheriting from `Sliver::Hook` just need to respond to `call`, and have access to `action` (your endpoint instance) and `response` (which will be turned into a proper Rack response). 151 | 152 | They are set in the action via a class method (which must return an array of classes), and a guard instance must respond to two methods: `continue?` and `respond`. If `continue?` returns true, then the main action `call` method is used. Otherwise, it's skipped, and the guard's `respond` method needs to set the alternative response. 153 | 154 | ### Processors 155 | 156 | Processors are behaviours that happen after the endpoint logic has been performed. These are particularly useful for transforming the response, perhaps to JSON if your API is expected to always return JSON: 157 | 158 | ```ruby 159 | app = Sliver::API.new do |api| 160 | api.connect :get, '/changes/:id', ChangeAction 161 | end 162 | 163 | class ChangeAction 164 | include Sliver::Action 165 | 166 | def self.processors 167 | [JSONProcessor] 168 | end 169 | 170 | def call 171 | change = Change.find path_params['id'] 172 | 173 | response.status = 200 174 | response.body = {:id => change.id, :name => change.name} 175 | end 176 | end 177 | 178 | class JSONProcessor < Sliver::Hook 179 | def call 180 | response.body = [JSON.generate(response.body)] 181 | response.headers['Content-Type'] = 'application/json' 182 | end 183 | end 184 | ``` 185 | 186 | Processors inheriting from `Sliver::Hook` just need to respond to `call`, and have access to `action` (your endpoint instance) and `response` (which will be turned into a proper Rack response). 187 | 188 | ### Testing 189 | 190 | Because your API is a Rack app, it can be tested using `rack-test`'s helper methods. Here's a quick example for RSpec: 191 | 192 | ```ruby 193 | describe 'My API' do 194 | include Rack::Test::Methods 195 | 196 | let(:app) { MyApi.new } 197 | 198 | it 'responds to GET requests' do 199 | get '/' 200 | 201 | expect(last_response.status).to eq(200) 202 | expect(last_response.headers['Content-Type']).to eq('text/plain') 203 | expect(last_response.body).to eq('foo') 204 | end 205 | end 206 | ``` 207 | 208 | ### Running via config.ru 209 | 210 | It's pretty easy to run your Sliver API via a `config.ru` file: 211 | 212 | ```ruby 213 | require 'rubygems' 214 | require 'bundler' 215 | 216 | Bundler.setup :default 217 | $:.unshift File.dirname(__FILE__) + '/lib' 218 | 219 | require 'my_app' 220 | 221 | run MyApp::API.new 222 | ``` 223 | 224 | ### Running via Rails 225 | 226 | Of course, you can also run your API within the context of Rails by mounting it in your `config/routes.rb` file: 227 | 228 | ```ruby 229 | MyRailsApp::Application.routes.draw do 230 | mount Api::V1.new => '/api/v1' 231 | end 232 | ``` 233 | 234 | There is also the [sliver-rails](https://github.com/pat/sliver-rails) gem which adds some nice extensions to Sliver with Rails in mind. 235 | 236 | ## Contributing 237 | 238 | Please note that this project now has a [Contributor Code of Conduct](http://contributor-covenant.org/version/1/0/0/). By participating in this project you agree to abide by its terms. 239 | 240 | 1. Fork it ( https://github.com/pat/sliver/fork ) 241 | 2. Create your feature branch (`git checkout -b my-new-feature`) 242 | 3. Commit your changes (`git commit -am 'Add some feature'`) 243 | 4. Push to the branch (`git push origin my-new-feature`) 244 | 5. Create a new Pull Request 245 | 246 | ## Licence 247 | 248 | Copyright (c) 2014-2015, Sliver is developed and maintained by Pat Allan, and is 249 | released under the open MIT Licence. 250 | --------------------------------------------------------------------------------