kiosk 0.0.1

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,18 @@
1
+ module Kiosk
2
+ module ProspectiveNode
3
+ # Attempts to match the given pattern against the resource URI.
4
+ #
5
+ def match_uri(pattern, shim_patterns = {})
6
+ resource_uri.match(pattern, shim_patterns)
7
+ rescue URI::BadURIError
8
+ nil
9
+ end
10
+
11
+ # Returns either the nodes +href+ or +src+ attribute as a parsed
12
+ # +ResourceURI+.
13
+ #
14
+ def resource_uri
15
+ ResourceURI.parse(self['href'] || self['src'] || '')
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,75 @@
1
+ require 'active_support/concern'
2
+
3
+ module Kiosk
4
+ module Prospector
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ # Tags portions of any resource content as "belonging" to this resource.
9
+ # For instance, a link to a post could belong to a Post resource. This
10
+ # allows for content to be later rewritten in the controller as deemed
11
+ # necessary. A typical scenario for this requirement might be when URIs
12
+ # within the content need to be targetted to the host app and not a CMS
13
+ # with which you are integrating.
14
+ #
15
+ # For example, links to http://cms.example/post/123 could be tagged
16
+ # by a Post resource and later rewritten by a PostsController to be
17
+ # http://app.example/posts/123.
18
+ #
19
+ # Available options:
20
+ #
21
+ # - +:selector+: The CSS selector used to find content nodes.
22
+ # - +:priority+: Claims with higher priority (a lower value) take
23
+ # precedence. Symbols +:high+, +:normal+, +:low+ map to
24
+ # values -9, 0, and 9 respectively.
25
+ # - +:pattern+: For use with +claims_path_content+. Specifies the
26
+ # pattern to use when matching a node's URI. See
27
+ # +ResourceURI#match+ for documentation on pattern syntax.
28
+ #
29
+ # Different types of claims may be supported and are made by calling
30
+ # +claims_<type>_content+. The default is a +Kiosk::Claim::NodeClaim+.
31
+ #
32
+ # Examples:
33
+ #
34
+ # class Attachment
35
+ # claims_content(:selector => 'img.attachment') do |node|
36
+ # (m = node['src'].match(/\d+$)) && { :id => m[1] }
37
+ # end
38
+ # end
39
+ #
40
+ # Typically, you'd want to use the extensions of +ProspectiveNode+.
41
+ #
42
+ # class Post
43
+ # claims_content(:selector => 'a.post') { |node| node.match_uri 'post/:slug' }
44
+ # end
45
+ #
46
+ # A +PathClaim+ is also available that simplified this pattern. See
47
+ # +ResourceURI#match+ for documentation regarding the URI pattern.
48
+ #
49
+ # class Post
50
+ # claims_path_content(:selector => 'a.post', :pattern => 'post/:slug')
51
+ # end
52
+ #
53
+ def claims_content(options = {}, &parser)
54
+ claims_x_content(:node, options, &parser)
55
+ end
56
+
57
+ # Handles calls to +claims_<type>_content+. See +method_missing+.
58
+ #
59
+ def claims_x_content(type, options = {}, &parser)
60
+ Kiosk.rewriter.add_claim(Kiosk::Claim.new(type, self, options, &parser), options)
61
+ end
62
+
63
+ # Implements +claims_<type>_content+ methods. See +claims_x_content+.
64
+ #
65
+ def method_missing(name, *args, &blk)
66
+ case name.to_s
67
+ when /^claims_(\w+)_content$/
68
+ claims_x_content($1.to_sym, *args, &blk)
69
+ else
70
+ super
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,313 @@
1
+ require 'active_resource'
2
+
3
+ # Proxy for content resources.
4
+ #
5
+ module Kiosk
6
+ class Resource < ::ActiveResource::Base
7
+ include Cacheable::Resource
8
+ include Localizable::Resource
9
+ include Prospector
10
+
11
+ ##############################################################################
12
+ # ActiveResource config
13
+ ##############################################################################
14
+ self.site = Kiosk.origin.site
15
+ self.format = :json
16
+
17
+ schema do
18
+ attribute 'slug', :string
19
+ end
20
+
21
+ ##############################################################################
22
+ # Caching
23
+ ##############################################################################
24
+ cached_expire_in { |resource| resource['status'] == 'error' ? 30.minutes : 1.day }
25
+
26
+ ##############################################################################
27
+ # Class methods
28
+ ##############################################################################
29
+ class << self
30
+ # Returns all instances of the resource.
31
+ #
32
+ def all
33
+ find(:all)
34
+ end
35
+
36
+ # Reimplements the +ActiveResource+ path constructor to work with the
37
+ # WordPress JSON-API plugin.
38
+ #
39
+ def element_path(id, prefix_options = {}, query_options = nil)
40
+ "#{api_path_to("get_#{element_name}")}#{query_string({:id => id}.merge(query_options || {}))}"
41
+ end
42
+
43
+ def element_path_by_slug(slug, prefix_options = {}, query_options = nil)
44
+ "#{api_path_to("get_#{element_name}")}#{query_string({:slug => slug}.merge(query_options || {}))}"
45
+ end
46
+
47
+ # Adds functionality to the +ActiveResource.find+ method to allow for
48
+ # specifying the WordPress JSON API method that should be used. This
49
+ # simplifies definition of scopes in derived models.
50
+ #
51
+ def find(*arguments)
52
+ scope = arguments.slice!(0)
53
+ options = arguments.slice!(0) || {}
54
+
55
+ if options.key?(:method)
56
+ options[:from] = api_path_to(options[:method])
57
+ options.delete(:method)
58
+ end
59
+
60
+ super(scope, options)
61
+ end
62
+
63
+ # Finds all resources by the given related resource.
64
+ #
65
+ # Example:
66
+ #
67
+ # Post.find_by_associated(category)
68
+ #
69
+ # Is the same as invoking:
70
+ #
71
+ # Post.find(:all, :method => "get_category_posts", :params => {:id => category.id})
72
+ #
73
+ def find_by_associated(resource, params = {})
74
+ find(:all,
75
+ :method => "get_#{resource.class.element_name}_#{element_name.pluralize}",
76
+ :params => params.merge({:id => resource.id}))
77
+ end
78
+
79
+ # Finds the resource by the given slug.
80
+ #
81
+ def find_by_slug(slug)
82
+ find(:one, :method => "get_#{element_name}", :params => {:slug => slug})
83
+ end
84
+
85
+ # Reimplements the +ActiveResource+ path constructor to work with the
86
+ # WordPress JSON-API plugin.
87
+ #
88
+ def collection_path(prefix_options = {}, query_options = nil)
89
+ "#{api_path_to("get_#{element_name}_index")}#{query_string(query_options)}"
90
+ end
91
+
92
+ # Reimplements the +ActiveResource+ method to check for bad responses
93
+ # before instantiating a collection.
94
+ #
95
+ def instantiate_collection(collection, prefix_options = {})
96
+ super(normalize_response(collection, true), prefix_options)
97
+ end
98
+
99
+ # Reimplements the +ActiveResource+ method to check for bad responses
100
+ # before instantiating an object.
101
+ #
102
+ def instantiate_record(record, prefix_options = {})
103
+ super(normalize_response(record), prefix_options)
104
+ end
105
+
106
+ # Executes the given block within a scope where all requests for this
107
+ # content resource are appended with the given parameters.
108
+ #
109
+ # class Post < Resource; end
110
+ #
111
+ # Post.with_parameters(:language => 'en') do
112
+ # english_posts = Post.find(:all)
113
+ # english_pages = Page.find(:all)
114
+ # end
115
+ #
116
+ # Scopes can be nested.
117
+ #
118
+ # Post.with_parameters(:language => 'es') do
119
+ # Post.with_parameters(:recent => true) do
120
+ # recent_spanish_posts = Post.find(:all)
121
+ # end
122
+ # end
123
+ #
124
+ # Scopes are inherited.
125
+ #
126
+ # Resource.with_parameters(:language => 'fr') do
127
+ # french_posts = Post.find(:all)
128
+ # end
129
+ #
130
+ # However, nesting is still respected.
131
+ #
132
+ # Resource.with_parameters(:language => 'fr') do
133
+ # Post.with_parameters(:language => 'en') do
134
+ # english_posts = Post.find(:all)
135
+ # end
136
+ # end
137
+ #
138
+ # Even with this nesting inverted.
139
+ #
140
+ # Post.with_parameters(:language => 'fr') do
141
+ # Resource.with_parameters(:language => 'en') do
142
+ # english_posts = Post.find(:all)
143
+ # end
144
+ # end
145
+ #
146
+ def with_parameters(params = {})
147
+ push_to_query_scope_stack(params)
148
+
149
+ begin
150
+ yield
151
+ ensure
152
+ pop_from_query_scope_stack
153
+ end
154
+ end
155
+
156
+ protected
157
+
158
+ # Returns the path to the given method of the WordPress API.
159
+ #
160
+ def api_path_to(method)
161
+ "#{site.path}api/#{method}/"
162
+ end
163
+
164
+ # Checks the given response for errors and normalizes its structure. A
165
+ # response from the API includes an envelope, which must be checked for
166
+ # the response status ("ok" or "error"). If an error is found, an
167
+ # exception is raised.
168
+ #
169
+ def normalize_response(response, collection = false)
170
+ response = case response['status']
171
+ when 'ok'
172
+ response[collection ? element_name.pluralize : element_name]
173
+ when 'error'
174
+ raise_error(response['error'])
175
+ else
176
+ # This isn't a response envelope. Just let it pass through.
177
+ response
178
+ end
179
+
180
+ camelcase_keys(response)
181
+ end
182
+
183
+ # Reimplements the parent method to include parameters of the current
184
+ # query scope. See +with_parameters+.
185
+ #
186
+ def query_string(options)
187
+ scoped_options = my_query_scope_stack.inject({}) do |scoped_options,(klass,opt_stack)|
188
+ if self.ancestors.include?(klass)
189
+ scoped_options = opt_stack.reduce(scoped_options) do |scoped_options,opts|
190
+ scoped_options.merge(opts)
191
+ end
192
+ else
193
+ scoped_options
194
+ end
195
+ end
196
+
197
+ super(scoped_options.merge(options))
198
+ end
199
+
200
+ # Handles errors returned by the WordPress JSON API.
201
+ #
202
+ def raise_error(error)
203
+ case error
204
+ when 'Not found.'
205
+ raise Kiosk::ResourceNotFound.new(error)
206
+ when /Un?known method '(\w+)'/ # note the possibility of a spelling error
207
+ raise NotImplementedError.new(error)
208
+ else
209
+ raise Kiosk::ResourceError.new(error)
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ mattr_accessor :query_scope_stack
216
+
217
+ # Filters and sorts the query-scope stack so that only scopes relevant
218
+ # to this class are applied and are applied in order from furthest
219
+ # ancestor to nearest.
220
+ #
221
+ def my_query_scope_stack
222
+ if query_scope_stack
223
+ Hash[query_scope_stack.select do |klass,stack|
224
+ self.ancestors.include?(klass)
225
+ end.sort_by do |klass,stack|
226
+ self.ancestors.index(klass) * -1
227
+ end]
228
+ else
229
+ {}
230
+ end
231
+ end
232
+
233
+ def push_to_query_scope_stack(params)
234
+ self.query_scope_stack ||= {}
235
+ self.query_scope_stack[self] ||= []
236
+
237
+ # Append stacks for this class and all descendent classes.
238
+ query_scope_stack.each_key do |klass|
239
+ query_scope_stack[klass].push(params) if klass.ancestors.include?(self)
240
+ end
241
+ end
242
+
243
+ def pop_from_query_scope_stack
244
+ # Pop stacks for this class and all descendent classes.
245
+ query_scope_stack.each_key do |klass|
246
+ query_scope_stack[klass].pop if klass.ancestors.include?(self)
247
+ end
248
+ end
249
+
250
+ # Recursively changes the keys of the given hash (or array of hashes)
251
+ # to camelcase.
252
+ #
253
+ def camelcase_keys(obj)
254
+ case obj
255
+ when Hash
256
+ obj.inject({}) { |hash,(k,v)| hash[k.to_s.underscore] = camelcase_keys(v); hash }
257
+ when Array
258
+ obj.map { |v| camelcase_keys(v) }
259
+ else
260
+ obj
261
+ end
262
+ end
263
+ end
264
+
265
+ ##############################################################################
266
+ # Instance methods
267
+ ##############################################################################
268
+
269
+ # Returns the rewritten resource content. See +raw_content+ for untouched
270
+ # content.
271
+ #
272
+ def content
273
+ @content ||= raw_content && Kiosk.rewriter.rewrite(raw_content)
274
+ end
275
+
276
+ # Returns the rewritten resource excerpt. See +raw_excerpt+ for untouched
277
+ # content.
278
+ #
279
+ def excerpt
280
+ @excerpt ||= raw_excerpt && Kiosk.rewriter.rewrite(raw_excerpt)
281
+ end
282
+
283
+ # Destroying is not supported.
284
+ #
285
+ def destroy
286
+ raise NotImplementedError
287
+ end
288
+
289
+ # Returns the resource content, untouched by the content rewriter.
290
+ #
291
+ def raw_content
292
+ attributes[:content]
293
+ end
294
+
295
+ # Returns the resource excerpt, untouched by the content rewriter.
296
+ #
297
+ def raw_excerpt
298
+ attributes[:excerpt]
299
+ end
300
+
301
+ # Saving is not supported.
302
+ #
303
+ def save
304
+ raise NotImplementedError
305
+ end
306
+
307
+ # Returns the value used in constructing a URL to this object.
308
+ #
309
+ def to_param
310
+ attributes[:slug] || attributes[:id]
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,5 @@
1
+ require 'active_resource'
2
+ require 'active_resource/exceptions'
3
+
4
+ class Kiosk::ResourceError < ActiveResource::ServerError #:nodoc:
5
+ end
@@ -0,0 +1,12 @@
1
+ require 'active_resource'
2
+ require 'active_resource/exceptions'
3
+
4
+ class Kiosk::ResourceNotFound < ActiveResource::ResourceNotFound #:nodoc:
5
+ def initialize(message)
6
+ super(404, message)
7
+ end
8
+
9
+ def to_s
10
+ @message
11
+ end
12
+ end
@@ -0,0 +1,82 @@
1
+ module Kiosk
2
+ module ResourceURI
3
+ DEFAULT_SHIM_PATTERN = /[^\/\?]+/
4
+
5
+ class << self
6
+ def parse(uri_string)
7
+ (uri = URI.parse(uri_string)) && uri.extend(InstanceMethods)
8
+ end
9
+ end
10
+
11
+ module InstanceMethods
12
+ # Matches the relative part of the URI path with the given pattern and
13
+ # returns a hash of parsed attributes constructed from the match. The
14
+ # pattern for each shim can be provided as a hash.
15
+ #
16
+ # Examples:
17
+ #
18
+ # uri = ResourceURI.parse('http://some.example/site/post/some-slug')
19
+ #
20
+ # uri.match('post/:slug')
21
+ # # => { :slug => 'some-slug' }
22
+ #
23
+ # uri.match('post/:id', :id => /\d+/)
24
+ # # => nil
25
+ #
26
+ # Tokens in the pattern starting with '!' are not captured. They can be
27
+ # used to represent portions of the path by name that are purely for
28
+ # readability.
29
+ #
30
+ def match(pattern, shim_patterns = nil)
31
+ shims = []
32
+ shim_patterns ||= {}
33
+
34
+ re_pattern = pattern.gsub(/!(\w+)/, DEFAULT_SHIM_PATTERN.to_s)
35
+ re_pattern = re_pattern.gsub(/:(\w+)/) do |s|
36
+ shims << (shim = $1.to_sym)
37
+ '(' + (shim_patterns[shim] || DEFAULT_SHIM_PATTERN).to_s + ')'
38
+ end
39
+
40
+ re = Regexp.new('^' + re_pattern + '.*$')
41
+
42
+ # Match against the part of the URI path that follows the content origin
43
+ # site path. If the resulting route contains no host and is relative,
44
+ # the URI is within our content origin.
45
+ if route = route_from(Kiosk.origin.site)
46
+ if route.host.nil? and route.relative?
47
+ route.path.match(re) do |matches|
48
+ attributes = {}
49
+ shims.each_with_index { |shim,i| attributes[shim] = matches[i + 1] }
50
+ attributes
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ # Reimplements parent method so as to further qualify routes that are
57
+ # relative but outside the base origin site path (routes that resolve to
58
+ # '../some/external/path').
59
+ #
60
+ def route_from(uri)
61
+ uri = URI.parse(uri) unless uri.is_a?(URI::Generic)
62
+
63
+ # allow a route from http to https to be relative if the host is the
64
+ # same
65
+ if (uri.host == self.host) and (uri.scheme == 'http' and self.scheme == 'https')
66
+ self.scheme = 'http'
67
+ self.port = uri.port
68
+ end
69
+
70
+ route = super(uri)
71
+
72
+ if route.relative? && route.path['../']
73
+ new_uri = uri.clone
74
+ new_uri.path = File.expand_path(route.path, uri.path)
75
+ new_uri
76
+ else
77
+ route
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end