useless-doc 0.2.1 → 0.2.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.
@@ -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