acceptable_api 0.0.2

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.
@@ -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: []