apiculture 0.0.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +15 -0
- data/LICENSE.txt +20 -0
- data/README.md +83 -0
- data/Rakefile +45 -0
- data/apiculture.gemspec +90 -0
- data/lib/apiculture.rb +266 -0
- data/lib/apiculture/action.rb +46 -0
- data/lib/apiculture/action_definition.rb +30 -0
- data/lib/apiculture/app_documentation.rb +54 -0
- data/lib/apiculture/app_documentation_tpl.mustache +103 -0
- data/lib/apiculture/markdown_segment.rb +6 -0
- data/lib/apiculture/method_documentation.rb +130 -0
- data/lib/apiculture/sinatra_instance_methods.rb +36 -0
- data/lib/apiculture/timestamp_promise.rb +6 -0
- data/lib/apiculture/version.rb +3 -0
- data/spec/apiculture/action_spec.rb +45 -0
- data/spec/apiculture/app_documentation_spec.rb +113 -0
- data/spec/apiculture/method_documentation_spec.rb +80 -0
- data/spec/apiculture_spec.rb +263 -0
- data/spec/spec_helper.rb +16 -0
- metadata +226 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: cea2109a603040e6f683ded9261b52bc2d03e53a
|
4
|
+
data.tar.gz: 8b99f10724eb4cbd0484ca8ecd85feedf24e3e1f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 90e06f221ad4da20e8ba09a51bde34d62761aad0374a9dd306db8fb3550d52a75fc8c24362277be2a542550de1dbff70443c99a14f998fc4444e292aa785cc5d
|
7
|
+
data.tar.gz: fe654fa36f250506311098ff322d3202f6b67d7d1c390fb8241eaddd7608b104e4e40725814e74ff063b1d87914c2a08ffa74e99ed27e5e029d935deb67bb2ec
|
data/Gemfile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
gem 'sinatra', '~> 1.4', :require => 'sinatra/base'
|
3
|
+
gem 'builder'
|
4
|
+
gem 'rdiscount'
|
5
|
+
gem 'github-markup'
|
6
|
+
gem "mustache"
|
7
|
+
|
8
|
+
group :development do
|
9
|
+
gem 'rack-test'
|
10
|
+
gem "rspec", "~> 3.1", '< 3.2'
|
11
|
+
gem "rdoc", "~> 3.12"
|
12
|
+
gem "bundler", "~> 1.0"
|
13
|
+
gem "jeweler", "~> 2.0.1"
|
14
|
+
gem "simplecov", ">= 0"
|
15
|
+
end
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2015 WeTransfer
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
# apiculture
|
2
|
+
|
3
|
+
A little toolkit for building RESTful API backends on top of Sinatra.
|
4
|
+
|
5
|
+
## Ideas
|
6
|
+
|
7
|
+
A simple API definition DSL with simple premises:
|
8
|
+
|
9
|
+
* Endpoint URLs should be _visible_ in the actual code. The reason for that is with nested
|
10
|
+
blocks you inevitably end up setting up context somewhere far away from the terminal route
|
11
|
+
that ends up using that context.
|
12
|
+
* Explicit allowed/required parameters (both payload/query string and body)
|
13
|
+
* Explicit description in front of the API action definition
|
14
|
+
* Wrap the actual work into Actions, so that the API definition is mostly routes
|
15
|
+
|
16
|
+
## A taste of honey
|
17
|
+
|
18
|
+
class Api::V2 < Sinatra::Base
|
19
|
+
|
20
|
+
use Rack::Parser, :content_types => {
|
21
|
+
'application/json' => JSON.method(:load).to_proc
|
22
|
+
}
|
23
|
+
|
24
|
+
extend Apiculture
|
25
|
+
|
26
|
+
desc 'Create a Contact'
|
27
|
+
required_param :name, 'Name of the person', String
|
28
|
+
param :email, 'Email address of the person', String
|
29
|
+
param :phone, 'Phone number', String, cast: ->(v) { v.scan(/\d/).flatten.join }
|
30
|
+
param :notes, 'Notes about this person', String
|
31
|
+
api_method :post, '/contacts' do
|
32
|
+
# anything allowed within Sinatra actions is allowed here, and
|
33
|
+
# works exactly the same - but we suggest using Actions instead.
|
34
|
+
action_result CreateContact # uses Api::V2::CreateContact
|
35
|
+
end
|
36
|
+
|
37
|
+
desc 'Fetch a Contact'
|
38
|
+
route_param :id, 'ID of the person'
|
39
|
+
responds_with 200, 'Contact data', {name: 'John Appleseed', id: "ac19...fefg"}
|
40
|
+
api_method :get, '/contacts/:id' do | person_id |
|
41
|
+
json Person.find(person_id).to_json
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
## Generating documentation
|
46
|
+
|
47
|
+
For the aforementioned example:
|
48
|
+
|
49
|
+
File.open('API.html', 'w') do |f|
|
50
|
+
f << Api::V2.api_documentation.to_html
|
51
|
+
end
|
52
|
+
|
53
|
+
or to get it in Markdown:
|
54
|
+
|
55
|
+
File.open('API.md', 'w') do |f|
|
56
|
+
f << Api::V2.api_documentation.to_markdown
|
57
|
+
end
|
58
|
+
|
59
|
+
## Running the tests
|
60
|
+
|
61
|
+
$bundle exec rspec
|
62
|
+
|
63
|
+
If you want to also examine the HTML documentation that gets built during the test, set `SHOW_TEST_DOC` in env:
|
64
|
+
|
65
|
+
$SHOW_TEST_DOC=yes bundle exec rspec
|
66
|
+
|
67
|
+
Note that this requires presence of the `open` commandline utility (should be available on both OSX and Linux).
|
68
|
+
|
69
|
+
## Contributing to apiculture
|
70
|
+
|
71
|
+
* Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
|
72
|
+
* Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
|
73
|
+
* Fork the project.
|
74
|
+
* Start a feature/bugfix branch.
|
75
|
+
* Commit and push until you are happy with your contribution.
|
76
|
+
* Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
|
77
|
+
* Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
|
78
|
+
|
79
|
+
## Copyright
|
80
|
+
|
81
|
+
Copyright (c) 2015 WeTransfer. See LICENSE.txt for
|
82
|
+
further details.
|
83
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'bundler'
|
5
|
+
begin
|
6
|
+
Bundler.setup(:default, :development)
|
7
|
+
rescue Bundler::BundlerError => e
|
8
|
+
$stderr.puts e.message
|
9
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
10
|
+
exit e.status_code
|
11
|
+
end
|
12
|
+
require 'rake'
|
13
|
+
|
14
|
+
require_relative 'lib/apiculture/version'
|
15
|
+
require 'jeweler'
|
16
|
+
Jeweler::Tasks.new do |gem|
|
17
|
+
gem.name = "apiculture"
|
18
|
+
gem.version = Apiculture::VERSION
|
19
|
+
gem.homepage = "https://github.com/WeTransfer/apiculture"
|
20
|
+
gem.license = "MIT"
|
21
|
+
gem.description = %Q{A toolkit for building REST APIs on top of Sinatra}
|
22
|
+
gem.summary = %Q{Sweet API sauce on top of Sintra}
|
23
|
+
gem.email = "me@julik.nl"
|
24
|
+
gem.authors = ["Julik Tarkhanov", "WeTransfer"]
|
25
|
+
# dependencies defined in Gemfile
|
26
|
+
end
|
27
|
+
Jeweler::RubygemsDotOrgTasks.new
|
28
|
+
|
29
|
+
require 'rspec/core'
|
30
|
+
require 'rspec/core/rake_task'
|
31
|
+
RSpec::Core::RakeTask.new(:spec) do |spec|
|
32
|
+
spec.pattern = FileList['spec/**/*_spec.rb']
|
33
|
+
end
|
34
|
+
|
35
|
+
task :default => :spec
|
36
|
+
|
37
|
+
require 'rdoc/task'
|
38
|
+
Rake::RDocTask.new do |rdoc|
|
39
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
40
|
+
|
41
|
+
rdoc.rdoc_dir = 'rdoc'
|
42
|
+
rdoc.title = "apiculture #{version}"
|
43
|
+
rdoc.rdoc_files.include('README*')
|
44
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
45
|
+
end
|
data/apiculture.gemspec
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
# stub: apiculture 0.0.12 ruby lib
|
6
|
+
|
7
|
+
Gem::Specification.new do |s|
|
8
|
+
s.name = "apiculture"
|
9
|
+
s.version = "0.0.12"
|
10
|
+
|
11
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
12
|
+
s.require_paths = ["lib"]
|
13
|
+
s.authors = ["Julik Tarkhanov", "WeTransfer"]
|
14
|
+
s.date = "2015-11-16"
|
15
|
+
s.description = "A toolkit for building REST APIs on top of Sinatra"
|
16
|
+
s.email = "me@julik.nl"
|
17
|
+
s.extra_rdoc_files = [
|
18
|
+
"LICENSE.txt",
|
19
|
+
"README.md"
|
20
|
+
]
|
21
|
+
s.files = [
|
22
|
+
"Gemfile",
|
23
|
+
"LICENSE.txt",
|
24
|
+
"README.md",
|
25
|
+
"Rakefile",
|
26
|
+
"apiculture.gemspec",
|
27
|
+
"lib/apiculture.rb",
|
28
|
+
"lib/apiculture/action.rb",
|
29
|
+
"lib/apiculture/action_definition.rb",
|
30
|
+
"lib/apiculture/app_documentation.rb",
|
31
|
+
"lib/apiculture/app_documentation_tpl.mustache",
|
32
|
+
"lib/apiculture/markdown_segment.rb",
|
33
|
+
"lib/apiculture/method_documentation.rb",
|
34
|
+
"lib/apiculture/sinatra_instance_methods.rb",
|
35
|
+
"lib/apiculture/timestamp_promise.rb",
|
36
|
+
"lib/apiculture/version.rb",
|
37
|
+
"spec/apiculture/action_spec.rb",
|
38
|
+
"spec/apiculture/app_documentation_spec.rb",
|
39
|
+
"spec/apiculture/method_documentation_spec.rb",
|
40
|
+
"spec/apiculture_spec.rb",
|
41
|
+
"spec/spec_helper.rb"
|
42
|
+
]
|
43
|
+
s.homepage = "https://github.com/WeTransfer/apiculture"
|
44
|
+
s.licenses = ["MIT"]
|
45
|
+
s.rubygems_version = "2.2.2"
|
46
|
+
s.summary = "Sweet API sauce on top of Sintra"
|
47
|
+
|
48
|
+
if s.respond_to? :specification_version then
|
49
|
+
s.specification_version = 4
|
50
|
+
|
51
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
52
|
+
s.add_runtime_dependency(%q<sinatra>, ["~> 1.4"])
|
53
|
+
s.add_runtime_dependency(%q<builder>, [">= 0"])
|
54
|
+
s.add_runtime_dependency(%q<rdiscount>, [">= 0"])
|
55
|
+
s.add_runtime_dependency(%q<github-markup>, [">= 0"])
|
56
|
+
s.add_runtime_dependency(%q<mustache>, [">= 0"])
|
57
|
+
s.add_development_dependency(%q<rack-test>, [">= 0"])
|
58
|
+
s.add_development_dependency(%q<rspec>, ["< 3.2", "~> 3.1"])
|
59
|
+
s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
|
60
|
+
s.add_development_dependency(%q<bundler>, ["~> 1.0"])
|
61
|
+
s.add_development_dependency(%q<jeweler>, ["~> 2.0.1"])
|
62
|
+
s.add_development_dependency(%q<simplecov>, [">= 0"])
|
63
|
+
else
|
64
|
+
s.add_dependency(%q<sinatra>, ["~> 1.4"])
|
65
|
+
s.add_dependency(%q<builder>, [">= 0"])
|
66
|
+
s.add_dependency(%q<rdiscount>, [">= 0"])
|
67
|
+
s.add_dependency(%q<github-markup>, [">= 0"])
|
68
|
+
s.add_dependency(%q<mustache>, [">= 0"])
|
69
|
+
s.add_dependency(%q<rack-test>, [">= 0"])
|
70
|
+
s.add_dependency(%q<rspec>, ["< 3.2", "~> 3.1"])
|
71
|
+
s.add_dependency(%q<rdoc>, ["~> 3.12"])
|
72
|
+
s.add_dependency(%q<bundler>, ["~> 1.0"])
|
73
|
+
s.add_dependency(%q<jeweler>, ["~> 2.0.1"])
|
74
|
+
s.add_dependency(%q<simplecov>, [">= 0"])
|
75
|
+
end
|
76
|
+
else
|
77
|
+
s.add_dependency(%q<sinatra>, ["~> 1.4"])
|
78
|
+
s.add_dependency(%q<builder>, [">= 0"])
|
79
|
+
s.add_dependency(%q<rdiscount>, [">= 0"])
|
80
|
+
s.add_dependency(%q<github-markup>, [">= 0"])
|
81
|
+
s.add_dependency(%q<mustache>, [">= 0"])
|
82
|
+
s.add_dependency(%q<rack-test>, [">= 0"])
|
83
|
+
s.add_dependency(%q<rspec>, ["< 3.2", "~> 3.1"])
|
84
|
+
s.add_dependency(%q<rdoc>, ["~> 3.12"])
|
85
|
+
s.add_dependency(%q<bundler>, ["~> 1.0"])
|
86
|
+
s.add_dependency(%q<jeweler>, ["~> 2.0.1"])
|
87
|
+
s.add_dependency(%q<simplecov>, [">= 0"])
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
data/lib/apiculture.rb
ADDED
@@ -0,0 +1,266 @@
|
|
1
|
+
# Allows brief definitions of APIs for documentation and parameter checks
|
2
|
+
module Apiculture
|
3
|
+
require_relative 'apiculture/version'
|
4
|
+
require_relative 'apiculture/action'
|
5
|
+
require_relative 'apiculture/sinatra_instance_methods'
|
6
|
+
require_relative 'apiculture/action_definition'
|
7
|
+
require_relative 'apiculture/markdown_segment'
|
8
|
+
require_relative 'apiculture/timestamp_promise'
|
9
|
+
|
10
|
+
def self.extended(in_class)
|
11
|
+
in_class.send(:include, SinatraInstanceMethods)
|
12
|
+
super
|
13
|
+
end
|
14
|
+
|
15
|
+
IDENTITY_PROC = ->(arg) { arg }
|
16
|
+
|
17
|
+
AC_APPLY_TYPECAST_PROC = ->(cast_proc_or_method, v) {
|
18
|
+
cast_proc_or_method.is_a?(Symbol) ? v.public_send(cast_proc_or_method) : cast_proc_or_method.call(v)
|
19
|
+
}
|
20
|
+
|
21
|
+
AC_CHECK_PRESENCE_PROC = ->(name_as_string, params) {
|
22
|
+
params.has_key?(name_as_string) or raise MissingParameter.new(name_as_string)
|
23
|
+
}
|
24
|
+
|
25
|
+
AC_CHECK_TYPE_PROC = ->(param, value) {
|
26
|
+
value.is_a?(param.ruby_type) or raise ParameterTypeMismatch.new(param, value.class)
|
27
|
+
}
|
28
|
+
|
29
|
+
AC_PERMIT_PROC = ->(maybe_strong_params, param_name) {
|
30
|
+
maybe_strong_params.permit(param_name) if maybe_strong_params.respond_to?(:permit)
|
31
|
+
}
|
32
|
+
|
33
|
+
class Parameter < Struct.new(:name, :description, :required, :ruby_type, :cast_proc_or_method)
|
34
|
+
# Return Strings since Sinatra prefers string keys for params{}
|
35
|
+
def name_as_string; name.to_s; end
|
36
|
+
end
|
37
|
+
|
38
|
+
class RouteParameter < Struct.new(:name, :description)
|
39
|
+
def name_as_string; name.to_s; end
|
40
|
+
end
|
41
|
+
|
42
|
+
class PossibleResponse < Struct.new(:http_status_code, :description, :jsonable_object_example)
|
43
|
+
def no_body?
|
44
|
+
jsonable_object_example.nil?
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Indicates where this API will be mounted. This is only used
|
49
|
+
# for the generated documentation. In general, this should match
|
50
|
+
# the SCRIPT_NAME of the Sinatra application when it will be called.
|
51
|
+
# For example, if you use this in your +config.ru+:
|
52
|
+
#
|
53
|
+
# map('/api/v3') { run MyApi }
|
54
|
+
#
|
55
|
+
# then it is handy to set that with +mounted_at+ as well so that the API
|
56
|
+
# documentation references the mountpoint:
|
57
|
+
#
|
58
|
+
# mounted_at '/api/v3'
|
59
|
+
#
|
60
|
+
# Again: this does not change the way requests are handled in any way,
|
61
|
+
# it just alters the documentation output.
|
62
|
+
def mounted_at(path)
|
63
|
+
@apiculture_mounted_at = path.to_s.gsub(/\/$/, '')
|
64
|
+
end
|
65
|
+
|
66
|
+
# Inserts the generation timestamp into the documentation at this point.
|
67
|
+
# The timestamp will be not very precise (to the minute) and in UTC time
|
68
|
+
def documentation_build_time!
|
69
|
+
apiculture_stack << Apiculture::TimestampPromise
|
70
|
+
end
|
71
|
+
|
72
|
+
# Inserts a literal Markdown string into the documentation at this point.
|
73
|
+
# For instance, if used after an API method declaration, it will insert
|
74
|
+
# the header between the API methods in the doc.
|
75
|
+
#
|
76
|
+
# api_method :get, '/foo/bar' do
|
77
|
+
# #...
|
78
|
+
# end
|
79
|
+
# markdown_string "# Subsequent methods do thing to Bars"
|
80
|
+
# api_method :get, '/bar/thing' do
|
81
|
+
# #...
|
82
|
+
# end
|
83
|
+
def markdown_string(str)
|
84
|
+
apiculture_stack << MarkdownSegment.new(str)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Inserts the contents of the file at +path+ into the documentation, using +markdown_string+.
|
88
|
+
# For instance, if used after an API method declaration, it will insert
|
89
|
+
# the header between the API methods in the doc.
|
90
|
+
#
|
91
|
+
# markdown_file "SECURITY_CONSIDERATIONS.md"
|
92
|
+
# api_method :get, '/bar/thing' do
|
93
|
+
# #...
|
94
|
+
# end
|
95
|
+
def markdown_file(path_to_markdown)
|
96
|
+
md = File.read(path_to_markdown).encode(Encoding::UTF_8)
|
97
|
+
markdown_string(md)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Describe the API method that is going to be defined
|
101
|
+
def desc(action_description)
|
102
|
+
@apiculture_action_definition ||= ActionDefinition.new
|
103
|
+
@apiculture_action_definition.description = action_description.to_s
|
104
|
+
end
|
105
|
+
|
106
|
+
# Add an optional parameter for the API call
|
107
|
+
def param(name, description, ruby_type, cast: IDENTITY_PROC)
|
108
|
+
@apiculture_action_definition ||= ActionDefinition.new
|
109
|
+
@apiculture_action_definition.parameters << Parameter.new(name, description, required=false, ruby_type, cast)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Add a requred parameter for the API call
|
113
|
+
def required_param(name, description, ruby_type, cast: IDENTITY_PROC)
|
114
|
+
@apiculture_action_definition ||= ActionDefinition.new
|
115
|
+
@apiculture_action_definition.parameters << Parameter.new(name, description, required=true, ruby_type, cast)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Describe a parameter that has to be included in the URL of the API call.
|
119
|
+
# Route parameters are always required, and all the parameters specified
|
120
|
+
# using +route_param+ should also be included in the path given for the route
|
121
|
+
# definition
|
122
|
+
def route_param(name, description)
|
123
|
+
@apiculture_action_definition ||= ActionDefinition.new
|
124
|
+
@apiculture_action_definition.route_parameters << RouteParameter.new(name, description)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Add a possible response, specifying the code and the JSON Response by example.
|
128
|
+
# Multiple response packages can be specified.
|
129
|
+
def responds_with(http_status, description, example_jsonable_object = nil)
|
130
|
+
@apiculture_action_definition ||= ActionDefinition.new
|
131
|
+
@apiculture_action_definition.responses << PossibleResponse.new(http_status, description, example_jsonable_object)
|
132
|
+
end
|
133
|
+
|
134
|
+
DefinitionError = Class.new(StandardError)
|
135
|
+
ValidationError = Class.new(StandardError)
|
136
|
+
|
137
|
+
class RouteParameterNotInPath < DefinitionError; end
|
138
|
+
class ReservedParameter < DefinitionError; end
|
139
|
+
class ConflictingParameter < DefinitionError; end
|
140
|
+
|
141
|
+
# Gets raised when a parameter is missing
|
142
|
+
class MissingParameter < ValidationError
|
143
|
+
def initialize(parameter_name)
|
144
|
+
super "Missing parameter :#{parameter_name}"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Gets raised when a parameter is supplied and has a wrong type
|
149
|
+
class ParameterTypeMismatch < ValidationError
|
150
|
+
def initialize(ac_parameter, received_ruby_type)
|
151
|
+
parameter_name, expected_type = ac_parameter.name, ac_parameter.ruby_type
|
152
|
+
received_type = received_ruby_type
|
153
|
+
super "Received #{received_type}, expected #{expected_type} for :#{parameter_name}"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Returns a Proc that calls the strong parameters to check the presence/types
|
158
|
+
def parametric_validator_proc_from(parametric_validators)
|
159
|
+
required_params = parametric_validators.select{|e| e.required }
|
160
|
+
# Return a lambda that will be called with the Sinatra params
|
161
|
+
parametric_validation_blk = ->{
|
162
|
+
# Within this block +params+ is the Sinatra's instance params
|
163
|
+
# Ensure the required parameters are present first, before applying casts/validations etc.
|
164
|
+
required_params.each { |param| AC_CHECK_PRESENCE_PROC.call(param.name_as_string, params) }
|
165
|
+
parametric_validators.each do |param|
|
166
|
+
param_name = param.name_as_string
|
167
|
+
next unless params.has_key?(param_name) # this is checked via required_params
|
168
|
+
|
169
|
+
# Apply the type cast and save it (since using our override we can mutate the params)
|
170
|
+
value_after_type_cast = AC_APPLY_TYPECAST_PROC.call(param.cast_proc_or_method, params[param_name])
|
171
|
+
params[param_name] = value_after_type_cast
|
172
|
+
|
173
|
+
# Ensure the typecast value adheres to the enforced Ruby type
|
174
|
+
AC_CHECK_TYPE_PROC.call(param, params[param_name])
|
175
|
+
# ..permit it in the strong parameters if we support them
|
176
|
+
AC_PERMIT_PROC.call(params, param_name)
|
177
|
+
end
|
178
|
+
|
179
|
+
# The following only applies if the app does not use strong_parameters -
|
180
|
+
# this makes use of parameter mutability again to kill the parameters that are not permitted
|
181
|
+
# or mentioned in the API specification
|
182
|
+
unexpected_parameters = params.keys.map(&:to_s) - parametric_validators.map(&:name).map(&:to_s)
|
183
|
+
unexpected_parameters.each do | parameter_to_discard |
|
184
|
+
# TODO: raise or record a warning
|
185
|
+
if env['rack.logger'].respond_to?(:warn)
|
186
|
+
env['rack.logger'].warn "Discarding disallowed parameter #{parameter_to_discard.inspect}"
|
187
|
+
end
|
188
|
+
params.delete(parameter_to_discard)
|
189
|
+
end
|
190
|
+
}
|
191
|
+
end
|
192
|
+
|
193
|
+
# Serve the documentation for the API at the given URL
|
194
|
+
def serve_api_documentation_at(url)
|
195
|
+
get(url) do
|
196
|
+
content_type :html
|
197
|
+
self.class.api_documentation.to_html
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Returns an +AppDocumentation+ object for all actions defined so far.
|
202
|
+
#
|
203
|
+
# MyApi.api_documentation.to_markdown #=> "..."
|
204
|
+
# MyApi.api_documentation.to_html #=> "..."
|
205
|
+
def api_documentation
|
206
|
+
require_relative 'apiculture/app_documentation'
|
207
|
+
AppDocumentation.new(self, @apiculture_mounted_at.to_s, @apiculture_actions_and_docs || [])
|
208
|
+
end
|
209
|
+
|
210
|
+
# Define an API method. Under the hood will call the related methods in Sinatra
|
211
|
+
# to define the route.
|
212
|
+
def api_method(http_verb, path, options={}, &blk)
|
213
|
+
action_def = (@apiculture_action_definition || ActionDefinition.new)
|
214
|
+
action_def.http_verb = http_verb
|
215
|
+
action_def.path = path
|
216
|
+
|
217
|
+
# Ensure no reserved Sinatra parameters are used
|
218
|
+
all_parameter_names = action_def.all_parameter_names_as_strings
|
219
|
+
%w( splat captures ).each do | reserved_param |
|
220
|
+
if all_parameter_names.include?(reserved_param)
|
221
|
+
raise ReservedParameter.new(":#{reserved_param} is a reserved magic parameter name in Sinatra")
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
# Ensure no conflations between route/req params
|
226
|
+
seen_params = {}
|
227
|
+
all_parameter_names.each do |e|
|
228
|
+
if seen_params[e]
|
229
|
+
raise ConflictingParameter.new(":#{e} mentioned twice as a possible parameter. Note that URL" +
|
230
|
+
" parameters and request parameters share a namespace.")
|
231
|
+
else
|
232
|
+
seen_params[e] = true
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# Ensure the path has the route parameters that were predeclared
|
237
|
+
action_def.route_parameters.map(&:name).each do | route_parameter_key |
|
238
|
+
unless path.include?(':%s' % route_parameter_key)
|
239
|
+
raise RouteParameterNotInPath.new("Parameter :#{route_parameter_key} not present in path #{path.inspect}")
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
# TODO: ensure all route parameters are documented
|
244
|
+
|
245
|
+
# Pick out all the defined parameters and set up a block that can validate them
|
246
|
+
# when the action is called. With that, set up the actual Sinatra method that will
|
247
|
+
# respond to the request.
|
248
|
+
parametric_checker_proc = parametric_validator_proc_from(action_def.parameters)
|
249
|
+
public_send(http_verb, path, options) do |*matched_sinatra_route_params|
|
250
|
+
# Verify the parameters first
|
251
|
+
instance_exec(¶metric_checker_proc)
|
252
|
+
# Execute the original action via instance_exec, passing along the route args
|
253
|
+
instance_exec(*matched_sinatra_route_params, &blk)
|
254
|
+
end
|
255
|
+
|
256
|
+
# Reset for the subsequent action definition
|
257
|
+
@apiculture_action_definition = ActionDefinition.new
|
258
|
+
# and store the just defined action for future use
|
259
|
+
apiculture_stack << action_def
|
260
|
+
end
|
261
|
+
|
262
|
+
def apiculture_stack
|
263
|
+
@apiculture_actions_and_docs ||= []
|
264
|
+
@apiculture_actions_and_docs
|
265
|
+
end
|
266
|
+
end
|