acceptable_api 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ 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
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in acceptable_api.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Craig R Webster
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,172 @@
1
+ # AcceptableApi
2
+
3
+ Build an acceptable API.
4
+
5
+ HTTP is pretty darn awesome you guys. Part of HTTP - the `Accept` header -
6
+ allows the clients of our API to tell us what representation they want to work
7
+ with. We should probably pay attention to them, hey?
8
+
9
+ This is expecially important when writing an API when you may need to deal with
10
+ several versions of a representation. When a client asks for JSON we don't
11
+ really know if they want JSON version 1 or 5 of our representation.
12
+
13
+ At some point I'll clean up my thoughts on this and write something decent.
14
+ Until then, more reading here:
15
+
16
+ http://barkingiguana.com/2011/12/05/principles-of-service-design-program-to-an-interface/
17
+
18
+ If you know better than me, please mail at me and tell me what I did wrong:
19
+ craig@barkingiguana.com.
20
+
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ gem 'acceptable_api'
27
+
28
+ And then execute:
29
+
30
+ $ bundle
31
+
32
+ Or install it yourself as:
33
+
34
+ $ gem install acceptable_api
35
+
36
+ ## Usage
37
+
38
+ Assume you have a class that you want to expose via a lovely HTTP API.
39
+
40
+ class Sandwich
41
+ def self.find id
42
+ # Look up a Sandwich by ID
43
+ end
44
+
45
+ attr_accessor :fillings
46
+ attr_accessor :bread
47
+ attr_accessor :name
48
+ attr_accessor :made_at
49
+ end
50
+
51
+ You'd declare that you wanted to expose it via the API as `/sandwiches/123` like
52
+ this:
53
+
54
+ class SandwichApi < AcceptableApi::Controller
55
+ get '/sandwiches/:id' do
56
+ Sandwich.find params[:id]
57
+ end
58
+ end
59
+
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:
62
+
63
+ require 'acceptable_api'
64
+ require 'sandwich_api'
65
+
66
+ run SandwichApi
67
+
68
+ You can now use `rackup` as normal to launch a web server, and `curl` to access
69
+ your API:
70
+
71
+ $ curl -i http://localhost:9292/sandwiches/123
72
+ HTTP/1.1 406 Not Acceptable
73
+ X-Frame-Options: sameorigin
74
+ X-Xss-Protection: 1; mode=block
75
+ Content-Type: application/javascript
76
+ 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
+
81
+ {
82
+ "links": [
83
+
84
+ ]
85
+ }
86
+
87
+ 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:
90
+
91
+ AcceptableApi.register Sandwich, 'application/json' do |sandwich, request|
92
+ JSON.generate :id => sandwich.id
93
+ end
94
+
95
+ Let's try requesting the resource again:
96
+
97
+ $ curl -i http://localhost:9293/sandwiches/123
98
+ HTTP/1.1 200 OK
99
+ X-Frame-Options: sameorigin
100
+ X-Xss-Protection: 1; mode=block
101
+ Content-Type: application/json
102
+ 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
+
107
+ {"id":"123"}
108
+
109
+ Ace, we got a response. What happens if we ask for a plain text response?
110
+
111
+ $ curl -H 'Accept: text/plain' -i http://localhost:9292/sandwiches/123
112
+ HTTP/1.1 406 Not Acceptable
113
+ X-Frame-Options: sameorigin
114
+ X-Xss-Protection: 1; mode=block
115
+ Content-Type: application/javascript
116
+ 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
+
121
+ {
122
+ "links": [
123
+ {
124
+ "rel": "alternative",
125
+ "type": "application/json",
126
+ "uri": "http://localhost:9293/sandwiches/123"
127
+ }
128
+ ]
129
+ }
130
+
131
+ As expected, this is a `406 Not Acceptable` response, but we take the
132
+ 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.
135
+
136
+ Time passes, and a we decide that our API would be more useful if it returned
137
+ the fillings and bread used in the sandwich, and we want to replace the database
138
+ ID with the name of the sandwich. We want to continue supporting the old API
139
+ because lots of people are using it. We coin a new mime type in the
140
+ `application/vnd.*` space, something we really should have done to start with,
141
+ which specifies the returned document:
142
+
143
+ application/vnd.acme.sandwich-v1+json
144
+
145
+ A valid JSON document containing these keys and meanings:
146
+
147
+ name:: the name of the sandwich
148
+ fillings:: an array of fillings in the sandwich
149
+ bread:: the type of bread used in the sandwich
150
+
151
+ And we register the type with AcceptableApi:
152
+
153
+ AcceptableApi.register Sandwich, 'application/vnd.acme.sandwich-v1+json' do |sandwich, request|
154
+ JSON.generate :name => sandwich.name, :fillings => sandwich.fillings,
155
+ :bread => sandwich.bread
156
+ end
157
+
158
+ And we make the request:
159
+
160
+
161
+
162
+ See `example/example.rb` for an example.
163
+
164
+ TODO: Write usage instructions here
165
+
166
+ ## Contributing
167
+
168
+ 1. Fork it
169
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
170
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
171
+ 4. Push to the branch (`git push origin my-new-feature`)
172
+ 5. Create new Pull Request
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
data/TODO.md ADDED
@@ -0,0 +1,13 @@
1
+ * Work out if I need to use Sinatra or if I could strip out the routing
2
+ stuff - that's just about all I've used.
3
+
4
+ * Support other HTTP verbs. How could I create a Document from a POSTed
5
+ `application/vnd.acme.document-v1+xml`?
6
+
7
+ * Better documentation.
8
+
9
+ * A real test-driven codebase instead of the spike I've pulled together
10
+ here.
11
+
12
+ * Do we need to consider HATEOAS style resource links in AcceptableApi or is
13
+ that for a different project? Is anything special needed at all for this?
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/acceptable_api/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Craig R Webster"]
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.}
8
+ gem.summary = %q{Build an Acceptable API}
9
+ gem.homepage = "http://barkingiguana.com/2011/12/05/principles-of-service-design-program-to-an-interface/"
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "acceptable_api"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = AcceptableApi::VERSION
17
+ gem.add_runtime_dependency 'rack'
18
+ gem.add_runtime_dependency 'rack-accept'
19
+ gem.add_runtime_dependency 'rack-accept-header-updater'
20
+ gem.add_runtime_dependency 'sinatra'
21
+ end
@@ -0,0 +1,2 @@
1
+ vendor/gems
2
+ .bundle
@@ -0,0 +1,10 @@
1
+ source :rubygems
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
+
10
+ gem 'builder'
@@ -0,0 +1,4 @@
1
+ require 'acceptable_api'
2
+ require './example'
3
+
4
+ run Example
@@ -0,0 +1,41 @@
1
+ require 'ostruct'
2
+ require 'json'
3
+ require 'builder'
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"
12
+ end
13
+ end
14
+
15
+ AcceptableApi.register Sandwich, 'application/json' do |sandwich, request|
16
+ JSON.generate :id => sandwich.id
17
+ end
18
+
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
22
+ end
23
+
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
36
+
37
+ class Example < AcceptableApi::Controller
38
+ get '/sandwiches/:id' do
39
+ Sandwich.find params[:id]
40
+ end
41
+ end
@@ -0,0 +1,158 @@
1
+ require 'sinatra'
2
+ require 'singleton'
3
+ require 'rack/accept'
4
+ require 'rack/accept_header_updater'
5
+
6
+ require "acceptable_api/version"
7
+
8
+ 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
+ end
@@ -0,0 +1,3 @@
1
+ module AcceptableApi
2
+ VERSION = "0.0.2"
3
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acceptable_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Craig R Webster
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-04-21 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rack
16
+ requirement: &70108579929200 !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: *70108579929200
25
+ - !ruby/object:Gem::Dependency
26
+ name: rack-accept
27
+ requirement: &70108576320140 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *70108576320140
36
+ - !ruby/object:Gem::Dependency
37
+ name: rack-accept-header-updater
38
+ requirement: &70108576318020 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *70108576318020
47
+ - !ruby/object:Gem::Dependency
48
+ name: sinatra
49
+ requirement: &70108576317600 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ 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.
60
+ email:
61
+ - craig@barkingiguana.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - .gitignore
67
+ - Gemfile
68
+ - LICENSE
69
+ - README.md
70
+ - Rakefile
71
+ - TODO.md
72
+ - acceptable_api.gemspec
73
+ - example/.gitignore
74
+ - example/Gemfile
75
+ - example/config.ru
76
+ - example/example.rb
77
+ - lib/acceptable_api.rb
78
+ - lib/acceptable_api/version.rb
79
+ homepage: http://barkingiguana.com/2011/12/05/principles-of-service-design-program-to-an-interface/
80
+ licenses: []
81
+ post_install_message:
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ none: false
87
+ requirements:
88
+ - - ! '>='
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ none: false
93
+ requirements:
94
+ - - ! '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubyforge_project:
99
+ rubygems_version: 1.8.10
100
+ signing_key:
101
+ specification_version: 3
102
+ summary: Build an Acceptable API
103
+ test_files: []