Merge lp:~rackspace-titan/glance/api-pagination-limiting into lp:~hudson-openstack/glance/trunk

Proposed by Brian Waldon
Status: Merged
Approved by: Dan Prince
Approved revision: 149
Merged at revision: 139
Proposed branch: lp:~rackspace-titan/glance/api-pagination-limiting
Merge into: lp:~hudson-openstack/glance/trunk
Prerequisite: lp:~rackspace-titan/glance/api-results-filtering
Diff against target: 991 lines (+712/-33)
10 files modified
glance/api/v1/images.py (+18/-4)
glance/client.py (+26/-4)
glance/registry/__init__.py (+8/-8)
glance/registry/client.py (+28/-4)
glance/registry/db/api.py (+20/-5)
glance/registry/server.py (+40/-2)
tests/functional/test_curl_api.py (+116/-0)
tests/stubs.py (+33/-6)
tests/unit/test_api.py (+193/-0)
tests/unit/test_clients.py (+230/-0)
To merge this branch: bzr merge lp:~rackspace-titan/glance/api-pagination-limiting
Reviewer Review Type Date Requested Status
Mark Washenberger (community) Approve
Dan Prince (community) Approve
Jay Pipes (community) Approve
Review via email: mp+61155@code.launchpad.net

Description of the change

Adding support for marker/limit query params from api, through registry client/api, and implementing at registry db api layer

To post a comment you must log in.
Revision history for this message
Jay Pipes (jaypipes) wrote :

Hi Brian,

Once again, great work. Couple little things:

1) Something I did not think about for the results filtering MP, as well as this one, is that we will want to bump the minor version since we are adding functionality to the API in these two MPs.

You can do this in a separate merge proposal, I suppose. Basically, what needs to happen is that we need a way of describing the 1.1 API (1.0 + filters and limits) in the /versions endpoint.

The reason this is important is that the API result for /images has changed: we go from returning ALL public images in no particular order to returning SOME public images in a particular order (by created_at DESC). So, we should bump the minor version of the API to indicate to clients that this has been done.

Since we are in the middle of the D1 milestone, I'm not too concerned about having the results-filter branch, then this branch, then the API 1.1 branch going into trunk in that particular order. I'll handle the versioning stuff next week when I return from vacation.

2) I'd prefer to set the default limit to < 1000. I'm thinking 25 or 50 would be a more appropriate default limit. Also, please make this a constant somewhere, not just a number in the method signature...

3) Unless I'm mistaken these functions:

50 +def limit_collection(items, marker, limit):
243 + def _limit_collection(self, req, images):

in glance.common.wsgi and glance.registry.server, respectively, can be removed. You've pushed (correctly) the limiting and offset down into the registry db query.

4) This:

114 if filters != None:
115 - action = "/images?%s" % urllib.urlencode(filters)
116 + params = filters
117 else:
118 - action = "/images"
119 + params = {}

Might be better as:

params = filters or {}

5)

184 + order_by(models.Image.id)

I think I would prefer:

order_by(desc(models.Image.created_at))

6)

192 + if marker != None:
193 + query = query.filter(models.Image.id > marker)

Should be:

if marker != None:
    query = query.offset(marker)

That way, SQLAlchemy will pass the marker as-is to the query as a LIMIT X OFFSET Y. This allows more than just the Image.id column to be used for ordering, and will allow you to add custom ordering to the API ;)

-jay

review: Needs Fixing
Revision history for this message
Brian Waldon (bcwaldon) wrote :
Download full text (3.2 KiB)

> Hi Brian,
>
> Once again, great work. Couple little things:
>
> 1) Something I did not think about for the results filtering MP, as well as
> this one, is that we will want to bump the minor version since we are adding
> functionality to the API in these two MPs.
>
> You can do this in a separate merge proposal, I suppose. Basically, what needs
> to happen is that we need a way of describing the 1.1 API (1.0 + filters and
> limits) in the /versions endpoint.
>
> The reason this is important is that the API result for /images has changed:
> we go from returning ALL public images in no particular order to returning
> SOME public images in a particular order (by created_at DESC). So, we should
> bump the minor version of the API to indicate to clients that this has been
> done.
>
> Since we are in the middle of the D1 milestone, I'm not too concerned about
> having the results-filter branch, then this branch, then the API 1.1 branch
> going into trunk in that particular order. I'll handle the versioning stuff
> next week when I return from vacation.

According to the last versioning-related discussion we had, these changes were going to be part of v1.0. We haven't cut an official v1.0 release yet, and the limiting changes are definitely a major version change. I say we let the filtering and pagination branches in then call it v1.0. Any new feature (sets?) bump the version.

> 2) I'd prefer to set the default limit to < 1000. I'm thinking 25 or 50 would
> be a more appropriate default limit. Also, please make this a constant
> somewhere, not just a number in the method signature...

I meant to do that before I pushed the branch. Now it's a constant in registry/server.py, set to 25

> 3) Unless I'm mistaken these functions:
>
> 50 +def limit_collection(items, marker, limit):
> 243 + def _limit_collection(self, req, images):
>
> in glance.common.wsgi and glance.registry.server, respectively, can be
> removed. You've pushed (correctly) the limiting and offset down into the
> registry db query.

Good catch. Removed them.

> 4) This:
>
> 114 if filters != None:
> 115 - action = "/images?%s" % urllib.urlencode(filters)
> 116 + params = filters
> 117 else:
> 118 - action = "/images"
> 119 + params = {}
>
> Might be better as:
>
> params = filters or {}

Yep. Did this in a bunch of places.

> 5)
>
> 184 + order_by(models.Image.id)
>
> I think I would prefer:
>
> order_by(desc(models.Image.created_at))

Done. I kept the secondary order_by id for predictability in the behavior of marker b/c created_at is not unique.

Shoot for custom ordering in a future milestone (for v1.1)?

> 6)
>
> 192 + if marker != None:
> 193 + query = query.filter(models.Image.id > marker)
>
> Should be:
>
> if marker != None:
> query = query.offset(marker)
>
> That way, SQLAlchemy will pass the marker as-is to the query as a LIMIT X
> OFFSET Y. This allows more than just the Image.id column to be used for
> ordering, and will allow you to add custom ordering to the API ;)

Actually, the term "offset" means something different than "marker". Offset is an index in a list of images, while marker is the actual id of ...

Read more...

Revision history for this message
Jay Pipes (jaypipes) wrote :
Download full text (3.5 KiB)

On Tue, May 17, 2011 at 8:42 PM, Brian Waldon
<email address hidden> wrote:
> According to the last versioning-related discussion we had, these changes were going to be part of v1.0. We haven't cut an official v1.0 release yet, and the limiting changes are definitely a major version change. I say we let the filtering and pagination branches in then call it v1.0. Any new feature (sets?) bump the version.

Cool, yep, that sounds fine.

>> 2) I'd prefer to set the default limit to < 1000. I'm thinking 25 or 50 would
>> be a more appropriate default limit. Also, please make this a constant
>> somewhere, not just a number in the method signature...
>
> I meant to do that before I pushed the branch. Now it's a constant in registry/server.py, set to 25

Cheers.

>> 3) Unless I'm mistaken these functions:
>>
>> 50      +def limit_collection(items, marker, limit):
>> 243     + def _limit_collection(self, req, images):
>>
>> in glance.common.wsgi and glance.registry.server, respectively, can be
>> removed. You've pushed (correctly) the limiting and offset down into the
>> registry db query.
>
> Good catch. Removed them.

Cool.

>> 4) This:
>>
>> 114     if filters != None:
>> 115     - action = "/images?%s" % urllib.urlencode(filters)
>> 116     + params = filters
>> 117     else:
>> 118     - action = "/images"
>> 119     + params = {}
>>
>> Might be better as:
>>
>> params = filters or {}
>
> Yep. Did this in a bunch of places.

Thanks.

>> 5)
>>
>> 184     + order_by(models.Image.id)
>>
>> I think I would prefer:
>>
>> order_by(desc(models.Image.created_at))
>
> Done. I kept the secondary order_by id for predictability in the behavior of marker b/c created_at is not unique.

Hmm, so you actually don't need created_at to be unique if you replace
the concept of marker with that of offset. See more below...

> Shoot for custom ordering in a future milestone (for v1.1)?

Cool with me :)

>> 6)
>>
>> 192     + if marker != None:
>> 193     + query = query.filter(models.Image.id > marker)
>>
>> Should be:
>>
>> if marker != None:
>>     query = query.offset(marker)
>>
>> That way, SQLAlchemy will pass the marker as-is to the query as a LIMIT X
>> OFFSET Y. This allows more than just the Image.id column to be used for
>> ordering, and will allow you to add custom ordering to the API ;)
>
> Actually, the term "offset" means something different than "marker". Offset is an index in a list of images, while marker is the actual id of an image in that list. I did have to change the code to support the new created_at sort, but I kept the original functionality.

I understand that marker is different from offset. I was recommending
that instead of using the concept of a marker, that you use the
concept of an offset. A marker isn't particularly useful unless you
have do ordering with a unique column. In addition, using a marker
requires you to do the pagination in code, whereas using an offset
allows you to do the pagination in the SQL query itself, which will be
much more efficient than in Python.

The code I demonstrated above would send the offset to the SQL query
and allow you to do pagination in the query instead of this:

398 + images = sorted(images, k...

Read more...

Revision history for this message
Brian Waldon (bcwaldon) wrote :

I updated the code to actually implement marker correctly. This branch should implement what was agreed upon (or proposed and waiting to be approved?) on the mailing list:

Quoted from that email:
1) Push the LIMIT variable into the database API layer
2) Ensure that all queries that return a set of results have an ORDER
BY expression to them
3) Push the marker into the database API layer. Continue to have the
marker variable be a value of a unique key (primary key for now at
least). Use a WHERE field > $marker LIMIT $pagesize construct.

Revision history for this message
Brian Waldon (bcwaldon) wrote :

sqlalchemy query is still bad. I didn't test it thoroughly enough

Revision history for this message
Brian Waldon (bcwaldon) wrote :

Washenberger helped me out. Should work now. Blame him if it doesn't.

Revision history for this message
Jay Pipes (jaypipes) wrote :

Heya!

Lookin' good...

One thing though; we decided on the ML thread that we would *not* do the additional filter on the created_at date; that the possibility of a page of results changing over time was not a big deal and the marker would just be used as the start of the subset.

So, this code:

173 + # images returned should be created before the image defined by marker
174 + marker_created_at = image_get(context, marker).created_at
175 + query = query.filter(
176 + or_(models.Image.created_at < marker_created_at,
177 + and_(models.Image.created_at == marker_created_at,
178 + models.Image.id < marker)))

Should just be:

query = query.filter(models.Image.id > marker)

marker should be the primary key (or a unique index) of the last record on the previous page, or None for the first page.

Make sense?

-jay

Revision history for this message
Brian Waldon (bcwaldon) wrote :

> One thing though; we decided on the ML thread that we would *not* do the
> additional filter on the created_at date; that the possibility of a page of
> results changing over time was not a big deal and the marker would just be
> used as the start of the subset.

[snip snip]

> query = query.filter(models.Image.id > marker)
>
> marker should be the primary key (or a unique index) of the last record on the
> previous page, or None for the first page.

So are you suggesting we sort by id asc (assuming your comparison isn't a typo)? My code does impose a default sort of created_at desc.

Revision history for this message
Jay Pipes (jaypipes) wrote :

> So are you suggesting we sort by id asc (assuming your comparison isn't a
> typo)? My code does impose a default sort of created_at desc.

Hmm, good point. Should have thought of that... :)

I'd keep the default order as ORDER BY created_at DESC, id DESC, but change the WHERE id > $marker to the other direction: WHERE id < $marker. When we do custom ordering, the direction (> or <) will be determined by the direction of the sort order. Am I correct in that thinking?

-jay

Revision history for this message
Brian Waldon (bcwaldon) wrote :

> I'd keep the default order as ORDER BY created_at DESC, id DESC, but change
> the WHERE id > $marker to the other direction: WHERE id < $marker. When we do
> custom ordering, the direction (> or <) will be determined by the direction of
> the sort order. Am I correct in that thinking?

Yes sir. So I think what I have should be good, assuming you understand why I am first doing a comparison on created_at.

Revision history for this message
Jay Pipes (jaypipes) wrote :

> > I'd keep the default order as ORDER BY created_at DESC, id DESC, but change
> > the WHERE id > $marker to the other direction: WHERE id < $marker. When we
> do
> > custom ordering, the direction (> or <) will be determined by the direction
> of
> > the sort order. Am I correct in that thinking?
>
> Yes sir. So I think what I have should be good, assuming you understand why I
> am first doing a comparison on created_at.

Yep.

And you understand in the future for custom ordering, you only need to do the or_ expression when the order is on a field that does not have a unique index :)

-jay

review: Approve
Revision history for this message
Brian Waldon (bcwaldon) wrote :

Added support to glance/client.py

Revision history for this message
Dan Prince (dan-prince) wrote :

Looks good to me.

review: Approve
Revision history for this message
Mark Washenberger (markwash) wrote :

This sqlalchemy looks exactly like what we talked about. Clear eyes full hearts can't lose.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'glance/api/v1/images.py'
2--- glance/api/v1/images.py 2011-05-17 13:27:09 +0000
3+++ glance/api/v1/images.py 2011-05-31 13:23:37 +0000
4@@ -92,8 +92,15 @@
5 'size': <SIZE>}, ...
6 ]}
7 """
8- filters = self._get_filters(req)
9- images = registry.get_images_list(self.options, filters)
10+ params = {'filters': self._get_filters(req)}
11+
12+ if 'limit' in req.str_params:
13+ params['limit'] = req.str_params.get('limit')
14+
15+ if 'marker' in req.str_params:
16+ params['marker'] = req.str_params.get('marker')
17+
18+ images = registry.get_images_list(self.options, **params)
19 return dict(images=images)
20
21 def detail(self, req):
22@@ -118,8 +125,15 @@
23 'properties': {'distro': 'Ubuntu 10.04 LTS', ...}}, ...
24 ]}
25 """
26- filters = self._get_filters(req)
27- images = registry.get_images_detail(self.options, filters)
28+ params = {'filters': self._get_filters(req)}
29+
30+ if 'limit' in req.str_params:
31+ params['limit'] = req.str_params.get('limit')
32+
33+ if 'marker' in req.str_params:
34+ params['marker'] = req.str_params.get('marker')
35+
36+ images = registry.get_images_detail(self.options, **params)
37 return dict(images=images)
38
39 def _get_filters(self, req):
40
41=== modified file 'glance/client.py'
42--- glance/client.py 2011-05-24 19:47:59 +0000
43+++ glance/client.py 2011-05-31 13:23:37 +0000
44@@ -209,19 +209,41 @@
45 return super(V1Client, self).do_request(method, action, body,
46 headers, params)
47
48- def get_images(self, filters=None):
49+ def get_images(self, filters=None, marker=None, limit=None):
50 """
51 Returns a list of image id/name mappings from Registry
52+
53+ :param filters: dictionary of attributes by which the resulting
54+ collection of images should be filtered
55+ :param marker: id after which to start the page of images
56+ :param limit: maximum number of items to return
57 """
58- res = self.do_request("GET", "/images", params=filters)
59+
60+ params = filters or {}
61+ if marker:
62+ params['marker'] = marker
63+ if limit:
64+ params['limit'] = limit
65+ res = self.do_request("GET", "/images", params=params)
66 data = json.loads(res.read())['images']
67 return data
68
69- def get_images_detailed(self, filters=None):
70+ def get_images_detailed(self, filters=None, marker=None, limit=None):
71 """
72 Returns a list of detailed image data mappings from Registry
73+
74+ :param filters: dictionary of attributes by which the resulting
75+ collection of images should be filtered
76+ :param marker: id after which to start the page of images
77+ :param limit: maximum number of items to return
78 """
79- res = self.do_request("GET", "/images/detail", params=filters)
80+
81+ params = filters or {}
82+ if marker:
83+ params['marker'] = marker
84+ if limit:
85+ params['limit'] = limit
86+ res = self.do_request("GET", "/images/detail", params=params)
87 data = json.loads(res.read())['images']
88 return data
89
90
91=== modified file 'glance/registry/__init__.py'
92--- glance/registry/__init__.py 2011-05-15 01:05:04 +0000
93+++ glance/registry/__init__.py 2011-05-31 13:23:37 +0000
94@@ -32,14 +32,14 @@
95 return client.RegistryClient(host, port)
96
97
98-def get_images_list(options, filters):
99- c = get_registry_client(options)
100- return c.get_images(filters)
101-
102-
103-def get_images_detail(options, filters):
104- c = get_registry_client(options)
105- return c.get_images_detailed(filters)
106+def get_images_list(options, **kwargs):
107+ c = get_registry_client(options)
108+ return c.get_images(**kwargs)
109+
110+
111+def get_images_detail(options, **kwargs):
112+ c = get_registry_client(options)
113+ return c.get_images_detailed(**kwargs)
114
115
116 def get_image_metadata(options, image_id):
117
118=== modified file 'glance/registry/client.py'
119--- glance/registry/client.py 2011-05-24 19:47:59 +0000
120+++ glance/registry/client.py 2011-05-31 13:23:37 +0000
121@@ -44,19 +44,43 @@
122 port = port or self.DEFAULT_PORT
123 super(RegistryClient, self).__init__(host, port, use_ssl)
124
125- def get_images(self, filters=None):
126+ def get_images(self, filters=None, marker=None, limit=None):
127 """
128 Returns a list of image id/name mappings from Registry
129+
130+ :param filters: dict of keys & expected values to filter results
131+ :param marker: image id after which to start page
132+ :param limit: max number of images to return
133 """
134- res = self.do_request("GET", "/images", params=filters)
135+ params = filters or {}
136+
137+ if marker != None:
138+ params['marker'] = marker
139+
140+ if limit != None:
141+ params['limit'] = limit
142+
143+ res = self.do_request("GET", "/images", params=params)
144 data = json.loads(res.read())['images']
145 return data
146
147- def get_images_detailed(self, filters=None):
148+ def get_images_detailed(self, filters=None, marker=None, limit=None):
149 """
150 Returns a list of detailed image data mappings from Registry
151+
152+ :param filters: dict of keys & expected values to filter results
153+ :param marker: image id after which to start page
154+ :param limit: max number of images to return
155 """
156- res = self.do_request("GET", "/images/detail", params=filters)
157+ params = filters or {}
158+
159+ if marker != None:
160+ params['marker'] = marker
161+
162+ if limit != None:
163+ params['limit'] = limit
164+
165+ res = self.do_request("GET", "/images/detail", params=params)
166 data = json.loads(res.read())['images']
167 return data
168
169
170=== modified file 'glance/registry/db/api.py'
171--- glance/registry/db/api.py 2011-05-27 02:24:55 +0000
172+++ glance/registry/db/api.py 2011-05-31 13:23:37 +0000
173@@ -23,11 +23,12 @@
174
175 import logging
176
177-from sqlalchemy import create_engine
178+from sqlalchemy import create_engine, desc
179 from sqlalchemy.ext.declarative import declarative_base
180 from sqlalchemy.orm import exc
181 from sqlalchemy.orm import joinedload
182 from sqlalchemy.orm import sessionmaker
183+from sqlalchemy.sql import or_, and_
184
185 from glance.common import config
186 from glance.common import exception
187@@ -127,23 +128,26 @@
188 raise exception.NotFound("No image found with ID %s" % image_id)
189
190
191-def image_get_all_public(context, filters=None):
192+def image_get_all_public(context, filters=None, marker=None, limit=None):
193 """Get all public images that match zero or more filters.
194
195 :param filters: dict of filter keys and values. If a 'properties'
196 key is present, it is treated as a dict of key/value
197 filters on the image properties attribute
198+ :param marker: image id after which to start page
199+ :param limit: maximum number of images to return
200
201 """
202- if filters == None:
203- filters = {}
204+ filters = filters or {}
205
206 session = get_session()
207 query = session.query(models.Image).\
208 options(joinedload(models.Image.properties)).\
209 filter_by(deleted=_deleted(context)).\
210 filter_by(is_public=True).\
211- filter(models.Image.status != 'killed')
212+ filter(models.Image.status != 'killed').\
213+ order_by(desc(models.Image.created_at)).\
214+ order_by(desc(models.Image.id))
215
216 if 'size_min' in filters:
217 query = query.filter(models.Image.size >= filters['size_min'])
218@@ -159,6 +163,17 @@
219 for (k, v) in filters.items():
220 query = query.filter(getattr(models.Image, k) == v)
221
222+ if marker != None:
223+ # images returned should be created before the image defined by marker
224+ marker_created_at = image_get(context, marker).created_at
225+ query = query.filter(
226+ or_(models.Image.created_at < marker_created_at,
227+ and_(models.Image.created_at == marker_created_at,
228+ models.Image.id < marker)))
229+
230+ if limit != None:
231+ query = query.limit(limit)
232+
233 return query.all()
234
235
236
237=== modified file 'glance/registry/server.py'
238--- glance/registry/server.py 2011-05-16 13:20:53 +0000
239+++ glance/registry/server.py 2011-05-31 13:23:37 +0000
240@@ -39,6 +39,8 @@
241 SUPPORTED_FILTERS = ['name', 'status', 'container_format', 'disk_format',
242 'size_min', 'size_max']
243
244+MAX_ITEM_LIMIT = 25
245+
246
247 class Controller(wsgi.Controller):
248 """Controller for the reference implementation registry server"""
249@@ -67,7 +69,15 @@
250 }
251
252 """
253- images = db_api.image_get_all_public(None, self._get_filters(req))
254+ params = {
255+ 'filters': self._get_filters(req),
256+ 'limit': self._get_limit(req),
257+ }
258+
259+ if 'marker' in req.str_params:
260+ params['marker'] = self._get_marker(req)
261+
262+ images = db_api.image_get_all_public(None, **params)
263
264 results = []
265 for image in images:
266@@ -89,7 +99,15 @@
267 all image model fields.
268
269 """
270- images = db_api.image_get_all_public(None, self._get_filters(req))
271+ params = {
272+ 'filters': self._get_filters(req),
273+ 'limit': self._get_limit(req),
274+ }
275+
276+ if 'marker' in req.str_params:
277+ params['marker'] = self._get_marker(req)
278+
279+ images = db_api.image_get_all_public(None, **params)
280
281 image_dicts = [make_image_dict(i) for i in images]
282 return dict(images=image_dicts)
283@@ -116,6 +134,26 @@
284
285 return filters
286
287+ def _get_limit(self, req):
288+ """Parse a limit query param into something usable."""
289+ try:
290+ limit = int(req.str_params.get('limit', MAX_ITEM_LIMIT))
291+ except ValueError:
292+ raise exc.HTTPBadRequest("limit param must be an integer")
293+
294+ if limit < 0:
295+ raise exc.HTTPBadRequest("limit param must be positive")
296+
297+ return min(MAX_ITEM_LIMIT, limit)
298+
299+ def _get_marker(self, req):
300+ """Parse a marker query param into something usable."""
301+ try:
302+ marker = int(req.str_params.get('marker', None))
303+ except ValueError:
304+ raise exc.HTTPBadRequest("marker param must be an integer")
305+ return marker
306+
307 def show(self, req, id):
308 """Return data about the given image id."""
309 try:
310
311=== modified file 'tests/functional/test_curl_api.py'
312--- tests/functional/test_curl_api.py 2011-05-27 14:29:59 +0000
313+++ tests/functional/test_curl_api.py 2011-05-31 13:23:37 +0000
314@@ -1001,3 +1001,119 @@
315 self.assertEqual(image["name"], "My Image!")
316
317 self.stop_servers()
318+
319+ def test_limited_images(self):
320+ """
321+ Ensure marker and limit query params work
322+ """
323+ self.cleanup()
324+ self.start_servers()
325+
326+ api_port = self.api_port
327+ registry_port = self.registry_port
328+
329+ # 0. GET /images
330+ # Verify no public images
331+ cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
332+
333+ exitcode, out, err = execute(cmd)
334+
335+ self.assertEqual(0, exitcode)
336+ self.assertEqual('{"images": []}', out.strip())
337+
338+ # 1. POST /images with three public images with various attributes
339+ cmd = ("curl -i -X POST "
340+ "-H 'Expect: ' " # Necessary otherwise sends 100 Continue
341+ "-H 'X-Image-Meta-Name: Image1' "
342+ "-H 'X-Image-Meta-Is-Public: True' "
343+ "http://0.0.0.0:%d/v1/images") % api_port
344+
345+ exitcode, out, err = execute(cmd)
346+ self.assertEqual(0, exitcode)
347+
348+ lines = out.split("\r\n")
349+ status_line = lines[0]
350+
351+ self.assertEqual("HTTP/1.1 201 Created", status_line)
352+
353+ cmd = ("curl -i -X POST "
354+ "-H 'Expect: ' " # Necessary otherwise sends 100 Continue
355+ "-H 'X-Image-Meta-Name: Image2' "
356+ "-H 'X-Image-Meta-Is-Public: True' "
357+ "http://0.0.0.0:%d/v1/images") % api_port
358+
359+ exitcode, out, err = execute(cmd)
360+ self.assertEqual(0, exitcode)
361+
362+ lines = out.split("\r\n")
363+ status_line = lines[0]
364+
365+ self.assertEqual("HTTP/1.1 201 Created", status_line)
366+
367+ cmd = ("curl -i -X POST "
368+ "-H 'Expect: ' " # Necessary otherwise sends 100 Continue
369+ "-H 'X-Image-Meta-Name: Image3' "
370+ "-H 'X-Image-Meta-Is-Public: True' "
371+ "http://0.0.0.0:%d/v1/images") % api_port
372+
373+ exitcode, out, err = execute(cmd)
374+ self.assertEqual(0, exitcode)
375+
376+ lines = out.split("\r\n")
377+ status_line = lines[0]
378+
379+ self.assertEqual("HTTP/1.1 201 Created", status_line)
380+
381+ # 2. GET /images with limit of 2
382+ # Verify only two images were returned
383+ cmd = "curl http://0.0.0.0:%d/v1/images?limit=2" % api_port
384+
385+ exitcode, out, err = execute(cmd)
386+
387+ self.assertEqual(0, exitcode)
388+ images = json.loads(out.strip())['images']
389+
390+ self.assertEqual(len(images), 2)
391+ self.assertEqual(int(images[0]['id']), 3)
392+ self.assertEqual(int(images[1]['id']), 2)
393+
394+ # 3. GET /images with marker
395+ # Verify only two images were returned
396+ cmd = "curl http://0.0.0.0:%d/v1/images?marker=3" % api_port
397+
398+ exitcode, out, err = execute(cmd)
399+
400+ self.assertEqual(0, exitcode)
401+ images = json.loads(out.strip())['images']
402+
403+ self.assertEqual(len(images), 2)
404+ self.assertEqual(int(images[0]['id']), 2)
405+ self.assertEqual(int(images[1]['id']), 1)
406+
407+ # 4. GET /images with marker and limit
408+ # Verify only one image was returned with the correct id
409+ cmd = ("curl 'http://0.0.0.0:%d/v1/images?"
410+ "limit=1&marker=2'" % api_port)
411+
412+ exitcode, out, err = execute(cmd)
413+
414+ self.assertEqual(0, exitcode)
415+ images = json.loads(out.strip())['images']
416+
417+ self.assertEqual(len(images), 1)
418+ self.assertEqual(int(images[0]['id']), 1)
419+
420+ # 5. GET /images/detail with marker and limit
421+ # Verify only one image was returned with the correct id
422+ cmd = ("curl 'http://0.0.0.0:%d/v1/images/detail?"
423+ "limit=1&marker=3'" % api_port)
424+
425+ exitcode, out, err = execute(cmd)
426+
427+ self.assertEqual(0, exitcode)
428+ images = json.loads(out.strip())['images']
429+
430+ self.assertEqual(len(images), 1)
431+ self.assertEqual(int(images[0]['id']), 2)
432+
433+ self.stop_servers()
434
435=== modified file 'tests/stubs.py'
436--- tests/stubs.py 2011-05-16 13:20:53 +0000
437+++ tests/stubs.py 2011-05-31 13:23:37 +0000
438@@ -19,6 +19,7 @@
439
440 import datetime
441 import httplib
442+import operator
443 import os
444 import shutil
445 import StringIO
446@@ -322,8 +323,10 @@
447 values['deleted'] = False
448 values['properties'] = values.get('properties', {})
449 values['location'] = values.get('location')
450- values['created_at'] = datetime.datetime.utcnow()
451- values['updated_at'] = datetime.datetime.utcnow()
452+
453+ now = datetime.datetime.utcnow()
454+ values['created_at'] = values.get('created_at', now)
455+ values['updated_at'] = values.get('updated_at', now)
456 values['deleted_at'] = None
457
458 props = []
459@@ -334,8 +337,8 @@
460 p['name'] = k
461 p['value'] = v
462 p['deleted'] = False
463- p['created_at'] = datetime.datetime.utcnow()
464- p['updated_at'] = datetime.datetime.utcnow()
465+ p['created_at'] = now
466+ p['updated_at'] = now
467 p['deleted_at'] = None
468 props.append(p)
469
470@@ -386,7 +389,8 @@
471 else:
472 return images[0]
473
474- def image_get_all_public(self, _context, filters):
475+ def image_get_all_public(self, _context, filters=None,
476+ marker=None, limit=1000):
477 images = [f for f in self.images if f['is_public'] == True]
478
479 if 'size_min' in filters:
480@@ -411,7 +415,30 @@
481 for k, v in filters.items():
482 images = [f for f in images if f[k] == v]
483
484- return images
485+ def image_cmp(x, y):
486+ if x['created_at'] > y['created_at']:
487+ return 1
488+ elif x['created_at'] == y['created_at']:
489+ if x['id'] > y['id']:
490+ return 1
491+ else:
492+ return -1
493+ else:
494+ return -1
495+
496+ images = sorted(images, cmp=image_cmp)
497+ images.reverse()
498+
499+ if marker == None:
500+ start_index = 0
501+ else:
502+ start_index = -1
503+ for i, image in enumerate(images):
504+ if image['id'] == marker:
505+ start_index = i + 1
506+ break
507+
508+ return images[start_index:start_index + limit]
509
510 fake_datastore = FakeDatastore()
511 stubs.Set(glance.registry.db.api, 'image_create',
512
513=== modified file 'tests/unit/test_api.py'
514--- tests/unit/test_api.py 2011-05-27 02:24:55 +0000
515+++ tests/unit/test_api.py 2011-05-31 13:23:37 +0000
516@@ -15,6 +15,7 @@
517 # License for the specific language governing permissions and limitations
518 # under the License.
519
520+import datetime
521 import hashlib
522 import httplib
523 import os
524@@ -88,6 +89,158 @@
525 for k, v in fixture.iteritems():
526 self.assertEquals(v, images[0][k])
527
528+ def test_get_index_marker(self):
529+ """Tests that the /images registry API returns list of
530+ public images that conforms to a marker query param
531+
532+ """
533+
534+ time1 = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
535+ time2 = datetime.datetime.utcnow()
536+
537+ extra_fixture = {'id': 3,
538+ 'status': 'active',
539+ 'is_public': True,
540+ 'disk_format': 'vhd',
541+ 'container_format': 'ovf',
542+ 'name': 'new name! #123',
543+ 'size': 19,
544+ 'checksum': None,
545+ 'created_at': time1}
546+
547+ glance.registry.db.api.image_create(None, extra_fixture)
548+
549+ extra_fixture = {'id': 4,
550+ 'status': 'active',
551+ 'is_public': True,
552+ 'disk_format': 'vhd',
553+ 'container_format': 'ovf',
554+ 'name': 'new name! #123',
555+ 'size': 20,
556+ 'checksum': None,
557+ 'created_at': time1}
558+
559+ glance.registry.db.api.image_create(None, extra_fixture)
560+
561+ extra_fixture = {'id': 5,
562+ 'status': 'active',
563+ 'is_public': True,
564+ 'disk_format': 'vhd',
565+ 'container_format': 'ovf',
566+ 'name': 'new name! #123',
567+ 'size': 20,
568+ 'checksum': None,
569+ 'created_at': time2}
570+
571+ glance.registry.db.api.image_create(None, extra_fixture)
572+
573+ req = webob.Request.blank('/images?marker=4')
574+ res = req.get_response(self.api)
575+ res_dict = json.loads(res.body)
576+ self.assertEquals(res.status_int, 200)
577+
578+ images = res_dict['images']
579+ # should be sorted by created_at desc, id desc
580+ # page should start after marker 4
581+ self.assertEquals(len(images), 3)
582+ self.assertEquals(int(images[0]['id']), 3)
583+ self.assertEquals(int(images[1]['id']), 5)
584+ self.assertEquals(int(images[2]['id']), 2)
585+
586+ def test_get_index_limit(self):
587+ """Tests that the /images registry API returns list of
588+ public images that conforms to a limit query param
589+
590+ """
591+ extra_fixture = {'id': 3,
592+ 'status': 'active',
593+ 'is_public': True,
594+ 'disk_format': 'vhd',
595+ 'container_format': 'ovf',
596+ 'name': 'new name! #123',
597+ 'size': 19,
598+ 'checksum': None}
599+
600+ glance.registry.db.api.image_create(None, extra_fixture)
601+
602+ extra_fixture = {'id': 4,
603+ 'status': 'active',
604+ 'is_public': True,
605+ 'disk_format': 'vhd',
606+ 'container_format': 'ovf',
607+ 'name': 'new name! #123',
608+ 'size': 20,
609+ 'checksum': None}
610+
611+ glance.registry.db.api.image_create(None, extra_fixture)
612+
613+ req = webob.Request.blank('/images?limit=1')
614+ res = req.get_response(self.api)
615+ res_dict = json.loads(res.body)
616+ self.assertEquals(res.status_int, 200)
617+
618+ images = res_dict['images']
619+ self.assertEquals(len(images), 1)
620+
621+ # expect list to be sorted by created_at desc
622+ self.assertTrue(int(images[0]['id']), 4)
623+
624+ def test_get_index_limit_negative(self):
625+ """Tests that the /images registry API returns list of
626+ public images that conforms to a limit query param
627+
628+ """
629+ req = webob.Request.blank('/images?limit=-1')
630+ res = req.get_response(self.api)
631+ self.assertEquals(res.status_int, 400)
632+
633+ def test_get_index_limit_non_int(self):
634+ """Tests that the /images registry API returns list of
635+ public images that conforms to a limit query param
636+
637+ """
638+ req = webob.Request.blank('/images?limit=a')
639+ res = req.get_response(self.api)
640+ self.assertEquals(res.status_int, 400)
641+
642+ def test_get_index_limit_marker(self):
643+ """Tests that the /images registry API returns list of
644+ public images that conforms to limit and marker query params
645+
646+ """
647+ extra_fixture = {'id': 3,
648+ 'status': 'active',
649+ 'is_public': True,
650+ 'disk_format': 'vhd',
651+ 'container_format': 'ovf',
652+ 'name': 'new name! #123',
653+ 'size': 19,
654+ 'checksum': None}
655+
656+ glance.registry.db.api.image_create(None, extra_fixture)
657+
658+ extra_fixture = {'id': 4,
659+ 'status': 'active',
660+ 'is_public': True,
661+ 'disk_format': 'vhd',
662+ 'container_format': 'ovf',
663+ 'name': 'new name! #123',
664+ 'size': 20,
665+ 'checksum': None}
666+
667+ glance.registry.db.api.image_create(None, extra_fixture)
668+
669+ req = webob.Request.blank('/images?marker=3&limit=1')
670+ res = req.get_response(self.api)
671+ res_dict = json.loads(res.body)
672+ self.assertEquals(res.status_int, 200)
673+
674+ images = res_dict['images']
675+ self.assertEquals(len(images), 1)
676+
677+ # expect list to be sorted by created_at desc
678+ self.assertEqual(int(images[0]['id']), 2)
679+
680 def test_get_index_filter_name(self):
681 """Tests that the /images registry API returns list of
682 public images that have a specific name. This is really a sanity
683@@ -157,6 +310,46 @@
684 for k, v in fixture.iteritems():
685 self.assertEquals(v, images[0][k])
686
687+ def test_get_details_limit_marker(self):
688+ """Tests that the /images/details registry API returns list of
689+ public images that conforms to limit and marker query params.
690+ This functionality is tested more thoroughly on /images, this is
691+ just a sanity check
692+
693+ """
694+ extra_fixture = {'id': 3,
695+ 'status': 'active',
696+ 'is_public': True,
697+ 'disk_format': 'vhd',
698+ 'container_format': 'ovf',
699+ 'name': 'new name! #123',
700+ 'size': 19,
701+ 'checksum': None}
702+
703+ glance.registry.db.api.image_create(None, extra_fixture)
704+
705+ extra_fixture = {'id': 4,
706+ 'status': 'active',
707+ 'is_public': True,
708+ 'disk_format': 'vhd',
709+ 'container_format': 'ovf',
710+ 'name': 'new name! #123',
711+ 'size': 20,
712+ 'checksum': None}
713+
714+ glance.registry.db.api.image_create(None, extra_fixture)
715+
716+ req = webob.Request.blank('/images/detail?marker=3&limit=1')
717+ res = req.get_response(self.api)
718+ res_dict = json.loads(res.body)
719+ self.assertEquals(res.status_int, 200)
720+
721+ images = res_dict['images']
722+ self.assertEquals(len(images), 1)
723+
724+ # expect list to be sorted by created_at desc
725+ self.assertEqual(int(images[0]['id']), 2)
726+
727 def test_get_details_filter_name(self):
728 """Tests that the /images/detail registry API returns list of
729 public images that have a specific name
730
731=== modified file 'tests/unit/test_clients.py'
732--- tests/unit/test_clients.py 2011-05-24 19:07:19 +0000
733+++ tests/unit/test_clients.py 2011-05-31 13:23:37 +0000
734@@ -70,6 +70,92 @@
735 for k, v in fixture.items():
736 self.assertEquals(v, images[0][k])
737
738+ def test_get_image_index_marker(self):
739+ """Test correct set of images returned with marker param."""
740+ extra_fixture = {'id': 3,
741+ 'status': 'saving',
742+ 'is_public': True,
743+ 'disk_format': 'vhd',
744+ 'container_format': 'ovf',
745+ 'name': 'new name! #123',
746+ 'size': 19,
747+ 'checksum': None}
748+
749+ glance.registry.db.api.image_create(None, extra_fixture)
750+
751+ extra_fixture = {'id': 4,
752+ 'status': 'saving',
753+ 'is_public': True,
754+ 'disk_format': 'vhd',
755+ 'container_format': 'ovf',
756+ 'name': 'new name! #125',
757+ 'size': 19,
758+ 'checksum': None}
759+
760+ glance.registry.db.api.image_create(None, extra_fixture)
761+
762+ images = self.client.get_images(marker=4)
763+ self.assertEquals(len(images), 2)
764+
765+ for image in images:
766+ self.assertTrue(image['id'] < 4)
767+
768+ def test_get_image_index_limit(self):
769+ """Test correct number of images returned with limit param."""
770+ extra_fixture = {'id': 3,
771+ 'status': 'saving',
772+ 'is_public': True,
773+ 'disk_format': 'vhd',
774+ 'container_format': 'ovf',
775+ 'name': 'new name! #123',
776+ 'size': 19,
777+ 'checksum': None}
778+
779+ glance.registry.db.api.image_create(None, extra_fixture)
780+
781+ extra_fixture = {'id': 4,
782+ 'status': 'saving',
783+ 'is_public': True,
784+ 'disk_format': 'vhd',
785+ 'container_format': 'ovf',
786+ 'name': 'new name! #125',
787+ 'size': 19,
788+ 'checksum': None}
789+
790+ glance.registry.db.api.image_create(None, extra_fixture)
791+
792+ images = self.client.get_images(limit=2)
793+ self.assertEquals(len(images), 2)
794+
795+ def test_get_image_index_marker_limit(self):
796+ """Test correct set of images returned with marker/limit params."""
797+ extra_fixture = {'id': 3,
798+ 'status': 'saving',
799+ 'is_public': True,
800+ 'disk_format': 'vhd',
801+ 'container_format': 'ovf',
802+ 'name': 'new name! #123',
803+ 'size': 19,
804+ 'checksum': None}
805+
806+ glance.registry.db.api.image_create(None, extra_fixture)
807+
808+ extra_fixture = {'id': 4,
809+ 'status': 'saving',
810+ 'is_public': True,
811+ 'disk_format': 'vhd',
812+ 'container_format': 'ovf',
813+ 'name': 'new name! #125',
814+ 'size': 19,
815+ 'checksum': None}
816+
817+ glance.registry.db.api.image_create(None, extra_fixture)
818+
819+ images = self.client.get_images(marker=3, limit=1)
820+ self.assertEquals(len(images), 1)
821+
822+ self.assertEquals(images[0]['id'], 2)
823+
824 def test_get_image_index_by_name(self):
825 """Test correct set of public, name-filtered image returned. This
826 is just a sanity check, we test the details call more in-depth."""
827@@ -108,6 +194,35 @@
828 for k, v in fixture.items():
829 self.assertEquals(v, images[0][k])
830
831+ def test_get_image_details_marker_limit(self):
832+ """Test correct set of images returned with marker/limit params."""
833+ extra_fixture = {'id': 3,
834+ 'status': 'saving',
835+ 'is_public': True,
836+ 'disk_format': 'vhd',
837+ 'container_format': 'ovf',
838+ 'name': 'new name! #123',
839+ 'size': 19,
840+ 'checksum': None}
841+
842+ glance.registry.db.api.image_create(None, extra_fixture)
843+
844+ extra_fixture = {'id': 4,
845+ 'status': 'saving',
846+ 'is_public': True,
847+ 'disk_format': 'vhd',
848+ 'container_format': 'ovf',
849+ 'name': 'new name! #125',
850+ 'size': 19,
851+ 'checksum': None}
852+
853+ glance.registry.db.api.image_create(None, extra_fixture)
854+
855+ images = self.client.get_images_detailed(marker=3, limit=1)
856+ self.assertEquals(len(images), 1)
857+
858+ self.assertEquals(images[0]['id'], 2)
859+
860 def test_get_image_details_by_name(self):
861 """Tests that a detailed call can be filtered by name"""
862 extra_fixture = {'id': 3,
863@@ -457,6 +572,92 @@
864 for k, v in fixture.items():
865 self.assertEquals(v, images[0][k])
866
867+ def test_get_image_index_marker(self):
868+ """Test correct set of public images returned with marker param."""
869+ extra_fixture = {'id': 3,
870+ 'status': 'saving',
871+ 'is_public': True,
872+ 'disk_format': 'vhd',
873+ 'container_format': 'ovf',
874+ 'name': 'new name! #123',
875+ 'size': 19,
876+ 'checksum': None}
877+
878+ glance.registry.db.api.image_create(None, extra_fixture)
879+
880+ extra_fixture = {'id': 4,
881+ 'status': 'saving',
882+ 'is_public': True,
883+ 'disk_format': 'vhd',
884+ 'container_format': 'ovf',
885+ 'name': 'new name! #125',
886+ 'size': 19,
887+ 'checksum': None}
888+
889+ glance.registry.db.api.image_create(None, extra_fixture)
890+
891+ images = self.client.get_images(marker=4)
892+ self.assertEquals(len(images), 2)
893+
894+ for image in images:
895+ self.assertTrue(image['id'] < 4)
896+
897+ def test_get_image_index_limit(self):
898+ """Test correct number of public images returned with limit param."""
899+ extra_fixture = {'id': 3,
900+ 'status': 'saving',
901+ 'is_public': True,
902+ 'disk_format': 'vhd',
903+ 'container_format': 'ovf',
904+ 'name': 'new name! #123',
905+ 'size': 19,
906+ 'checksum': None}
907+
908+ glance.registry.db.api.image_create(None, extra_fixture)
909+
910+ extra_fixture = {'id': 4,
911+ 'status': 'saving',
912+ 'is_public': True,
913+ 'disk_format': 'vhd',
914+ 'container_format': 'ovf',
915+ 'name': 'new name! #125',
916+ 'size': 19,
917+ 'checksum': None}
918+
919+ glance.registry.db.api.image_create(None, extra_fixture)
920+
921+ images = self.client.get_images(limit=2)
922+ self.assertEquals(len(images), 2)
923+
924+ def test_get_image_index_marker_limit(self):
925+ """Test correct set of images returned with marker/limit params."""
926+ extra_fixture = {'id': 3,
927+ 'status': 'saving',
928+ 'is_public': True,
929+ 'disk_format': 'vhd',
930+ 'container_format': 'ovf',
931+ 'name': 'new name! #123',
932+ 'size': 19,
933+ 'checksum': None}
934+
935+ glance.registry.db.api.image_create(None, extra_fixture)
936+
937+ extra_fixture = {'id': 4,
938+ 'status': 'saving',
939+ 'is_public': True,
940+ 'disk_format': 'vhd',
941+ 'container_format': 'ovf',
942+ 'name': 'new name! #125',
943+ 'size': 19,
944+ 'checksum': None}
945+
946+ glance.registry.db.api.image_create(None, extra_fixture)
947+
948+ images = self.client.get_images(marker=3, limit=1)
949+ self.assertEquals(len(images), 1)
950+
951+ self.assertEquals(images[0]['id'], 2)
952+
953 def test_get_image_index_by_base_attribute(self):
954 """Tests that an index call can be filtered by a base attribute"""
955 extra_fixture = {'id': 3,
956@@ -522,6 +723,35 @@
957 for k, v in expected.items():
958 self.assertEquals(v, images[0][k])
959
960+ def test_get_image_details_marker_limit(self):
961+ """Test detailed calls are filtered by marker/limit params."""
962+ extra_fixture = {'id': 3,
963+ 'status': 'saving',
964+ 'is_public': True,
965+ 'disk_format': 'vhd',
966+ 'container_format': 'ovf',
967+ 'name': 'new name! #123',
968+ 'size': 19,
969+ 'checksum': None}
970+
971+ glance.registry.db.api.image_create(None, extra_fixture)
972+
973+ extra_fixture = {'id': 4,
974+ 'status': 'saving',
975+ 'is_public': True,
976+ 'disk_format': 'vhd',
977+ 'container_format': 'ovf',
978+ 'name': 'new name! #125',
979+ 'size': 19,
980+ 'checksum': None}
981+
982+ glance.registry.db.api.image_create(None, extra_fixture)
983+
984+ images = self.client.get_images_detailed(marker=3, limit=1)
985+ self.assertEquals(len(images), 1)
986+
987+ self.assertEquals(images[0]['id'], 2)
988+
989 def test_get_image_details_by_base_attribute(self):
990 """Tests that a detailed call can be filtered by a base attribute"""
991 extra_fixture = {'id': 3,

Subscribers

People subscribed via source and target branches