Skip to content

Commit 5ec17d8

Browse files
author
Mike Taylor
committed
Merge pull request #446 from /issues/420/2
Fixes #420 - Follow Link header links for pagination traversal.
2 parents 1f05ac4 + 43a1cc7 commit 5ec17d8

File tree

5 files changed

+163
-61
lines changed

5 files changed

+163
-61
lines changed

tests/test_helpers.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# This Source Code Form is subject to the terms of the Mozilla Public
4+
# License, v. 2.0. If a copy of the MPL was not distributed with this
5+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
6+
7+
'''Tests for helper methods in webcompat/helpers.py.'''
8+
9+
import os.path
10+
import sys
11+
import unittest
12+
13+
# Add webcompat module to import path
14+
sys.path.append(os.path.realpath(os.pardir))
15+
import webcompat
16+
17+
from webcompat.helpers import rewrite_links
18+
from webcompat.helpers import sanitize_link
19+
from webcompat.helpers import rewrite_and_sanitize_link
20+
21+
ACCESS_TOKEN_LINK = '<https://api.github.com/repositories/17839063/issues?per_page=50&page=3&access_token=12345>; rel="next", <https://api.github.com/repositories/17839063/issues?access_token=12345&per_page=50&page=4>; rel="last", <https://api.github.com/repositories/17839063/issues?per_page=50&access_token=12345&page=1>; rel="first", <https://api.github.com/repositories/17839063/issues?per_page=50&page=1&access_token=12345>; rel="prev"'
22+
GITHUB_ISSUES_LINK_HEADER = '<https://api.github.com/repositories/17839063/issues?per_page=50&page=3>; rel="next", <https://api.github.com/repositories/17839063/issues?per_page=50&page=4>; rel="last", <https://api.github.com/repositories/17839063/issues?per_page=50&page=1>; rel="first", <https://api.github.com/repositories/17839063/issues?per_page=50&page=1>; rel="prev"'
23+
GITHUB_SEARCH_LINK_HEADER = '<https://api.github.com/search/issues?q=taco&page=2>; rel="next", <https://api.github.com/search/issues?q=taco&page=26>; rel="last"'
24+
REWRITTEN_ISSUES_LINK_HEADER = '</api/issues?per_page=50&page=3>; rel="next", </api/issues?per_page=50&page=4>; rel="last", </api/issues?per_page=50&page=1>; rel="first", </api/issues?per_page=50&page=1>; rel="prev"'
25+
REWRITTEN_SEARCH_LINK_HEADER = '</api/issues/search?q=taco&page=2>; rel="next", </api/issues/search?q=taco&page=26>; rel="last"'
26+
27+
28+
class TestHelpers(unittest.TestCase):
29+
def setUp(self):
30+
webcompat.app.config['TESTING'] = True
31+
self.app = webcompat.app.test_client()
32+
33+
def tearDown(self):
34+
pass
35+
36+
def test_rewrite_link(self):
37+
'''Test we're correctly rewriting the passed in link.'''
38+
self.assertEqual(rewrite_links(GITHUB_ISSUES_LINK_HEADER),
39+
REWRITTEN_ISSUES_LINK_HEADER)
40+
self.assertEqual(rewrite_links(GITHUB_SEARCH_LINK_HEADER),
41+
REWRITTEN_SEARCH_LINK_HEADER)
42+
43+
def test_sanitize_link(self):
44+
'''Test that we're removing access_token parameters.'''
45+
self.assertNotIn('access_token=', sanitize_link(ACCESS_TOKEN_LINK))
46+
47+
def test_rewrite_and_sanitize_link(self):
48+
self.assertNotIn('access_token=',
49+
rewrite_and_sanitize_link(ACCESS_TOKEN_LINK))
50+
self.assertEqual(rewrite_and_sanitize_link(ACCESS_TOKEN_LINK),
51+
REWRITTEN_ISSUES_LINK_HEADER)
52+
53+
54+
if __name__ == '__main__':
55+
unittest.main()

webcompat/api/endpoints.py

+22-19
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
back to GitHub'''
99

1010
import json
11+
import re
1112

1213
from flask import abort
1314
from flask import Blueprint
@@ -35,7 +36,7 @@
3536

3637

3738
def get_username():
38-
return session.get('username', 'proxy-user')
39+
return session.get('username', 'proxy-user')
3940

4041

4142
@api.route('/issues/<int:number>')
@@ -107,13 +108,13 @@ def get_issue_category(issue_category):
107108
params = request.args.copy()
108109

109110
if issue_category in category_list:
110-
params.update({'labels': issue_category})
111+
params['labels'] = issue_category
111112
if g.user:
112113
issues = github.raw_request('GET', issues_path, params=params)
113114
else:
114115
issues = proxy_request('get', params=params)
115116
elif issue_category == 'closed':
116-
params.update({'state': 'closed'})
117+
params['state'] = 'closed'
117118
if g.user:
118119
issues = github.raw_request('GET', issues_path, params=params)
119120
else:
@@ -171,26 +172,28 @@ def get_search_results(query_string=None):
171172
headers=request_headers)
172173
else:
173174
results = proxy_request('get', params=params, uri=search_uri)
174-
# The issues are returned in the items property of the response JSON,
175-
# so throw everything else away.
176-
json_response = json.loads(results.content)
177-
if 'items' in json_response:
178-
result = json.dumps(json_response['items'])
179-
else:
180-
result = results.content
181-
return (result, results.status_code, get_headers(results))
175+
return (results.content, results.status_code, get_headers(results))
176+
182177

178+
@api.route('/issues/search/<issue_category>')
179+
def get_category_from_search(issue_category):
180+
'''XHR endpoint to get issues categories from GitHub's Search API.
183181
184-
@api.route('/issues/search/untriaged')
185-
def get_untriaged_from_search():
186-
'''XHR endpoint to get "untriaged" issues from GitHub's Search API.
182+
There is some overlap between /issues/category/<issue_category> as used on
183+
the home page - but this endpoint returns paginated results.
187184
188-
There is some overlap between /issues/category/untriaged as used on the
189-
home page - but this endpoint returns paginated results paginated.
190-
TODO: Unify that at some point.
185+
Note: until GitHub fixes a bug where requesting issues filtered by labels
186+
doesn't return pagination via Link, we get those results from this endpoint.
187+
Once it's fixed, we can get "contactready", "needsdiagnosis" and "sitewait"
188+
issues from /issues/category/<issue_category>.
191189
'''
192-
query_string = ('state:open -label:contactready '
193-
'-label:sitewait -label:needsdiagnosis')
190+
category_list = ['contactready', 'needsdiagnosis', 'sitewait']
191+
192+
if issue_category in category_list:
193+
query_string = 'label:{0}'.format(issue_category)
194+
elif issue_category == 'untriaged':
195+
query_string = ('state:open -label:contactready '
196+
'-label:sitewait -label:needsdiagnosis')
194197
return get_search_results(query_string)
195198

196199

webcompat/helpers.py

+31-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import datetime
88
import math
99
import urlparse
10+
import re
1011

1112
from babel.dates import format_timedelta
1213
from flask import session
@@ -105,7 +106,7 @@ def get_headers(response):
105106
'content-type': JSON_MIME}
106107

107108
if response.headers.get('link'):
108-
headers['link'] = sanitize_link(response.headers.get('link'))
109+
headers['link'] = rewrite_and_sanitize_link(response.headers.get('link'))
109110
return headers
110111

111112

@@ -138,13 +139,37 @@ def get_referer(request):
138139
return None
139140

140141

142+
def rewrite_links(link_header):
143+
'''Rewrites Link header Github API endpoints to our own.
144+
145+
<https://api.github.com/repositories/17839063/issues?per_page=50&page=2>; rel="next",
146+
<https://api.github.com/repositories/17839063/issues?per_page=50&page=4>; rel="last"
147+
148+
is transformed into
149+
150+
</api/issues?per_page=50&page=2>; rel="next",
151+
</api/issues?per_page=50&page=4>; rel="last" etc.
152+
'''
153+
links = link_header.split(',')
154+
new_links = []
155+
for link in links:
156+
api_path, endpoint_path = link.rsplit('/', 1)
157+
if api_path.strip().startswith('<https://api.github.com/repositories'):
158+
new_links.append(endpoint_path.replace('issues?', '</api/issues?'))
159+
if api_path.strip().startswith('<https://api.github.com/search'):
160+
new_links.append(endpoint_path.replace('issues?', '</api/issues/search?'))
161+
return ', '.join(new_links)
162+
163+
141164
def sanitize_link(link_header):
142-
'''Remove any oauth tokens from the Link header that GitHub gives to us.'''
165+
'''Remove any oauth tokens from the Link header that GitHub gives to us,
166+
and return a rewritten Link header (see rewrite_links)'''
143167
links_list = link_header.split(',')
144168
clean_links_list = []
145169
for link in links_list:
146170
uri_info, rel_info = link.split(';')
147171
uri_info = uri_info.strip()
172+
rel_info = rel_info.strip()
148173
uri = uri_info[1:-1]
149174
uri_group = urlparse.urlparse(uri)
150175
parameters = uri_group.query.split('&')
@@ -155,3 +180,7 @@ def sanitize_link(link_header):
155180
clean_links_list.append('<{0}>; {1}'.format(
156181
urlparse.urlunparse(clean_uri), rel_info))
157182
return ', '.join(clean_links_list)
183+
184+
185+
def rewrite_and_sanitize_link(link_header):
186+
return rewrite_links(sanitize_link(link_header))

webcompat/static/js/lib/issue-list.js

+40-21
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ issueList.IssueView = Backbone.View.extend({
270270
initialize: function() {
271271
this.issues = new issueList.IssueCollection();
272272
// check to see if we should pre-filter results
273+
// otherwise load default (unfiltered "all")
273274
this.loadIssues();
274275

275276
// set up event listeners.
@@ -331,10 +332,10 @@ issueList.IssueView = Backbone.View.extend({
331332
var nextButton = $('.js-pagination-next');
332333
var prevButton = $('.js-pagination-previous');
333334
var isLastPage = _.bind(function() {
334-
return this.issues.getNextPageNumber() == null;
335+
return this.issues.getNextPage() == null;
335336
}, this);
336337
var isFirstPage = _.bind(function() {
337-
return this.issues.getPreviousPageNumber() == null;
338+
return this.issues.getPrevPage() == null;
338339
}, this);
339340
var isSinglePage = isLastPage() && isFirstPage();
340341

@@ -365,61 +366,79 @@ issueList.IssueView = Backbone.View.extend({
365366
e.preventDefault();
366367
},
367368
requestNextPage: function() {
368-
if (this.issues.getNextPageNumber()) {
369-
// chop off the last character, which is the page number
370-
// TODO: this feels gross. ideally we should get the URL from the
371-
// link header and send that to an API endpoint which then requests
372-
// it from GitHub.
373-
this.updateModelParams("page", this.issues.getNextPageNumber());
369+
var nextPage;
370+
if (nextPage = this.issues.getNextPage()) {
371+
this.issues.url = nextPage;
372+
this.fetchAndRenderIssues();
374373
}
375374
},
376375
requestPreviousPage: function() {
377-
if (this.issues.getPreviousPageNumber()) {
378-
this.updateModelParams("page", this.issues.getPreviousPageNumber());
376+
var prevPage;
377+
if (prevPage = this.issues.getPrevPage()) {
378+
this.issues.url = prevPage;
379+
this.fetchAndRenderIssues();
379380
}
380381
},
381382
updateIssues: function(category) {
382383
// depending on what category was clicked (or if a search came in),
383384
// update the collection instance url property and fetch the issues.
384-
var labelCategories = ['closed', 'contactready', 'needsdiagnosis', 'sitewait'];
385385

386-
//TODO(miket): make generic getModelParams method which can get the latest state
386+
// note: until GitHub fixes a bug where requesting issues filtered by labels
387+
// doesn't return pagination via Link, we get those results via the Search API.
388+
var searchCategories = ['untriaged', 'contactready', 'needsdiagnosis', 'sitewait'];
389+
390+
// TODO(miket): make generic getModelParams method which can get the latest state
387391
// merge param objects and serialize
388-
var paramsBag = $.extend({page: 1}, this.getPageLimit());
392+
var paramsBag = $.extend({page: 1, per_page: 50}, this.getPageLimit());
389393
var params = $.param(paramsBag);
390394

391395
// note: if query is the empty string, it will load all issues from the
392396
// '/api/issues' endpoint (which I think we want).
393397
if (category && category.query) {
394398
params = $.param($.extend(paramsBag, {q: category.query}));
395399
this.issues.url = '/api/issues/search?' + params;
396-
} else if (_.contains(labelCategories, category)) {
400+
} else if (_.contains(searchCategories, category)) {
401+
this.issues.url = '/api/issues/search/' + category + '?' + params;
402+
} else if (category === "closed") {
397403
this.issues.url = '/api/issues/category/' + category + '?' + params;
398-
} else if (category === "untriaged") {
399-
this.issues.url = '/api/issues/search/untriaged?' + params;
400404
} else {
401405
this.issues.url = '/api/issues?' + params;
402406
}
403407
this.fetchAndRenderIssues();
404408
},
405409
updateModelParams: function(paramKey, paramValue) {
406-
var modelUrl = this.issues.url.split('?');
407-
var modelPath = modelUrl[0];
408-
var modelParams = modelUrl[1];
410+
var decomposeUrl = function(url) {
411+
var _url = url.split('?');
412+
return {path: _url[0], params: _url[1]};
413+
};
414+
var linkUrl;
415+
var newParams;
416+
var modelUrl = decomposeUrl(this.issues.url);
417+
var parsedModelParams = $.deparam(modelUrl.params);
409418

410419
var updateParams = {};
411420
updateParams[paramKey] = paramValue;
412421

422+
// do we have a ?link param in the model URL from traversing pagination?
423+
if (parsedModelParams.hasOwnProperty('link')) {
424+
// if so, decompose link param url, merge updated params, and recompose
425+
linkUrl = decomposeUrl(parsedModelParams.link);
426+
newParams = $.extend($.deparam(linkUrl.params), updateParams);
427+
this.issues.url = modelUrl.path + '?link=' + encodeURIComponent(linkUrl.path + '?' + $.param(newParams));
428+
this.fetchAndRenderIssues();
429+
return;
430+
}
431+
413432
// merge old params with passed in param data
414433
// $.extend will update existing object keys, and add new ones
415-
var newParams = $.extend($.deparam(modelParams), updateParams);
434+
newParams = $.extend($.deparam(modelUrl.params), updateParams);
416435

417436
if (paramKey === 'per_page') {
418437
this._pageLimit = paramValue;
419438
}
420439

421440
// construct new model URL and re-request issues
422-
this.issues.url = modelPath + '?' + $.param(newParams);
441+
this.issues.url = modelUrl.path + '?' + $.param(newParams);
423442
this.fetchAndRenderIssues();
424443
}
425444
});

webcompat/static/js/lib/models/issue.js

+15-19
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,13 @@ issueList.IssueCollection = Backbone.Collection.extend({
115115
} else {
116116
this.linkHeader = null;
117117
}
118-
return response;
118+
if (response.hasOwnProperty('items')) {
119+
// Search API results return an Object with the
120+
// issues array in the items key
121+
return response.items;
122+
} else {
123+
return response;
124+
}
119125
},
120126
parseHeader: function(linkHeader) {
121127
/* Returns an object like so:
@@ -146,28 +152,18 @@ issueList.IssueCollection = Backbone.Collection.extend({
146152

147153
return result;
148154
},
149-
getPageFromRel: function(relation) {
150-
// GitHub will only send us a Link header if pagination is possible.
151-
// if we return early with null, we'll know that next and prev pagination
152-
// should be disabled.
153-
if (this.linkHeader == null) {
155+
getNextPage: function() {
156+
if (this.linkHeader && this.linkHeader.hasOwnProperty('next')) {
157+
return this.linkHeader.next;
158+
} else {
154159
return null;
155160
}
156-
var page;
157-
// we only return the page number
158-
var re = new RegExp('[&?]page=(\\d)');
159-
160-
if (page = (this.linkHeader.hasOwnProperty(relation) &&
161-
this.linkHeader[relation].match(re))) {
162-
return page[1];
161+
},
162+
getPrevPage: function() {
163+
if (this.linkHeader && this.linkHeader.hasOwnProperty('prev')) {
164+
return this.linkHeader.prev;
163165
} else {
164166
return null;
165167
}
166168
},
167-
getNextPageNumber: function() {
168-
return this.getPageFromRel('next');
169-
},
170-
getPreviousPageNumber: function() {
171-
return this.getPageFromRel('prev');
172-
}
173169
});

0 commit comments

Comments
 (0)