xenon 0.0.1 → 0.0.2

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: 278d552255e77d646a1670ab310a3dd786991efc
4
- data.tar.gz: f1924cc643df82774d2fdda50432919bd012b24f
3
+ metadata.gz: f05aa3e37aef60ceb5222b097b411a15d3c38874
4
+ data.tar.gz: 2b1c1226260313cc76c9a2768c3c761038adeedb
5
5
  SHA512:
6
- metadata.gz: dfd9fb60f55c963b10d6e8c7e7d5155ceb848555ae7a045c965dd672b0106d12bea485de077c39e4f6cadbd4b915040e89898eedbd3345de86ede6fda91592fa
7
- data.tar.gz: 6211e6a63cccd8e2a6832e7dfc18e39e18370bcd1c70b21f8d0fa0aad7111d3e8261d2349e818a8c3c40289eeeda5070ae69761c2e3990c0f333e512166d4e46
6
+ metadata.gz: b19aaf5f2e92fe435ed3ca6723af7484aa410578684a0fb0b6ba908ad442d9d866159912afc087eb03ddfd112a02013bca5a73c99c9d7549dfc3ad9203fb737a
7
+ data.tar.gz: 74594a32ddfce37b50224584a2b5a13dc869a8a5a7ffa87c50e20a0af79c1ea38cafa9b8f332e2c55e3971e752d7e62b7ddade445990e8b8340e72fd36e23c19
data/.gitignore CHANGED
@@ -1,22 +1,23 @@
1
1
  *.gem
2
2
  *.rbc
3
- .bundle
4
- .config
5
- .yardoc
3
+ .ruby-version
4
+ .ruby-gemset
6
5
  Gemfile.lock
7
- InstalledFiles
8
- _yardoc
9
- coverage
10
- doc/
11
- lib/bundler/man
12
- pkg
13
- rdoc
14
- spec/reports
15
- test/tmp
16
- test/version_tmp
17
- tmp
18
- *.bundle
19
- *.so
20
- *.o
21
- *.a
22
- mkmf.log
6
+ /.config
7
+ /coverage/
8
+ /InstalledFiles
9
+ /pkg/
10
+ /spec/reports/
11
+ /test/tmp/
12
+ /test/version_tmp/
13
+ /tmp/
14
+
15
+ ## Documentation cache and generated files:
16
+ /.yardoc/
17
+ /_yardoc/
18
+ /doc/
19
+ /rdoc/
20
+
21
+ ## Environment normalisation:
22
+ /.bundle/
23
+ /lib/bundler/man/
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --format documentation
data/Guardfile ADDED
@@ -0,0 +1,48 @@
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
+ clearing :on
9
+
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
+ guard :rspec, cmd: "bundle exec rspec" do
36
+ require "guard/rspec/dsl"
37
+ dsl = Guard::RSpec::Dsl.new(self)
38
+
39
+ # RSpec files
40
+ rspec = dsl.rspec
41
+ watch(rspec.spec_helper) { rspec.spec_dir }
42
+ watch(rspec.spec_support) { rspec.spec_dir }
43
+ watch(rspec.spec_files)
44
+
45
+ # Ruby files
46
+ ruby = dsl.ruby
47
+ dsl.watch_spec_files_for(ruby.lib_files)
48
+ end
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Greg Beech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Xenon
2
2
 
3
- An HTTP framework for building RESTful APIs, inspired by [Spray](http://spray.io).
3
+ An HTTP framework for building RESTful APIs, inspired by [Spray][spray].
4
4
 
5
5
  ## Installation
6
6
 
@@ -27,3 +27,6 @@ At the moment I probably wouldn't use this gem for anything you actually depend
27
27
  3. Commit your changes (`git commit -am 'Add some feature'`)
28
28
  4. Push to the branch (`git push origin my-new-feature`)
29
29
  5. Create a new Pull Request
30
+
31
+
32
+ [spray]: http://spray.io/ "spray"
data/lib/xenon.rb CHANGED
@@ -1,5 +1,240 @@
1
- require "xenon/version"
1
+ require 'json'
2
+ require 'rack'
3
+ require 'active_support/core_ext/string'
4
+ require 'xenon/headers'
5
+ require 'xenon/routing'
6
+ require 'xenon/version'
2
7
 
3
8
  module Xenon
4
- # Your code goes here...
9
+
10
+ class Rejection
11
+ ACCEPT = 'ACCEPT'
12
+ HEADER = 'HEADER'
13
+ METHOD = 'METHOD'
14
+
15
+ attr_reader :reason, :info
16
+
17
+ def initialize(reason, info = {})
18
+ @reason = reason
19
+ @info = info
20
+ end
21
+
22
+ def [](name)
23
+ @info[name]
24
+ end
25
+ end
26
+
27
+ class Request
28
+ attr_accessor :unmatched_path
29
+
30
+ def initialize(rack_req)
31
+ @rack_req = rack_req
32
+ @unmatched_path = rack_req.path
33
+ freeze
34
+ end
35
+
36
+ def request_method
37
+ @rack_req.request_method.downcase.to_sym
38
+ end
39
+
40
+ def form_hash
41
+ @rack_req.POST
42
+ end
43
+
44
+ def query_hash
45
+ @rack_req.GET
46
+ end
47
+
48
+ def header(name)
49
+ snake_name = name.to_s.tr('-', '_')
50
+ value = @rack_req.env['HTTP_' + snake_name.upcase]
51
+ return nil if value.nil?
52
+
53
+ klass = Xenon::Headers.header_class(name)
54
+ klass ? klass.parse(value) : Xenon::Headers::Raw.new(name, value)
55
+ end
56
+
57
+ def copy(changes = {})
58
+ 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
66
+ end
67
+ end
68
+
69
+ class Response
70
+ attr_reader :status, :headers, :body
71
+
72
+ def initialize
73
+ @headers = Headers.new
74
+ @complete = false
75
+ freeze
76
+ end
77
+
78
+ def complete?
79
+ @complete
80
+ end
81
+
82
+ def copy(changes = {})
83
+ r = dup
84
+ changes.each { |k, v| r.instance_variable_set("@#{k}", v) }
85
+ r.freeze
86
+ end
87
+
88
+ def freeze
89
+ @headers.freeze
90
+ @body.freeze
91
+ super
92
+ 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
+ end
102
+
103
+ class Context
104
+ attr_accessor :request, :response, :rejections
105
+
106
+ def initialize(request, response)
107
+ @request = request
108
+ @response = response
109
+ @rejections = []
110
+ end
111
+
112
+ def branch
113
+ original_request = @request
114
+ original_response = @response
115
+ yield
116
+ ensure
117
+ @request = original_request
118
+ @response = original_response unless @response.complete?
119
+ end
120
+ end
121
+
122
+ class JsonMarshaller
123
+ def media_type
124
+ MediaType::JSON
125
+ end
126
+
127
+ def content_type
128
+ media_type.with_charset(Encoding::UTF_8)
129
+ end
130
+
131
+ def marshal_to?(media_range)
132
+ media_range =~ media_type
133
+ end
134
+
135
+ def marshal(obj)
136
+ [obj.to_json]
137
+ end
138
+ end
139
+
140
+ class XmlMarshaller
141
+ def media_type
142
+ MediaType::XML
143
+ end
144
+
145
+ def content_type
146
+ media_type.with_charset(Encoding::UTF_8)
147
+ end
148
+
149
+ def marshal_to?(media_range)
150
+ media_range =~ media_type
151
+ end
152
+
153
+ def marshal(obj)
154
+ raise "#{obj.class} does not support #to_xml" unless obj.respond_to?(:to_xml)
155
+ [obj.to_xml]
156
+ end
157
+ end
158
+
159
+ class Api
160
+ include Xenon::Routing::Directives
161
+
162
+ DEFAULT_MARSHALLERS = [JsonMarshaller.new]
163
+
164
+ class << self
165
+ def marshallers(*marshallers)
166
+ @marshallers = marshallers unless marshallers.nil? || marshallers.empty?
167
+ (@marshallers.nil? || @marshallers.empty?) ? DEFAULT_MARSHALLERS : @marshallers
168
+ end
169
+
170
+ def select_marshaller(media_ranges)
171
+ weighted = marshallers.sort_by do |m|
172
+ media_range = media_ranges.find { |mr| m.marshal_to?(mr) }
173
+ media_range ? media_range.q : 0.0
174
+ end
175
+ weighted.last
176
+ end
177
+ end
178
+
179
+ attr_reader :context
180
+
181
+ def route
182
+ raise NotImplementedError.new
183
+ end
184
+
185
+ def call(env)
186
+ dup.call!(env)
187
+ end
188
+
189
+ def call!(env)
190
+ @context = Context.new(Request.new(Rack::Request.new(env)), Response.new)
191
+
192
+ accept = @context.request.header('Accept')
193
+ 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 }
199
+ end
200
+ @context.response = handle_rejections(@context.rejections) unless @context.response.complete?
201
+ rescue => e
202
+ @context.response = handle_error(e)
203
+ end
204
+ @context.response = Response.error(501, 'The response was not completed') unless @context.response.complete?
205
+
206
+ marshaller ||= self.class.marshallers.first
207
+ resp = @context.response.copy(
208
+ headers: @context.response.headers.set(Headers::ContentType.new(marshaller.content_type)),
209
+ body: marshaller.marshal(@context.response.body))
210
+ [resp.status, resp.headers.map { |h| [h.name, h.to_s] }.to_h, resp.body]
211
+ end
212
+
213
+ def handle_error(e)
214
+ puts "handle_error: #{e}"
215
+ @context.response = Response.error(500)
216
+ end
217
+
218
+ def handle_rejections(rejections)
219
+ puts "handle_rejections: #{rejections}"
220
+ if rejections.empty?
221
+ Response.error(404)
222
+ else
223
+ rejection = rejections.first
224
+ 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)
234
+ end
235
+ end
236
+ end
237
+
238
+ end
239
+
5
240
  end
@@ -0,0 +1,4 @@
1
+ module Xenon
2
+ class Error < StandardError; end
3
+ class ParseError < Error; end
4
+ end
@@ -0,0 +1,114 @@
1
+ require 'active_support/core_ext/string'
2
+
3
+ module Xenon
4
+ class Headers
5
+ include Enumerable
6
+
7
+ def initialize
8
+ @hash = {}
9
+ end
10
+
11
+ def initialize_dup(other)
12
+ super
13
+ @hash = @hash.dup
14
+ end
15
+
16
+ def freeze
17
+ @hash.freeze
18
+ super
19
+ end
20
+
21
+ def each(&block)
22
+ @hash.values.each(&block)
23
+ end
24
+
25
+ def set!(header)
26
+ @hash[header.name] = header
27
+ self
28
+ end
29
+
30
+ def add!(header)
31
+ existing = @hash[header.name]
32
+ if existing
33
+ if existing.respond_to?(:merge)
34
+ set!(existing.merge(header))
35
+ else
36
+ raise "Unmergeable header '#{header.name}' already exists"
37
+ end
38
+ else
39
+ set!(header)
40
+ end
41
+ self
42
+ end
43
+
44
+ %i(set add).each do |name|
45
+ define_method name do |header|
46
+ dup.send("#{name}!", header)
47
+ end
48
+ end
49
+
50
+ alias_method :<<, :add!
51
+
52
+ class << self
53
+ def register(klass)
54
+ (@registered ||= {})[klass.const_get(:NAME)] = klass
55
+ end
56
+
57
+ def header_class(name)
58
+ (@registered || {})[name]
59
+ end
60
+
61
+ def Header(name)
62
+ klass = Class.new do
63
+ def name
64
+ self.class.const_get(:NAME)
65
+ end
66
+
67
+ def self.inherited(base)
68
+ Headers.register(base)
69
+ end
70
+ end
71
+ Headers.const_set("#{name.tr('-', '_').classify}Header", klass)
72
+ klass.const_set(:NAME, name)
73
+ klass
74
+ end
75
+
76
+ def ListHeader(name)
77
+ klass = Header(name)
78
+ klass.class_eval do
79
+ attr_reader :values
80
+
81
+ def initialize(values)
82
+ @values = values
83
+ end
84
+
85
+ def merge(other)
86
+ self.class.new(*(@values + other.values))
87
+ end
88
+
89
+ def to_s
90
+ @values.map(&:to_s).join(', ')
91
+ end
92
+ end
93
+ klass
94
+ end
95
+ end
96
+
97
+ class Raw
98
+ attr_reader :name, :value
99
+
100
+ def initialize(name, value)
101
+ @name = name
102
+ @value = value
103
+ end
104
+
105
+ def to_s
106
+ @value
107
+ end
108
+ end
109
+
110
+ [:Accept, :AcceptCharset, :AcceptEncoding, :CacheControl, :ContentType].each do |sym|
111
+ autoload sym, "xenon/headers/#{sym.to_s.underscore}"
112
+ end
113
+ end
114
+ end