useless-doc 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,21 +4,28 @@ module Useless
4
4
 
5
5
  # Documentation for an entire API.
6
6
  #
7
+ # @!attribute [r] url
8
+ # @return [String] a the URL of the API.
9
+ #
7
10
  # @!attribute [r] description
8
11
  # @return [String] a description of the API.
9
12
  #
13
+ # @!attribute [r] timestamp
14
+ # @return [Time] the time that this API doc was last updated.
15
+ #
10
16
  # @!attribute [r] resources
11
17
  # @return [Array<Resource>] the resources included in the API.
12
18
  #
13
19
  class API
14
20
 
15
- attr_accessor :url, :description, :resources
21
+ attr_accessor :url, :description, :timestamp, :resources
16
22
 
17
23
  # @param [Hash] attrs corresponds to the class's instance attributes.
18
24
  #
19
25
  def initialize(attrs = {})
20
26
  @url = attrs[:url]
21
27
  @description = attrs[:description]
28
+ @timestamp = attrs[:timestamp]
22
29
  @resources = attrs[:resources]
23
30
  end
24
31
  end
@@ -1,3 +1,4 @@
1
+ require 'useless/doc/core/api'
1
2
  require 'useless/doc/core/body'
2
3
  require 'useless/doc/core/header'
3
4
  require 'useless/doc/core/request'
@@ -7,14 +8,14 @@ require 'useless/doc/core/response'
7
8
  module Useless
8
9
  module Doc
9
10
 
10
- # A simple DSL for building resource documentation.
11
+ # A simple DSL for building API documentation.
11
12
  #
12
13
  # @example
13
- # Useless::Doc::DSL::Resource.build do
14
- # path '/resources'
15
- # description 'The entire collection of resources.'
14
+ # Useless::Doc.api 'resource.useless.io' do
15
+ # description 'A source of resources'
16
16
  #
17
- # get 'Returns a full listing of the resources.' do
17
+ # get '/resources' do
18
+ # description 'Returns a full listing of the resources.'
18
19
  # authentication_required false
19
20
  # parameter 'page', 'The page of resources to be returned.'
20
21
  # header 'X-Twiddle', 'The twiddle threshold.'
@@ -42,7 +43,10 @@ module Useless
42
43
  end
43
44
 
44
45
  def self.included(base)
45
- base.send(:extend, ClassMethods)
46
+ base.instance_eval do
47
+ extend ClassMethods
48
+ attr_reader :attributes
49
+ end
46
50
  end
47
51
 
48
52
  def initialize(attributes = {})
@@ -60,6 +64,89 @@ module Useless
60
64
  end
61
65
  end
62
66
 
67
+ class API
68
+ include DSL::Member
69
+
70
+ def initialize(attributes = {})
71
+ @resource_dsls = []
72
+ super
73
+ end
74
+
75
+ def default_attributes
76
+ { resources: [] }
77
+ end
78
+
79
+ def generate
80
+ @attributes[:resources] = @resource_dsls.map do |resource_dsl|
81
+ resource_dsl.generate
82
+ end
83
+
84
+ super
85
+ end
86
+
87
+ def url(url)
88
+ @attributes[:url] = url
89
+ end
90
+
91
+ def description(description)
92
+ @attributes[:description] = description
93
+ end
94
+
95
+ def timestamp(timestamp)
96
+ if timestamp.is_a?(String)
97
+ timestamp = Time.parse(timestamp)
98
+ end
99
+
100
+ @attributes[:timestamp] = timestamp
101
+ end
102
+
103
+ def get(path, &block)
104
+ resource(path).get(&block)
105
+ end
106
+
107
+ def head(path, &block)
108
+ resource(path).head(&block)
109
+ end
110
+
111
+ def post(path, &block)
112
+ resource(path).post(&block)
113
+ end
114
+
115
+ def put(path, &block)
116
+ resource(path).put(&block)
117
+ end
118
+
119
+ def patch(path, &block)
120
+ resource(path).patch(&block)
121
+ end
122
+
123
+ def delete(path, &block)
124
+ resource(path).delete(&block)
125
+ end
126
+
127
+ def trace(path, &block)
128
+ resource(path).trace(&block)
129
+ end
130
+
131
+ def connect(path, &block)
132
+ resource(path).connect(&block)
133
+ end
134
+
135
+ def resource(path, &block)
136
+ resource_dsl = @resource_dsls.find do |resource|
137
+ resource.attributes[:path] == path
138
+ end
139
+
140
+ unless resource_dsl
141
+ resource_dsl = Resource.new(path: path)
142
+ @resource_dsls << resource_dsl
143
+ end
144
+
145
+ resource_dsl.instance_eval(&block) if block_given?
146
+ resource_dsl
147
+ end
148
+ end
149
+
63
150
  class Resource
64
151
  include DSL::Member
65
152
 
@@ -107,8 +194,6 @@ module Useless
107
194
  method(Doc::Core::Request::Method::CONNECT, description, &block)
108
195
  end
109
196
 
110
- private
111
-
112
197
  def method(type, description, &block)
113
198
  attributes = { method: type, description: description }
114
199
  @attributes[:requests] << Request.build(attributes, &block)
@@ -1,3 +1,4 @@
1
+ require 'time'
1
2
  require 'typhoeus'
2
3
 
3
4
  module Useless
@@ -25,9 +26,32 @@ module Useless
25
26
  end
26
27
 
27
28
  module Standard
29
+ NotModified = 304
30
+
31
+ @cache = {}
32
+
28
33
  def self.retrieve(url)
29
- response = Typhoeus.options url, headers: { 'Accept' => 'application/json' }
30
- response.response_body
34
+ headers = { 'Accept' => 'application/json' }
35
+
36
+ if @cache[url]
37
+ headers['If-Modified-Since'] = @cache[url].timestamp.httpdate()
38
+ end
39
+
40
+ response = Typhoeus.options url, headers: headers
41
+
42
+ unless response.response_code == NotModified
43
+ @cache[url] = CacheItem.new(response.response_body, Time.now)
44
+ end
45
+
46
+ @cache[url].response_body
47
+ end
48
+
49
+ class CacheItem
50
+ attr_accessor :response_body, :timestamp
51
+
52
+ def initialize(response_body, timestamp)
53
+ @response_body, @timestamp = response_body, timestamp
54
+ end
31
55
  end
32
56
  end
33
57
 
@@ -5,40 +5,74 @@ require 'useless/doc/serialization/dump'
5
5
 
6
6
  module Useless
7
7
  module Doc
8
+
9
+ # Provides access to the +Doc::DSL+ via the +.doc+ method. The JSON of the
10
+ # API doc that is built will be served via an OPTIONS request to the root.
11
+ # Resource documentation is similarly served via an OPTIONS request to the
12
+ # corresponding path.
13
+ #
14
+ # @example
15
+ # class ResourceApp < Sinatra::Base
16
+ # register Useless::Doc::Sinatra
17
+ #
18
+ # doc 'resources.useless.io' do
19
+ # description 'A place with resources'
20
+ # end
21
+ #
22
+ # doc.get '/some-resources' do
23
+ # description 'Get all of these resources'
24
+ #
25
+ # request do
26
+ # parameter 'page', 'The page of resources to return.'
27
+ # end
28
+ #
29
+ # response do
30
+ # body do
31
+ # attribute 'name', 'The name of the resource.'
32
+ # end
33
+ # end
34
+ # end
35
+ #
36
+ # ...
37
+ #
38
+ # end
39
+ #
8
40
  module Sinatra
41
+ def doc=(doc)
42
+ @doc = doc
43
+ end
44
+
45
+ def doc(url = nil, &block)
46
+ @dsl ||= Useless::Doc::DSL::API.new(url: url)
47
+ @dsl.instance_eval(&block) if block_given?
48
+ @dsl
49
+ end
50
+
51
+ def generated_doc
52
+ @doc ||= @dsl.generate if @dsl
53
+ end
54
+
55
+ def self.registered(app)
56
+ app.options '/' do
57
+ if api = self.class.generated_doc
58
+ last_modified api.timestamp
59
+ Useless::Doc::Serialization::Dump.api(api)
60
+ end
61
+ end
62
+
63
+ app.options '/*' do
64
+ if api = self.class.generated_doc
65
+ resource = api.resources.find do |resource|
66
+ resource.path == "/#{params[:splat].first}"
67
+ end
68
+
69
+ if resource
70
+ last_modified api.timestamp
71
+ return Useless::Doc::Serialization::Dump.resource(resource)
72
+ end
73
+ end
9
74
 
10
- # Provides access to the +Doc::DSL+. The JSON of the resource that is
11
- # created will then be served via an OPTIONS request to the specified
12
- # +path+.
13
- #
14
- # @param [String] path the path of the resource to be documented.
15
- #
16
- # @param block is passed to the DSL to build the resource.
17
- #
18
- # @example
19
- # class ResourceApp < Sinatra::Base
20
- # register Useless::Doc::Sinatra
21
- #
22
- # doc '/some-resources' do
23
- # get 'Get all of these resources' do
24
- # request do
25
- # parameter 'page', 'The page of resources to return.'
26
- # end
27
- #
28
- # response do
29
- # body do
30
- # attribute 'name', 'The name of the resource.'
31
- # end
32
- # end
33
- # end
34
- # end
35
- # end
36
- #
37
- def doc(path, &block)
38
- resource = Useless::Doc::DSL::Resource.build path: path, &block
39
-
40
- options(path) do
41
- Doc::Serialization::Dump.resource(resource)
75
+ pass
42
76
  end
43
77
  end
44
78
  end
@@ -1,5 +1,5 @@
1
1
  module Useless
2
2
  module Doc
3
- VERSION = '0.2.1'
3
+ VERSION = '0.2.2'
4
4
  end
5
5
  end
data/lib/useless/doc.rb CHANGED
@@ -1,4 +1,9 @@
1
+ require 'useless/doc/dsl'
2
+
1
3
  module Useless
2
4
  module Doc
5
+ def self.api(url, &block)
6
+ DSL::API.build url: url, &block
7
+ end
3
8
  end
4
9
  end
@@ -1,17 +1,22 @@
1
1
  require File.dirname(__FILE__) + '/../../spec_helper'
2
2
 
3
+ require 'time'
3
4
  require 'rack/test'
5
+ require 'useless/doc'
4
6
  require 'useless/doc/dsl'
5
7
 
6
8
  describe Useless::Doc::DSL do
7
9
  describe '.build' do
8
- it 'should provide a terse DSL for building resource documentation' do
9
- resource = Useless::Doc::DSL::Resource.build do
10
- path '/widgets'
11
- description 'The entire collection of widgets'
10
+ it 'should provide a terse DSL for building API documentation (through Useless::Doc.api)' do
11
+ api = Useless::Doc.api 'widget.useless.io' do
12
+ description 'The canonical source of worldwide widgets.'
12
13
 
13
- get 'Returns a full listing of the collection' do
14
+ resource('/widgets').description 'The whole kit and kaboodle.'
15
+
16
+ get '/widgets' do
17
+ description 'List the entire collection of widgets.'
14
18
  authentication_required false
19
+
15
20
  parameter 'page', 'The page of widgets that you\'d like',
16
21
  type: :query, required: false, default: 1
17
22
  header 'X-Twiddle', 'The twiddle threshold.'
@@ -30,7 +35,7 @@ describe Useless::Doc::DSL do
30
35
  end
31
36
  end
32
37
 
33
- post do
38
+ post '/widgets' do
34
39
  authentication_required true
35
40
  description 'Creates a new widget'
36
41
 
@@ -44,17 +49,42 @@ describe Useless::Doc::DSL do
44
49
  response 201, 'The widget was successfully created'
45
50
  response 422, 'The widget couldn\'t be created because name was missing'
46
51
  end
52
+
53
+ delete '/widgets/:id' do
54
+ response 404, 'The widget could not be found.'
55
+ response 200, 'The widgets was deleted successfully'
56
+ end
57
+
58
+ resource '/widgets/:id' do
59
+ put do
60
+ description 'Resource-oriented specification.'
61
+ end
62
+ end
63
+
64
+ timestamp '2013-01-12 12:44 AM'
47
65
  end
48
66
 
49
- resource.description.should == 'The entire collection of widgets'
50
- resource.requests[0].method.should == 'GET'
51
- resource.requests[0].headers[0].description.should == 'The twiddle threshold.'
52
- resource.requests[0].responses[1].code.should == 200
53
- resource.requests[1].method.should == 'POST'
54
- resource.requests[1].description.should == 'Creates a new widget'
55
- resource.requests[1].body.content_type.should == 'application/json'
56
- resource.requests[1].body.attributes[0].key.should == 'name'
57
- resource.requests[1].responses[1].description.should == 'The widget couldn\'t be created because name was missing'
67
+ api.url.should == 'widget.useless.io'
68
+ api.description.should == 'The canonical source of worldwide widgets.'
69
+ api.timestamp.should == Time.parse('2013-01-12 12:44 AM')
70
+
71
+ collection = api.resources.find { |resource| resource.path == '/widgets' }
72
+ collection.description.should == 'The whole kit and kaboodle.'
73
+ collection.requests[0].method.should == 'GET'
74
+ collection.requests[0].headers[0].description.should == 'The twiddle threshold.'
75
+ collection.requests[0].responses[1].code.should == 200
76
+ collection.requests[1].method.should == 'POST'
77
+ collection.requests[1].description.should == 'Creates a new widget'
78
+ collection.requests[1].body.content_type.should == 'application/json'
79
+ collection.requests[1].body.attributes[0].key.should == 'name'
80
+ collection.requests[1].responses[1].description.should == 'The widget couldn\'t be created because name was missing'
81
+
82
+ instance = api.resources.find { |resource| resource.path == '/widgets/:id' }
83
+ instance.requests[0].method.should == 'DELETE'
84
+ instance.requests[0].responses[0].code.should == 404
85
+ instance.requests[0].responses[1].code.should == 200
86
+ instance.requests[1].method.should == 'PUT'
87
+ instance.requests[1].description.should == 'Resource-oriented specification.'
58
88
  end
59
89
  end
60
90
  end
@@ -1,5 +1,6 @@
1
1
  require File.dirname(__FILE__) + '/../../../spec_helper'
2
2
 
3
+ require 'time'
3
4
  require 'rack/test'
4
5
  require 'useless/doc/rack/retriever'
5
6
 
@@ -42,8 +43,34 @@ describe Useless::Doc::Rack::Retriever do
42
43
  end
43
44
 
44
45
  describe Useless::Doc::Rack::Retriever::Standard do
45
- it 'should respond to `retrieve`' do
46
- Useless::Doc::Rack::Retriever::Standard.should respond_to(:retrieve)
46
+ before(:each) do
47
+ Useless::Doc::Rack::Retriever::Standard.instance_variable_set(:@cache, {})
48
+ end
49
+
50
+ it 'should make a normal request if the cache is empty.' do
51
+ Typhoeus.should_receive(:options).
52
+ with('http://some-api.granmal.com/some/resource', headers: { 'Accept' => 'application/json' }).
53
+ and_return(mock(:response, response_code: 200, response_body: '{ "some": "json" }'))
54
+
55
+ response = Useless::Doc::Rack::Retriever::Standard.retrieve('http://some-api.granmal.com/some/resource')
56
+ response.should == '{ "some": "json" }'
57
+ end
58
+
59
+ it 'should make a request with a cache control header if there is a cache hit.' do
60
+ now = Time.now
61
+ Time.should_receive(:now).once.and_return(now)
62
+
63
+ Typhoeus.should_receive(:options).once.
64
+ with('http://some-api.granmal.com/some/resource', headers: { 'Accept' => 'application/json' }).
65
+ and_return(mock(:response, response_code: 200, response_body: '{ "some": "json" }'))
66
+
67
+ Typhoeus.should_receive(:options).once.
68
+ with('http://some-api.granmal.com/some/resource', headers: { 'Accept' => 'application/json', 'If-Modified-Since' => now.httpdate}).
69
+ and_return(mock(:response, response_code: 304, response_body: ''))
70
+
71
+ Useless::Doc::Rack::Retriever::Standard.retrieve('http://some-api.granmal.com/some/resource')
72
+ response = Useless::Doc::Rack::Retriever::Standard.retrieve('http://some-api.granmal.com/some/resource')
73
+ response.should == '{ "some": "json" }'
47
74
  end
48
75
  end
49
76
 
@@ -9,23 +9,20 @@ describe Useless::Doc::Sinatra do
9
9
  class DocApp < Sinatra::Base
10
10
  register Useless::Doc::Sinatra
11
11
 
12
- doc '/some-resources' do
13
- get 'Get all of these resources' do
14
- parameter 'since', 'Only resources after this date.'
15
-
16
- response 200, 'The responses were fetched correctly.' do
17
- body do
18
- attribute 'name', 'The name of the resource.'
19
- end
20
- end
21
- end
12
+ doc 'resource.useless.io' do
13
+ description 'A resource repository'
14
+ timestamp '2013-01-01 12:00 PM'
15
+ end
16
+
17
+ doc.get '/some-resources' do
18
+ description 'Get all of these resources'
22
19
 
23
- post 'Make a new resource' do
20
+ parameter 'since', 'Only resources after this date.'
21
+
22
+ response 200, 'The responses were fetched correctly.' do
24
23
  body do
25
24
  attribute 'name', 'The name of the resource.'
26
25
  end
27
-
28
- response 201, 'The resource was successfully created.'
29
26
  end
30
27
  end
31
28
 
@@ -33,9 +30,33 @@ describe Useless::Doc::Sinatra do
33
30
  []
34
31
  end
35
32
 
33
+ doc.post '/some-resources' do
34
+ description 'Make a new resource'
35
+
36
+ body do
37
+ attribute 'name', 'The name of the resource.'
38
+ end
39
+
40
+ response 201, 'The resource was successfully created.'
41
+ end
42
+
36
43
  post '/some-resources' do
37
44
  201
38
45
  end
46
+
47
+ doc.put '/some-resources/:id' do
48
+ description 'Update a resource'
49
+
50
+ body do
51
+ attribute 'name', 'The name of the resource.'
52
+ end
53
+
54
+ response 200, 'The resource was successfully updated.'
55
+ end
56
+
57
+ put '/some-resources/:id' do
58
+ 200
59
+ end
39
60
  end
40
61
 
41
62
  include Rack::Test::Methods
@@ -44,7 +65,17 @@ describe Useless::Doc::Sinatra do
44
65
  DocApp
45
66
  end
46
67
 
47
- it 'should serve documentation for the documented resource' do
68
+ it 'should serve documentation for the whole API from root.' do
69
+ options 'http://some-api.granmal.com/'
70
+ api = Useless::Doc::Serialization::Load.api(last_response.body)
71
+
72
+ paths = api.resources.map { |resource| resource.path }
73
+ paths.length.should == 2
74
+ paths.should include '/some-resources'
75
+ paths.should include '/some-resources/:id'
76
+ end
77
+
78
+ it 'should serve documentation for the specified resource.' do
48
79
  options 'http://some-api.granmal.com/some-resources'
49
80
  resource = Useless::Doc::Serialization::Load.resource(last_response.body)
50
81
 
@@ -57,4 +88,24 @@ describe Useless::Doc::Sinatra do
57
88
  post.responses[0].code.should == 201
58
89
  end
59
90
 
91
+ it 'should return a 404 if the specified resource doesn\'t exist' do
92
+ options 'http://some-api.granmal.com/some-nonexistant-resources'
93
+ last_response.should be_not_found
94
+ end
95
+
96
+ it 'should return a 304 if the doc has not been modified since specified time' do
97
+ header 'If-Modified-Since', Time.parse('2013-01-02 12:00 PM').httpdate
98
+ options 'http://some-api.granmal.com/'
99
+ last_response.status.should == 304
100
+ options 'http://some-api.granmal.com/some-resources'
101
+ last_response.status.should == 304
102
+ end
103
+
104
+ it 'should not return a 304 if the doc has been modified since the specified time' do
105
+ header 'If-Modified-Since', Time.parse('2012-12-31 12:00 PM').httpdate
106
+ options 'http://some-api.granmal.com/'
107
+ last_response.status.should_not == 304
108
+ options 'http://some-api.granmal.com/some-resources'
109
+ last_response.status.should_not == 304
110
+ end
60
111
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: useless-doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-01-09 00:00:00.000000000 Z
12
+ date: 2013-01-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: oj