taylorbarstow-nytimes-articles 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,118 @@
1
+ require 'open-uri'
2
+ require 'json'
3
+ require 'htmlentities'
4
+
5
+ module Nytimes
6
+ module Articles
7
+ class Base
8
+ API_SERVER = 'api.nytimes.com'
9
+ API_VERSION = 'v1'
10
+ API_NAME = 'article'
11
+ API_BASE = "/svc/search/#{API_VERSION}/#{API_NAME}"
12
+
13
+ @@api_key = nil
14
+ @@debug = false
15
+
16
+ ##
17
+ # Set the API key used for operations. This needs to be called before any requests against the API. To obtain an API key, go to http://developer.nytimes.com/
18
+ def self.api_key=(key)
19
+ @@api_key = key
20
+ end
21
+
22
+ def self.debug=(flag)
23
+ @@debug = flag
24
+ end
25
+
26
+ ##
27
+ # Returns the current value of the API Key
28
+ def self.api_key
29
+ @@api_key
30
+ end
31
+
32
+ ##
33
+ # Builds a request URI to call the API server
34
+ def self.build_request_url(params)
35
+ URI::HTTP.build :host => API_SERVER,
36
+ :path => API_BASE,
37
+ :query => params.map {|k,v| "#{URI.escape(k)}=#{URI.escape(v)}"}.join('&')
38
+ end
39
+
40
+ def self.text_field(value)
41
+ return nil if value.nil?
42
+ coder = HTMLEntities.new
43
+ coder.decode(value)
44
+ end
45
+
46
+ def self.integer_field(value)
47
+ return nil if value.nil?
48
+ value.to_i
49
+ end
50
+
51
+ def self.date_field(value)
52
+ return nil unless value =~ /^\d{8}$/
53
+ Date.strptime(value, "%Y%m%d")
54
+ end
55
+
56
+ def self.boolean_field(value)
57
+ case value
58
+ when nil
59
+ false
60
+ when TrueClass
61
+ true
62
+ when FalseClass
63
+ false
64
+ when 'Y'
65
+ true
66
+ when 'N'
67
+ false
68
+ else
69
+ false
70
+ end
71
+ end
72
+
73
+ def self.invoke(params={})
74
+ begin
75
+ if @@api_key.nil?
76
+ raise AuthenticationError, "You must initialize the API key before you run any API queries"
77
+ end
78
+
79
+ full_params = params.merge 'api-key' => @@api_key
80
+ uri = build_request_url(full_params)
81
+
82
+ puts "REQUEST: #{uri}" if @@debug
83
+
84
+ reply = uri.read
85
+ parsed_reply = JSON.parse reply
86
+
87
+ if parsed_reply.nil?
88
+ raise BadResponseError, "Empty reply returned from API"
89
+ end
90
+
91
+ #case parsed_reply['status']
92
+ # FIXME
93
+ #end
94
+
95
+ parsed_reply
96
+ rescue OpenURI::HTTPError => e
97
+ # FIXME: Return message from body?
98
+ case e.message
99
+ when /^400/
100
+ raise BadRequestError
101
+ when /^403/
102
+ raise AuthenticationError
103
+ when /^404/
104
+ return nil
105
+ when /^500/
106
+ raise ServerError
107
+ else
108
+ raise ConnectionError
109
+ end
110
+
111
+ raise "Error connecting to URL #{uri} #{e}"
112
+ rescue JSON::ParserError => e
113
+ raise BadResponseError, "Invalid JSON returned from API:\n#{reply}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,38 @@
1
+ module Nytimes
2
+ module Articles
3
+ ##
4
+ # The generic Error class from which all other Errors are derived.
5
+ class Error < ::RuntimeError
6
+ end
7
+
8
+ ##
9
+ # This error is thrown if there are problems authenticating your API key.
10
+ class AuthenticationError < Error
11
+ end
12
+
13
+ ##
14
+ # This error is thrown if the request was not parsable by the API server.
15
+ class BadRequestError < Error
16
+ end
17
+
18
+ ##
19
+ # This error is thrown if the response from the API server is not parsable.
20
+ class BadResponseError < Error
21
+ end
22
+
23
+ ##
24
+ # This error is thrown if there is an error connecting to the API server.
25
+ class ServerError < Error
26
+ end
27
+
28
+ ##
29
+ # This error is thrown if there is a timeout connecting to the server (to be implemented).
30
+ class TimeoutError < Error
31
+ end
32
+
33
+ ##
34
+ # This error is thrown for general connection errors to the API server.
35
+ class ConnectionError < Error
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,128 @@
1
+ module Nytimes
2
+ module Articles
3
+
4
+ ##
5
+ # This class represents a Facet used in the ArticleSearch API. Facets can be used to both search for matching articles (see Article#search) and
6
+ # are also returned as article and search metadata. Facets are made up of 3 parts:
7
+ # * <tt>facet_type</tt> - a string; see Article#search for a list of facet types
8
+ # * <tt>term</tt> - a string as well
9
+ # * <tt>count</tt> - Facets returned as search metadata (via the <tt>:facets</tt> parameter to Article#search) also include a non-nil count of matching articles for that facet
10
+ class Facet
11
+ ##
12
+ # The term for the facet
13
+ attr_reader :term
14
+
15
+ ##
16
+ # The number of times this facet has appeared in the search results (note: this only applies for facets returned in the facets header on an Article#search)
17
+ attr_reader :count
18
+
19
+ ##
20
+ # The facet type
21
+ attr_reader :facet_type
22
+
23
+ # Facet name constants
24
+ CLASSIFIERS = 'classifiers_facet'
25
+ COLUMN = 'column_facet'
26
+ DATE = 'date'
27
+ DAY_OF_WEEK = 'day_of_week_facet'
28
+ DESCRIPTION = 'des_facet'
29
+ DESK = 'desk_facet'
30
+ GEO = 'geo_facet'
31
+ MATERIAL_TYPE = 'material_type_facet'
32
+ ORGANIZATION = 'org_facet'
33
+ PAGE = 'page_facet'
34
+ PERSON = 'per_facet'
35
+ PUB_DAY = 'publication_day'
36
+ PUB_MONTH = 'publication_month'
37
+ PUB_YEAR = 'publication_year'
38
+ SECTION_PAGE = 'section_page_facet'
39
+ SOURCE = 'source_facet'
40
+ WORKS_MENTIONED = 'works_mentioned_facet'
41
+
42
+ # Facets of content formatted for nytimes.com
43
+ NYTD_BYLINE = 'nytd_byline'
44
+ NYTD_DESCRIPTION = 'nytd_des_facet'
45
+ NYTD_GEO = 'nytd_geo_facet'
46
+ NYTD_ORGANIZATION = 'nytd_org_facet'
47
+ NYTD_PERSON = 'nytd_per_facet'
48
+ NYTD_SECTION = 'nytd_section_facet'
49
+ NYTD_WORKS_MENTIONED = 'nytd_works_mentioned_facet'
50
+
51
+ # The default 5 facets to return
52
+ DEFAULT_RETURN_FACETS = [DESCRIPTION, GEO, ORGANIZATION, PERSON, DESK]
53
+
54
+ ALL_FACETS = [CLASSIFIERS, COLUMN, DATE, DAY_OF_WEEK, DESCRIPTION, DESK, GEO, MATERIAL_TYPE, ORGANIZATION, PAGE, PERSON, PUB_DAY,
55
+ PUB_MONTH, PUB_YEAR, SECTION_PAGE, SOURCE, WORKS_MENTIONED, NYTD_BYLINE, NYTD_DESCRIPTION, NYTD_GEO,
56
+ NYTD_ORGANIZATION, NYTD_PERSON, NYTD_SECTION, NYTD_WORKS_MENTIONED]
57
+
58
+ ##
59
+ # Initializes the facet. There is seldom a reason for you to call this.
60
+ def initialize(facet_type, term, count)
61
+ @facet_type = facet_type
62
+ @term = term
63
+ @count = count
64
+ end
65
+
66
+ ##
67
+ # Takes a symbol name and subs it to a string constant
68
+ def self.symbol_name(facet)
69
+ case facet
70
+ when String
71
+ return facet
72
+ when Facet
73
+ return facet.facet_type
74
+ when Symbol
75
+ # fall through
76
+ else
77
+ raise ArgumentError, "Unsupported type to Facet#symbol_to_api_name"
78
+ end
79
+
80
+ case facet
81
+ when :geography
82
+ GEO
83
+ when :org, :orgs
84
+ ORGANIZATION
85
+ when :people
86
+ PERSON
87
+ when :nytd_geography
88
+ NYTD_GEO
89
+ when :nytd_org, :nytd_orgs
90
+ NYTD_ORGANIZATION
91
+ when :nytd_people
92
+ NYTD_PERSON
93
+ else
94
+ name = facet.to_s.upcase
95
+
96
+ if const_defined?(name)
97
+ const_get(name)
98
+ elsif name =~ /S$/ && const_defined?(name.gsub(/S$/, ''))
99
+ const_get(name.gsub(/S$/, ''))
100
+ else
101
+ raise ArgumentError, "Unable to find a matching facet key for symbol :#{facet}"
102
+ end
103
+ end
104
+ end
105
+
106
+ ##
107
+ # Initializes a selection of Facet objects returned from the API. Used for marshaling Facets in articles and metadata from search results
108
+ # (Note: some facets are returned as scalar values)
109
+ def self.init_from_api(api_hash)
110
+ return nil if api_hash.nil?
111
+
112
+ unless api_hash.is_a? Hash
113
+ raise ArgumentError, "expecting a Hash only"
114
+ else
115
+ return nil if api_hash.empty?
116
+ end
117
+
118
+ out = {}
119
+
120
+ api_hash.each_pair do |k,v|
121
+ out[k] = v.map {|f| Facet.new(k, f['term'], f['count'])}
122
+ end
123
+
124
+ out
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,28 @@
1
+ require 'digest'
2
+
3
+ module Nytimes
4
+ module Articles
5
+ ##
6
+ # The Query class represents a single query to the Article Search API. Supports
7
+ # all of the named parameters to Article.search as accessor methods.
8
+ #
9
+ class Query
10
+ FIELDS = [:only_facets, :except_facets, :begin_date, :end_date, :since,
11
+ :before, :fee, :has_thumbnail, :facets, :fields, :query, :offset] + Article::TEXT_FIELDS.map(&:to_sym)
12
+ FIELDS.each {|f| attr_accessor f}
13
+
14
+ # Produce a hash which uniquely identifies this query
15
+ def hash
16
+ strs = FIELDS.collect {|f| "#{f}:#{send(f).inspect}"}
17
+ Digest::SHA256.hexdigest(strs.join(' '))
18
+ end
19
+
20
+ # Perform this query. Returns result of Article.search
21
+ def perform
22
+ params = {}
23
+ FIELDS.each {|f| params[f] = send(f) unless send(f).nil?}
24
+ Article.search(params)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,66 @@
1
+ require 'rubygems'
2
+ require 'forwardable'
3
+
4
+ module Nytimes
5
+ module Articles
6
+ ##
7
+ # The ResultSet is returned by Article#search and contains an array of up to 10 results out of the total matches. For your convenience, this
8
+ # object provides a selection of array methods on the underlying collection of articles.
9
+ class ResultSet < Base
10
+ extend Forwardable
11
+
12
+ ##
13
+ # The offset of the result_set. Note that this is essentially the ordinal position of the batch among all results. First 10 results are offset
14
+ # 0, the next 10 are offset 1, etc.
15
+ attr_reader :offset
16
+
17
+ ##
18
+ # The total results that matched the query.
19
+ attr_reader :total_results
20
+
21
+ ##
22
+ # The results array of articles returned. Note that if you call Articles#find with :fields => :none, this will return nil even if
23
+ # there are matching results.
24
+ attr_reader :results
25
+
26
+ ##
27
+ # If you have specified a list of <tt>:facets</tt> for Article#search, they will be returned in a hash keyed by the facet name here.
28
+ attr_reader :facets
29
+
30
+ BATCH_SIZE = 10
31
+
32
+ def_delegators :@results, :&, :*, :+, :-, :[], :at, :collect, :compact, :each, :each_index, :empty?, :fetch, :first, :include?, :index, :last, :length, :map, :nitems, :reject, :reverse, :reverse_each, :rindex, :select, :size, :slice
33
+
34
+ def initialize(params)
35
+ @offset = params[:offset]
36
+ @total_results = params[:total_results]
37
+ @results = params[:results]
38
+ @facets = params[:facets]
39
+ end
40
+
41
+ ##
42
+ # For your convenience, the page_number method is an alternate version of #offset that counts up from 1.
43
+ def page_number
44
+ return 0 if @total_results == 0
45
+ @offset + 1
46
+ end
47
+
48
+ ##
49
+ # Calculates the total number of pages in the results based on the standard batch size and total results.
50
+ def total_pages
51
+ return 0 if @total_results == 0
52
+ (@total_results.to_f / BATCH_SIZE).ceil
53
+ end
54
+
55
+ ##
56
+ # Used to initialize a new result_set from Article#search.
57
+ def self.init_from_api(api_hash)
58
+ self.new(:offset => integer_field(api_hash['offset']),
59
+ :total_results => integer_field(api_hash['total']),
60
+ :results => api_hash['results'].map {|r| Article.init_from_api(r)},
61
+ :facets => Facet.init_from_api(api_hash['facets'])
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ module Nytimes
2
+ module Articles
3
+ ##
4
+ # If requested in <tt>:fields</tt> for an article search, some articles are returned with a matching thumbnail image. The several thumbnail
5
+ # fields are collected together into a single Thumbnail instance for your convenience.
6
+ class Thumbnail
7
+ attr_reader :url, :width, :height
8
+
9
+ def initialize(url, width, height)
10
+ @url = url
11
+ @width = width
12
+ @height = height
13
+ end
14
+
15
+ def self.init_from_api(api_hash)
16
+ return nil unless !api_hash.nil? && api_hash['small_image_url']
17
+
18
+ unless api_hash['small_image_width'].nil?
19
+ width = api_hash['small_image_width'].to_i
20
+ end
21
+
22
+ unless api_hash['small_image_height'].nil?
23
+ height = api_hash['small_image_height'].to_i
24
+ end
25
+
26
+ new(api_hash['small_image_url'], width, height)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,640 @@
1
+ require File.dirname(__FILE__) + '/../../test_helper.rb'
2
+
3
+ ARTICLE_API_HASH = {"page_facet"=>"8", "lead_paragraph"=>"", "classifiers_facet"=>["Top/News/Business", "Top/Classifieds/Job Market/Job Categories/Banking, Finance and Insurance", "Top/News/Business/Markets"], "title"=>"Wall St. Treads Water as It Waits on Washington", "nytd_title"=>"Wall St. Treads Water as It Waits on Washington", "byline"=>"By JACK HEALY", "body"=>"Wall Street held its breath on Monday as it awaited details on a banking bailout from Washington. Investors had expected to start the week with an announcement from the Treasury Department outlining its latest plans to stabilize the financial system. But the Obama administration delayed releasing the details until at least Tuesday to keep the focus", "material_type_facet"=>["News"], "url"=>"http://www.nytimes.com/2009/02/10/business/10markets.html", "publication_month"=>"02", "date"=>"20090210", "publication_year"=>"2009", "nytd_section_facet"=>["Business"], "source_facet"=>"The New York Times", "desk_facet"=>"Business", "publication_day"=>"10", "des_facet"=>["STOCKS AND BONDS"], "day_of_week_facet"=>"Tuesday"}
4
+
5
+ ARTICLE_API_HASH2 = {"page_facet"=>"29", "lead_paragraph"=>"", "geo_facet"=>["WALL STREET (NYC)"], "small_image_width"=>"75", "classifiers_facet"=>["Top/News/New York and Region", "Top/Classifieds/Job Market/Job Categories/Education", "Top/Features/Travel/Guides/Destinations/North America", "Top/Classifieds/Job Market/Job Categories/Banking, Finance and Insurance", "Top/Features/Travel/Guides/Destinations/North America/United States/New York", "Top/Features/Travel/Guides/Destinations/North America/United States", "Top/News/Education"], "title"=>"OUR TOWNS; As Pipeline to Wall Street Narrows, Princeton Students Adjust Sights", "nytd_title"=>"As Pipeline to Wall Street Narrows, Princeton Students Adjust Sights", "byline"=>"By PETER APPLEBOME", "body"=>"Princeton, N.J. There must be a screenplay in the fabulous Schoppe twins, Christine and Jennifer, Princeton University juniors from Houston. They had the same G.P.A. and SATs in high school, where they became Gold Award Girl Scouts , sort of the female version of Eagle Scouts. They live together and take all the same courses, wear identical necklac", "material_type_facet"=>["News"], "url"=>"http://www.nytimes.com/2009/02/08/nyregion/08towns.html", "publication_month"=>"02", "small_image_height"=>"75", "date"=>"20090208", "column_facet"=>"Our Towns", "small_image"=>"Y", "publication_year"=>"2009", "nytd_section_facet"=>["New York and Region", "Education"], "source_facet"=>"The New York Times", "org_facet"=>["PRINCETON UNIVERSITY"], "desk_facet"=>"New York Region", "publication_day"=>"08", "small_image_url"=>"http://graphics8.nytimes.com/images/2009/02/08/nyregion/08towns.751.jpg", "des_facet"=>["EDUCATION AND SCHOOLS", "BANKS AND BANKING"], "day_of_week_facet"=>"Sunday"}
6
+
7
+ class TestNytimes::TestArticles::TestArticle < Test::Unit::TestCase
8
+ include Nytimes::Articles
9
+
10
+ def setup
11
+ init_test_key
12
+ Article.stubs(:parse_reply)
13
+ end
14
+
15
+ context "Article.search" do
16
+ should "accept a String for the first argument that is passed through to the query in the API" do
17
+ Article.expects(:invoke).with(has_entry("query", "FOO BAR"))
18
+ Article.search "FOO BAR"
19
+ end
20
+
21
+ should "accept a Hash for the first argument" do
22
+ Article.expects(:invoke).with(has_entry("query", "FOO BAR"))
23
+ Article.search :query => 'FOO BAR', :page => 2
24
+ end
25
+
26
+ context "date ranges" do
27
+ should "pass a string argument to begin_date straight through" do
28
+ date = "20081212"
29
+ Article.expects(:invoke).with(has_entry("begin_date", date))
30
+ Article.search :begin_date => date
31
+ end
32
+
33
+ should "convert begin_date from a Date or Time to YYYYMMDD format" do
34
+ time = Time.now
35
+ Article.expects(:invoke).with(has_entry("begin_date", time.strftime("%Y%m%d")))
36
+ Article.search :begin_date => time
37
+ end
38
+
39
+ should "pass a string argument to end_date straight through" do
40
+ date = "20081212"
41
+ Article.expects(:invoke).with(has_entry("end_date", date))
42
+ Article.search :end_date => date
43
+ end
44
+
45
+ should "convert end_date from a Date or Time to YYYYMMDD format" do
46
+ time = Time.now
47
+ Article.expects(:invoke).with(has_entry("end_date", time.strftime("%Y%m%d")))
48
+ Article.search :end_date => time
49
+ end
50
+
51
+ should "raise an ArgumentError if the begin_date is NOT a string and does not respond_to strftime" do
52
+ assert_raise(ArgumentError) { Article.search :begin_date => 23 }
53
+ end
54
+
55
+ should "raise an ArgumentError if the end_date is NOT a string and does not respond_to strftime" do
56
+ assert_raise(ArgumentError) { Article.search :end_date => 23 }
57
+ end
58
+
59
+ context ":before" do
60
+ should "send the :before value through as a end_date" do
61
+ t = Time.now
62
+ Article.expects(:invoke).with(has_entry('end_date', t.strftime("%Y%m%d")))
63
+ Article.search :before => t
64
+ end
65
+
66
+ should "not send through :before as an argument to the API" do
67
+ t = Time.now
68
+ Article.expects(:invoke).with(Not(has_key('before')))
69
+ Article.search :before => t
70
+ end
71
+
72
+ should "raise an ArgumentError if the before_date is NOT a string and does not respond_to strftime" do
73
+ assert_raise(ArgumentError) { Article.search :before => 23 }
74
+ end
75
+
76
+ should "add a begin_date in 1980 if no :since or :begin_date argument is provided" do
77
+ Article.expects(:invoke).with(has_entry('begin_date', Article::EARLIEST_BEGIN_DATE))
78
+ Article.search :before => Time.now
79
+ end
80
+
81
+ should "not automatically add a begin_date is there is a :since argument" do
82
+ since = Time.now - 12000
83
+ Article.expects(:invoke).with(has_entry('begin_date', since.strftime("%Y%m%d")))
84
+ Article.search :before => Time.now, :since => since
85
+ end
86
+
87
+ should "not automatically add a begin_date if there is a :begin_date argument already" do
88
+ since = Time.now - 12000
89
+ Article.expects(:invoke).with(has_entry('begin_date', since.strftime("%Y%m%d")))
90
+ Article.search :before => Time.now, :begin_date => since
91
+ end
92
+
93
+ should "raise an ArgumentError if there is also an :end_date argument" do
94
+ assert_raise(ArgumentError) { Article.search :before => Time.now, :end_date => Time.now }
95
+ end
96
+ end
97
+
98
+ context ":since" do
99
+ should "send the :since value through as a begin_date" do
100
+ t = Time.now - 1200
101
+ Article.expects(:invoke).with(has_entry('begin_date', t.strftime("%Y%m%d")))
102
+ Article.search :since => t
103
+ end
104
+
105
+ should "not send through :since as an argument to the API" do
106
+ t = Time.now
107
+ Article.expects(:invoke).with(Not(has_key('since')))
108
+ Article.search :since => t
109
+ end
110
+
111
+ should "raise an ArgumentError if the before_date is NOT a string and does not respond_to strftime" do
112
+ assert_raise(ArgumentError) { Article.search :since => 23 }
113
+ end
114
+
115
+ # This is to fix an error where the begin and end date are the same
116
+ should "add a end_date of tomorrow if no :before or :end_date argument is provided" do
117
+ Article.expects(:invoke).with(has_entry('end_date', (Date.today + 1).strftime("%Y%m%d")))
118
+ Article.search :since => Date.today
119
+ end
120
+
121
+ should "not automatically add a end_date is there is a :before argument" do
122
+ since = '19990101'
123
+ Article.expects(:invoke).with(has_entry('end_date', '20030101'))
124
+ Article.search :before => '20030101', :since => since
125
+ end
126
+
127
+ should "not automatically add a end_date if there is a :end_date argument already" do
128
+ since = '19990101'
129
+ Article.expects(:invoke).with(has_entry('end_date', '20030101'))
130
+ Article.search :end_date => '20030101', :since => since
131
+ end
132
+
133
+ should "raise an ArgumentError if there is also an :begin_date argument" do
134
+ assert_raise(ArgumentError) { Article.search :since => Time.now, :begin_date => Time.now }
135
+ end
136
+ end
137
+ end
138
+
139
+ context "facets" do
140
+ should "accept a single string" do
141
+ Article.expects(:invoke).with(has_entry("facets", Facet::DATE))
142
+ Article.search "FOO BAR", :facets => Facet::DATE
143
+ end
144
+
145
+ should "accept an array of strings" do
146
+ Article.expects(:invoke).with(has_entry("facets", [Facet::DATE, Facet::GEO].join(',')))
147
+ Article.search "FOO BAR", :facets => [Facet::DATE, Facet::GEO]
148
+ end
149
+ end
150
+
151
+ context "only_facets" do
152
+ should "accept a String" do
153
+ Article.expects(:invoke).with(has_entry("query", "#{Facet::GEO}:[CALIFORNIA]"))
154
+ Article.search :only_facets => "#{Facet::GEO}:[CALIFORNIA]"
155
+ end
156
+
157
+ should "accept a single hash value Facet string to a term" do
158
+ Article.expects(:invoke).with(has_entry("query", "#{Facet::GEO}:[CALIFORNIA]"))
159
+ Article.search :only_facets => {Facet::GEO => 'CALIFORNIA'}
160
+ end
161
+
162
+ should "accept an Facet string hashed to an array terms" do
163
+ Article.expects(:invoke).with(has_entry("query", "#{Facet::GEO}:[CALIFORNIA] #{Facet::GEO}:[GREAT BRITAIN]"))
164
+ Article.search :only_facets => {Facet::GEO => ['CALIFORNIA', 'GREAT BRITAIN']}
165
+ end
166
+
167
+ should "accept a single Facet object" do
168
+ f = Facet.new(Facet::GEO, 'CALIFORNIA', 2394)
169
+ Article.expects(:invoke).with(has_entry("query", "#{Facet::GEO}:[CALIFORNIA]"))
170
+ Article.search :only_facets => f
171
+ end
172
+
173
+ should "accept an array of Facet objects" do
174
+ f = Facet.new(Facet::GEO, 'CALIFORNIA', 2394)
175
+ f2 = Facet.new(Facet::NYTD_ORGANIZATION, 'University Of California', 12)
176
+
177
+ Article.expects(:invoke).with(has_entry("query", "#{Facet::GEO}:[CALIFORNIA] #{Facet::NYTD_ORGANIZATION}:[University Of California]"))
178
+ Article.search :only_facets => [f, f2]
179
+ end
180
+
181
+ should "merge multiple Facets objects in the array of the same type into one array" do
182
+ f = Facet.new(Facet::GEO, 'CALIFORNIA', 2394)
183
+ f2 = Facet.new(Facet::GEO, 'IOWA', 12)
184
+
185
+ Article.expects(:invoke).with(has_entry("query", "#{Facet::GEO}:[CALIFORNIA] #{Facet::GEO}:[IOWA]"))
186
+ Article.search :only_facets => [f, f2]
187
+ end
188
+
189
+ should "not stomp on an existing query string" do
190
+ Article.expects(:invoke).with(has_entry("query", "ice cream #{Facet::GEO}:[CALIFORNIA]"))
191
+ Article.search "ice cream", :only_facets => {Facet::GEO => "CALIFORNIA"}
192
+ end
193
+ end
194
+
195
+ context "except_facets" do
196
+ should "accept a String" do
197
+ Article.expects(:invoke).with(has_entry("query", "-#{Facet::GEO}:[CALIFORNIA]"))
198
+ Article.search :except_facets => "-#{Facet::GEO}:[CALIFORNIA]"
199
+ end
200
+
201
+ should "accept a single hash value Facet string to a term" do
202
+ Article.expects(:invoke).with(has_entry("query", "-#{Facet::GEO}:[CALIFORNIA]"))
203
+ Article.search :except_facets => {Facet::GEO => 'CALIFORNIA'}
204
+ end
205
+
206
+ should "accept an Facet string hashed to an array terms" do
207
+ Article.expects(:invoke).with(has_entry("query", "-#{Facet::GEO}:[CALIFORNIA] -#{Facet::GEO}:[GREAT BRITAIN]"))
208
+ Article.search :except_facets => {Facet::GEO => ['CALIFORNIA', 'GREAT BRITAIN']}
209
+ end
210
+
211
+ should "accept a single Facet object" do
212
+ f = Facet.new(Facet::GEO, 'CALIFORNIA', 2394)
213
+ Article.expects(:invoke).with(has_entry("query", "-#{Facet::GEO}:[CALIFORNIA]"))
214
+ Article.search :except_facets => f
215
+ end
216
+
217
+ should "accept an array of Facet objects" do
218
+ f = Facet.new(Facet::GEO, 'CALIFORNIA', 2394)
219
+ f2 = Facet.new(Facet::NYTD_ORGANIZATION, 'University Of California', 12)
220
+
221
+ Article.expects(:invoke).with(has_entry("query", "-#{Facet::GEO}:[CALIFORNIA] -#{Facet::NYTD_ORGANIZATION}:[University Of California]"))
222
+ Article.search :except_facets => [f, f2]
223
+ end
224
+
225
+ should "merge multiple Facets objects in the array of the same type into one array" do
226
+ f = Facet.new(Facet::GEO, 'CALIFORNIA', 2394)
227
+ f2 = Facet.new(Facet::GEO, 'IOWA', 12)
228
+
229
+ Article.expects(:invoke).with(has_entry("query", "-#{Facet::GEO}:[CALIFORNIA] -#{Facet::GEO}:[IOWA]"))
230
+ Article.search :except_facets => [f, f2]
231
+ end
232
+
233
+ should "not stomp on an existing query string" do
234
+ Article.expects(:invoke).with(has_entry("query", "ice cream -#{Facet::GEO}:[CALIFORNIA]"))
235
+ Article.search "ice cream", :except_facets => {Facet::GEO => "CALIFORNIA"}
236
+ end
237
+ end
238
+
239
+ context ":fee" do
240
+ should "send through as fee:Y if set to true" do
241
+ Article.expects(:invoke).with(has_entry("query", "ice cream fee:Y"))
242
+ Article.search "ice cream", :fee => true
243
+ end
244
+
245
+ should "send through as -fee:Y if set to false" do
246
+ Article.expects(:invoke).with(has_entry("query", "ice cream -fee:Y"))
247
+ Article.search "ice cream", :fee => false
248
+ end
249
+ end
250
+
251
+ context ":fields" do
252
+ context "for the :all argument" do
253
+ should "pass all fields in a comma-delimited list" do
254
+ Article.expects(:invoke).with(has_entry('fields', Article::ALL_FIELDS.join(',')))
255
+ Article.search "FOO BAR", :fields => :all
256
+ end
257
+ end
258
+
259
+ context "for the :none argument" do
260
+ should "request a blank space for the fields argument" do
261
+ Article.expects(:invoke).with(has_entry('fields', ' '))
262
+ Article.search "FOO BAR", :fields => :none
263
+ end
264
+
265
+ should "request the standard :facets if no :facets have been explicitly provided" do
266
+ Article.expects(:invoke).with(has_entry('facets', Facet::DEFAULT_RETURN_FACETS.join(',')))
267
+ Article.search "FOO BAR", :fields => :none
268
+ end
269
+
270
+ should "request the given :facets field if provided" do
271
+ Article.expects(:invoke).with(has_entry('facets', "#{Facet::GEO}"))
272
+ Article.search "FOO BAR", :fields => :none, :facets => Facet::GEO
273
+ end
274
+ end
275
+
276
+ context ":thumbnail" do
277
+ should "accept the symbol version of the argument" do
278
+ Article.expects(:invoke).with(has_entry('fields', Article::IMAGE_FIELDS.join(',')))
279
+ Article.search "FOO BAR", :fields => :thumbnail
280
+ end
281
+
282
+ should "accept the string version of the argument" do
283
+ Article.expects(:invoke).with(has_entry('fields', Article::IMAGE_FIELDS.join(',')))
284
+ Article.search "FOO BAR", :fields => 'thumbnail'
285
+ end
286
+ end
287
+
288
+ context ":multimedia" do
289
+ should "be implemented"
290
+ end
291
+
292
+ should "accept a single string as an argument" do
293
+ Article.expects(:invoke).with(has_entry('fields', 'body'))
294
+ Article.search "FOO BAR", :fields => 'body'
295
+ end
296
+
297
+ should "accept a single symbol as an argument" do
298
+ Article.expects(:invoke).with(has_entry('fields', 'body'))
299
+ Article.search "FOO BAR", :fields => :body
300
+ end
301
+
302
+ should "accept an array of strings and symbols" do
303
+ Article.expects(:invoke).with(has_entry('fields', 'abstract,body'))
304
+ Article.search "FOO BAR", :fields => [:abstract, 'body']
305
+ end
306
+
307
+ should "raise an ArgumentError otherwise" do
308
+ assert_raise(ArgumentError) { Article.search :fields => 12 }
309
+ end
310
+ end
311
+
312
+ context ":has_multimedia" do
313
+ should "send through as related_multimedia:Y if set to true" do
314
+ Article.expects(:invoke).with(has_entry("query", "ice cream related_multimedia:Y"))
315
+ Article.search "ice cream", :has_multimedia => true
316
+ end
317
+
318
+ should "send through as -related_multimedia:Y if set to false" do
319
+ Article.expects(:invoke).with(has_entry("query", "ice cream -related_multimedia:Y"))
320
+ Article.search "ice cream", :has_multimedia => false
321
+ end
322
+ end
323
+
324
+ context ":has_thumbnail" do
325
+ should "send through as small_image:Y if set to true" do
326
+ Article.expects(:invoke).with(has_entry("query", "ice cream small_image:Y"))
327
+ Article.search "ice cream", :has_thumbnail => true
328
+ end
329
+
330
+ should "send through as -small_image:Y if set to false" do
331
+ Article.expects(:invoke).with(has_entry("query", "ice cream -small_image:Y"))
332
+ Article.search "ice cream", :has_thumbnail => false
333
+ end
334
+ end
335
+
336
+ context ":offset" do
337
+ should "pass through an explicit offset parameter if specified" do
338
+ Article.expects(:invoke).with(has_entry("offset", 10))
339
+ Article.search :offset => 10
340
+ end
341
+
342
+ should "raise an ArgumentError if the offset is not an Integer" do
343
+ assert_raise(ArgumentError) { Article.search :offset => 'apple' }
344
+ end
345
+
346
+ should "pass through an offset of page - 1 if :page is used instead" do
347
+ Article.expects(:invoke).with(has_entry("offset", 2))
348
+ Article.search :page => 3
349
+ end
350
+
351
+ should "not pass through a page parameter to the API" do
352
+ Article.expects(:invoke).with(Not(has_key("page")))
353
+ Article.search :page => 3
354
+ end
355
+
356
+ should "raise an ArgumentError if the page is not an Integer" do
357
+ assert_raise(ArgumentError) { Article.search :page => 'orange' }
358
+ end
359
+
360
+ should "raise an ArgumentError if the page is less than 1" do
361
+ assert_raise(ArgumentError) { Article.search :page => 0 }
362
+ end
363
+
364
+ should "use the :offset argument if both an :offset and :page are provided" do
365
+ Article.expects(:invoke).with(has_entry("offset", 2))
366
+ Article.search :offset => 2, :page => 203
367
+ end
368
+ end
369
+
370
+ context "rank" do
371
+ %w(newest oldest closest).each do |rank|
372
+ should "accept #{rank} as the argument to rank" do
373
+ Article.expects(:invoke).with(has_entry("rank", rank))
374
+ Article.search :rank => rank.to_sym
375
+ end
376
+ end
377
+
378
+ should "raise an ArgumentError if rank is something else" do
379
+ assert_raise(ArgumentError) { Article.search :rank => :clockwise }
380
+ end
381
+ end
382
+
383
+ Article::TEXT_FIELDS.each do |tf|
384
+ context ":#{tf} parameter" do
385
+ should "prefix each non-quoted term with the #{tf}: field identifier in the query to the API" do
386
+ Article.expects(:invoke).with(has_entry("query", "#{tf}:ice #{tf}:cream"))
387
+ Article.search tf.to_sym => 'ice cream'
388
+ end
389
+
390
+ should "prefix -terms (excluded terms) with -#{tf}:" do
391
+ Article.expects(:invoke).with(has_entry("query", "#{tf}:ice -#{tf}:cream"))
392
+ Article.search tf.to_sym => 'ice -cream'
393
+ end
394
+
395
+ should "put quoted terms behind the field spec" do
396
+ Article.expects(:invoke).with(has_entry("query", "#{tf}:\"ice cream\" #{tf}:cone"))
397
+ Article.search tf.to_sym => '"ice cream" cone'
398
+ end
399
+
400
+ should "handle complicated combinations of expressions" do
401
+ Article.expects(:invoke).with(has_entry("query", "#{tf}:\"ice cream\" -#{tf}:cone #{tf}:\"waffle\""))
402
+ Article.search tf.to_sym => '"ice cream" -cone "waffle"'
403
+ end
404
+ end
405
+ end
406
+
407
+ # context "query parameters" do
408
+ # context "abstract" do
409
+ # should "be prefixed with the abstract: field identifier in the query"
410
+ # should "cast the argument to a string (will figure out processing later)"
411
+ # end
412
+ #
413
+ # context "author" do
414
+ # should "be prefixed with the author: field identifier in the query"
415
+ # should "cast the argument to a string (will figure out processing later)"
416
+ # end
417
+ #
418
+ # context "body" do
419
+ # should "be prefixed with the body: field identifier in the query"
420
+ # should "cast the argument to a string (will figure out processing later)"
421
+ # end
422
+ #
423
+ # context "byline" do
424
+ # should "be prefixed with the body: field identifier in the query"
425
+ # should "cast the argument to a string (will figure out processing later)"
426
+ # end
427
+ # end
428
+ end
429
+
430
+ context "Article.init_from_api" do
431
+ setup do
432
+ @article = Article.init_from_api(ARTICLE_API_HASH2)
433
+ end
434
+
435
+ Article::TEXT_FIELDS.each do |tf|
436
+ context "@#{tf}" do
437
+ should "read the value from the hash input" do
438
+ hash = {}
439
+ hash[tf] = "TEST TEXT"
440
+ article = Article.init_from_api(hash)
441
+ assert_equal "TEST TEXT", article.send(tf)
442
+ end
443
+
444
+ should "properly translate HTML entities back into characters" do
445
+ article = Article.init_from_api(tf => '&#8220;Money for Nothing&#8221;')
446
+ assert_equal "“Money for Nothing”", article.send(tf), article.inspect
447
+ end
448
+
449
+ should "only provide read-only access to the field" do
450
+ article = Article.init_from_api(tf => "TEST TEXT")
451
+ assert !article.respond_to?("#{tf}=")
452
+ end
453
+
454
+ should "return nil if the value is not provided in the hash" do
455
+ article = Article.init_from_api({"foo" => "bar"})
456
+ assert_nil article.send(tf)
457
+ end
458
+ end
459
+ end
460
+
461
+ Article::NUMERIC_FIELDS.each do |tf|
462
+ context "@#{tf}" do
463
+ should "read and coerce the string value from the hash input" do
464
+ article = Article.init_from_api(tf => "23")
465
+ assert_equal 23, article.send(tf)
466
+ end
467
+
468
+ should "only provide read-only access to the field" do
469
+ article = Article.init_from_api(tf => "23")
470
+ assert !article.respond_to?("#{tf}=")
471
+ end
472
+
473
+ should "return nil if the value is not provided in the hash" do
474
+ article = Article.init_from_api({"foo" => "bar"})
475
+ assert_nil article.send(tf)
476
+ end
477
+ end
478
+ end
479
+
480
+ # all the rest
481
+ context "@fee" do
482
+ setup do
483
+ @article = Article.init_from_api(ARTICLE_API_HASH)
484
+ end
485
+
486
+ should "be true if returned as true from the API" do
487
+ article = Article.init_from_api('fee' => true)
488
+ assert_equal true, article.fee?
489
+ assert_equal false, article.free?
490
+ end
491
+
492
+ should "be true if returned as Y from the API" do
493
+ article = Article.init_from_api('fee' => 'Y')
494
+ assert_equal true, article.fee?
495
+ assert_equal false, article.free?
496
+ end
497
+
498
+ should "default to false if not specified in the hash" do
499
+ assert_equal false, @article.fee?
500
+ assert_equal true, @article.free?
501
+ end
502
+
503
+ should "default to false if returned as N from the API" do
504
+ article = Article.init_from_api('fee' => 'N')
505
+ assert_equal false, article.fee?
506
+ assert_equal true, article.free?
507
+ end
508
+ end
509
+
510
+ context "@url" do
511
+ setup do
512
+ @article = Article.init_from_api(ARTICLE_API_HASH)
513
+ end
514
+
515
+ should "read the value from the hash" do
516
+ assert_equal ARTICLE_API_HASH['url'], @article.url
517
+ end
518
+
519
+ should "return a String" do
520
+ assert_kind_of(String, @article.url)
521
+ end
522
+
523
+ should "only provide read-only access to the field" do
524
+ assert !@article.respond_to?("url=")
525
+ end
526
+
527
+ should "return nil if the value is not provided in the hash" do
528
+ article = Article.init_from_api({"foo" => "bar"})
529
+ assert_nil article.url
530
+ end
531
+ end
532
+
533
+ context "@page" do
534
+ should "read the value from the page_facet field" do
535
+ assert_equal ARTICLE_API_HASH2['page_facet'].to_i, @article.page
536
+ end
537
+
538
+ should "only provide read-only access to the field" do
539
+ article = Article.new
540
+ assert !article.respond_to?("page=")
541
+ end
542
+
543
+ should "return nil if the value is not provided in the hash" do
544
+ article = Article.init_from_api({"foo" => "bar"})
545
+ assert_nil article.page
546
+ end
547
+ end
548
+
549
+ context "@thumbnail" do
550
+ should "assign nil to thumbnail otherwise" do
551
+ article = Article.init_from_api({"foo" => "bar"})
552
+ assert_nil article.thumbnail
553
+ end
554
+
555
+ should "create a thumbnail object if a small_image_url is part of the return hash" do
556
+ article = Article.init_from_api(ARTICLE_API_HASH2)
557
+ thumbnail = article.thumbnail
558
+ assert_not_nil thumbnail
559
+ assert_kind_of Thumbnail, thumbnail
560
+ assert_equal ARTICLE_API_HASH2['small_image_url'], thumbnail.url
561
+ assert_equal ARTICLE_API_HASH2['small_image_width'].to_i, thumbnail.width
562
+ assert_equal ARTICLE_API_HASH2['small_image_height'].to_i, thumbnail.height
563
+ end
564
+ end
565
+ end
566
+ end
567
+
568
+
569
+ # abstract String X X A summary of the article, written by Times indexers
570
+ # author String X X An author note, such as an e-mail address or short biography (compare byline)
571
+ # body String X X A portion of the beginning of the article. Note: Only a portion of the article body is included in responses. But when you search against the body field, you search the full text of the article.
572
+ # byline String X X The article byline, including the author's name
573
+ # classifers_facet Array (Strings) X X Taxonomic classifiers that reflect Times content categories, such as Top/News/Sports
574
+ # column_facet String X X A Times column title (if applicable), such as Weddings or Ideas & Trends
575
+ # date Date X X The publication date in YYYYMMDD format
576
+ # day_of_week_facet String X X The day of the week (e.g., Monday, Tuesday) the article was published (compare publication_day, which is the numeric date rather than the day of the week)
577
+ # des_facet Array (Strings) X X Descriptive subject terms assigned by Times indexers
578
+ #
579
+ # When used in a request, values must be UPPERCASE
580
+ # desk_facet
581
+ # desk_facet String X X The Times desk that produced the story (e.g., Business/Financial Desk)
582
+ # fee Boolean X X Indicates whether users must pay a fee to retrieve the full article
583
+ # geo_facet Array (Strings) X X Standardized names of geographic locations, assigned by Times indexers
584
+ #
585
+ # When used in a request, values must be UPPERCASE
586
+ # lead_paragraph String X X The first paragraph of the article (as it appeared in the printed newspaper)
587
+ # material_type_facet Array (Strings) X X The general article type, such as Biography, Editorial or Review
588
+ # multimedia Array X Associated multimedia features, including URLs (see also the related_multimedia field)
589
+ # nytd_byline_facet String X X The article byline, formatted for NYTimes.com
590
+ # nytd_des_facet Array (Strings) X X Descriptive subject terms, assigned for use on NYTimes.com (to get standardized terms, use the TimesTags API)
591
+ #
592
+ # When used in a request, values must be Mixed Case
593
+ # nytd_geo_facet Array (Strings) X X Standardized names of geographic locations, assigned for use on NYTimes.com (to get standardized terms, use the TimesTags API)
594
+ #
595
+ # When used in a request, values must be Mixed Case
596
+ # nytd_lead_paragraph String X X The first paragraph of the article (as it appears on NYTimes.com)
597
+ # nytd_org_facet Array (Strings) X X Standardized names of organizations, assigned for use on NYTimes.com (to get standardized terms, use the TimesTags API)
598
+ #
599
+ # When used in a request, values must be Mixed Case
600
+ # nytd_per_facet Array (Strings) X X Standardized names of people, assigned for use on NYTimes.com (to get standardized terms, use the TimesTags API)
601
+ #
602
+ # When used in a request, values must be Mixed Case
603
+ # nytd_section_facet Array (Strings) X X The section the article appears in (on NYTimes.com)
604
+ # nytd_title String X X The article title on NYTimes.com (this field may or may not match the title field; headlines may be shortened and edited for the Web)
605
+ # nytd_works_mentioned
606
+ # _facet String X X Literary works mentioned (titles formatted for use on NYTimes.com)
607
+ # org_facet Array (Strings) X X Standardized names of organizations, assigned by Times indexers
608
+ #
609
+ # When used in a request, values must be UPPERCASE
610
+ # page_facet String X X The page the article appeared on (in the printed paper)
611
+ # per_facet Array (Strings) X X Standardized names of people, assigned by Times indexers
612
+ #
613
+ # When used in a request, values must be UPPERCASE
614
+ # publication_day
615
+ # publication_month
616
+ # publication_year Date
617
+ # Date
618
+ # Date X
619
+ # X
620
+ # X X
621
+ # x
622
+ # x The day (DD), month (MM) and year (YYYY) segments of date, separated for use as facets
623
+ # related_multimedia Boolean X X Indicates whether multimedia features are associated with this article. Additional metadata for each related multimedia feature appears in the multimedia array.
624
+ # section_page_facet String X X The full page number of the printed article (e.g., D00002)
625
+ # small_image
626
+ # small_image_url
627
+ # small_image_height
628
+ # small_image_width Boolean
629
+ # String
630
+ # Integer
631
+ # Integer X X
632
+ # X
633
+ # X
634
+ # X The small_image field indicates whether a smaller thumbnail image is associated with the article. The small_image_url field provides the URL of the image on NYTimes.com. The small_image_height and small_image_width fields provide the image dimensions.
635
+ # source_facet String X X The originating body (e.g., AP, Dow Jones, The New York Times)
636
+ # text String X The text field consists of title + byline + body (combined in an OR search) and is the default field for keyword searches. For more information, see Constructing a Search Query.
637
+ # title String X X The article title (headline); corresponds to the headline that appeared in the printed newspaper
638
+ # url String X X The URL of the article on NYTimes.com
639
+ # word_count Integer X The full article word count
640
+ # works_mentioned_facet