ALD 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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