apiculture 0.0.19 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: b6f0c8bf33ab6d850b0e5ebe4137248b2eece90b
4
- data.tar.gz: ff2545d4a6b2d335670722f52052b3081976afb6
3
+ metadata.gz: aa9f65f24dcec58c1fa839c4b06ad6707ff94929
4
+ data.tar.gz: 972227095a632256fba17ff766701d6626135f19
5
5
  SHA512:
6
- metadata.gz: 0cd7c2561d5d0139893fbfc78c58e46ee889c84e91756f51773d1000ce4a94b8c5e1af3d2cf0c997835dfc35d6de2fdbaff9b65cece17c3c9835f31915349126
7
- data.tar.gz: 2870ec784e365047cf7c737bec1510b98e19fb3efa1d5fbb6c3b3cecabdf78aaa09fea584d3a83abca63ffd99d190831a46867b1ee36c397a9eb275bc3b85bf4
6
+ metadata.gz: e4f2f860e3ede4b6969b6a42cd25157ff58ac1f9d5c9fa37d984e4e2eabe48b35c912efd358584a0392655c292e392feaed2e69fd13d992dba418419541c8139
7
+ data.tar.gz: 03754e831d7b7a2e44fdb431906ffb6119d78ee1263a2978caf89e43983f216ad5f1d8ad61e331fcdedfe03f491ce9ff996cb6c39034fc78234adae370bccf91
data/.travis.yml CHANGED
@@ -1,10 +1,13 @@
1
+ gemfile:
2
+ - gemfiles/Gemfile.rack-1.x
3
+ - gemfiles/Gemfile.rack-2.x
1
4
  rvm:
2
- - 2.1.5
3
- - 2.2.2
4
- - 2.3.3
5
- - 2.4.1
5
+ - 2.3.7
6
+ - 2.4.4
7
+ - 2.5.1
8
+ - 2.6.0-preview1
6
9
  sudo: false
7
10
  cache: bundler
8
11
  matrix:
9
12
  allow_failures:
10
- - rvm: 2.1.5 # incompatible with the new jeweler version
13
+ - rvm: 2.6.0-preview1
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2015 WeTransfer
1
+ Copyright (c) 2015-2018 WeTransfer
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,31 +1,31 @@
1
1
  # apiculture
2
2
 
3
- A little toolkit for building RESTful API backends on top of Sinatra.
3
+ A little toolkit for building RESTful API backends on top of Rack.
4
4
 
5
5
  [![Build Status](https://travis-ci.org/WeTransfer/apiculture.svg?branch=master)](https://travis-ci.org/WeTransfer/apiculture)
6
6
 
7
7
  ## Ideas
8
8
 
9
9
  A simple API definition DSL with simple premises:
10
-
10
+
11
11
  * Endpoint URLs should be _visible_ in the actual code. The reason for that is with nested
12
12
  blocks you inevitably end up setting up context somewhere far away from the terminal route
13
13
  that ends up using that context.
14
14
  * Explicit allowed/required parameters (both payload/query string and body)
15
15
  * Explicit description in front of the API action definition
16
16
  * Wrap the actual work into Actions, so that the API definition is mostly routes
17
-
17
+
18
18
  ## A taste of honey
19
19
 
20
20
  ```ruby
21
- class Api::V2 < Sinatra::Base
22
-
21
+ class Api::V2 < Apiculture::App
22
+
23
23
  use Rack::Parser, :content_types => {
24
24
  'application/json' => JSON.method(:load).to_proc
25
25
  }
26
-
26
+
27
27
  extend Apiculture
28
-
28
+
29
29
  desc 'Create a Contact'
30
30
  required_param :name, 'Name of the person', String
31
31
  param :email, 'Email address of the person', String
@@ -36,7 +36,7 @@ class Api::V2 < Sinatra::Base
36
36
  # works exactly the same - but we suggest using Actions instead.
37
37
  action_result CreateContact # uses Api::V2::CreateContact
38
38
  end
39
-
39
+
40
40
  desc 'Fetch a Contact'
41
41
  route_param :id, 'ID of the person'
42
42
  responds_with 200, 'Contact data', {name: 'John Appleseed', id: "ac19...fefg"}
@@ -75,7 +75,7 @@ If you want to also examine the HTML documentation that gets built during the te
75
75
  Note that this requires presence of the `open` commandline utility (should be available on both OSX and Linux).
76
76
 
77
77
  ## Contributing to apiculture
78
-
78
+
79
79
  * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
80
80
  * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
81
81
  * Fork the project.
@@ -86,6 +86,5 @@ Note that this requires presence of the `open` commandline utility (should be av
86
86
 
87
87
  ## Copyright
88
88
 
89
- Copyright (c) 2015 WeTransfer. See LICENSE.txt for
89
+ Copyright (c) 2015-2018 WeTransfer. See LICENSE.txt for
90
90
  further details.
91
-
data/apiculture.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |s|
10
10
  s.require_paths = ["lib"]
11
11
  s.authors = ["Julik Tarkhanov", "WeTransfer"]
12
12
  s.homepage = 'http://github.com/wetransfer/zip_tricks'
13
- s.description = "A toolkit for building REST APIs on top of Sinatra"
13
+ s.description = "A toolkit for building REST APIs on top of Rack"
14
14
  s.email = "me@julik.nl"
15
15
 
16
16
  # Prevent pushing this gem to RubyGems.org.
@@ -22,7 +22,7 @@ Gem::Specification.new do |s|
22
22
  raise 'RubyGems 2.0 or newer is required to protect against ' \
23
23
  'public gem pushes.'
24
24
  end
25
-
25
+
26
26
  s.files = `git ls-files -z`.split("\x0")
27
27
  s.extra_rdoc_files = [
28
28
  "LICENSE.txt",
@@ -31,17 +31,17 @@ Gem::Specification.new do |s|
31
31
  s.homepage = "https://github.com/WeTransfer/apiculture"
32
32
  s.licenses = ["MIT"]
33
33
  s.rubygems_version = "2.4.5.1"
34
- s.summary = "Sweet API sauce on top of Sintra"
34
+ s.summary = "Sweet API sauce on top of Rack"
35
35
 
36
- s.add_runtime_dependency 'sinatra', '~> 1'
36
+ s.add_runtime_dependency 'mustermann', '~> 1'
37
37
  s.add_runtime_dependency 'builder', '~> 3'
38
38
  s.add_runtime_dependency 'rdiscount', '~> 2'
39
39
  s.add_runtime_dependency 'github-markup', '~> 1'
40
- s.add_runtime_dependency "mustache", '~> 1'
40
+ s.add_runtime_dependency 'mustache', '~> 1'
41
41
 
42
42
  s.add_development_dependency 'rack-test'
43
43
  s.add_development_dependency "rspec", "~> 3.1", '< 3.2'
44
- s.add_development_dependency "rdoc", "~> 3.12"
44
+ s.add_development_dependency "rdoc", "~> 6.0"
45
45
  s.add_development_dependency "rake", "~> 10"
46
46
  s.add_development_dependency "bundler", "~> 1.0"
47
47
  s.add_development_dependency "simplecov", ">= 0"
@@ -0,0 +1,17 @@
1
+ source "http://rubygems.org"
2
+ gem 'rack', "~> 1"
3
+
4
+ gem 'mustermann', '~> 1'
5
+ gem 'builder', '~> 3'
6
+ gem 'rdiscount', '~> 2'
7
+ gem 'github-markup', '~> 1'
8
+ gem 'mustache', '~> 1'
9
+
10
+ group :development do
11
+ gem 'rack-test'
12
+ gem "rspec", "~> 3.1", '< 3.2'
13
+ gem "rdoc", "~> 6.0"
14
+ gem "rake", "~> 10"
15
+ gem "bundler", "~> 1.0"
16
+ gem "simplecov", ">= 0"
17
+ end
@@ -0,0 +1,17 @@
1
+ source "http://rubygems.org"
2
+ gem 'rack', "~> 2"
3
+
4
+ gem 'mustermann', '~> 1'
5
+ gem 'builder', '~> 3'
6
+ gem 'rdiscount', '~> 2'
7
+ gem 'github-markup', '~> 1'
8
+ gem 'mustache', '~> 1'
9
+
10
+ group :development do
11
+ gem 'rack-test'
12
+ gem "rspec", "~> 3.1", '< 3.2'
13
+ gem "rdoc", "~> 6.0"
14
+ gem "rake", "~> 10"
15
+ gem "bundler", "~> 1.0"
16
+ gem "simplecov", ">= 0"
17
+ end
@@ -11,9 +11,9 @@
11
11
  class Apiculture::Action
12
12
  # Initialize a new BasicAction, with the given Sintra application and a hash
13
13
  # of keyword arguments that will be converted into instance variables.
14
- def initialize(sinatra_app, **ivars)
14
+ def initialize(app_receiver, **ivars)
15
15
  ivars.each_pair {|k,v| instance_variable_set("@#{k}", v) }
16
- @_sinatra_app = sinatra_app
16
+ @_sinatra_app = app_receiver
17
17
  end
18
18
 
19
19
  # Halt with a JSON error message (delegates to Sinatra's halt() under the hood)
@@ -0,0 +1,131 @@
1
+ require'mustermann'
2
+
3
+ class Apiculture::App
4
+
5
+ class << self
6
+ def use(middlreware_factory, middleware_options, &middleware_blk)
7
+ @middleware_configurations ||= []
8
+ @middleware_configurations << [middleware_factory, middleware_options, middleware_blk]
9
+ end
10
+
11
+ def middleware_configurations
12
+ @middleware_configurations || []
13
+ end
14
+
15
+ def get(url, **options, &handler_blk)
16
+ define_action :get, url, options, &handler_blk
17
+ end
18
+
19
+ def post(url, **options, &handler_blk)
20
+ define_action :post, url, options, &handler_blk
21
+ end
22
+
23
+ def put(url, **options, &handler_blk)
24
+ define_action :put, url, options, &handler_blk
25
+ end
26
+
27
+ def delete(url, **options, &handler_blk)
28
+ define_action :delete, url, options, &handler_blk
29
+ end
30
+
31
+ def actions
32
+ @actions || []
33
+ end
34
+
35
+ def define_action(http_method, url_path, **options, &handler_blk)
36
+ @actions ||= []
37
+ @actions << [http_method.to_s.upcase, url_path, options, handler_blk]
38
+ end
39
+ end
40
+
41
+ def call_without_middleware(env)
42
+ @env = env
43
+
44
+ # First try to route via actions...
45
+ given_http_method = env.fetch('REQUEST_METHOD')
46
+ given_path = env.fetch('PATH_INFO')
47
+ given_path = '/' + given_path unless given_path.start_with?('/')
48
+
49
+ action_list = self.class.actions
50
+ # TODO: I believe Sinatra matches bottom-up, not top-down.
51
+ action_list.reverse.each do | (action_http_method, action_url_path, action_options, action_handler_callable)|
52
+ route_pattern = Mustermann.new(action_url_path)
53
+ if given_http_method == action_http_method && route_params = route_pattern.params(given_path)
54
+ @request = Rack::Request.new(env)
55
+ @params.merge!(@request.params)
56
+ @route_params = route_params
57
+
58
+ match = route_pattern.match(given_path)
59
+ @route_params['captures'] = match.captures unless match.nil?
60
+ @params.merge!(@route_params)
61
+ return perform_action_block(&action_handler_callable)
62
+ end
63
+ end
64
+
65
+ # and if nothing works out - respond with a 404
66
+ out = JSON.pretty_generate({
67
+ error: 'No matching action found for %s %s' % [given_http_method, given_path],
68
+ })
69
+ [404, {'Content-Type' => 'application/json', 'Content-Length' => out.bytesize.to_s}, [out]]
70
+ end
71
+
72
+ def self.call(env)
73
+ app = new
74
+ Rack::Builder.new do
75
+ (@middleware_configurations || []).each do |middleware_args|
76
+ use(*middleware_args)
77
+ end
78
+ run ->(env) { app.call_without_middleware(env) }
79
+ end.to_app.call(env)
80
+ end
81
+
82
+ attr_reader :request
83
+ attr_reader :env
84
+ attr_reader :params
85
+
86
+ def initialize
87
+ @status = 200
88
+ @content_type = 'text/plain'
89
+ @params = Apiculture::IndifferentHash.new
90
+ end
91
+
92
+ def content_type(new_type)
93
+ @content_type = Rack::Mime.mime_type('.%s' % new_type)
94
+ end
95
+
96
+ def status(status_code)
97
+ @status = status_code.to_i
98
+ end
99
+
100
+ def halt(rack_status, rack_headers, rack_body)
101
+ throw :halt, [rack_status, rack_headers, rack_body]
102
+ end
103
+
104
+ def perform_action_block(&blk)
105
+ # Execut the action in a Sinatra-like fashion - passing the route parameter values as
106
+ # arguments to the given block/callable. This is where in the future we should ditch
107
+ # the Sinatra calling conventions - Sinatra mandates that the action accept the route parameters
108
+ # as arguments and grab all the useful stuff from instance methods like `params` etc. whereas
109
+ # we probably want to have just Rack apps mounted per route (under an action)
110
+ response = catch(:halt) do
111
+ body_string_or_rack_triplet = instance_exec(*@route_params.values, &blk)
112
+
113
+ if rack_triplet?(body_string_or_rack_triplet)
114
+ return body_string_or_rack_triplet
115
+ end
116
+
117
+ [@status, {'Content-Type' => @content_type}, [body_string_or_rack_triplet]]
118
+ end
119
+
120
+ return response
121
+ end
122
+
123
+ def rack_triplet?(maybe_triplet)
124
+ maybe_triplet.is_a?(Array) &&
125
+ maybe_triplet.length == 3 &&
126
+ maybe_triplet[0].is_a?(Integer) &&
127
+ maybe_triplet[1].is_a?(Hash) &&
128
+ maybe_triplet[1].keys.all? {|k| k.is_a?(String) } &&
129
+ maybe_triplet[2].respond_to?(:each)
130
+ end
131
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+ module Apiculture
3
+ # A poor man's ActiveSupport::HashWithIndifferentAccess, with all the Rails-y
4
+ # stuff removed.
5
+ #
6
+ # Implements a hash where keys <tt>:foo</tt> and <tt>"foo"</tt> are
7
+ # considered to be the same.
8
+ #
9
+ # rgb = Sinatra::IndifferentHash.new
10
+ #
11
+ # rgb[:black] = '#000000' # symbol assignment
12
+ # rgb[:black] # => '#000000' # symbol retrieval
13
+ # rgb['black'] # => '#000000' # string retrieval
14
+ #
15
+ # rgb['white'] = '#FFFFFF' # string assignment
16
+ # rgb[:white] # => '#FFFFFF' # symbol retrieval
17
+ # rgb['white'] # => '#FFFFFF' # string retrieval
18
+ #
19
+ # Internally, symbols are mapped to strings when used as keys in the entire
20
+ # writing interface (calling e.g. <tt>[]=</tt>, <tt>merge</tt>). This mapping
21
+ # belongs to the public interface. For example, given:
22
+ #
23
+ # hash = Sinatra::IndifferentHash.new(:a=>1)
24
+ #
25
+ # You are guaranteed that the key is returned as a string:
26
+ #
27
+ # hash.keys # => ["a"]
28
+ #
29
+ # Technically other types of keys are accepted:
30
+ #
31
+ # hash = Sinatra::IndifferentHash.new(:a=>1)
32
+ # hash[0] = 0
33
+ # hash # => { "a"=>1, 0=>0 }
34
+ #
35
+ # But this class is intended for use cases where strings or symbols are the
36
+ # expected keys and it is convenient to understand both as the same. For
37
+ # example the +params+ hash in Sinatra.
38
+ class IndifferentHash < Hash
39
+ def self.[](*args)
40
+ new.merge!(Hash[*args])
41
+ end
42
+
43
+ def initialize(*args)
44
+ super(*args.map(&method(:convert_value)))
45
+ end
46
+
47
+ def default(*args)
48
+ super(*args.map(&method(:convert_key)))
49
+ end
50
+
51
+ def default=(value)
52
+ super(convert_value(value))
53
+ end
54
+
55
+ def assoc(key)
56
+ super(convert_key(key))
57
+ end
58
+
59
+ def rassoc(value)
60
+ super(convert_value(value))
61
+ end
62
+
63
+ def fetch(key, *args)
64
+ super(convert_key(key), *args.map(&method(:convert_value)))
65
+ end
66
+
67
+ def [](key)
68
+ super(convert_key(key))
69
+ end
70
+
71
+ def []=(key, value)
72
+ super(convert_key(key), convert_value(value))
73
+ end
74
+
75
+ alias_method :store, :[]=
76
+
77
+ def key(value)
78
+ super(convert_value(value))
79
+ end
80
+
81
+ def key?(key)
82
+ super(convert_key(key))
83
+ end
84
+
85
+ alias_method :has_key?, :key?
86
+ alias_method :include?, :key?
87
+ alias_method :member?, :key?
88
+
89
+ def value?(value)
90
+ super(convert_value(value))
91
+ end
92
+
93
+ alias_method :has_value?, :value?
94
+
95
+ def delete(key)
96
+ super(convert_key(key))
97
+ end
98
+
99
+ def dig(key, *other_keys)
100
+ super(convert_key(key), *other_keys)
101
+ end if method_defined?(:dig) # Added in Ruby 2.3
102
+
103
+ def fetch_values(*keys)
104
+ super(*keys.map(&method(:convert_key)))
105
+ end if method_defined?(:fetch_values) # Added in Ruby 2.3
106
+
107
+ def slice(*keys)
108
+ keys.map!(&method(:convert_key))
109
+ self.class[super(*keys)]
110
+ end if method_defined?(:slice) # Added in Ruby 2.5
111
+
112
+ def values_at(*keys)
113
+ super(*keys.map(&method(:convert_key)))
114
+ end
115
+
116
+ def merge!(other_hash)
117
+ return super if other_hash.is_a?(self.class)
118
+
119
+ other_hash.each_pair do |key, value|
120
+ key = convert_key(key)
121
+ value = yield(key, self[key], value) if block_given? && key?(key)
122
+ self[key] = convert_value(value)
123
+ end
124
+
125
+ self
126
+ end
127
+
128
+ alias_method :update, :merge!
129
+
130
+ def merge(other_hash, &block)
131
+ dup.merge!(other_hash, &block)
132
+ end
133
+
134
+ def replace(other_hash)
135
+ super(other_hash.is_a?(self.class) ? other_hash : self.class[other_hash])
136
+ end
137
+
138
+ private
139
+
140
+ def convert_key(key)
141
+ key.is_a?(Symbol) ? key.to_s : key
142
+ end
143
+
144
+ def convert_value(value)
145
+ case value
146
+ when Hash
147
+ value.is_a?(self.class) ? value : self.class[value]
148
+ when Array
149
+ value.map(&method(:convert_value))
150
+ else
151
+ value
152
+ end
153
+ end
154
+ end
155
+ end
@@ -21,15 +21,14 @@ module Apiculture::SinatraInstanceMethods
21
21
  def json_halt(with_error_message, status: 400, **attrs_for_json_response)
22
22
  # Pretty-print + newline to be terminal-friendly
23
23
  err_str = JSON.pretty_generate({error: with_error_message}.merge(attrs_for_json_response)) + NEWLINE
24
- halt status, {'Content-Type' => 'application/json'}, [err_str]
24
+ halt status, {'Content-Type' => 'application/json'}, [err_str]
25
25
  end
26
26
 
27
27
  # Handles the given action via the given class, passing it the instance variables
28
28
  # given in the keyword arguments
29
29
  def action_result(action_class, **action_ivars)
30
30
  call_result = action_class.new(self, **action_ivars).perform
31
-
32
- unless call_result.is_a?(Array) || call_result.is_a?(Hash) || (call_result.nil? && status == 204)
31
+ unless call_result.is_a?(Array) || call_result.is_a?(Hash) || (call_result.nil? && @status == 204)
33
32
  raise "Action result should be an Array, a Hash or it can be nil but only if status is 204, instead it was a #{call_result.class}"
34
33
  end
35
34
 
@@ -1,3 +1,3 @@
1
1
  module Apiculture
2
- VERSION = '0.0.19'
2
+ VERSION = '0.1.0'
3
3
  end
data/lib/apiculture.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # Allows brief definitions of APIs for documentation and parameter checks
2
2
  module Apiculture
3
3
  require_relative 'apiculture/version'
4
+ require_relative 'apiculture/indifferent_hash'
5
+ require_relative 'apiculture/app'
4
6
  require_relative 'apiculture/action'
5
7
  require_relative 'apiculture/sinatra_instance_methods'
6
8
  require_relative 'apiculture/action_definition'
@@ -4,42 +4,42 @@ describe Apiculture::Action do
4
4
  context '.new' do
5
5
  it 'exposes the methods of the object given as a first argument to initialize' do
6
6
  action_class = Class.new(described_class)
7
- fake_sinatra = double('Sinatra::Base', something: 'value')
8
- action = action_class.new(fake_sinatra)
7
+ fake_app = double('Apiculture::App', something: 'value')
8
+ action = action_class.new(fake_app)
9
9
  expect(action).to respond_to(:something)
10
10
  expect(action.something).to eq('value')
11
11
  end
12
-
12
+
13
13
  it 'converts keyword arguments to instance variables' do
14
14
  action_class = Class.new(described_class)
15
15
  action = action_class.new(nil, foo: 'a string')
16
16
  expect(action.instance_variable_get('@foo')).to eq('a string')
17
17
  end
18
18
  end
19
-
19
+
20
20
  it 'responds to perform()' do
21
21
  expect(described_class.new(nil)).to respond_to(:perform)
22
22
  end
23
-
23
+
24
24
  it 'can use bail() to throw a Sinatra halt' do
25
- fake_sinatra = double('Sinatra::Base')
26
- expect(fake_sinatra).to receive(:json_halt).with('Failure', status: 400)
25
+ fake_app = double('Apiculture::App')
26
+ expect(fake_app).to receive(:json_halt).with('Failure', status: 400)
27
27
  action_class = Class.new(described_class)
28
- action_class.new(fake_sinatra).bail "Failure"
28
+ action_class.new(fake_app).bail "Failure"
29
29
  end
30
-
30
+
31
31
  it 'can use bail() to throw a Sinatra halt with a custom status' do
32
- fake_sinatra = double('Sinatra::Base')
33
- expect(fake_sinatra).to receive(:json_halt).with("Failure", status: 417)
34
-
32
+ fake_app = double('Apiculture::App')
33
+ expect(fake_app).to receive(:json_halt).with("Failure", status: 417)
34
+
35
35
  action_class = Class.new(described_class)
36
- action_class.new(fake_sinatra).bail "Failure", status: 417
36
+ action_class.new(fake_app).bail "Failure", status: 417
37
37
  end
38
-
38
+
39
39
  it 'can use bail() to throw a Sinatra halt with extra JSON attributes' do
40
- fake_sinatra = double('Sinatra::Base')
41
- expect(fake_sinatra).to receive(:json_halt).with("Failure", status: 417, message: "Totale")
40
+ fake_app = double('Apiculture::App')
41
+ expect(fake_app).to receive(:json_halt).with("Failure", status: 417, message: "Totale")
42
42
  action_class = Class.new(described_class)
43
- action_class.new(fake_sinatra).bail "Failure", status: 417, message: 'Totale'
43
+ action_class.new(fake_app).bail "Failure", status: 417, message: 'Totale'
44
44
  end
45
45
  end
@@ -2,7 +2,7 @@ require_relative '../spec_helper'
2
2
 
3
3
  describe "Apiculture.api_documentation" do
4
4
  let(:app) {
5
- Class.new(Sinatra::Base) do
5
+ Class.new(Apiculture::App) do
6
6
  extend Apiculture
7
7
 
8
8
  markdown_string 'This API is very important. Because it has to do with pancakes.'
@@ -71,7 +71,7 @@ describe "Apiculture.api_documentation" do
71
71
  end
72
72
 
73
73
  it 'generates app documentation honoring the mount point' do
74
- overridden = Class.new(Sinatra::Base) do
74
+ overridden = Class.new(Apiculture::App) do
75
75
  extend Apiculture
76
76
  mounted_at '/api/v2/'
77
77
  api_method :get, '/pancakes' do
@@ -83,7 +83,7 @@ describe "Apiculture.api_documentation" do
83
83
  end
84
84
 
85
85
  it 'generates app documentation injecting the inline Markdown strings' do
86
- app_class = Class.new(Sinatra::Base) do
86
+ app_class = Class.new(Apiculture::App) do
87
87
  extend Apiculture
88
88
  markdown_string '# This describes important stuff'
89
89
  api_method :get, '/pancakes' do
@@ -103,7 +103,7 @@ describe "Apiculture.api_documentation" do
103
103
  before(:each) { File.open('./TEST.md', 'w') {|f| f << "# This is an important header"} }
104
104
  after(:each) { File.unlink('./TEST.md') }
105
105
  it 'splices the contents of the file using markdown_file' do
106
- app_class = Class.new(Sinatra::Base) do
106
+ app_class = Class.new(Apiculture::App) do
107
107
  extend Apiculture
108
108
  markdown_file './TEST.md'
109
109
  api_method :get, '/pancakes' do
@@ -2,71 +2,69 @@ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
2
 
3
3
  describe "Apiculture" do
4
4
  include Rack::Test::Methods
5
-
5
+
6
6
  before(:each) { @app_class = nil }
7
7
  def app
8
8
  @app_class or raise "No @app_class defined in the example"
9
9
  end
10
-
10
+
11
11
  context 'as API definition DSL' do
12
12
  it 'allows all the standard Siantra DSL to go through without modifications' do
13
- @app_class = Class.new(Sinatra::Base) do
14
- settings.show_exceptions = false
15
- settings.raise_errors = true
13
+ @app_class = Class.new(Apiculture::App) do
16
14
  extend Apiculture
17
-
15
+
18
16
  post '/things/*' do
19
- return params.inspect
17
+ params.inspect
20
18
  end
21
19
  end
22
-
20
+
23
21
  post '/things/a/b/c/d', {'foo' => 'bar'}
24
22
  expect(last_response.body).to eq("{\"foo\"=>\"bar\", \"splat\"=>[\"a/b/c/d\"], \"captures\"=>[\"a/b/c/d\"]}")
25
23
  end
26
-
24
+
27
25
  it 'flags :captures as a reserved Sinatra parameter when used as a URL param' do
28
26
  expect {
29
- Class.new(Sinatra::Base) do
27
+ Class.new(Apiculture::App) do
30
28
  extend Apiculture
31
29
  route_param :captures, "Something it captures"
32
30
  api_method(:get, '/thing/:captures') { raise "Should never be called" }
33
31
  end
34
32
  }.to raise_error(/\:captures is a reserved magic parameter name/)
35
33
  end
36
-
34
+
37
35
  it 'flags :captures as a reserved Sinatra parameter when used as a request param' do
38
36
  expect {
39
- Class.new(Sinatra::Base) do
37
+ Class.new(Apiculture::App) do
40
38
  extend Apiculture
41
39
  param :captures, "Something it captures", String
42
40
  api_method(:get, '/thing') { raise "Should never be called" }
43
41
  end
44
42
  }.to raise_error(/\:captures is a reserved magic parameter name/)
45
43
  end
46
-
44
+
47
45
  it 'flags :splat as a reserved Sinatra parameter when used as a URL param' do
48
46
  expect {
49
- Class.new(Sinatra::Base) do
47
+ Class.new(Apiculture::App) do
50
48
  extend Apiculture
51
49
  route_param :splat, "Something it splats"
52
50
  api_method(:get, '/thing/:splat') { raise "Should never be called" }
53
51
  end
54
52
  }.to raise_error(/\:splat is a reserved magic parameter name/)
55
53
  end
56
-
54
+
57
55
  it 'flags :splat as a reserved Sinatra parameter when used as a request param' do
58
56
  expect {
59
- Class.new(Sinatra::Base) do
57
+ Class.new(Apiculture::App) do
60
58
  extend Apiculture
61
59
  param :splat, "Something it splats", String
62
60
  api_method(:get, '/thing') { raise "Should never be called" }
63
61
  end
64
62
  }.to raise_error(/\:splat is a reserved magic parameter name/)
65
63
  end
66
-
64
+
67
65
  it 'flags URL and request params of the same name' do
68
66
  expect {
69
- Class.new(Sinatra::Base) do
67
+ Class.new(Apiculture::App) do
70
68
  extend Apiculture
71
69
  route_param :id, 'Id of the thing'
72
70
  param :id, "Something it identifies (conflict)", String
@@ -74,15 +72,12 @@ describe "Apiculture" do
74
72
  end
75
73
  }.to raise_error(/\:id mentioned twice/)
76
74
  end
77
-
75
+
78
76
  it "defines a basic API that can be called" do
79
77
  $created_thing = nil
80
- @app_class = Class.new(Sinatra::Base) do
81
- settings.show_exceptions = false
82
- settings.raise_errors = true
83
-
78
+ @app_class = Class.new(Apiculture::App) do
84
79
  extend Apiculture
85
-
80
+
86
81
  desc "Create a Thing with a name"
87
82
  route_param :id, "The ID of the thing"
88
83
  required_param :name, "Name of the thing", String
@@ -91,60 +86,53 @@ describe "Apiculture" do
91
86
  'Wild success'
92
87
  end
93
88
  end
94
-
89
+
95
90
  post '/thing/123', {name: 'Monsieur Thing'}
96
91
  expect(last_response.body).to eq('Wild success')
97
92
  expect($created_thing).to eq({id: '123', name: 'Monsieur Thing'})
98
93
  end
99
-
94
+
100
95
  it "serves the API documentation at a given URL using serve_api_documentation_at" do
101
96
  $created_thing = nil
102
- @app_class = Class.new(Sinatra::Base) do
103
- settings.show_exceptions = false
104
- settings.raise_errors = true
105
-
97
+ @app_class = Class.new(Apiculture::App) do
106
98
  extend Apiculture
107
-
99
+
108
100
  desc "Create a Thing with a name"
109
101
  required_param :name, "Name of the thing", String
110
102
  api_method( :post, '/thing/:id') {}
111
103
  serve_api_documentation_at('/documentation')
112
104
  end
113
-
105
+
114
106
  get '/documentation'
115
107
  expect(last_response['Content-Type']).to include('text/html')
116
108
  expect(last_response.body).to include('Create a Thing')
117
109
  end
118
-
110
+
119
111
  it 'raises when a required param is not provided' do
120
- @app_class = Class.new(Sinatra::Base) do
121
- settings.show_exceptions = false
122
- settings.raise_errors = true
112
+ @app_class = Class.new(Apiculture::App) do
123
113
  extend Apiculture
124
-
114
+
125
115
  required_param :name, "Name of the thing", String
126
116
  api_method :post, '/thing' do
127
117
  raise "Should never be called"
128
118
  end
129
119
  end
130
-
120
+
131
121
  expect {
132
122
  post '/thing', {}
133
123
  }.to raise_error('Missing parameter :name')
134
124
  end
135
-
125
+
136
126
  it 'verifies the parameter type' do
137
- @app_class = Class.new(Sinatra::Base) do
138
- settings.show_exceptions = false
139
- settings.raise_errors = true
127
+ @app_class = Class.new(Apiculture::App) do
140
128
  extend Apiculture
141
-
129
+
142
130
  required_param :number, "Number of the thing", Integer
143
131
  api_method :post, '/thing' do
144
132
  raise "Should never be called"
145
133
  end
146
134
  end
147
-
135
+
148
136
  expect {
149
137
  post '/thing', {number: '123'}
150
138
  }.to raise_error('Received String, expected Integer for :number')
@@ -156,12 +144,10 @@ describe "Apiculture" do
156
144
  value == "Magic word"
157
145
  end
158
146
  end.new
159
-
160
- @app_class = Class.new(Sinatra::Base) do
161
- settings.show_exceptions = false
162
- settings.raise_errors = true
147
+
148
+ @app_class = Class.new(Apiculture::App) do
163
149
  extend Apiculture
164
-
150
+
165
151
  required_param :pretty_please, "Only a magic word will do", custom_matcher
166
152
  api_method :post, '/thing' do
167
153
  'Ohai!'
@@ -177,25 +163,21 @@ describe "Apiculture" do
177
163
  end
178
164
 
179
165
  it 'suppresses parameters that are not defined in the action definition' do
180
- @app_class = Class.new(Sinatra::Base) do
181
- settings.show_exceptions = false
182
- settings.raise_errors = true
166
+ @app_class = Class.new(Apiculture::App) do
183
167
  extend Apiculture
184
-
168
+
185
169
  api_method :post, '/thing' do
186
170
  raise ":evil_ssh_injection should have wiped from params{}" if params[:evil_ssh_injection]
187
171
  'All is well'
188
172
  end
189
173
  end
190
-
174
+
191
175
  post '/thing', {evil_ssh_injection: 'I am Homakov!'}
192
176
  expect(last_response).to be_ok
193
177
  end
194
178
 
195
179
  it 'allows route parameters that are not mentioned in the action definition, but are given in Sinatra path' do
196
- @app_class = Class.new(Sinatra::Base) do
197
- settings.show_exceptions = false
198
- settings.raise_errors = true
180
+ @app_class = Class.new(Apiculture::App) do
199
181
  extend Apiculture
200
182
 
201
183
  api_method :post, '/api-thing/:id_of_thing' do |id|
@@ -214,7 +196,7 @@ describe "Apiculture" do
214
196
  'All is well'
215
197
  end
216
198
  end
217
-
199
+
218
200
  post '/vanilla-thing/123456'
219
201
  expect(last_response).to be_ok
220
202
 
@@ -223,9 +205,7 @@ describe "Apiculture" do
223
205
  end
224
206
 
225
207
  it 'does not clobber the status set in a separate mutating call when using json_response' do
226
- @app_class = Class.new(Sinatra::Base) do
227
- settings.show_exceptions = false
228
- settings.raise_errors = true
208
+ @app_class = Class.new(Apiculture::App) do
229
209
  extend Apiculture
230
210
 
231
211
  api_method :post, '/api/:id' do
@@ -233,27 +213,38 @@ describe "Apiculture" do
233
213
  json_response({was_created: true})
234
214
  end
235
215
  end
236
-
216
+
237
217
  post '/api/123'
238
218
  expect(last_response.status).to eq(201)
239
219
  end
240
-
220
+
241
221
  it 'raises when describing a route parameter that is not included in the path' do
242
222
  expect {
243
- Class.new(Sinatra::Base) do
223
+ Class.new(Apiculture::App) do
244
224
  extend Apiculture
245
225
  route_param :thing_id, "The ID of the thing"
246
226
  api_method(:get, '/thing/:id') { raise "Should never be called" }
247
227
  end
248
228
  }.to raise_error('Parameter :thing_id not present in path "/thing/:id"')
249
229
  end
250
-
230
+
231
+ it 'returns a 404 when a non existing route is called' do
232
+ @app_class = Class.new(Apiculture::App) do
233
+ extend Apiculture
234
+
235
+ api_method :post, '/api' do
236
+ [1]
237
+ end
238
+ end
239
+
240
+ post '/api-404'
241
+ expect(last_response.status).to eq(404)
242
+ end
243
+
251
244
  it 'applies a symbol typecast by calling a method on the parameter value' do
252
- @app_class = Class.new(Sinatra::Base) do
253
- settings.show_exceptions = false
254
- settings.raise_errors = true
245
+ @app_class = Class.new(Apiculture::App) do
255
246
  extend Apiculture
256
-
247
+
257
248
  required_param :number, "Number of the thing", Integer, :cast => :to_i
258
249
  api_method :post, '/thing' do
259
250
  raise "Not cast" unless params[:number] == 123
@@ -265,11 +256,9 @@ describe "Apiculture" do
265
256
  end
266
257
 
267
258
  it 'ensures current behaviour for route params is not changed' do
268
- @app_class = Class.new(Sinatra::Base) do
269
- settings.show_exceptions = false
270
- settings.raise_errors = true
259
+ @app_class = Class.new(Apiculture::App) do
271
260
  extend Apiculture
272
-
261
+
273
262
  route_param :number, "Number of the thing"
274
263
  api_method :post, '/thing/:number' do
275
264
  raise "Casted to int" if params[:number] == 123
@@ -280,12 +269,22 @@ describe "Apiculture" do
280
269
  expect(last_response.body).to eq('Total success')
281
270
  end
282
271
 
272
+ it 'supports returning a rack triplet' do
273
+ @app_class = Class.new(Apiculture::App) do
274
+ extend Apiculture
275
+ api_method :get, '/rack' do
276
+ [402, {'X-Money-In-The-Bank' => 'yes, please'}, ['Buy bitcoin']]
277
+ end
278
+ end
279
+ get '/rack'
280
+ expect(last_response.status).to eq 402
281
+ expect(last_response.body).to eq 'Buy bitcoin'
282
+ end
283
+
283
284
  it 'ensures current behaviour when no route params are present does not change' do
284
- @app_class = Class.new(Sinatra::Base) do
285
- settings.show_exceptions = false
286
- settings.raise_errors = true
285
+ @app_class = Class.new(Apiculture::App) do
287
286
  extend Apiculture
288
-
287
+
289
288
  param :number, "Number of the thing", Integer, cast: :to_i
290
289
  api_method :post, '/thing' do
291
290
  raise "Behaviour changed" unless params[:number] == 123
@@ -297,11 +296,9 @@ describe "Apiculture" do
297
296
  end
298
297
 
299
298
  it 'applies a symbol typecast by calling a method on the route parameter value' do
300
- @app_class = Class.new(Sinatra::Base) do
301
- settings.show_exceptions = false
302
- settings.raise_errors = true
299
+ @app_class = Class.new(Apiculture::App) do
303
300
  extend Apiculture
304
-
301
+
305
302
  route_param :number, "Number of the thing", Integer, :cast => :to_i
306
303
  api_method :post, '/thing/:number' do
307
304
  raise "Not cast" unless params[:number] == 123
@@ -314,28 +311,29 @@ describe "Apiculture" do
314
311
 
315
312
 
316
313
  it 'cast block arguments to the right type', run: true do
317
- @app_class = Class.new(Sinatra::Base) do
318
- settings.show_exceptions = false
319
- settings.raise_errors = true
314
+ @app_class = Class.new(Apiculture::App) do
320
315
  extend Apiculture
321
-
316
+
322
317
  route_param :number, "Number of the thing", Integer, :cast => :to_i
323
318
  api_method :post, '/thing/:number' do |number|
324
- raise "Not cast" unless number.class == Integer
319
+ raise "Not cast" unless number.is_a?(Integer)
325
320
  'Total success'
326
321
  end
327
322
  end
328
323
  post '/thing/123'
329
324
  expect(last_response.body).to eq('Total success')
325
+
326
+ # Double checking that bignums are okay, too
327
+ bignum = 10**30
328
+ post "/thing/#{bignum}"
329
+ expect(last_response.body).to eq('Total success')
330
330
  end
331
331
 
332
-
332
+
333
333
  it 'merges route_params and regular params' do
334
- @app_class = Class.new(Sinatra::Base) do
335
- settings.show_exceptions = false
336
- settings.raise_errors = true
334
+ @app_class = Class.new(Apiculture::App) do
337
335
  extend Apiculture
338
-
336
+
339
337
  param :number, "Number of the thing", Integer, :cast => :to_i
340
338
  route_param :id, "Id of the thingy", Integer, :cast => :to_i
341
339
  route_param :awesome, "Hash of the thingy"
@@ -352,11 +350,9 @@ describe "Apiculture" do
352
350
 
353
351
 
354
352
  it 'applies a Proc typecast by calling the proc (for example - for ISO8601 time)' do
355
- @app_class = Class.new(Sinatra::Base) do
356
- settings.show_exceptions = false
357
- settings.raise_errors = true
353
+ @app_class = Class.new(Apiculture::App) do
358
354
  extend Apiculture
359
-
355
+
360
356
  required_param :when, "When it happened", Time, cast: ->(v){ Time.parse(v) }
361
357
  api_method :post, '/occurrence' do
362
358
  raise "Not cast" unless params[:when].year == 2015
@@ -368,18 +364,16 @@ describe "Apiculture" do
368
364
  expect(last_response.body).to eq('Total success')
369
365
  end
370
366
  end
371
-
367
+
372
368
  context 'Sinatra instance method extensions' do
373
369
  it 'adds support for json_response' do
374
- @app_class = Class.new(Sinatra::Base) do
370
+ @app_class = Class.new(Apiculture::App) do
375
371
  extend Apiculture
376
- settings.show_exceptions = false
377
- settings.raise_errors = true
378
372
  api_method :get, '/some-json' do
379
373
  json_response({foo: 'bar'})
380
374
  end
381
375
  end
382
-
376
+
383
377
  get '/some-json'
384
378
  expect(last_response).to be_ok
385
379
  expect(last_response['Content-Type']).to include('application/json')
@@ -388,24 +382,20 @@ describe "Apiculture" do
388
382
  end
389
383
 
390
384
  it 'adds support for json_response to set http status code', run: true do
391
- @app_class = Class.new(Sinatra::Base) do
385
+ @app_class = Class.new(Apiculture::App) do
392
386
  extend Apiculture
393
- settings.show_exceptions = false
394
- settings.raise_errors = true
395
387
  api_method :post, '/some-json' do
396
388
  json_response({foo: 'bar'}, status: 201)
397
389
  end
398
390
  end
399
-
391
+
400
392
  post '/some-json'
401
393
  expect(last_response.status).to eq(201)
402
394
  end
403
395
 
404
396
  it 'adds support for json_halt' do
405
- @app_class = Class.new(Sinatra::Base) do
397
+ @app_class = Class.new(Apiculture::App) do
406
398
  extend Apiculture
407
- settings.show_exceptions = false
408
- settings.raise_errors = true
409
399
  api_method :get, '/simple-halt' do
410
400
  json_halt "Nein."
411
401
  raise "This should never be called"
@@ -419,13 +409,13 @@ describe "Apiculture" do
419
409
  raise "This should never be called"
420
410
  end
421
411
  end
422
-
412
+
423
413
  get '/simple-halt'
424
414
  expect(last_response.status).to eq(400)
425
415
  expect(last_response['Content-Type']).to include('application/json')
426
416
  parsed_body = JSON.load(last_response.body)
427
417
  expect(parsed_body).to eq({"error"=>"Nein."})
428
-
418
+
429
419
  get '/halt-with-error-payload'
430
420
  expect(last_response.status).to eq(400)
431
421
  expect(last_response['Content-Type']).to include('application/json')
@@ -441,10 +431,8 @@ describe "Apiculture" do
441
431
  end
442
432
  end
443
433
  it 'allows returning an empty body when the status is 204' do
444
- @app_class = Class.new(Sinatra::Base) do
434
+ @app_class = Class.new(Apiculture::App) do
445
435
  extend Apiculture
446
- settings.show_exceptions = false
447
- settings.raise_errors = true
448
436
  api_method :get, '/nil204' do
449
437
  action_result NilTestAction
450
438
  end
@@ -458,10 +446,8 @@ describe "Apiculture" do
458
446
  it "does not allow returning an empty body when the status isn't 204" do
459
447
  # Mock out the perform call so that status doesn't change from the default of 200
460
448
  expect_any_instance_of(NilTestAction).to receive(:perform).with(any_args).and_return(nil)
461
- @app_class = Class.new(Sinatra::Base) do
449
+ @app_class = Class.new(Apiculture::App) do
462
450
  extend Apiculture
463
- settings.show_exceptions = false
464
- settings.raise_errors = true
465
451
  api_method :get, '/nil200' do
466
452
  action_result NilTestAction
467
453
  end
data/spec/spec_helper.rb CHANGED
@@ -5,7 +5,6 @@ require 'rspec'
5
5
  require 'apiculture'
6
6
  require 'rack'
7
7
  require 'rack/test'
8
- require 'sinatra/base'
9
8
 
10
9
  # Requires supporting files with custom matchers and macros, etc,
11
10
  # in ./support/ and its subdirectories.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apiculture
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.19
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -9,10 +9,10 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2017-11-03 00:00:00.000000000 Z
12
+ date: 2018-05-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
- name: sinatra
15
+ name: mustermann
16
16
  requirement: !ruby/object:Gem::Requirement
17
17
  requirements:
18
18
  - - "~>"
@@ -121,14 +121,14 @@ dependencies:
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: '3.12'
124
+ version: '6.0'
125
125
  type: :development
126
126
  prerelease: false
127
127
  version_requirements: !ruby/object:Gem::Requirement
128
128
  requirements:
129
129
  - - "~>"
130
130
  - !ruby/object:Gem::Version
131
- version: '3.12'
131
+ version: '6.0'
132
132
  - !ruby/object:Gem::Dependency
133
133
  name: rake
134
134
  requirement: !ruby/object:Gem::Requirement
@@ -171,7 +171,7 @@ dependencies:
171
171
  - - ">="
172
172
  - !ruby/object:Gem::Version
173
173
  version: '0'
174
- description: A toolkit for building REST APIs on top of Sinatra
174
+ description: A toolkit for building REST APIs on top of Rack
175
175
  email: me@julik.nl
176
176
  executables: []
177
177
  extensions: []
@@ -186,11 +186,15 @@ files:
186
186
  - README.md
187
187
  - Rakefile
188
188
  - apiculture.gemspec
189
+ - gemfiles/Gemfile.rack-1.x
190
+ - gemfiles/Gemfile.rack-2.x
189
191
  - lib/apiculture.rb
190
192
  - lib/apiculture/action.rb
191
193
  - lib/apiculture/action_definition.rb
194
+ - lib/apiculture/app.rb
192
195
  - lib/apiculture/app_documentation.rb
193
196
  - lib/apiculture/app_documentation_tpl.mustache
197
+ - lib/apiculture/indifferent_hash.rb
194
198
  - lib/apiculture/markdown_segment.rb
195
199
  - lib/apiculture/method_documentation.rb
196
200
  - lib/apiculture/sinatra_instance_methods.rb
@@ -222,8 +226,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
226
  version: '0'
223
227
  requirements: []
224
228
  rubyforge_project:
225
- rubygems_version: 2.6.11
229
+ rubygems_version: 2.6.14.1
226
230
  signing_key:
227
231
  specification_version: 4
228
- summary: Sweet API sauce on top of Sintra
232
+ summary: Sweet API sauce on top of Rack
229
233
  test_files: []