ahora 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.
- data/.gitignore +4 -0
- data/Gemfile +3 -0
- data/README.md +117 -0
- data/Rakefile +7 -0
- data/ahora.gemspec +27 -0
- data/lib/ahora/middleware/last_modified_caching.rb +88 -0
- data/lib/ahora/middleware/request_logger.rb +85 -0
- data/lib/ahora/middleware.rb +3 -0
- data/lib/ahora/representation.rb +122 -0
- data/lib/ahora/resource.rb +68 -0
- data/lib/ahora/version.rb +3 -0
- data/lib/ahora.rb +3 -0
- data/test/ahora/representation_test.rb +39 -0
- data/test/ahora_test.rb +214 -0
- data/test/fixtures/employee.xml +6 -0
- data/test/fixtures/user_posts.xml +42 -0
- data/test/test_helper.rb +11 -0
- metadata +128 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
Ahora
|
2
|
+
=====
|
3
|
+
|
4
|
+
An alternative to ActiveResource for consuming Java-ish XML HTTP Resources easily. *Ahora* glues together two amazing Ruby libraries: [Faraday](https://github.com/technoweenie/faraday) and [Nibbler](https://github.com/mislav/nibbler).
|
5
|
+
|
6
|
+
If your project meets any of the following requirements, you might be interested.
|
7
|
+
|
8
|
+
* You want to consume an external XML resource.
|
9
|
+
* You want to map camelCase names to underscore_case.
|
10
|
+
* You want to parse Date, Time and boolean values automatically.
|
11
|
+
* You want to be a good citizen on the web and support caching (e.g. If-Modified-Since).
|
12
|
+
* You want to use fragment caching on the front-end. So it should generate a cache-key.
|
13
|
+
* You might have a recently cached fragment, so XML collections should be lazy loaded.
|
14
|
+
|
15
|
+
This is a big list, you might not need all these requirements. At least the code can act as an example on how to approach one of these problems.
|
16
|
+
|
17
|
+
Example
|
18
|
+
---
|
19
|
+
|
20
|
+
Let's say you want to display to following XML on a page in a Rails project
|
21
|
+
|
22
|
+
~~~ xml
|
23
|
+
<?xml version="1.0"?>
|
24
|
+
<userPosts type="array">
|
25
|
+
<userPost>
|
26
|
+
<hidden>false</hidden>
|
27
|
+
<objectId>1</objectId>
|
28
|
+
<userObjectId>1</userObjectId>
|
29
|
+
<user>
|
30
|
+
<firstName>John</firstName>
|
31
|
+
<lastName>Doe</lastName>
|
32
|
+
</user>
|
33
|
+
<body>How is everybody today?</body>
|
34
|
+
<createdAt>2011-10-26T17:01:52+02:00</createdAt>
|
35
|
+
|
36
|
+
<replies type="array">
|
37
|
+
<userPost>
|
38
|
+
<objectId>2</objectId>
|
39
|
+
<parentObjectId>1</parentObjectId>
|
40
|
+
<userObjectId>2</userObjectId>
|
41
|
+
<user>
|
42
|
+
<firstName>Mike</firstName>
|
43
|
+
<lastName>Smith</lastName>
|
44
|
+
</user>
|
45
|
+
<body>I am fine, thanks for asking.</body>
|
46
|
+
<createdAt>2011-10-27T9:00:00+02:00</createdAt>
|
47
|
+
</userPost>
|
48
|
+
<userPost>
|
49
|
+
<objectId>3</objectId>
|
50
|
+
<parentObjectId>1</parentObjectId>
|
51
|
+
<userObjectId>1</userObjectId>
|
52
|
+
<user>
|
53
|
+
<firstName>John</firstName>
|
54
|
+
<lastName>Doe</lastName>
|
55
|
+
</user>
|
56
|
+
<body>You are more than welcome.</body>
|
57
|
+
<createdAt>2011-10-27T9:00:00+02:00</createdAt>
|
58
|
+
</userPost>
|
59
|
+
</replies>
|
60
|
+
</userPost>
|
61
|
+
<userPost>
|
62
|
+
<hidden>true</hidden>
|
63
|
+
</userPost>
|
64
|
+
</userPosts>
|
65
|
+
~~~
|
66
|
+
|
67
|
+
You start by defining a class with methods to retrieve the resource and another class to act as a mapper.
|
68
|
+
|
69
|
+
~~~ ruby
|
70
|
+
class Post < Ahora::Representation
|
71
|
+
objectid :id, :user_id, :parent_id
|
72
|
+
date :created_at
|
73
|
+
element :body
|
74
|
+
element 'user', :with => Ahora::Representation do
|
75
|
+
string :first_name, :last_name
|
76
|
+
end
|
77
|
+
boolean :hidden
|
78
|
+
elements 'replies/userPost' => :replies, :with => Post
|
79
|
+
end
|
80
|
+
|
81
|
+
class PostResource
|
82
|
+
include Ahora::Resource
|
83
|
+
|
84
|
+
def all
|
85
|
+
collection Post, get("/api/posts.xml")
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
def host
|
90
|
+
'http://test.net'
|
91
|
+
end
|
92
|
+
|
93
|
+
def extend_middleware(builder)
|
94
|
+
builder.use Ahora::Middleware::LastModifiedCaching, Rails.cache
|
95
|
+
end
|
96
|
+
end
|
97
|
+
~~~
|
98
|
+
|
99
|
+
Now you can define a controller as usual.
|
100
|
+
|
101
|
+
~~~ ruby
|
102
|
+
class PostsController < ApplicationController::Base
|
103
|
+
def index
|
104
|
+
@posts = PostResource.new.all
|
105
|
+
end
|
106
|
+
end
|
107
|
+
~~~
|
108
|
+
|
109
|
+
And a view template
|
110
|
+
|
111
|
+
~~~ html
|
112
|
+
<% cache @posts, 'index' %>
|
113
|
+
<%= render(:partial => @posts) %>
|
114
|
+
<% end >
|
115
|
+
~~~
|
116
|
+
|
117
|
+
And that's all there is. The XML response will be cached, so it saves a bandwith. The XML response will only be parsed if there is no existing HTML fragment cache. All cache will be invalidated when the request to posts.xml returns a new body instead of a 304 Not Modified.
|
data/Rakefile
ADDED
data/ahora.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "ahora/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "ahora"
|
7
|
+
s.version = Ahora::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Matthijs Langenberg"]
|
10
|
+
s.email = ["matthijs.langenberg@nedap.com"]
|
11
|
+
s.homepage = ""
|
12
|
+
s.summary = %q{Consume Java-ish XML HTTP Resources easily}
|
13
|
+
s.description = %q{Consume Java-ish XML HTTP Resources easily}
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test}/*`.split("\n")
|
17
|
+
#s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
|
20
|
+
s.add_dependency "nibbler", '>= 1.3.0'
|
21
|
+
s.add_dependency "faraday", '>= 0.7'
|
22
|
+
s.add_dependency "nokogiri", "~> 1.5"
|
23
|
+
s.add_dependency "activesupport"
|
24
|
+
|
25
|
+
s.add_development_dependency "fakeweb"
|
26
|
+
s.add_development_dependency "minitest"
|
27
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Ahora
|
2
|
+
module Middleware
|
3
|
+
class LastModifiedCaching < Faraday::Middleware
|
4
|
+
attr_reader :cache
|
5
|
+
|
6
|
+
extend Forwardable
|
7
|
+
def_delegators :'Faraday::Utils', :parse_query, :build_query
|
8
|
+
|
9
|
+
# Public: initialize the middleware.
|
10
|
+
#
|
11
|
+
# cache - An object that responds to read, write and fetch (default: nil).
|
12
|
+
# options - An options Hash (default: {}):
|
13
|
+
# :ignore_params - String name or Array names of query params
|
14
|
+
# that should be ignored when forming the cache
|
15
|
+
# key (default: []).
|
16
|
+
# :cache_header - String name of response header that should be
|
17
|
+
# used to retrieve cache timestamp from.
|
18
|
+
# Defaults to 'last-modified'
|
19
|
+
# :expire_in - Integer time to live value in seconds for a key.
|
20
|
+
# Defaults to never expire.
|
21
|
+
#
|
22
|
+
# Yields if no cache is given. The block should return a cache object.
|
23
|
+
def initialize(app, cache = nil, options = {})
|
24
|
+
super(app)
|
25
|
+
options, cache = cache, nil if cache.is_a? Hash and block_given?
|
26
|
+
@cache = cache || yield
|
27
|
+
@options = options
|
28
|
+
end
|
29
|
+
|
30
|
+
def call(env)
|
31
|
+
if env[:method] == :get
|
32
|
+
|
33
|
+
timestamp_key = cache_key(env) + ':timestamp'
|
34
|
+
data_key = cache_key(env) + ':response'
|
35
|
+
|
36
|
+
if date = cache.read(timestamp_key)
|
37
|
+
# WARN FakeWeb cannot test this
|
38
|
+
env[:request_headers]['If-Modified-Since'] = Time.parse(date).httpdate
|
39
|
+
end
|
40
|
+
|
41
|
+
response = @app.call(env)
|
42
|
+
|
43
|
+
if response.status == 304
|
44
|
+
response = cache.read data_key
|
45
|
+
elsif date = response.headers[@options[:cache_header] || 'Last-Modified']
|
46
|
+
response.env[:response_headers]['X-Ahora-Cache-Key'] = fragment_cache_key(env, date)
|
47
|
+
cache.write timestamp_key, date, :expire_in => @options[:expire_in]
|
48
|
+
cache.write data_key, response, :expire_in => @options[:expire_in]
|
49
|
+
end
|
50
|
+
|
51
|
+
finalize_response(response, env)
|
52
|
+
else
|
53
|
+
@app.call(env)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def fragment_cache_key(env, last_modified)
|
58
|
+
cache_key(env) + ":fragment_#{last_modified}"
|
59
|
+
end
|
60
|
+
|
61
|
+
def cache_key(env)
|
62
|
+
url = env[:url].dup
|
63
|
+
if url.query && params_to_ignore.any?
|
64
|
+
params = parse_query url.query
|
65
|
+
params.reject! {|k,| params_to_ignore.include? k }
|
66
|
+
url.query = build_query params
|
67
|
+
end
|
68
|
+
url.normalize!
|
69
|
+
url.to_s
|
70
|
+
end
|
71
|
+
|
72
|
+
def params_to_ignore
|
73
|
+
@params_to_ignore ||= Array(@options[:ignore_params]).map { |p| p.to_s }
|
74
|
+
end
|
75
|
+
|
76
|
+
def finalize_response(response, env)
|
77
|
+
response = response.dup if response.frozen?
|
78
|
+
env[:response] = response
|
79
|
+
unless env[:response_headers]
|
80
|
+
env.update response.env
|
81
|
+
# FIXME: omg hax
|
82
|
+
response.instance_variable_set('@env', env)
|
83
|
+
end
|
84
|
+
response
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Ahora
|
2
|
+
module Middleware
|
3
|
+
class RequestLogger < Faraday::Response::Middleware
|
4
|
+
def initialize(app, logger)
|
5
|
+
super(app)
|
6
|
+
@logger = logger || begin
|
7
|
+
require 'logger'
|
8
|
+
Logger.new(STDOUT)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
@logger.info "#{env[:method].to_s.upcase} #{env[:url].to_s}"
|
14
|
+
@started = Time.now
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def on_complete(env)
|
19
|
+
duration = 1000.0 * (Time.now - @started)
|
20
|
+
kbytes = env[:body].to_s.length / 1024.0
|
21
|
+
@logger.info "--> %d %s %.2fKB (%.1fms)" % [env[:status], HTTP_STATUS_CODES[env[:status]], kbytes, duration]
|
22
|
+
end
|
23
|
+
|
24
|
+
# Every standard HTTP code mapped to the appropriate message.
|
25
|
+
# Generated with:
|
26
|
+
# curl -s http://www.iana.org/assignments/http-status-codes | \
|
27
|
+
# ruby -ane 'm = /^(\d{3}) +(\S[^\[(]+)/.match($_) and
|
28
|
+
# puts " #{m[1]} => \x27#{m[2].strip}x27,"'
|
29
|
+
HTTP_STATUS_CODES = {
|
30
|
+
100 => 'Continue',
|
31
|
+
101 => 'Switching Protocols',
|
32
|
+
102 => 'Processing',
|
33
|
+
200 => 'OK',
|
34
|
+
201 => 'Created',
|
35
|
+
202 => 'Accepted',
|
36
|
+
203 => 'Non-Authoritative Information',
|
37
|
+
204 => 'No Content',
|
38
|
+
205 => 'Reset Content',
|
39
|
+
206 => 'Partial Content',
|
40
|
+
207 => 'Multi-Status',
|
41
|
+
226 => 'IM Used',
|
42
|
+
300 => 'Multiple Choices',
|
43
|
+
301 => 'Moved Permanently',
|
44
|
+
302 => 'Found',
|
45
|
+
303 => 'See Other',
|
46
|
+
304 => 'Not Modified',
|
47
|
+
305 => 'Use Proxy',
|
48
|
+
306 => 'Reserved',
|
49
|
+
307 => 'Temporary Redirect',
|
50
|
+
400 => 'Bad Request',
|
51
|
+
401 => 'Unauthorized',
|
52
|
+
402 => 'Payment Required',
|
53
|
+
403 => 'Forbidden',
|
54
|
+
404 => 'Not Found',
|
55
|
+
405 => 'Method Not Allowed',
|
56
|
+
406 => 'Not Acceptable',
|
57
|
+
407 => 'Proxy Authentication Required',
|
58
|
+
408 => 'Request Timeout',
|
59
|
+
409 => 'Conflict',
|
60
|
+
410 => 'Gone',
|
61
|
+
411 => 'Length Required',
|
62
|
+
412 => 'Precondition Failed',
|
63
|
+
413 => 'Request Entity Too Large',
|
64
|
+
414 => 'Request-URI Too Long',
|
65
|
+
415 => 'Unsupported Media Type',
|
66
|
+
416 => 'Requested Range Not Satisfiable',
|
67
|
+
417 => 'Expectation Failed',
|
68
|
+
418 => "I'm a Teapot",
|
69
|
+
422 => 'Unprocessable Entity',
|
70
|
+
423 => 'Locked',
|
71
|
+
424 => 'Failed Dependency',
|
72
|
+
426 => 'Upgrade Required',
|
73
|
+
500 => 'Internal Server Error',
|
74
|
+
501 => 'Not Implemented',
|
75
|
+
502 => 'Bad Gateway',
|
76
|
+
503 => 'Service Unavailable',
|
77
|
+
504 => 'Gateway Timeout',
|
78
|
+
505 => 'HTTP Version Not Supported',
|
79
|
+
506 => 'Variant Also Negotiates',
|
80
|
+
507 => 'Insufficient Storage',
|
81
|
+
510 => 'Not Extended',
|
82
|
+
}
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'nibbler'
|
2
|
+
require 'nokogiri'
|
3
|
+
require 'date'
|
4
|
+
require 'active_support/core_ext/string'
|
5
|
+
require 'delegate'
|
6
|
+
|
7
|
+
module Ahora
|
8
|
+
class Representation < Nibbler
|
9
|
+
INTEGER_PARSER = lambda { |node| Integer(node.content) if node.content.present? }
|
10
|
+
DATE_PARSER = lambda { |node| Date.parse(node.content) if node.content.present? }
|
11
|
+
TIME_PARSER = lambda { |node| Time.parse(node.content) if node.content.present? }
|
12
|
+
BOOL_PARSER =
|
13
|
+
lambda { |node| node.content.to_s.downcase == 'true' if node.content.present? }
|
14
|
+
|
15
|
+
module Definition
|
16
|
+
def element(*)
|
17
|
+
name = super
|
18
|
+
define_method "#{name}?" do
|
19
|
+
!!instance_variable_get("@#{name}")
|
20
|
+
end
|
21
|
+
name
|
22
|
+
end
|
23
|
+
|
24
|
+
def attribute(*names)
|
25
|
+
names = names.flatten
|
26
|
+
parser = names.pop if names.last.is_a?(Proc)
|
27
|
+
names.each do |name|
|
28
|
+
selector = name
|
29
|
+
if name.is_a? Hash
|
30
|
+
selector, name = name.first
|
31
|
+
end
|
32
|
+
element selector.to_s.camelcase(:lower) => name.to_s, :with => parser
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Public: define Java style object id mapping
|
37
|
+
#
|
38
|
+
# *names - Array of String or Symbol ruby style names
|
39
|
+
#
|
40
|
+
# Examples
|
41
|
+
#
|
42
|
+
# objectid :id, parent_id
|
43
|
+
# # is equivalent to
|
44
|
+
# element 'objectId' => 'id'
|
45
|
+
# element 'parentObjectId' => 'parent_id'
|
46
|
+
def objectid(*names)
|
47
|
+
attribute names.map { |name| { name.to_s.gsub('id', 'object_id') => name } }, INTEGER_PARSER
|
48
|
+
end
|
49
|
+
|
50
|
+
def string(*names)
|
51
|
+
attribute(names)
|
52
|
+
end
|
53
|
+
|
54
|
+
def integer(*names)
|
55
|
+
attribute(names, INTEGER_PARSER)
|
56
|
+
end
|
57
|
+
|
58
|
+
def date(*names)
|
59
|
+
attribute(names, DATE_PARSER)
|
60
|
+
end
|
61
|
+
|
62
|
+
# FIXME test
|
63
|
+
def time(*names)
|
64
|
+
attribute(names, TIME_PARSER)
|
65
|
+
end
|
66
|
+
|
67
|
+
def boolean(*names)
|
68
|
+
attribute(names, BOOL_PARSER)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
extend Definition
|
73
|
+
|
74
|
+
def initialize(doc_or_atts = {})
|
75
|
+
if doc_or_atts.is_a? Hash
|
76
|
+
super(nil)
|
77
|
+
doc_or_atts.each do |key, val|
|
78
|
+
send("#{key}=", val)
|
79
|
+
end
|
80
|
+
else
|
81
|
+
super
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
class Collection < DelegateClass(Array)
|
87
|
+
NoCacheKeyAvailable = Class.new(StandardError)
|
88
|
+
def initialize(instantiator, document_parser, response)
|
89
|
+
@instantiator = instantiator
|
90
|
+
@document_parser = document_parser
|
91
|
+
@response = response
|
92
|
+
@cache_key = response.env[:response_headers]['X-Ahora-Cache-Key']
|
93
|
+
super([])
|
94
|
+
end
|
95
|
+
|
96
|
+
def cache_key
|
97
|
+
@cache_key or raise NoCacheKeyAvailable,
|
98
|
+
"No caching middleware is used or resource does not support caching."
|
99
|
+
end
|
100
|
+
|
101
|
+
%w( to_s to_a size each first last [] inspect pretty_print ).each do |method_name|
|
102
|
+
eval "def #{method_name}(*); kicker; super; end"
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
def kicker
|
107
|
+
@_collection ||= __setobj__ doc.search("/*[@type='array']/*").map { |element|
|
108
|
+
@instantiator.call element
|
109
|
+
}.to_a.compact
|
110
|
+
end
|
111
|
+
|
112
|
+
def doc
|
113
|
+
@document_parser.call(@response.body)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
class XmlParser
|
118
|
+
def self.parse(body)
|
119
|
+
Nokogiri::XML.parse(body, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
|
3
|
+
module Ahora
|
4
|
+
module Resource
|
5
|
+
attr_writer :document_parser
|
6
|
+
|
7
|
+
def get(url, params = {})
|
8
|
+
connection.get do |req|
|
9
|
+
set_common_headers(req)
|
10
|
+
req.url url, params
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# FIXME test
|
15
|
+
def post(url, body)
|
16
|
+
connection.post do |req|
|
17
|
+
set_common_headers(req)
|
18
|
+
req.url url
|
19
|
+
req.body = body
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# FIXME test
|
24
|
+
def put(url, body)
|
25
|
+
connection.put do |req|
|
26
|
+
set_common_headers(req)
|
27
|
+
req.url url
|
28
|
+
req.body = body
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def connection
|
33
|
+
conn = Faraday.new(host, :ssl => { :verify => false }) do |builder|
|
34
|
+
builder.use Faraday::Response::RaiseError
|
35
|
+
extend_middleware(builder)
|
36
|
+
builder.adapter Faraday.default_adapter
|
37
|
+
end
|
38
|
+
conn.headers['User-Agent'] = 'Ahora'
|
39
|
+
conn
|
40
|
+
end
|
41
|
+
|
42
|
+
# @abstract override to use custom Faraday middleware
|
43
|
+
def extend_middleware(builder); end;
|
44
|
+
|
45
|
+
def collection(*args, &block)
|
46
|
+
if args.size == 2
|
47
|
+
klass, response = args
|
48
|
+
instantiator = lambda do |doc|
|
49
|
+
klass.parse(doc)
|
50
|
+
end
|
51
|
+
else
|
52
|
+
response = args.first
|
53
|
+
instantiator = block
|
54
|
+
end
|
55
|
+
Collection.new instantiator, document_parser, response
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
def document_parser
|
60
|
+
@document_parser ||= XmlParser.method(:parse)
|
61
|
+
end
|
62
|
+
|
63
|
+
def set_common_headers(req)
|
64
|
+
req.headers['Content-Type'] = 'application/xml'
|
65
|
+
req.headers['Accept'] = 'application/xml'
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/ahora.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require 'minitest/pride'
|
3
|
+
require_relative '../test_helper'
|
4
|
+
require_relative '../../lib/ahora/representation'
|
5
|
+
|
6
|
+
class Employee < Ahora::Representation
|
7
|
+
boolean :is_rockstar, :slacker, :fired
|
8
|
+
end
|
9
|
+
|
10
|
+
describe "boolean elements" do
|
11
|
+
let(:employee) { Employee.parse(fixture('employee').read) }
|
12
|
+
|
13
|
+
it "parsing 'true' adds the correct reader" do
|
14
|
+
employee.is_rockstar.must_equal true
|
15
|
+
employee.is_rockstar = false
|
16
|
+
employee.is_rockstar.must_equal false
|
17
|
+
employee.is_rockstar?.must_equal false
|
18
|
+
end
|
19
|
+
|
20
|
+
it "parsing 'false' adds the correct reader" do
|
21
|
+
employee.slacker.must_equal false
|
22
|
+
end
|
23
|
+
|
24
|
+
it "parsing missing value adds the correct reader" do
|
25
|
+
employee.fired.must_equal nil
|
26
|
+
end
|
27
|
+
|
28
|
+
it "parsing 'true' adds the correct question mark reader" do
|
29
|
+
employee.is_rockstar?.must_equal true
|
30
|
+
end
|
31
|
+
|
32
|
+
it "parsing 'false' adds the correct question mark reader" do
|
33
|
+
employee.slacker?.must_equal false
|
34
|
+
end
|
35
|
+
|
36
|
+
it "parsing missing value adds the correct question mark reader" do
|
37
|
+
employee.fired?.must_equal false
|
38
|
+
end
|
39
|
+
end
|
data/test/ahora_test.rb
ADDED
@@ -0,0 +1,214 @@
|
|
1
|
+
require 'fakeweb'
|
2
|
+
require 'minitest/autorun'
|
3
|
+
require 'minitest/pride'
|
4
|
+
require_relative 'test_helper'
|
5
|
+
require_relative '../lib/ahora'
|
6
|
+
|
7
|
+
FakeWeb.allow_net_connect = false
|
8
|
+
|
9
|
+
require 'singleton'
|
10
|
+
class MemCache
|
11
|
+
include Singleton
|
12
|
+
def initialize
|
13
|
+
@store = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def read(key)
|
17
|
+
@store[key] ? Marshal.load(@store[key]) : nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def write(key, value, options = {})
|
21
|
+
@store[key] = Marshal.dump(value)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class PostRepository
|
26
|
+
include Ahora::Resource
|
27
|
+
USERNAME = 'user'
|
28
|
+
PASSWORD = 'pass'
|
29
|
+
|
30
|
+
def find_by_user_id(id)
|
31
|
+
collection Post, get("/users/#{id}/posts.xml")
|
32
|
+
end
|
33
|
+
|
34
|
+
def extend_middleware(builder)
|
35
|
+
builder.use Faraday::Request::BasicAuthentication, USERNAME, PASSWORD
|
36
|
+
builder.use Ahora::Middleware::LastModifiedCaching, MemCache.instance
|
37
|
+
end
|
38
|
+
|
39
|
+
def host
|
40
|
+
'http://test.net/'
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class Post < Ahora::Representation
|
45
|
+
objectid :id, :user_id, :parent_id
|
46
|
+
date :created_at
|
47
|
+
element :body
|
48
|
+
element 'user', :with => Ahora::Representation do
|
49
|
+
string :first_name, :last_name
|
50
|
+
end
|
51
|
+
boolean :hidden
|
52
|
+
elements 'replies/userPost' => :replies, :with => Post
|
53
|
+
end
|
54
|
+
|
55
|
+
class PostDomainRepository < PostRepository
|
56
|
+
def find_by_user_id(id)
|
57
|
+
collection PostDomain, get("/users/#{id}/posts.xml")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class PostDomain < DelegateClass(Post)
|
62
|
+
def self.parse(doc)
|
63
|
+
post = Post.parse(doc)
|
64
|
+
post.hidden? ? nil : new(post)
|
65
|
+
end
|
66
|
+
|
67
|
+
def initialize(post)
|
68
|
+
super(post)
|
69
|
+
end
|
70
|
+
|
71
|
+
def published?
|
72
|
+
created_at < Date.today
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class BlogPostRepository < PostRepository
|
77
|
+
include Ahora::Resource
|
78
|
+
|
79
|
+
def all
|
80
|
+
collection get("/users/1/posts.xml") do |doc|
|
81
|
+
Post.parse(doc)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
describe "requesting a collection" do
|
87
|
+
before do
|
88
|
+
fake_http '/users/1/posts.xml', fixture('user_posts')
|
89
|
+
@posts = PostRepository.new.find_by_user_id(1)
|
90
|
+
end
|
91
|
+
|
92
|
+
it "has the right size" do
|
93
|
+
@posts.size.must_equal 2
|
94
|
+
end
|
95
|
+
|
96
|
+
it "raises on #cache_key, because the backend does not has support for it" do
|
97
|
+
-> { @posts.cache_key }.must_raise(Ahora::Collection::NoCacheKeyAvailable)
|
98
|
+
end
|
99
|
+
|
100
|
+
describe "a single post from the collection" do
|
101
|
+
subject { @posts.first }
|
102
|
+
|
103
|
+
it "has the right body" do
|
104
|
+
subject.body.must_equal "How is everybody today?"
|
105
|
+
end
|
106
|
+
|
107
|
+
it "generates a questionmark method" do
|
108
|
+
subject.body?.must_equal true
|
109
|
+
end
|
110
|
+
|
111
|
+
it "renames foreign element names and converts integers" do
|
112
|
+
subject.id.must_equal 1
|
113
|
+
subject.user_id.must_equal 1
|
114
|
+
end
|
115
|
+
|
116
|
+
it "must handle date conversion" do
|
117
|
+
subject.created_at.must_equal Date.parse("2011-10-26T17:01:52+02:00")
|
118
|
+
end
|
119
|
+
|
120
|
+
it "handles nested resources" do
|
121
|
+
subject.user.first_name.must_equal "John"
|
122
|
+
end
|
123
|
+
|
124
|
+
it "handles nested collection resources" do
|
125
|
+
subject.replies.size.must_equal 2
|
126
|
+
end
|
127
|
+
|
128
|
+
describe "a single reply" do
|
129
|
+
let(:reply) { subject.replies.first }
|
130
|
+
|
131
|
+
it "behaves the same as a single post" do
|
132
|
+
reply.id.must_equal 2
|
133
|
+
reply.user_id.must_equal 2
|
134
|
+
reply.parent_id.must_equal 1
|
135
|
+
reply.user.last_name.must_equal "Smith"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
describe 'requesting a collection with if-modified-since support' do
|
142
|
+
before do
|
143
|
+
FakeWeb.register_uri :get, uri('/users/1/posts.xml'), [
|
144
|
+
{ :body => fixture('user_posts'), 'Last-Modified' => 'Mon, 02 Apr 2012 15:20:41 GMT' },
|
145
|
+
{ :body => nil, :status => [304, 'Not Modified'] }
|
146
|
+
]
|
147
|
+
@repository = PostRepository.new
|
148
|
+
end
|
149
|
+
|
150
|
+
it "has a cache key" do
|
151
|
+
@posts = @repository.find_by_user_id(1)
|
152
|
+
@posts.cache_key.
|
153
|
+
must_equal 'http://test.net/users/1/posts.xml:fragment_Mon, 02 Apr 2012 15:20:41 GMT'
|
154
|
+
end
|
155
|
+
|
156
|
+
it "has a cache key for cached response" do
|
157
|
+
@repository.find_by_user_id(1).cache_key.
|
158
|
+
must_equal @repository.find_by_user_id(1).cache_key
|
159
|
+
end
|
160
|
+
|
161
|
+
it "caches when response header includes Last-Modified" do
|
162
|
+
@repository.find_by_user_id(1).size.must_equal(2)
|
163
|
+
@posts = @repository.find_by_user_id(1).size.must_equal(2)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
describe 'lazy loading' do
|
168
|
+
before do
|
169
|
+
@repository = PostRepository.new
|
170
|
+
@repository.document_parser = -> body { raise('NotLazyEnough') }
|
171
|
+
end
|
172
|
+
|
173
|
+
it "should not parse the response if not necessary" do
|
174
|
+
fake_http '/users/1/posts.xml', fixture('user_posts')
|
175
|
+
@posts = PostRepository.new.find_by_user_id(1)
|
176
|
+
end
|
177
|
+
|
178
|
+
it "should work the same for domain layer type models" do
|
179
|
+
fake_http '/users/1/posts.xml', fixture('user_posts')
|
180
|
+
PostDomainRepository.new.find_by_user_id(1)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
describe "creating a new instance" do
|
185
|
+
it "allows to create a new instance with an attribute hash" do
|
186
|
+
p = Post.new :body => 'hi'
|
187
|
+
p.body.must_equal 'hi'
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
describe "being wrapped by a domain layer" do
|
192
|
+
before do
|
193
|
+
fake_http '/users/1/posts.xml', fixture('user_posts')
|
194
|
+
@posts = PostDomainRepository.new.find_by_user_id(1)
|
195
|
+
@post = @posts.first
|
196
|
+
end
|
197
|
+
|
198
|
+
it "handles a collection" do
|
199
|
+
@post.published?.must_equal true
|
200
|
+
@post.user.first_name.must_equal 'John'
|
201
|
+
end
|
202
|
+
|
203
|
+
it "allows filtering by letting the instantiator return nil" do
|
204
|
+
@posts.size.must_equal 1
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
describe "passing a block instead of a class to collection" do
|
209
|
+
it "returns the parsed document" do
|
210
|
+
fake_http '/users/1/posts.xml', fixture('user_posts')
|
211
|
+
repository = BlogPostRepository.new
|
212
|
+
repository.all.first.id.must_equal 1
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
<?xml version="1.0"?>
|
2
|
+
<userPosts type="array">
|
3
|
+
<userPost>
|
4
|
+
<hidden>false</hidden>
|
5
|
+
<objectId>1</objectId>
|
6
|
+
<userObjectId>1</userObjectId>
|
7
|
+
<user>
|
8
|
+
<firstName>John</firstName>
|
9
|
+
<lastName>Doe</lastName>
|
10
|
+
</user>
|
11
|
+
<body>How is everybody today?</body>
|
12
|
+
<createdAt>2011-10-26T17:01:52+02:00</createdAt>
|
13
|
+
|
14
|
+
<replies type="array">
|
15
|
+
<userPost>
|
16
|
+
<objectId>2</objectId>
|
17
|
+
<parentObjectId>1</parentObjectId>
|
18
|
+
<userObjectId>2</userObjectId>
|
19
|
+
<user>
|
20
|
+
<firstName>Mike</firstName>
|
21
|
+
<lastName>Smith</lastName>
|
22
|
+
</user>
|
23
|
+
<body>I am fine, thanks for asking.</body>
|
24
|
+
<createdAt>2011-10-27T9:00:00+02:00</createdAt>
|
25
|
+
</userPost>
|
26
|
+
<userPost>
|
27
|
+
<objectId>3</objectId>
|
28
|
+
<parentObjectId>1</parentObjectId>
|
29
|
+
<userObjectId>1</userObjectId>
|
30
|
+
<user>
|
31
|
+
<firstName>John</firstName>
|
32
|
+
<lastName>Doe</lastName>
|
33
|
+
</user>
|
34
|
+
<body>You are more than welcome.</body>
|
35
|
+
<createdAt>2011-10-27T9:00:00+02:00</createdAt>
|
36
|
+
</userPost>
|
37
|
+
</replies>
|
38
|
+
</userPost>
|
39
|
+
<userPost>
|
40
|
+
<hidden>true</hidden>
|
41
|
+
</userPost>
|
42
|
+
</userPosts>
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
def fake_http(path, body)
|
2
|
+
FakeWeb.register_uri :get, uri(path), :body => body
|
3
|
+
end
|
4
|
+
|
5
|
+
def uri(path)
|
6
|
+
'http://user:pass@test.net' + path
|
7
|
+
end
|
8
|
+
|
9
|
+
def fixture(name)
|
10
|
+
File.open File.join(File.dirname('__FILE__'), 'test', 'fixtures', "#{name}.xml")
|
11
|
+
end
|
metadata
ADDED
@@ -0,0 +1,128 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ahora
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Matthijs Langenberg
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-04-12 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: nibbler
|
16
|
+
requirement: &70160681904060 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 1.3.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70160681904060
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: faraday
|
27
|
+
requirement: &70160681901920 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0.7'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70160681901920
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: nokogiri
|
38
|
+
requirement: &70160681900040 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ~>
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '1.5'
|
44
|
+
type: :runtime
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70160681900040
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: activesupport
|
49
|
+
requirement: &70160681898560 !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: *70160681898560
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: fakeweb
|
60
|
+
requirement: &70160681897280 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
type: :development
|
67
|
+
prerelease: false
|
68
|
+
version_requirements: *70160681897280
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: minitest
|
71
|
+
requirement: &70160681896360 !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: *70160681896360
|
80
|
+
description: Consume Java-ish XML HTTP Resources easily
|
81
|
+
email:
|
82
|
+
- matthijs.langenberg@nedap.com
|
83
|
+
executables: []
|
84
|
+
extensions: []
|
85
|
+
extra_rdoc_files: []
|
86
|
+
files:
|
87
|
+
- .gitignore
|
88
|
+
- Gemfile
|
89
|
+
- README.md
|
90
|
+
- Rakefile
|
91
|
+
- ahora.gemspec
|
92
|
+
- lib/ahora.rb
|
93
|
+
- lib/ahora/middleware.rb
|
94
|
+
- lib/ahora/middleware/last_modified_caching.rb
|
95
|
+
- lib/ahora/middleware/request_logger.rb
|
96
|
+
- lib/ahora/representation.rb
|
97
|
+
- lib/ahora/resource.rb
|
98
|
+
- lib/ahora/version.rb
|
99
|
+
- test/ahora/representation_test.rb
|
100
|
+
- test/ahora_test.rb
|
101
|
+
- test/fixtures/employee.xml
|
102
|
+
- test/fixtures/user_posts.xml
|
103
|
+
- test/test_helper.rb
|
104
|
+
homepage: ''
|
105
|
+
licenses: []
|
106
|
+
post_install_message:
|
107
|
+
rdoc_options: []
|
108
|
+
require_paths:
|
109
|
+
- lib
|
110
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
111
|
+
none: false
|
112
|
+
requirements:
|
113
|
+
- - ! '>='
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
117
|
+
none: false
|
118
|
+
requirements:
|
119
|
+
- - ! '>='
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '0'
|
122
|
+
requirements: []
|
123
|
+
rubyforge_project:
|
124
|
+
rubygems_version: 1.8.15
|
125
|
+
signing_key:
|
126
|
+
specification_version: 3
|
127
|
+
summary: Consume Java-ish XML HTTP Resources easily
|
128
|
+
test_files: []
|