acceptable_api 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
data/.rvmrc ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # This is an RVM Project .rvmrc file, used to automatically load the ruby
4
+ # development environment upon cd'ing into the directory
5
+
6
+ # First we specify our desired <ruby>[@<gemset>], the @gemset name is optional,
7
+ # Only full ruby name is supported here, for short names use:
8
+ # echo "rvm use 1.9.3" > .rvmrc
9
+ environment_id="ruby-1.9.3-p194@acceptable_api"
10
+
11
+ # Uncomment the following lines if you want to verify rvm version per project
12
+ # rvmrc_rvm_version="1.14.5 (stable)" # 1.10.1 seams as a safe start
13
+ # eval "$(echo ${rvm_version}.${rvmrc_rvm_version} | awk -F. '{print "[[ "$1*65536+$2*256+$3" -ge "$4*65536+$5*256+$6" ]]"}' )" || {
14
+ # echo "This .rvmrc file requires at least RVM ${rvmrc_rvm_version}, aborting loading."
15
+ # return 1
16
+ # }
17
+
18
+ # First we attempt to load the desired environment directly from the environment
19
+ # file. This is very fast and efficient compared to running through the entire
20
+ # CLI and selector. If you want feedback on which environment was used then
21
+ # insert the word 'use' after --create as this triggers verbose mode.
22
+ if [[ -d "${rvm_path:-$HOME/.rvm}/environments"
23
+ && -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]]
24
+ then
25
+ \. "${rvm_path:-$HOME/.rvm}/environments/$environment_id"
26
+ [[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]] &&
27
+ \. "${rvm_path:-$HOME/.rvm}/hooks/after_use" || true
28
+ else
29
+ # If the environment file has not yet been created, use the RVM CLI to select.
30
+ rvm --create "$environment_id" || {
31
+ echo "Failed to create RVM environment '${environment_id}'."
32
+ return 1
33
+ }
34
+ fi
35
+
36
+ # If you use bundler, this might be useful to you:
37
+ # if [[ -s Gemfile ]] && {
38
+ # ! builtin command -v bundle >/dev/null ||
39
+ # builtin command -v bundle | GREP_OPTIONS= \grep $rvm_path/bin/bundle >/dev/null
40
+ # }
41
+ # then
42
+ # printf "%b" "The rubygem 'bundler' is not installed. Installing it now.\n"
43
+ # gem install bundler
44
+ # fi
45
+ # if [[ -s Gemfile ]] && builtin command -v bundle >/dev/null
46
+ # then
47
+ # bundle install | GREP_OPTIONS= \grep -vE '^Using|Your bundle is complete'
48
+ # fi
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Build an acceptable API.
4
4
 
5
- HTTP is pretty darn awesome you guys. Part of HTTP - the `Accept` header -
5
+ HTTP is pretty darn awesome you guys. Part of HTTP - `Accept` headers -
6
6
  allows the clients of our API to tell us what representation they want to work
7
7
  with. We should probably pay attention to them, hey?
8
8
 
@@ -33,13 +33,22 @@ Or install it yourself as:
33
33
 
34
34
  $ gem install acceptable_api
35
35
 
36
+
36
37
  ## Usage
37
38
 
38
- Assume you have a class that you want to expose via a lovely HTTP API.
39
+ Assume you have a class that you want to expose via a lovely HTTP ReST API:
39
40
 
41
+ # app/models/sandwich.rb
40
42
  class Sandwich
41
- def self.find id
42
- # Look up a Sandwich by ID
43
+ attr_accessor :id
44
+ private :id=
45
+
46
+ def initialize id
47
+ self.id = id
48
+ self.bread = "Brown"
49
+ self.fillings = %w(mayo chicken salad)
50
+ self.name = "Chicken Mayo Salad"
51
+ self.made_at = Time.now
43
52
  end
44
53
 
45
54
  attr_accessor :fillings
@@ -48,35 +57,38 @@ Assume you have a class that you want to expose via a lovely HTTP API.
48
57
  attr_accessor :made_at
49
58
  end
50
59
 
51
- You'd declare that you wanted to expose it via the API as `/sandwiches/123` like
52
- this:
60
+ You'd declare that you wanted to expose it via the API like this:
61
+
62
+ # app/resources/sandwich_resource.rb
63
+ class SandwichResource
64
+ include AcceptableApi::Controller
53
65
 
54
- class SandwichApi < AcceptableApi::Controller
55
- get '/sandwiches/:id' do
56
- Sandwich.find params[:id]
66
+ def show_sandwich
67
+ # Normally this would be a database lookup but since this is just an
68
+ # example I create a new instance to keep things simple
69
+ Sandwich.new params[:id]
57
70
  end
58
71
  end
59
72
 
60
- Of course, this needs to be run somehow. An `AcceptableApi::Controller` can be
61
- used as a Rack application. In `config.ru` do this:
73
+ Of course, this needs to be run somehow. I've chosen to do this via Rack. In
74
+ `config.ru` do this:
62
75
 
63
76
  require 'acceptable_api'
64
- require 'sandwich_api'
77
+ require 'app/models/sandwich'
78
+ require 'app/resource/sandwich_resource'
65
79
 
66
- run SandwichApi
80
+ app = AcceptableApi::Builder.new
81
+ app.expose 'SandwichResource#show_sandwich', at: '/sandwiches/:id',
82
+ via: 'get'
83
+ run app.to_app
67
84
 
68
85
  You can now use `rackup` as normal to launch a web server, and `curl` to access
69
- your API:
86
+ your API, requesting a plain text representation of sandwich 123:
70
87
 
71
- $ curl -i http://localhost:9292/sandwiches/123
88
+ $ curl -H 'Accept: application/json' -i http://localhost:9292/sandwiches/123
72
89
  HTTP/1.1 406 Not Acceptable
73
- X-Frame-Options: sameorigin
74
- X-Xss-Protection: 1; mode=block
75
90
  Content-Type: application/javascript
76
91
  Content-Length: 21
77
- Server: WEBrick/1.3.1 (Ruby/1.9.2/2011-07-09)
78
- Date: Sat, 21 Apr 2012 19:57:59 GMT
79
- Connection: Keep-Alive
80
92
 
81
93
  {
82
94
  "links": [
@@ -85,53 +97,45 @@ your API:
85
97
  }
86
98
 
87
99
  We got a `406 Not Acceptable` response because AcceptableApi doesn't know how to
88
- respond with any of the representations we'd accept. That's fair: we haven't
89
- told it how to respond to /any/ representations yet. Do it like this:
100
+ respond with an `application/json` representation of a Sandwich. That's fair: we
101
+ haven't told it how to respond with /any/ representations of sandwiches yet. Do
102
+ it like this in `config.ru`, before calling `#to_app`:
90
103
 
91
- AcceptableApi.register Sandwich, 'application/json' do |sandwich, request|
104
+ app.register Sandwich => 'application/json' do |sandwich|
92
105
  JSON.generate :id => sandwich.id
93
106
  end
94
107
 
95
108
  Let's try requesting the resource again:
96
109
 
97
- $ curl -i http://localhost:9293/sandwiches/123
110
+ $ curl -H 'Accept: application/json' -i http://localhost:9292/sandwiches/123
98
111
  HTTP/1.1 200 OK
99
- X-Frame-Options: sameorigin
100
- X-Xss-Protection: 1; mode=block
101
112
  Content-Type: application/json
102
113
  Content-Length: 12
103
- Server: WEBrick/1.3.1 (Ruby/1.9.2/2011-07-09)
104
- Date: Sat, 21 Apr 2012 20:03:14 GMT
105
- Connection: Keep-Alive
106
114
 
107
115
  {"id":"123"}
108
116
 
109
- Ace, we got a response. What happens if we ask for a plain text response?
117
+ Ace, we got a response, and it's the JSON represenation of the sandwich. What
118
+ happens if we ask for a plain text representation?
110
119
 
111
120
  $ curl -H 'Accept: text/plain' -i http://localhost:9292/sandwiches/123
112
121
  HTTP/1.1 406 Not Acceptable
113
- X-Frame-Options: sameorigin
114
- X-Xss-Protection: 1; mode=block
115
122
  Content-Type: application/javascript
116
123
  Content-Length: 146
117
- Server: WEBrick/1.3.1 (Ruby/1.9.2/2011-07-09)
118
- Date: Sat, 21 Apr 2012 20:04:46 GMT
119
- Connection: Keep-Alive
120
124
 
121
125
  {
122
126
  "links": [
123
- {
124
- "rel": "alternative",
125
- "type": "application/json",
126
- "uri": "http://localhost:9293/sandwiches/123"
127
- }
127
+ {
128
+ "rel": "alternative",
129
+ "type": "application/json",
130
+ "uri": "http://localhost:9292/sandwiches/123"
131
+ }
128
132
  ]
129
133
  }
130
134
 
131
135
  As expected, this is a `406 Not Acceptable` response, but we take the
132
136
  opportunity to provide a list of alternative representations that the client may
133
- want to check out. Our `application/json` is listed with the type and the URI to
134
- request should the client want to do so.
137
+ want to check out. The `application/json` representation is listed with the type
138
+ and the URI to request should the client want to do so.
135
139
 
136
140
  Time passes, and a we decide that our API would be more useful if it returned
137
141
  the fillings and bread used in the sandwich, and we want to replace the database
@@ -150,18 +154,31 @@ which specifies the returned document:
150
154
 
151
155
  And we register the type with AcceptableApi:
152
156
 
153
- AcceptableApi.register Sandwich, 'application/vnd.acme.sandwich-v1+json' do |sandwich, request|
157
+ app.register Sandwich => 'application/vnd.acme.sandwich-v1+json' do |sandwich|
154
158
  JSON.generate :name => sandwich.name, :fillings => sandwich.fillings,
155
159
  :bread => sandwich.bread
156
160
  end
157
161
 
158
162
  And we make the request:
159
163
 
160
-
164
+ $ curl -H 'Accept: application/vnd.acme.sandwich-v1+json' -i http://localhost:9292/sandwiches/123
165
+ HTTP/1.1 200 OK
166
+ Content-Type: application/vnd.acme.sandwich-v1+json
167
+ Content-Length: 75
168
+
169
+ {"name":"Bleaugh","fillings":["jam","avacado","anchovies"],"bread":"brown"}
170
+
171
+ Making a request for the normal `application/json` representation still works:
172
+
173
+ $ curl -H 'Accept: application/json' -i http://localhost:9292/sandwiches/123
174
+ HTTP/1.1 200 OK
175
+ Content-Type: application/json
176
+ Content-Length: 12
177
+
178
+ {"id":"123"}
161
179
 
162
- See `example/example.rb` for an example.
180
+ See the example directory, `example/`, for a working example.
163
181
 
164
- TODO: Write usage instructions here
165
182
 
166
183
  ## Contributing
167
184
 
@@ -170,3 +187,54 @@ TODO: Write usage instructions here
170
187
  3. Commit your changes (`git commit -am 'Added some feature'`)
171
188
  4. Push to the branch (`git push origin my-new-feature`)
172
189
  5. Create new Pull Request
190
+
191
+
192
+ ## TODO
193
+
194
+ * Still need to add tests and discover how to work with the other HTTP verbs,
195
+ starting with POST, PUT, DELETE and OPTIONS. HEAD could be handy as well but
196
+ it's quite possible that software further up the stack could just strip out
197
+ the GET entity to create a valid HEAD response. As a first scratch I'm
198
+ imagining changing the #expose call to something like:
199
+
200
+ app.expose SandwichResource, at: '/sandwiches/:id',
201
+ get: 'show', put: 'update', delete: 'destroy'
202
+
203
+ * I don't like defining the conversions in`config.ru`. It would be lovely if
204
+ these could be picked up automatically on start-up - but I'd settle for
205
+ something less verbose that I currently have. Possibly a slight adaptation of
206
+ the expose call:
207
+
208
+ app.expose Sandwich, at: '/sandwiches/:id',
209
+ get: 'show', put: 'update', delete: 'destroy'
210
+
211
+ This could guess we want the use `SandwichResource` as the controller, and it
212
+ could examine `app/views/sandwiches/**/*.rb` for convertors:
213
+
214
+ convert Sandwich => 'application/vnd.acme.sandwich-v1+xml' do |sandwich|
215
+ xml = Builder::XmlMarkup.new
216
+ xml.sandwich do |s|
217
+ s.name sandwich.name
218
+ s.bread sandwich.bread
219
+ s.fillings do |f|
220
+ sandwich.fillings.each do |filling|
221
+ f.filling filling
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ * Need to work out how I should deal with authentication etc. Want to keep
228
+ this clean. A controller should be able to return any of the HTTP statuses
229
+ that makes sense, including 401 / 403.
230
+
231
+
232
+ ## Authors
233
+
234
+ Craig R Webster <craig@barkingiguana.com>
235
+
236
+
237
+ ## Licence
238
+
239
+ Released under the terms of the MIT licence, a copy of which can be found in the
240
+ `LICENCE` file distributed with this project.
data/Rakefile CHANGED
@@ -1,2 +1,12 @@
1
1
  #!/usr/bin/env rake
2
- require "bundler/gem_tasks"
2
+ require 'bundler/gem_tasks'
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ t.verbose = true
9
+ end
10
+
11
+ desc "Run tests"
12
+ task :default => :test
@@ -4,7 +4,7 @@ require File.expand_path('../lib/acceptable_api/version', __FILE__)
4
4
  Gem::Specification.new do |gem|
5
5
  gem.authors = ["Craig R Webster"]
6
6
  gem.email = ["craig@barkingiguana.com"]
7
- gem.description = %q{HTTP lets clients sned an Accept header. We should probably use that to accept more than the bog-standard mime-types.}
7
+ gem.description = %q{HTTP lets clients send Accept headers. We should probably use that to work out what they'll accept as a response, yea?}
8
8
  gem.summary = %q{Build an Acceptable API}
9
9
  gem.homepage = "http://barkingiguana.com/2011/12/05/principles-of-service-design-program-to-an-interface/"
10
10
 
@@ -14,8 +14,12 @@ Gem::Specification.new do |gem|
14
14
  gem.name = "acceptable_api"
15
15
  gem.require_paths = ["lib"]
16
16
  gem.version = AcceptableApi::VERSION
17
+ gem.add_runtime_dependency 'json'
17
18
  gem.add_runtime_dependency 'rack'
18
19
  gem.add_runtime_dependency 'rack-accept'
19
20
  gem.add_runtime_dependency 'rack-accept-header-updater'
20
- gem.add_runtime_dependency 'sinatra'
21
+
22
+ gem.add_development_dependency 'rack-test'
23
+ gem.add_development_dependency 'test-unit'
24
+ gem.add_development_dependency 'rake'
21
25
  end
@@ -1,10 +1,4 @@
1
1
  source :rubygems
2
2
 
3
- gem 'acceptable_api'
4
- gem 'sinatra'
5
- gem 'rack'
6
- gem 'rack-accept-header-updater'
7
- gem 'rack-accept'
8
- gem 'rake'
9
-
3
+ gemspec :path => '../'
10
4
  gem 'builder'
@@ -1,4 +1,43 @@
1
1
  require 'acceptable_api'
2
2
  require './example'
3
3
 
4
- run Example
4
+ require 'json'
5
+ require 'builder'
6
+
7
+ app = AcceptableApi::Builder.new
8
+
9
+ app.register Sandwich => 'text/plain' do |sandwich|
10
+ s = []
11
+ s << sandwich.name
12
+ s << sandwich.fillings.sort.join(',')
13
+ s << sandwich.bread
14
+ s << sandwich.made_at.iso8601
15
+ s.join "\n"
16
+ end
17
+
18
+ app.register Sandwich => 'application/vnd.acme.sandwich-v1+xml' do |sandwich|
19
+ xml = Builder::XmlMarkup.new
20
+ xml.sandwich do |s|
21
+ s.name sandwich.name
22
+ s.bread sandwich.bread
23
+ s.fillings do |f|
24
+ sandwich.fillings.each do |filling|
25
+ f.filling filling
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ app.register Sandwich => 'application/json' do |sandwich|
32
+ JSON.generate :id => sandwich.id
33
+ end
34
+
35
+ app.register Sandwich => 'application/vnd.acme.sandwich-v1+json' do |sandwich|
36
+ JSON.generate :name => sandwich.name, :fillings => sandwich.fillings,
37
+ :bread => sandwich.bread
38
+ end
39
+
40
+ app.expose 'SandwichResource#show_sandwich', at: '/sandwiches/:id',
41
+ via: 'get'
42
+
43
+ run app.to_app
@@ -1,41 +1,27 @@
1
- require 'ostruct'
2
- require 'json'
3
- require 'builder'
1
+ class Sandwich
2
+ attr_accessor :id
3
+ private :id=
4
4
 
5
- # One of the resources we're working with.
6
- # For simplicity I'm faking it out using OpenStruct.
7
- class Sandwich < OpenStruct
8
- # Look up a Sandwich by ID
9
- def self.find id
10
- new :fillings => %w(jam avacado anchovies), :bread => "brown",
11
- :made_at => Time.now, :id => id, :name => "Bleaugh"
5
+ def initialize id
6
+ self.id = id
7
+ self.bread = "Brown"
8
+ self.fillings = %w(mayo chicken salad)
9
+ self.name = "Chicken Mayo Salad"
10
+ self.made_at = Time.now
12
11
  end
13
- end
14
-
15
- AcceptableApi.register Sandwich, 'application/json' do |sandwich, request|
16
- JSON.generate :id => sandwich.id
17
- end
18
12
 
19
- AcceptableApi.register Sandwich, 'application/vnd.acme.sandwich-v1+json' do |sandwich, request|
20
- JSON.generate :name => sandwich.name, :fillings => sandwich.fillings,
21
- :bread => sandwich.bread
13
+ attr_accessor :fillings
14
+ attr_accessor :bread
15
+ attr_accessor :name
16
+ attr_accessor :made_at
22
17
  end
23
18
 
24
- AcceptableApi.register Sandwich, 'application/vnd.acme.sandwich-v1+xml' do |sandwich, request|
25
- xml = Builder::XmlMarkup.new
26
- xml.sandwich do |s|
27
- s.name sandwich.name
28
- s.bread sandwich.bread
29
- s.fillings do |f|
30
- sandwich.fillings.each do |filling|
31
- f.filling filling
32
- end
33
- end
34
- end
35
- end
19
+ class SandwichResource
20
+ include AcceptableApi::Controller
36
21
 
37
- class Example < AcceptableApi::Controller
38
- get '/sandwiches/:id' do
39
- Sandwich.find params[:id]
22
+ def show_sandwich
23
+ # Normally this would be a database lookup but since this is just an
24
+ # example I create a new instance to keep things simple
25
+ Sandwich.new params[:id]
40
26
  end
41
27
  end
@@ -1,158 +1,21 @@
1
- require 'sinatra'
2
1
  require 'singleton'
2
+ require 'json'
3
3
  require 'rack/accept'
4
4
  require 'rack/accept_header_updater'
5
5
 
6
- require "acceptable_api/version"
6
+ require 'acceptable_api/accepts.rb'
7
+ require 'acceptable_api/action.rb'
8
+ require 'acceptable_api/application.rb'
9
+ require 'acceptable_api/builder.rb'
10
+ require 'acceptable_api/controller.rb'
11
+ require 'acceptable_api/mapper.rb'
12
+ require 'acceptable_api/mappers.rb'
13
+ require 'acceptable_api/missing_controller.rb'
14
+ require 'acceptable_api/missing_mapper.rb'
15
+ require 'acceptable_api/missing_route.rb'
16
+ require 'acceptable_api/route.rb'
17
+ require 'acceptable_api/routes.rb'
18
+ require 'acceptable_api/version.rb'
7
19
 
8
20
  module AcceptableApi
9
- def self.register klass, mime_type, &map_block
10
- Mapper.register klass, mime_type, &map_block
11
- end
12
-
13
- class MissingMapper
14
- include Singleton
15
-
16
- def mime_type
17
- 'application/javascript'
18
- end
19
-
20
- def execute resource, request
21
- # set the response to "no acceptable representation available"
22
- mappers = Mapper.for resource.class
23
- body = {
24
- :links => mappers.mime_types.map do |mime_type|
25
- { :rel => "alternative", :type => mime_type, :uri => request.uri }
26
- end
27
- }
28
- [ 406, { 'Content-Type' => 'application/javascript' }, JSON.pretty_generate(body) ]
29
- end
30
- end
31
-
32
- class Mappers
33
- attr_accessor :mappers
34
- private :mappers=, :mappers
35
-
36
- def initialize mappers
37
- self.mappers = mappers
38
- end
39
-
40
- def to accepts
41
- acceptable_mime_types = accepts.order mime_types
42
- acceptable_mappers = acceptable_mime_types.map { |mt|
43
- mappers.detect { |m| m.mime_type == mt }
44
- }
45
- return acceptable_mappers if acceptable_mappers.any?
46
- [ MissingMapper.instance ]
47
- end
48
-
49
- def mime_types
50
- mappers.map { |m| m.mime_type }.sort.uniq
51
- end
52
- end
53
-
54
- class Mapper
55
- [ :klass, :mime_type, :map_block ].each do |a|
56
- attr_accessor a
57
- private "#{a}="
58
- end
59
- private :map_block
60
-
61
- def initialize klass, mime_type, &map_block
62
- self.klass = klass
63
- self.mime_type = mime_type
64
- self.map_block = map_block
65
- end
66
-
67
- def execute resource, request
68
- body = map_block.call resource, request
69
- [ status, headers, body ]
70
- end
71
-
72
- def status
73
- 200
74
- end
75
-
76
- def headers
77
- { 'Content-Type' => mime_type }
78
- end
79
-
80
- def self.for klass
81
- klass_mappers = mappers.select { |m| m.klass == klass }
82
- Mappers.new klass_mappers
83
- end
84
-
85
- class << self;
86
- attr_accessor :mappers
87
- protected :mappers=, :mappers
88
- end
89
- self.mappers = []
90
-
91
- def self.register klass, mime_type, &map_block
92
- mapper = Mapper.new klass, mime_type, &map_block
93
- self.mappers << mapper
94
- end
95
- end
96
-
97
- # This needs to mimic as much of the Sinatra API as we use.
98
- # Probably we want to set headers and content_type, not sure what else.
99
- class Response
100
- attr_accessor :params
101
- def initialize options = {}
102
- self.params = options[:params]
103
- end
104
- end
105
-
106
- class Accepts
107
- attr_accessor :request
108
- private :request=, :request
109
-
110
- def initialize env
111
- self.request = Rack::Accept::Request.new env
112
- end
113
-
114
- def order mime_types
115
- ordered = request.media_type.sort_with_qvalues mime_types, false
116
- ordered.map! { |q, mt| mt }
117
- ordered
118
- end
119
- end
120
-
121
- class Request
122
- attr_accessor :request
123
- private :request=, :request
124
-
125
- def initialize rack_request
126
- self.request = rack_request
127
- end
128
-
129
- def respond_with resource
130
- accepts = Accepts.new request.env
131
- mappers = Mapper.for(resource.class).to(accepts)
132
- mapper = mappers[0]
133
- code, headers, body = mapper.execute resource, self
134
- headers["Content-Length"] = body.bytesize.to_s
135
- [ code, headers, body ]
136
- end
137
-
138
- def uri
139
- request.url
140
- end
141
- end
142
-
143
- class Controller < Sinatra::Base
144
- use Rack::AcceptHeaderUpdater
145
-
146
- def self.get path, &block
147
- super path do
148
- response = Response.new :params => params
149
- resource = response.instance_eval &block
150
- api = Request.new request
151
- s, h, body = api.respond_with resource
152
- status s
153
- headers h
154
- body
155
- end
156
- end
157
- end
158
21
  end
@@ -0,0 +1,16 @@
1
+ module AcceptableApi
2
+ class Accepts
3
+ attr_accessor :request
4
+ private :request=, :request
5
+
6
+ def initialize env
7
+ self.request = Rack::Accept::Request.new env
8
+ end
9
+
10
+ def order mime_types
11
+ ordered = request.media_type.sort_with_qvalues mime_types, false
12
+ ordered.map! { |q, mt| mt }
13
+ ordered
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ module AcceptableApi
2
+ class Action
3
+ attr_accessor :route
4
+ private :route=, :route
5
+
6
+ attr_accessor :request
7
+ private :request=, :request
8
+
9
+ attr_accessor :mappers
10
+ private :mappers=, :mappers
11
+
12
+ def initialize route, request, mappers
13
+ self.route = route
14
+ self.request = request
15
+ self.mappers = mappers
16
+ end
17
+
18
+ def execute
19
+ resource = controller.perform_action
20
+ resource_mappers = mappers.from resource.class
21
+ mapper = resource_mappers.to acceptable_mime_types
22
+ if mapper.missing?
23
+ body = {
24
+ :links => resource_mappers.mime_types.map do |mime_type|
25
+ { :rel => "alternative", 'Content-Type' => mime_type, :uri => request.url, :method => request.request_method }
26
+ end
27
+ }
28
+ [ 406, { 'Content-Type' => 'application/json' }, [ JSON.pretty_generate(body) ] ]
29
+ else
30
+ body = mapper.execute resource, request
31
+ [ 200, { 'Content-Type' => mapper.to }, [ body ] ]
32
+ end
33
+ end
34
+
35
+ def controller
36
+ route.controller_for_request request
37
+ end
38
+
39
+ def acceptable_mime_types
40
+ Accepts.new request.env
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ module AcceptableApi
2
+ class Application
3
+ attr_accessor :routes
4
+ protected :routes=, :routes
5
+
6
+ attr_accessor :mappers
7
+ protected :mappers=, :mappers
8
+
9
+ def initialize mappers, routes
10
+ self.mappers = mappers
11
+ self.routes = routes
12
+ end
13
+
14
+ def call env
15
+ request = Rack::Request.new env
16
+ action = action_for request
17
+ action.execute
18
+ end
19
+
20
+ def action_for request
21
+ route = routes.for request
22
+ Action.new route, request, mappers
23
+ end
24
+ private :action_for
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ module AcceptableApi
2
+ class Builder
3
+ attr_accessor :mappers
4
+ protected :mappers=, :mappers
5
+
6
+ attr_accessor :routes
7
+ protected :routes=, :routes
8
+
9
+ def initialize
10
+ self.mappers = Mappers.new
11
+ self.routes = Routes.new
12
+ end
13
+
14
+ def register from_to, &map_block
15
+ from = from_to.keys[0]
16
+ to = from_to.values[0]
17
+ mapper = Mapper.new from, to, &map_block
18
+ self.mappers << mapper
19
+ end
20
+
21
+ def expose controller_action, options = {}
22
+ route = Route.new options, controller_action
23
+ self.routes << route
24
+ end
25
+
26
+ def to_app
27
+ Application.new mappers, routes
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,18 @@
1
+ module AcceptableApi
2
+ module Controller
3
+ attr_accessor :params
4
+ protected :params, :params=
5
+
6
+ attr_accessor :action
7
+ protected :action, :action=
8
+
9
+ def initialize params, action
10
+ self.params = params
11
+ self.action = action
12
+ end
13
+
14
+ def perform_action
15
+ send action
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ module AcceptableApi
2
+ class Mapper
3
+ [ :from, :to, :map_block ].each do |a|
4
+ attr_accessor a
5
+ private "#{a}="
6
+ end
7
+ private :map_block
8
+
9
+ def missing?
10
+ false
11
+ end
12
+
13
+ def initialize from, to, &map_block
14
+ self.from = from
15
+ self.to = to
16
+ self.map_block = map_block
17
+ end
18
+
19
+ def from? desired
20
+ from == desired
21
+ end
22
+
23
+ def to? desired
24
+ to == desired
25
+ end
26
+
27
+ def execute resource, request
28
+ map_block.call resource, request
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,36 @@
1
+ module AcceptableApi
2
+ class Mappers
3
+ attr_accessor :mappers
4
+ private :mappers=, :mappers
5
+
6
+ def initialize mappers = []
7
+ self.mappers = mappers
8
+ end
9
+
10
+ def << mapper
11
+ mappers << mapper
12
+ end
13
+
14
+ def from what
15
+ Mappers.new mappers.select { |m| m.from? what }
16
+ end
17
+
18
+ def to accepts
19
+ acceptable_mime_types = accepts.order mime_types
20
+ mapper = acceptable_mime_types.map { |mt|
21
+ mappers.detect { |m| m.to? mt }
22
+ }[0]
23
+ return mapper unless mapper.nil?
24
+ MissingMapper.instance
25
+ end
26
+
27
+ def mime_types
28
+ mappers.map { |m| m.to }.sort.uniq
29
+ end
30
+
31
+ def each &block
32
+ mappers.each &block
33
+ end
34
+ include Enumerable
35
+ end
36
+ end
@@ -0,0 +1,8 @@
1
+ module AcceptableApi
2
+ class MissingController
3
+ include Singleton
4
+
5
+ def perform_action
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ module AcceptableApi
2
+ class MissingMapper
3
+ include Singleton
4
+
5
+ def missing?
6
+ true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module AcceptableApi
2
+ class MissingRoute
3
+ include Singleton
4
+
5
+ def controller_for_request request
6
+ MissingController.instance
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,50 @@
1
+ module AcceptableApi
2
+ class Route
3
+ [ :controller_action, :constraints ].each do |a|
4
+ attr_accessor a
5
+ private "#{a}=", a
6
+ end
7
+
8
+ def initialize constraints, controller_action
9
+ self.constraints = constraints
10
+ self.controller_action = controller_action
11
+ end
12
+
13
+ def match? request
14
+ return false unless constraints[:via].upcase == request.request_method
15
+ return false unless path_regex.match request.path
16
+ true
17
+ end
18
+
19
+ def path_regex
20
+ named_captures = constraints[:at].gsub /\:([^\/]+)/, '(?<\1>[^\/]+)'
21
+ Regexp.new "^#{named_captures}$"
22
+ end
23
+
24
+ def params_from request
25
+ matches = path_regex.match request.path
26
+ matches.names.inject({}) { |a,e|
27
+ a.merge! e.to_sym => matches[e]
28
+ a
29
+ }
30
+ end
31
+
32
+ def controller_for_request request
33
+ controller_class.new params_from(request), controller_method
34
+ end
35
+
36
+ def controller_class
37
+ controller_class_name.split(/::/).inject(Object) { |scope, const_name|
38
+ scope.const_get const_name
39
+ }
40
+ end
41
+
42
+ def controller_class_name
43
+ controller_action.split(/#/, 2)[0]
44
+ end
45
+
46
+ def controller_method
47
+ controller_action.split(/#/, 2)[-1]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,20 @@
1
+ module AcceptableApi
2
+ class Routes
3
+ attr_accessor :routes
4
+ private :routes=, :routes
5
+
6
+ def initialize routes = []
7
+ self.routes = routes
8
+ end
9
+
10
+ def << route
11
+ routes << route
12
+ end
13
+
14
+ def for request
15
+ route = routes.detect { |m| m.match? request }
16
+ return MissingRoute.instance unless route
17
+ route
18
+ end
19
+ end
20
+ end
@@ -1,3 +1,3 @@
1
1
  module AcceptableApi
2
- VERSION = "0.0.2"
2
+ VERSION = "0.0.4"
3
3
  end
@@ -0,0 +1,15 @@
1
+ require 'acceptance/test_helper'
2
+
3
+ class RequestDifferentMimeTypeVersionsTest < AcceptableApi::AcceptanceTest
4
+ test "the correct representation is returned" do
5
+ header 'Accept', 'application/vnd.acceptable-api.example-v1+txt'
6
+ get '/example/123'
7
+ assert_equal 'Ducks token 123', last_response.body
8
+ assert_equal 'application/vnd.acceptable-api.example-v1+txt', last_response.headers['Content-Type']
9
+
10
+ header 'Accept', 'application/vnd.acceptable-api.example-v2+txt'
11
+ get '/example/123'
12
+ assert_equal 'Chickens token 123', last_response.body
13
+ assert_equal 'application/vnd.acceptable-api.example-v2+txt', last_response.headers['Content-Type']
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ # We can't generate an entity body that's acceptable for the client.
2
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.7
3
+ class RequestUnknowntMimeTypeTest < AcceptableApi::AcceptanceTest
4
+ test "the correct status code is returned" do
5
+ header 'Accept', 'application/pdf'
6
+ get '/example/123'
7
+ assert_equal 406, last_response.status
8
+ end
9
+
10
+ test "a JSON list of alternatives is returned" do
11
+ header 'Accept', 'application/pdf'
12
+ get '/example/123'
13
+ assert_equal 'application/json', last_response.header['Content-Type']
14
+ json = JSON.parse last_response.body
15
+ links = json["links"]
16
+ assert_equal links.size, 2, "I expected only two links"
17
+ v1 = links.detect { |l| l["Content-Type"] == "application/vnd.acceptable-api.example-v1+txt" }
18
+ assert_not_nil v1, "Expected a link with Content-Type 'application/vnd.acceptable-api.example-v1+txt'"
19
+ assert_equal last_request.url, v1['uri']
20
+ assert_equal 'GET', v1['method']
21
+ assert_equal 'alternative', v1['rel']
22
+
23
+ v2 = links.detect { |l| l["Content-Type"] == "application/vnd.acceptable-api.example-v2+txt" }
24
+ assert_not_nil v2, "Expected a link with Content-Type 'application/vnd.acceptable-api.example-v2+txt'"
25
+ assert_equal last_request.url, v2['uri']
26
+ assert_equal 'GET', v2['method']
27
+ assert_equal 'alternative', v2['rel']
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ require 'test_helper'
2
+
3
+ module AcceptableApi
4
+ class AcceptanceTest < Test::Unit::TestCase
5
+ include Rack::Test::Methods
6
+
7
+ class Example
8
+ attr_accessor :token
9
+ private :token=, :token
10
+
11
+ def initialize token
12
+ self.token = token
13
+ end
14
+
15
+ def self.find token
16
+ # Normally this would load the resource from the data store
17
+ new token
18
+ end
19
+
20
+ def to_s
21
+ "token #{token}"
22
+ end
23
+ end
24
+
25
+ class ExampleResource
26
+ include AcceptableApi::Controller
27
+
28
+ def show
29
+ Example.find params[:token]
30
+ end
31
+ end
32
+
33
+ def app
34
+ a = AcceptableApi::Builder.new
35
+ a.register Example => 'application/vnd.acceptable-api.example-v1+txt' do |ex|
36
+ 'Ducks ' + ex.to_s
37
+ end
38
+
39
+ a.register Example => 'application/vnd.acceptable-api.example-v2+txt' do |ex|
40
+ 'Chickens ' + ex.to_s
41
+ end
42
+
43
+ a.expose 'AcceptableApi::AcceptanceTest::ExampleResource#show', at: '/example/:token', via: 'get'
44
+ a.to_app
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ require 'test/unit'
2
+ require 'rack/test'
3
+ require 'acceptable_api'
4
+
5
+ Test::Unit::TestCase.class_eval do
6
+ def self.test name, &block
7
+ define_method "test #{name}", &block
8
+ end
9
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acceptable_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,11 +9,27 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-04-21 00:00:00.000000000Z
12
+ date: 2012-07-29 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: json
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
14
30
  - !ruby/object:Gem::Dependency
15
31
  name: rack
16
- requirement: &70108579929200 !ruby/object:Gem::Requirement
32
+ requirement: !ruby/object:Gem::Requirement
17
33
  none: false
18
34
  requirements:
19
35
  - - ! '>='
@@ -21,10 +37,15 @@ dependencies:
21
37
  version: '0'
22
38
  type: :runtime
23
39
  prerelease: false
24
- version_requirements: *70108579929200
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
25
46
  - !ruby/object:Gem::Dependency
26
47
  name: rack-accept
27
- requirement: &70108576320140 !ruby/object:Gem::Requirement
48
+ requirement: !ruby/object:Gem::Requirement
28
49
  none: false
29
50
  requirements:
30
51
  - - ! '>='
@@ -32,10 +53,15 @@ dependencies:
32
53
  version: '0'
33
54
  type: :runtime
34
55
  prerelease: false
35
- version_requirements: *70108576320140
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
36
62
  - !ruby/object:Gem::Dependency
37
63
  name: rack-accept-header-updater
38
- requirement: &70108576318020 !ruby/object:Gem::Requirement
64
+ requirement: !ruby/object:Gem::Requirement
39
65
  none: false
40
66
  requirements:
41
67
  - - ! '>='
@@ -43,20 +69,62 @@ dependencies:
43
69
  version: '0'
44
70
  type: :runtime
45
71
  prerelease: false
46
- version_requirements: *70108576318020
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
47
78
  - !ruby/object:Gem::Dependency
48
- name: sinatra
49
- requirement: &70108576317600 !ruby/object:Gem::Requirement
79
+ name: rack-test
80
+ requirement: !ruby/object:Gem::Requirement
50
81
  none: false
51
82
  requirements:
52
83
  - - ! '>='
53
84
  - !ruby/object:Gem::Version
54
85
  version: '0'
55
- type: :runtime
86
+ type: :development
56
87
  prerelease: false
57
- version_requirements: *70108576317600
58
- description: HTTP lets clients sned an Accept header. We should probably use that
59
- to accept more than the bog-standard mime-types.
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: test-unit
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rake
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ description: HTTP lets clients send Accept headers. We should probably use that to
127
+ work out what they'll accept as a response, yea?
60
128
  email:
61
129
  - craig@barkingiguana.com
62
130
  executables: []
@@ -64,6 +132,7 @@ extensions: []
64
132
  extra_rdoc_files: []
65
133
  files:
66
134
  - .gitignore
135
+ - .rvmrc
67
136
  - Gemfile
68
137
  - LICENSE
69
138
  - README.md
@@ -75,7 +144,23 @@ files:
75
144
  - example/config.ru
76
145
  - example/example.rb
77
146
  - lib/acceptable_api.rb
147
+ - lib/acceptable_api/accepts.rb
148
+ - lib/acceptable_api/action.rb
149
+ - lib/acceptable_api/application.rb
150
+ - lib/acceptable_api/builder.rb
151
+ - lib/acceptable_api/controller.rb
152
+ - lib/acceptable_api/mapper.rb
153
+ - lib/acceptable_api/mappers.rb
154
+ - lib/acceptable_api/missing_controller.rb
155
+ - lib/acceptable_api/missing_mapper.rb
156
+ - lib/acceptable_api/missing_route.rb
157
+ - lib/acceptable_api/route.rb
158
+ - lib/acceptable_api/routes.rb
78
159
  - lib/acceptable_api/version.rb
160
+ - test/acceptance/request_different_mime_type_versions_test.rb
161
+ - test/acceptance/request_unknown_mime_type_test.rb
162
+ - test/acceptance/test_helper.rb
163
+ - test/test_helper.rb
79
164
  homepage: http://barkingiguana.com/2011/12/05/principles-of-service-design-program-to-an-interface/
80
165
  licenses: []
81
166
  post_install_message:
@@ -88,16 +173,26 @@ required_ruby_version: !ruby/object:Gem::Requirement
88
173
  - - ! '>='
89
174
  - !ruby/object:Gem::Version
90
175
  version: '0'
176
+ segments:
177
+ - 0
178
+ hash: 4567978281283614839
91
179
  required_rubygems_version: !ruby/object:Gem::Requirement
92
180
  none: false
93
181
  requirements:
94
182
  - - ! '>='
95
183
  - !ruby/object:Gem::Version
96
184
  version: '0'
185
+ segments:
186
+ - 0
187
+ hash: 4567978281283614839
97
188
  requirements: []
98
189
  rubyforge_project:
99
- rubygems_version: 1.8.10
190
+ rubygems_version: 1.8.24
100
191
  signing_key:
101
192
  specification_version: 3
102
193
  summary: Build an Acceptable API
103
- test_files: []
194
+ test_files:
195
+ - test/acceptance/request_different_mime_type_versions_test.rb
196
+ - test/acceptance/request_unknown_mime_type_test.rb
197
+ - test/acceptance/test_helper.rb
198
+ - test/test_helper.rb