acceptable_api 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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