ahora 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|