xenon 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +18 -0
  3. data/.gitignore +2 -0
  4. data/.travis.yml +6 -0
  5. data/Gemfile +10 -0
  6. data/Guardfile +0 -32
  7. data/README.md +59 -5
  8. data/examples/hello_world/config.ru +3 -0
  9. data/examples/hello_world/hello_world.rb +17 -0
  10. data/lib/xenon.rb +62 -49
  11. data/lib/xenon/auth.rb +63 -0
  12. data/lib/xenon/errors.rb +1 -0
  13. data/lib/xenon/etag.rb +48 -0
  14. data/lib/xenon/headers.rb +2 -4
  15. data/lib/xenon/headers/accept.rb +3 -2
  16. data/lib/xenon/headers/accept_charset.rb +5 -5
  17. data/lib/xenon/headers/accept_encoding.rb +5 -5
  18. data/lib/xenon/headers/accept_language.rb +5 -5
  19. data/lib/xenon/headers/authorization.rb +7 -53
  20. data/lib/xenon/headers/cache_control.rb +3 -3
  21. data/lib/xenon/headers/content_type.rb +1 -1
  22. data/lib/xenon/headers/if_match.rb +53 -0
  23. data/lib/xenon/headers/if_modified_since.rb +22 -0
  24. data/lib/xenon/headers/if_none_match.rb +53 -0
  25. data/lib/xenon/headers/if_range.rb +45 -0
  26. data/lib/xenon/headers/if_unmodified_since.rb +22 -0
  27. data/lib/xenon/headers/user_agent.rb +65 -0
  28. data/lib/xenon/headers/www_authenticate.rb +70 -0
  29. data/lib/xenon/media_type.rb +24 -2
  30. data/lib/xenon/parsers/basic_rules.rb +38 -7
  31. data/lib/xenon/parsers/header_rules.rb +49 -3
  32. data/lib/xenon/parsers/media_type.rb +4 -3
  33. data/lib/xenon/quoted_string.rb +11 -1
  34. data/lib/xenon/routing/directives.rb +14 -0
  35. data/lib/xenon/routing/header_directives.rb +32 -0
  36. data/lib/xenon/routing/method_directives.rb +26 -0
  37. data/lib/xenon/routing/param_directives.rb +22 -0
  38. data/lib/xenon/routing/path_directives.rb +37 -0
  39. data/lib/xenon/routing/route_directives.rb +51 -0
  40. data/lib/xenon/routing/security_directives.rb +20 -0
  41. data/lib/xenon/version.rb +1 -1
  42. data/spec/spec_helper.rb +3 -0
  43. data/spec/xenon/etag_spec.rb +19 -0
  44. data/spec/xenon/headers/if_match_spec.rb +73 -0
  45. data/spec/xenon/headers/if_modified_since_spec.rb +19 -0
  46. data/spec/xenon/headers/if_none_match_spec.rb +79 -0
  47. data/spec/xenon/headers/if_range_spec.rb +45 -0
  48. data/spec/xenon/headers/if_unmodified_since_spec.rb +19 -0
  49. data/spec/xenon/headers/user_agent_spec.rb +67 -0
  50. data/spec/xenon/headers/www_authenticate_spec.rb +43 -0
  51. data/xenon.gemspec +4 -3
  52. metadata +60 -10
  53. data/lib/xenon/routing.rb +0 -133
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f05aa3e37aef60ceb5222b097b411a15d3c38874
4
- data.tar.gz: 2b1c1226260313cc76c9a2768c3c761038adeedb
3
+ metadata.gz: c82b7b50f76ea05e462ab4969825863e27e1f51a
4
+ data.tar.gz: 033692599c1325d96064ee10d72ff2302b51db5f
5
5
  SHA512:
6
- metadata.gz: b19aaf5f2e92fe435ed3ca6723af7484aa410578684a0fb0b6ba908ad442d9d866159912afc087eb03ddfd112a02013bca5a73c99c9d7549dfc3ad9203fb737a
7
- data.tar.gz: 74594a32ddfce37b50224584a2b5a13dc869a8a5a7ffa87c50e20a0af79c1ea38cafa9b8f332e2c55e3971e752d7e62b7ddade445990e8b8340e72fd36e23c19
6
+ metadata.gz: 55964419254b71d4c37032fc6c51fb0bda9ac770b04353b0b0f6e79428b372ce723504a33b0331380440398586ba1f647171e142188d34557e144b8632409689
7
+ data.tar.gz: 1ff9d197b45558a4dc20ad0f5dbd8ce037ea4301384149d66ebc29d80787c05f3b4be3b5dde9ea9e2eb110af18af0834423a9edc0f3f04cbd4d0dc3d89bae391
@@ -0,0 +1,18 @@
1
+ # For more details, see here:
2
+ # http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform
3
+
4
+ # For a list of all available engines, see here:
5
+ # http://docs.codeclimate.com/article/296-engines-available-engines
6
+
7
+ engines:
8
+ rubocop:
9
+ enabled: true
10
+
11
+ ratings:
12
+ paths:
13
+ - lib/**/*
14
+
15
+ exclude_paths:
16
+ - examples/**/*
17
+ - spec/**/*
18
+ - vendor/**/*
data/.gitignore CHANGED
@@ -21,3 +21,5 @@ Gemfile.lock
21
21
  ## Environment normalisation:
22
22
  /.bundle/
23
23
  /lib/bundler/man/
24
+ .ruby-gemset
25
+ .ruby-version
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.3
4
+ before_install:
5
+ - gem install bundler
6
+ cache: bundler
data/Gemfile CHANGED
@@ -1,3 +1,13 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
+
5
+ group :development, :test do
6
+ gem 'guard', require: false
7
+ gem 'guard-rspec', require: false
8
+ gem 'yard', require: false
9
+ end
10
+
11
+ group :test do
12
+ gem "codeclimate-test-reporter", require: false
13
+ end
data/Guardfile CHANGED
@@ -1,37 +1,5 @@
1
- # A sample Guardfile
2
- # More info at https://github.com/guard/guard#readme
3
-
4
- ## Uncomment and set this to only include directories you want to watch
5
- # directories %w(app lib config test spec features)
6
-
7
- ## Uncomment to clear the screen before every task
8
1
  clearing :on
9
2
 
10
- ## Guard internally checks for changes in the Guardfile and exits.
11
- ## If you want Guard to automatically start up again, run guard in a
12
- ## shell loop, e.g.:
13
- ##
14
- ## $ while bundle exec guard; do echo "Restarting Guard..."; done
15
- ##
16
- ## Note: if you are using the `directories` clause above and you are not
17
- ## watching the project directory ('.'), then you will want to move
18
- ## the Guardfile to a watched dir and symlink it back, e.g.
19
- #
20
- # $ mkdir config
21
- # $ mv Guardfile config/
22
- # $ ln -s config/Guardfile .
23
- #
24
- # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
25
-
26
- # Note: The cmd option is now required due to the increasing number of ways
27
- # rspec may be run, below are examples of the most common uses.
28
- # * bundler: 'bundle exec rspec'
29
- # * bundler binstubs: 'bin/rspec'
30
- # * spring: 'bin/rspec' (This will use spring if running and you have
31
- # installed the spring binstubs per the docs)
32
- # * zeus: 'zeus rspec' (requires the server to be started separately)
33
- # * 'just' rspec: 'rspec'
34
-
35
3
  guard :rspec, cmd: "bundle exec rspec" do
36
4
  require "guard/rspec/dsl"
37
5
  dsl = Guard::RSpec::Dsl.new(self)
data/README.md CHANGED
@@ -1,7 +1,55 @@
1
1
  # Xenon
2
2
 
3
+ [![Gem Version][fury-badge]][fury] [![Build Status][travis-badge]][travis] [![Code Climate][cc-badge]][cc] [![Test Coverage][ccc-badge]][ccc] [![YARD Docs][docs-badge]][docs]
4
+
3
5
  An HTTP framework for building RESTful APIs, inspired by [Spray][spray].
4
6
 
7
+ At the moment I probably wouldn't use this gem for anything you actually depend on because it's _very_ early in its lifecycle. However, this is a flavour of what's in here.
8
+
9
+ ## HTTP Model
10
+
11
+ A set of model objects for the HTTP protocol which can parse and format the strings you typically get into proper objects you can work with. At the moment this covers key things like media types and the most common headers, but it will expand to cover the whole protocol. You can use the model by itself without any other parts of the library.
12
+
13
+ This is how things tend to look:
14
+
15
+ ```ruby
16
+ accept = Xenon::Headers::Accept.parse('application/json, application/*; q=0.5')
17
+ accept.media_ranges.first.media_type.json? #=> true
18
+ accept.media_ranges.last.q #=> 0.5
19
+ # etc.
20
+ ```
21
+
22
+ ## Routing
23
+
24
+ A tree-based routing approach based on Spray, giving you great flexibility in building APIs and without the need to write extensions, helpers, etc. because everything is a directive and you extend it by simply writing directives! This is highly unstable and in flux at the moment.
25
+
26
+ This is the kind of syntax I'm aiming for which _sort of_ works, but needs a load of changes to allow composition so what's there now is really just a proof of concept of the basic syntax rather than anything close to useful.
27
+
28
+ ```ruby
29
+ path_prefix 'users' do
30
+ path_end do
31
+ get do
32
+ complete 200, User.all
33
+ end
34
+ post do
35
+ body as: User do |user|
36
+ user.save!
37
+ respond_with_header 'Location' => "/users/#{user.id}" do
38
+ complete 201, user
39
+ end
40
+ end
41
+ end
42
+ end
43
+ path /[0-9]+/ do |user_id|
44
+ get do
45
+ complete 200, User.get_by_id(user_id)
46
+ end
47
+ end
48
+ end
49
+ ```
50
+
51
+ Of course, it'll do all the things you'd expect like support content negotiation properly and return the correct status codes when paths or methods aren't found.
52
+
5
53
  ## Installation
6
54
 
7
55
  Add this line to your application's Gemfile:
@@ -16,17 +64,23 @@ Or install it yourself as:
16
64
 
17
65
  $ gem install xenon
18
66
 
19
- ## Usage
20
-
21
- At the moment I probably wouldn't use this gem for anything you actually depend on because it's _very_ early in its lifecycle. However, feel free to have a play around, raise bugs, and contribute if you're interested.
22
-
23
67
  ## Contributing
24
68
 
25
- 1. Fork it ( https://github.com/[my-github-username]/xenon/fork )
69
+ 1. Fork it ( https://github.com/gregbeech/xenon/fork )
26
70
  2. Create your feature branch (`git checkout -b my-new-feature`)
27
71
  3. Commit your changes (`git commit -am 'Add some feature'`)
28
72
  4. Push to the branch (`git push origin my-new-feature`)
29
73
  5. Create a new Pull Request
30
74
 
31
75
 
76
+ [fury]: http://badge.fury.io/rb/xenon "Xenon at Rubygems"
77
+ [fury-badge]: https://badge.fury.io/rb/xenon.svg "Gem Version"
78
+ [travis]: https://travis-ci.org/gregbeech/xenon "Xenon at Travis CI"
79
+ [travis-badge]: https://travis-ci.org/gregbeech/xenon.svg "Build Status"
80
+ [cc]: https://codeclimate.com/github/gregbeech/xenon "Xenon Quality at Code Climate"
81
+ [cc-badge]: https://codeclimate.com/github/gregbeech/xenon/badges/gpa.svg "Code Quality"
82
+ [ccc]: https://codeclimate.com/github/gregbeech/xenon/coverage "Xenon Coverage at Code Climate"
83
+ [ccc-badge]: https://codeclimate.com/github/gregbeech/xenon/badges/coverage.svg "Code Coverage"
84
+ [docs]: http://www.rubydoc.info/github/gregbeech/xenon "YARD Docs"
85
+ [docs-badge]: http://img.shields.io/badge/yard-docs-blue.svg "YARD Docs"
32
86
  [spray]: http://spray.io/ "spray"
@@ -0,0 +1,3 @@
1
+ require_relative 'hello_world'
2
+
3
+ run HelloWorld.new
@@ -0,0 +1,17 @@
1
+ require 'xenon'
2
+
3
+ class HelloWorld < Xenon::API
4
+ authenticator = Xenon::BasicAuth.new realm: 'hello world' do |credentials|
5
+ credentials.username # should actually auth here!
6
+ end
7
+
8
+ path '/' do
9
+ get do
10
+ authenticate(authenticator) do |user|
11
+ params :greeting do |greeting|
12
+ complete :ok, { greeting => user }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -2,16 +2,12 @@ require 'json'
2
2
  require 'rack'
3
3
  require 'active_support/core_ext/string'
4
4
  require 'xenon/headers'
5
- require 'xenon/routing'
5
+ require 'xenon/routing/directives'
6
6
  require 'xenon/version'
7
7
 
8
8
  module Xenon
9
9
 
10
10
  class Rejection
11
- ACCEPT = 'ACCEPT'
12
- HEADER = 'HEADER'
13
- METHOD = 'METHOD'
14
-
15
11
  attr_reader :reason, :info
16
12
 
17
13
  def initialize(reason, info = {})
@@ -29,8 +25,7 @@ module Xenon
29
25
 
30
26
  def initialize(rack_req)
31
27
  @rack_req = rack_req
32
- @unmatched_path = rack_req.path
33
- freeze
28
+ @unmatched_path = rack_req.path.freeze
34
29
  end
35
30
 
36
31
  def request_method
@@ -38,11 +33,12 @@ module Xenon
38
33
  end
39
34
 
40
35
  def form_hash
41
- @rack_req.POST
36
+ @form_hash ||= @rack_req.POST.with_indifferent_access.freeze
42
37
  end
43
38
 
44
- def query_hash
45
- @rack_req.GET
39
+ def param_hash
40
+ puts "GET = #{@rack_req.GET.inspect}"
41
+ @param_hash ||= @rack_req.GET.with_indifferent_access.freeze
46
42
  end
47
43
 
48
44
  def header(name)
@@ -56,13 +52,8 @@ module Xenon
56
52
 
57
53
  def copy(changes = {})
58
54
  r = dup
59
- changes.each { |k, v| r.instance_variable_set("@#{k}", v) }
60
- r.freeze
61
- end
62
-
63
- def freeze
64
- @unmatched_path.freeze
65
- super
55
+ changes.each { |k, v| r.instance_variable_set("@#{k}", v.freeze) }
56
+ r
66
57
  end
67
58
  end
68
59
 
@@ -90,14 +81,6 @@ module Xenon
90
81
  @body.freeze
91
82
  super
92
83
  end
93
-
94
- def self.error(status, developer_message = nil)
95
- body = {
96
- status: status,
97
- developer_message: developer_message || Rack::Utils::HTTP_STATUS_CODES[status]
98
- }
99
- Response.new.copy(complete: true, status: status, body: body)
100
- end
101
84
  end
102
85
 
103
86
  class Context
@@ -156,7 +139,7 @@ module Xenon
156
139
  end
157
140
  end
158
141
 
159
- class Api
142
+ class API
160
143
  include Xenon::Routing::Directives
161
144
 
162
145
  DEFAULT_MARSHALLERS = [JsonMarshaller.new]
@@ -178,8 +161,18 @@ module Xenon
178
161
 
179
162
  attr_reader :context
180
163
 
181
- def route
182
- raise NotImplementedError.new
164
+ class << self
165
+ def routes
166
+ @routes ||= []
167
+ end
168
+
169
+ def method_missing(name, *args, &block)
170
+ if instance_methods.include?(name)
171
+ routes << [name, args, block]
172
+ else
173
+ super
174
+ end
175
+ end
183
176
  end
184
177
 
185
178
  def call(env)
@@ -191,17 +184,23 @@ module Xenon
191
184
 
192
185
  accept = @context.request.header('Accept')
193
186
  marshaller = accept ? self.class.select_marshaller(accept.media_ranges) : self.class.marshallers.first
194
- begin
195
- if marshaller.nil?
196
- @context.rejections << Rejection.new(Rejection::ACCEPT, { supported: self.class.marshallers.map(&:media_type) })
197
- else
198
- catch (:complete) { route }
187
+
188
+ catch (:complete) do
189
+ begin
190
+ if marshaller.nil?
191
+ @context.rejections << Rejection.new(:accept, { supported: self.class.marshallers.map(&:media_type) })
192
+ else
193
+ self.class.routes.each do |route|
194
+ name, args, block = route
195
+ route_block = proc { instance_eval(&block) }
196
+ send(name, *args, &route_block)
197
+ end
198
+ end
199
+ handle_rejections(@context.rejections)
200
+ rescue => e
201
+ handle_error(e)
199
202
  end
200
- @context.response = handle_rejections(@context.rejections) unless @context.response.complete?
201
- rescue => e
202
- @context.response = handle_error(e)
203
203
  end
204
- @context.response = Response.error(501, 'The response was not completed') unless @context.response.complete?
205
204
 
206
205
  marshaller ||= self.class.marshallers.first
207
206
  resp = @context.response.copy(
@@ -211,26 +210,40 @@ module Xenon
211
210
  end
212
211
 
213
212
  def handle_error(e)
214
- puts "handle_error: #{e}"
215
- @context.response = Response.error(500)
213
+ puts "handle_error: #{e.class}: #{e}\n #{e.backtrace.join("\n ")}"
214
+ case e
215
+ when ParseError
216
+ fail 400, e.message
217
+ else
218
+ fail 500, e.message # TODO: Only if verbose errors configured
219
+ end
216
220
  end
217
221
 
218
222
  def handle_rejections(rejections)
219
223
  puts "handle_rejections: #{rejections}"
220
224
  if rejections.empty?
221
- Response.error(404)
225
+ fail 404
222
226
  else
223
227
  rejection = rejections.first
224
228
  case rejection.reason
225
- when Rejection::ACCEPT
226
- Response.error(406, "Supported media types: #{rejection[:supported].join(", ")}")
227
- when Rejection::HEADER
228
- Response.error(400, "Missing required header: #{rejection[:required]}")
229
- when Rejection::METHOD
230
- supported = rejections.take_while { |r| r.reason == Rejection::METHOD }.map { |r| r[:supported].upcase }
231
- Response.error(405, "Supported methods: #{supported.join(", ")}")
232
- else
233
- Response.error(500)
229
+ when :accept
230
+ fail 406, "Supported media types: #{rejection[:supported].join(", ")}"
231
+ when :header
232
+ fail 400, "Missing required header: #{rejection[:required]}"
233
+ when :method
234
+ supported = rejections.take_while { |r| r.reason == :method }.map { |r| r[:supported].upcase }
235
+ fail 405, "Supported methods: #{supported.join(", ")}"
236
+ when :unauthorized
237
+ if rejection[:scheme]
238
+ challenge = Headers::Challenge.new(rejection[:scheme], rejection.info.except(:scheme))
239
+ respond_with_header Headers::WWWAuthenticate.new(challenge) do
240
+ fail 401
241
+ end
242
+ else
243
+ fail 401
244
+ end
245
+ else
246
+ fail 500
234
247
  end
235
248
  end
236
249
  end
@@ -0,0 +1,63 @@
1
+ require 'xenon/quoted_string'
2
+
3
+ module Xenon
4
+ class BasicCredentials
5
+ attr_reader :username, :password
6
+
7
+ def initialize(username, password)
8
+ @username = username
9
+ @password = password
10
+ end
11
+
12
+ def token
13
+ Base64.strict_encode64("#{@username}:#{@password}")
14
+ end
15
+
16
+ def self.decode(s)
17
+ str = Base64.strict_decode64(s)
18
+ username, password = str.split(':', 2)
19
+ BasicCredentials.new(username, password)
20
+ end
21
+
22
+ def to_s
23
+ "Basic #{token}"
24
+ end
25
+ end
26
+
27
+ class GenericCredentials
28
+ using QuotedString
29
+
30
+ attr_reader :scheme, :token, :params
31
+
32
+ def initialize(scheme, token: nil, params: {})
33
+ @scheme = scheme
34
+ @token = token
35
+ @params = params
36
+ end
37
+
38
+ def to_s
39
+ s = @scheme.dup
40
+ s << ' ' << @token if @token
41
+ s << ' ' << @params.map { |n, v| "#{n}=#{v.quote}" }.join(', ')
42
+ s
43
+ end
44
+ end
45
+
46
+ class BasicAuth
47
+ attr_reader :auth_params
48
+
49
+ def initialize(auth_params = {}, &store)
50
+ @auth_params = auth_params
51
+ @store = store
52
+ end
53
+
54
+ def scheme
55
+ 'Basic'
56
+ end
57
+
58
+ def call(request)
59
+ header = request.header('Authorization') rescue nil
60
+ @store.call(header.credentials) if header && header.credentials.is_a?(BasicCredentials)
61
+ end
62
+ end
63
+ end