ALD 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,216 @@
1
+ require 'semantic'
2
+
3
+ module ALD
4
+ class API
5
+ # Internal: used by Collection classes to work with special conditions in #where.
6
+ #
7
+ # Requires @conditions to be the instance's condition Hash.
8
+ module Conditioned
9
+ # Public: Filter the Collection's data.
10
+ # See the documentation on the individual classes for more information.
11
+ def where(conditions)
12
+ return self if conditions.nil? || conditions.empty?
13
+ new_conditions = merge_conditions(conditions)
14
+
15
+ if initialized? && Collection::LocalFilter.can_apply?(conditions, self.class::LOCAL_CONDITIONS)
16
+ self.class::new(
17
+ @api,
18
+ new_conditions,
19
+ Collection::LocalFilter.apply_conditions(@data, conditions)
20
+ )
21
+ else
22
+ self.class::new(@api, new_conditions)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # Internal: The HTTP query conditions for the range specified in the
29
+ # instance's conditions.
30
+ #
31
+ # Returns a Hash, that contains the query parameters matching the range
32
+ # specified in the conditions, or an empty Hash if there is no range
33
+ # specified.
34
+ def range_query
35
+ data = {}
36
+ if @conditions.key?(:range)
37
+ data['start'] = @conditions[:range].min
38
+ data['count'] = @conditions[:range].max - @conditions[:range].min + 1
39
+ end
40
+ data
41
+ end
42
+
43
+ # Internal: The HTTP query conditions for the sorting specified in the
44
+ # instance's conditions.
45
+ #
46
+ # Returns a Hash containing the query parameters matching the specified
47
+ # sorting, or an empty Hash if there's no sorting specified.
48
+ def sort_query
49
+ Hash[
50
+ present_conditions(%w[sort]).map { |cond| [cond, @conditions[cond.to_sym].map { |k, dir| "#{dir == :desc ? '-' : ''}#{k}" }.join(',')] }
51
+ ]
52
+ end
53
+
54
+ # Internal: The HTTP query conditions for exact queries for a set of
55
+ # given conditions.
56
+ #
57
+ # conds - an Array of Strings, containing the condition names to handle
58
+ #
59
+ # Returns a Hash with the query parameters matching the instance's values
60
+ # on the specified conditions, or an empty Hash, if there are none.
61
+ def exact_queries(conds)
62
+ Hash[
63
+ present_conditions(conds).map { |cond| [cond, @conditions[cond.to_sym]] }
64
+ ]
65
+ end
66
+
67
+ # Internal: The HTTP query conditions for queries for an array of values
68
+ # for the given conditions.
69
+ #
70
+ # conds - an Array of Strings, containing the condition names to handle
71
+ #
72
+ # Returns a Hash with the query parameters matching the instance's values
73
+ # on the specified conditions, or an empty Hash.
74
+ def array_queries(conds)
75
+ Hash[
76
+ present_conditions(conds).map { |cond| [cond, @conditions[cond.to_sym].join(',')] }
77
+ ]
78
+ end
79
+
80
+ # Internal: The HTTP query conditions for queries with conditions that
81
+ # can be switched on, off or indeterminate.
82
+ #
83
+ # conds - an Array of Strings, containing the condition names to handle
84
+ #
85
+ # Returns a Hash with the query parameters matching the instance's values
86
+ # on the specified conditions, or an empty Hash.
87
+ def switch_queries(conds)
88
+ map = {true => 'true', false => 'false', nil => 'both'}
89
+ Hash[
90
+ present_conditions(conds).map { |cond| [cond, map[@conditions[cond.to_sym]]] }
91
+ ]
92
+ end
93
+
94
+ # Internal: The HTTP query conditions for queries with conditions that
95
+ # allow specifying a range of values.
96
+ #
97
+ # conds - an Array of Strings, containing the condition names to handle
98
+ #
99
+ # Returns a Hash with the query parameters matching the instance's values
100
+ # on the specified conditions, or an empty Hash.
101
+ def range_condition_queries(conds)
102
+ Hash[
103
+ present_conditions(conds).map { |cond|
104
+ fields = @conditions[cond.to_sym].is_a?(Array) ? @conditions[cond.to_sym] : [@conditions[cond.to_sym]]
105
+ fields.map do |field|
106
+ match = RANGE_REGEX.match(field)
107
+ if match.nil? # just a specific field
108
+ [cond, field]
109
+ else # min or max
110
+ ["#{cond}-#{match[1] == '>=' ? 'min' : 'max'}", match[2]]
111
+ end
112
+ end
113
+ }.flatten(1)
114
+ ]
115
+ end
116
+
117
+ # Internal: Get the subset of conditions that are present on the instance
118
+ #
119
+ # conds - an Array of Strings, containing the condition names to check
120
+ #
121
+ # Returns an Array of Strings with a subset of the given conds, namely
122
+ # those that are present on @conditions.
123
+ def present_conditions(conds)
124
+ conds.select { |cond| @conditions.key?(cond.to_sym) }
125
+ end
126
+
127
+ # Internal: Merge new conditions with the current ones.
128
+ #
129
+ # conditions - the new condition Hash to merge
130
+ #
131
+ # Returns the merged Hash
132
+ #
133
+ # Raises ArgumentError if the conditions are incompatible
134
+ def merge_conditions(conditions)
135
+ @conditions.merge(conditions) do |key, old_value, new_value|
136
+ if self.class::RANGE_CONDITIONS.include?(key.to_s)
137
+ merge_ranges(key, old_value, new_value) # handle merging for cases like 'downloads >= 5' and 'downloads <= 9' etc.
138
+ elsif self.class::ARRAY_CONDITIONS.include?(key.to_s)
139
+ old_value + new_value
140
+ elsif key == :range # not a "range condition" in the sense used above
141
+ range_offset(new_value)
142
+ elsif key == :sort
143
+ new_value # enable re-sorting
144
+ else
145
+ raise ArgumentError # for other overwrites fail!
146
+ end
147
+ end
148
+ end
149
+
150
+ # Internal: A regex to determine if a range condition is specifying a range.
151
+ RANGE_REGEX = /^\s*(<\=|>\=)\s*(.*)$/
152
+
153
+ # Internal: Handle condition conflicts for range conditions. Used by #where.
154
+ #
155
+ # key - the Symbol key whose range is merged
156
+ # old - the old range condition value
157
+ # new - the new range condition value to be applied on top of the old one
158
+ #
159
+ # Returns the value that should be used in the merged conditions.
160
+ #
161
+ # Raises ArgumentError if the conflict cannot be resolved.
162
+ def merge_ranges(key, old, new)
163
+ constraints = [new, old]
164
+ data = constraints.map do |c|
165
+ match = RANGE_REGEX.match(c)
166
+ if match.nil?
167
+ [nil, c]
168
+ elsif key == :version
169
+ [match[1], Semantic::Version.new(match[2])]
170
+ else
171
+ [match[1], match[2]]
172
+ end
173
+ end
174
+ ops, values = data.map(&:first), data.map(&:last)
175
+
176
+ if ops[0] != ops[1] # one min, one max OR one min/max, one exact
177
+ constraints # => keep both
178
+
179
+ elsif ops.none?(&:'nil?') # two range constraints of same type
180
+ ops[0] == '>=' ? ">= #{values.max.to_s}" : "<= #{values.min.to_s}"
181
+
182
+ else # two exact values
183
+ if constraints[0].strip == constraints[1].strip # if both are the same, just keep one
184
+ constraints[0]
185
+ else # otherwise this can't be good - throw an error
186
+ raise ArgumentError
187
+ end
188
+ end
189
+ end
190
+
191
+ # Internal: Compute an absolute Range from a given relative Range. As
192
+ # ranges specified in #where are relative to this collection, they must
193
+ # be transformed to absolute ranges before being passed to ::new.
194
+ #
195
+ # new_range - the relative Range to transform
196
+ #
197
+ # Returns the absolute Range.
198
+ #
199
+ # Raises ArgumentError if the relative Range does not fit into this
200
+ # collection's Range.
201
+ def range_offset(new_range)
202
+ if @conditions[:range].nil?
203
+ min, max = 0, Float::Infinity
204
+ else
205
+ min, max = @conditions[:range].min, @conditions[:range].max
206
+ end
207
+
208
+ new_min = min + new_range.min
209
+ new_max = new_min + new_range.max - new_range.min # == new_min + new_range.size - 1
210
+ raise ArgumentError if new_min > max || new_max > max
211
+
212
+ (new_min..new_max)
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,186 @@
1
+ require_relative 'collection_entry'
2
+ require 'date'
3
+
4
+ module ALD
5
+ class API
6
+ # Public: An item (e.g. a library or app) uploaded to an ALD server.
7
+ class Item < CollectionEntry
8
+
9
+ # Public: Get the ID of the item.
10
+ #
11
+ # Examples
12
+ #
13
+ # puts item.id
14
+ #
15
+ # Returns a String of 32 characters, containing the item's GUID.
16
+ #
17
+ # Signature
18
+ #
19
+ # item()
20
+
21
+ # Public: Get the name of the item.
22
+ #
23
+ # Examples
24
+ #
25
+ # puts "Item: #{item.name}"
26
+ #
27
+ # Returns a String containing the item name.
28
+ #
29
+ # Signature
30
+ #
31
+ # name()
32
+
33
+ # Public: Get the item version.
34
+ #
35
+ # Examples
36
+ #
37
+ # puts "#{item.name} v#{item.version}"
38
+ #
39
+ # Returns a String containing the version of the item.
40
+ #
41
+ # Signature
42
+ #
43
+ # version()
44
+
45
+ # Public: Get the item's summary text. This method might trigger a HTTP
46
+ # request.
47
+ #
48
+ # Returns a String summarizing the item's purpose and contents.
49
+ #
50
+ # Signature
51
+ #
52
+ # summary()
53
+
54
+ # Public: Get the item's description text. This method might trigger a
55
+ # HTTP request.
56
+ #
57
+ # Returns a String with the item's description.
58
+ #
59
+ # Signature
60
+ #
61
+ # description()
62
+
63
+ # Public: Get the time the item was uploaded. This method might trigger a
64
+ # HTTP request.
65
+ #
66
+ # Returns a DateTime describing the time the item was first uploaded to
67
+ # the ALD server.
68
+ #
69
+ # Signature
70
+ #
71
+ # uploaded()
72
+
73
+ # Public: Get if the item has been marked as reviewed by the ALD server.
74
+ # This method might trigger a HTTP request.
75
+ #
76
+ # Returns a Boolean indicating if the item was revieed or not.
77
+ #
78
+ # Signature
79
+ #
80
+ # reviewed()
81
+
82
+ # Public: Get the number of downloads for the item. This method might
83
+ # trigger a HTTP request.
84
+ #
85
+ # Returns an Integer indicating how often the item was downloaded.
86
+ #
87
+ # Signature
88
+ #
89
+ # downloads()
90
+
91
+ # Public: Get the tags the item was tagged with. This method might
92
+ # trigger a HTTP request.
93
+ #
94
+ # Returns an Array of Symbols representing the tags.
95
+ #
96
+ # Signature
97
+ #
98
+ # tags()
99
+
100
+ # Public: get author information from the item. This method might trigger
101
+ # a HTTP request.
102
+ #
103
+ # Returns an Array of Hashes describing the authors.
104
+ #
105
+ # Signature
106
+ #
107
+ # authors()
108
+
109
+ # Public: Get the user who owns the item. This method might trigger a
110
+ # HTTP request.
111
+ #
112
+ # Returns the ALD::API::User who owns the item.
113
+ #
114
+ # Signature
115
+ #
116
+ # user()
117
+
118
+ # Public: Get the ratings the item was given. This method might trigger a
119
+ # HTTP request.
120
+ #
121
+ # Returns an Array of Integers representing the ratings given to the item
122
+ # by users.
123
+ #
124
+ # Signature
125
+ #
126
+ # ratings()
127
+
128
+ # Internal: Create a new instance for given data. This method should not
129
+ # called by library consumers. Instead access entries via API#item or
130
+ # ItemCollection#[].
131
+ #
132
+ # api - the ALD::API instance this item belongs to
133
+ # data - a Hash containing data concerning the item:
134
+ # id - the GUID of the item
135
+ # name - the name of the item
136
+ # version - the semver version of the item
137
+ # The above fields are mandatory. However, the hash may
138
+ # contain a lot more data about the item.
139
+ # initialized - a Boolean indicating if data only contains the mandatory
140
+ # fields or *all* data on the item.
141
+ def initialize(api, data, initialized = false)
142
+ raise ArgumentError unless Item.valid_data?(data)
143
+ super(api, data, initialized)
144
+ end
145
+
146
+ private
147
+
148
+ # Internal: If the item was initialized with only mandatory data, use the
149
+ # API to request all missing information.
150
+ #
151
+ # Returns nothing.
152
+ def request
153
+ @data = @api.request("/items/#{id}")
154
+ @data['uploaded'] = DateTime.parse(@data['uploaded'])
155
+ @data['tags'].map!(&:to_sym)
156
+ @data['user'] = @api.user(@data['user'])
157
+ end
158
+
159
+ # Internal: Ensure a Hash contains all information necessary to be passed
160
+ # to #new.
161
+ #
162
+ # data - the Hash to check for mandatory fields
163
+ #
164
+ # Returns true if the Hash is valid, false otherwise.
165
+ def self.valid_data?(data)
166
+ data.is_a?(Hash) && initialized_attributes.all? { |k| data.key?(k) }
167
+ end
168
+
169
+ # Internal: Override of CollectionEntry#initialized_attributes to enable
170
+ # automatic method definition, in this case #id, #name and #version.
171
+ #
172
+ # Returns an Array of attribute names (String)
173
+ def self.initialized_attributes
174
+ %w[id name version]
175
+ end
176
+
177
+ # Internal: Override of CollectionEntry#requested_attributes to enable
178
+ # automatic method definition.
179
+ #
180
+ # Returns an Array of attribute names (String)
181
+ def self.requested_attributes
182
+ %w[summary description uploaded reviewed downloads tags authors user ratings]
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,169 @@
1
+ require_relative 'collection'
2
+ require_relative 'conditioned'
3
+ require_relative 'local_filter'
4
+
5
+ module ALD
6
+ class API
7
+ # Public: Represents a (possibly filtered) set of items on an ALD server
8
+ class ItemCollection < Collection
9
+ include Conditioned
10
+
11
+ # Internal: Create a new instance. This should not be called by library
12
+ # consumers. Instead use API#items or #where to get a new instance.
13
+ #
14
+ # api - the ALD::API instance this collection belongs to
15
+ # conditions - a Hash of conditions items in this collection must meet
16
+ # data - an Array of Hashes representing the items in this
17
+ # collection:
18
+ # id - the GUID of the item
19
+ # name - the item's name
20
+ # version - the item's semver version
21
+ def initialize(api, conditions = {}, data = nil)
22
+ super(api, conditions, data)
23
+ end
24
+
25
+ # Public: Access an individual item by ID, name and version or index in
26
+ # the collection. This method may trigger a HTTP request.
27
+ #
28
+ # Examples
29
+ #
30
+ # items['185d265f24654545aad3f88e8a383339'] # access the item with this ID
31
+ # items['MyApp', '0.1.2'] # access a specific version of an item
32
+ # items[4] # access the 5th item in the collection (zero-based index)
33
+ # # This makes most sense in an explicitly ordered collection
34
+ #
35
+ # Returns the corresponding ALD::API::Item instance, or nil if not found
36
+ #
37
+ # Signature
38
+ #
39
+ # [](id)
40
+ # [](name, version)
41
+ # [](index)
42
+ #
43
+ # id - a GUID String uniquely identifying the item
44
+ # name - a String containing the item's name
45
+ # version - a String containing the item's semver version
46
+ # index - an Integer with the item's zero-based index within the
47
+ # collection.
48
+
49
+ # Internal: filter conditions that allow specifying a range, like
50
+ # 'version-min=0.2.1&version-max=3.4.5'
51
+ RANGE_CONDITIONS = %w[version downloads rating]
52
+
53
+ # Internal: filter conditions that allow specifying an array.
54
+ ARRAY_CONDITIONS = %w[tags]
55
+
56
+ # Internal: filter conditions that can be handled locally.
57
+ LOCAL_CONDITIONS = %w[name version]
58
+
59
+ # Public: Filter and/or sort this collection and return a new collection
60
+ # containing a subset of its items.
61
+ #
62
+ # conditions - a Hash of conditions to filter for
63
+ # :name - filter for items with this name (String)
64
+ # :user - only return items by this user (given the user
65
+ # name or the ID) (String)
66
+ # :type - only return items of this type (String)
67
+ # :downloads - If given only a number, return only items with
68
+ # this number of downloads. More commonly, pass
69
+ # a string like '>= 4' or '<= 5' (or an array of
70
+ # such strings) to select items in a range of
71
+ # download counts.
72
+ # :rating - Select items with a given rating. Like
73
+ # for :downloads, ranges can be specified.
74
+ # :version - Only items with a given semver version number.
75
+ # Here as well, ranges can be specified. Semver
76
+ # rules are taken into account when sorting.
77
+ # :stable - Set to true to only return items whose semver
78
+ # version indicates they're stable.
79
+ # :reviewed - Set to true to filter for items that are marked
80
+ # as reviewed by the server.
81
+ # :tags - A tag or an array of tags to filter for.
82
+ # :sort - an Array of sorting criteria, in descending
83
+ # order of precedence; or a Hash where the keys
84
+ # are the sorting criteria, and the values (:asc,
85
+ # :desc) indicate sorting order.
86
+ # :range - A zero-based Range of items to return. This
87
+ # makes most sense in combination with sorting.
88
+ # Note that the range is relative to the
89
+ # collection the operation is performed upon.
90
+ #
91
+ # Returns a new ItemCollection instance (or self, if conditions is nil)
92
+ #
93
+ # Raises ArgumentError if the conditions are invalid or incompatible with
94
+ # this collection's conditions.
95
+ #
96
+ # Signature
97
+ #
98
+ # where(conditions)
99
+
100
+ private
101
+
102
+ # Internal: Make a HTTP request to the ALD server to get the list of item
103
+ # hashes matching this collection's conditions.
104
+ #
105
+ # Returns nothing.
106
+ def request
107
+ data = [
108
+ exact_queries(%w[name user type]),
109
+ switch_queries(%w[stable reviewed]),
110
+ array_queries(%w[tags]),
111
+ range_condition_queries(%w[downloads rating version]),
112
+ sort_query,
113
+ range_query
114
+ ].reduce({}, :merge)
115
+
116
+ url = "/items/#{data.empty? ? '' : '?'}#{URI.encode_www_form(data)}"
117
+ @data = @api.request(url).map do |hash|
118
+ hash['id'] = @api.normalize_id(hash['id'])
119
+ hash
120
+ end
121
+ end
122
+
123
+ # Internal: Make a HTTP request to the ALD server to get a single item.
124
+ # Used by Collection#[].
125
+ #
126
+ # filter - a filter Hash as returned by #entry_filter
127
+ #
128
+ # Returns a Hash with all information about the item.
129
+ #
130
+ # Raises ArgumentError if the filters cannot be handled.
131
+ def request_entry(filter)
132
+ url = if filter.key?(:id)
133
+ "/items/#{filter[:id]}"
134
+ elsif %w[name version].all? { |k| filter.key?(k.to_sym) }
135
+ "/items/#{filter[:name]}/#{filter[:version]}"
136
+ else
137
+ raise ArgumentError
138
+ end
139
+
140
+ @api.request(url)
141
+ end
142
+
143
+ # Internal: Used by Collection#each and Collection#[] to create new items.
144
+ #
145
+ # hash - a Hash describing the item, with the keys 'id', 'name'
146
+ # and 'version'.
147
+ # initialized - a Boolean indicating if the given Hash already contains
148
+ # all information about the item or only name and id.
149
+ def entry(hash, initialized = false)
150
+ @api.item(hash, initialized)
151
+ end
152
+
153
+ # Internal: Implements item access for #[]. See Collection#entry_filter
154
+ # for more information.
155
+ #
156
+ # ItemCollection allows access by ID (String) or name and version
157
+ # (both String).
158
+ def entry_filter(args)
159
+ if args.length == 1 && args.first.is_a?(String)
160
+ { id: @api.normalize_id(args.first) }
161
+ elsif args.length == 2
162
+ { name: args.first, version: args.last }
163
+ else
164
+ raise ArgumentError
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end