Skip to content

Commit

Permalink
Merge pull request #43 from xsnippet/filtering_by_title_and_tag
Browse files Browse the repository at this point in the history
Add support for filtering by title and tag
  • Loading branch information
malor committed Oct 23, 2017
2 parents 69873a0 + 189ee4e commit 2baa46e
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 10 deletions.
12 changes: 12 additions & 0 deletions contrib/openapi/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ paths:
Returned snippets are sorted in reversed chronological order, so
most recent ones appear first.
parameters:
- name: title
in: query
type: string
description: |
If passed, only the snippets with titles starting with the given
string will be returned.
- name: tag
in: query
type: string
description: |
If passed, only the snippets that have the given string tag will
be returned.
- name: marker
in: query
type: integer
Expand Down
148 changes: 148 additions & 0 deletions tests/resources/test_snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,150 @@ async def test_get_snippets(self):
for snippet_db, snippet_api in zip(expected, await resp.json()):
self._compare_snippets(snippet_db, snippet_api)

async def test_get_snippets_filter_by_title(self):
async with AIOTestApp(self.app) as testapp:
await self.app['db'].snippets.insert(self.snippets)

resp = await testapp.get(
'/snippets?title=snippet+%231',
headers={
'Accept': 'application/json',
})
assert resp.status == 200
expected = [self.snippets[0]]
for snippet_db, snippet_api in zip(expected, await resp.json()):
self._compare_snippets(snippet_db, snippet_api)

nonexistent = await testapp.get(
'/snippets?title=nonexistent',
headers={
'Accept': 'application/json',
})
assert nonexistent.status == 200
assert list(nonexistent.json()) == []

regexes_are_escaped = await testapp.get(
'/snippets?title=%5Esnippet.%2A', # title=^snippet.*
headers={
'Accept': 'application/json',
})
assert regexes_are_escaped.status == 200
assert list(regexes_are_escaped.json()) == []

async def test_get_snippets_filter_by_title_bad_request(self):
async with AIOTestApp(self.app) as testapp:
await self.app['db'].snippets.insert(self.snippets)

resp = await testapp.get(
'/snippets?title=',
headers={
'Accept': 'application/json',
})
assert resp.status == 400
assert await resp.json() == {
'message': '`title` - empty values not allowed.'
}

async def test_get_snippets_filter_by_tag(self):
async with AIOTestApp(self.app) as testapp:
await self.app['db'].snippets.insert(self.snippets)

resp = await testapp.get(
'/snippets?tag=tag_c',
headers={
'Accept': 'application/json',
})
assert resp.status == 200
expected = [self.snippets[1]]
for snippet_db, snippet_api in zip(expected, await resp.json()):
self._compare_snippets(snippet_db, snippet_api)

nonexistent = await testapp.get(
'/snippets?tag=nonexistent_tag',
headers={
'Accept': 'application/json',
})
assert nonexistent.status == 200
assert list(nonexistent.json()) == []

@pytest.mark.parametrize(
'value',
['', 'test%20tag'],
ids=['empty', 'whitespace']
)
async def test_get_snippets_filter_by_tag_bad_request(self, value):
async with AIOTestApp(self.app) as testapp:
await self.app['db'].snippets.insert(self.snippets)

resp = await testapp.get(
'/snippets?tag=' + value,
headers={
'Accept': 'application/json',
})
assert resp.status == 400
assert await resp.json() == {
'message': "`tag` - value does not match regex '[\\w_-]+'."
}

async def test_get_snippets_filter_by_title_and_tag(self):
async with AIOTestApp(self.app) as testapp:
await self.app['db'].snippets.insert(self.snippets)

resp = await testapp.get(
'/snippets?title=snippet+%231&tag=tag_a',
headers={
'Accept': 'application/json',
})
assert resp.status == 200
expected = [self.snippets[0]]
for snippet_db, snippet_api in zip(expected, await resp.json()):
self._compare_snippets(snippet_db, snippet_api)

nonexistent = await testapp.get(
'/snippets?title=snippet+%231&tag=tag_c',
headers={
'Accept': 'application/json',
})
assert nonexistent.status == 200
assert list(nonexistent.json()) == []

async def test_get_snippets_filter_by_title_and_tag_with_pagination(self):
now = datetime.datetime.utcnow().replace(microsecond=0)
snippet = {
'id': 3,
'title': 'snippet #1',
'content': '(println "Hello, World!")',
'syntax': 'clojure',
'tags': ['tag_a'],
'author_id': None,
'is_public': True,
'created_at': now,
'updated_at': now,
}

async with AIOTestApp(self.app) as testapp:
await self.app['db'].snippets.insert(self.snippets + [snippet])

resp = await testapp.get(
'/snippets?title=snippet+%231&tag=tag_a&limit=1',
headers={
'Accept': 'application/json',
})
assert resp.status == 200
expected = [snippet]
for snippet_db, snippet_api in zip(expected, await resp.json()):
self._compare_snippets(snippet_db, snippet_api)

resp = await testapp.get(
'/snippets?title=snippet+%231&tag=tag_a&limit=1&marker=3',
headers={
'Accept': 'application/json',
})
assert resp.status == 200
expected = [self.snippets[0]]
for snippet_db, snippet_api in zip(expected, await resp.json()):
self._compare_snippets(snippet_db, snippet_api)

async def test_get_snippets_pagination(self):
now = datetime.datetime.utcnow().replace(microsecond=0)
snippet = {
Expand Down Expand Up @@ -322,6 +466,10 @@ async def test_data_model_indexes_exist(self):
res = await self.app['db'].snippets.index_information()

assert res['author_idx']['key'] == [('author_id', 1)]
assert res['title_idx']['key'] == [('title', 1)]
assert res['title_idx']['partialFilterExpression'] == {
'title': {'$type': 'string'}
}
assert res['tags_idx']['key'] == [('tags', 1)]
assert res['updated_idx']['key'] == [('updated_at', -1)]
assert res['created_idx']['key'] == [('created_at', -1)]
Expand Down
46 changes: 46 additions & 0 deletions tests/services/test_snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,52 @@ async def test_get_pagination_not_found(self):
excinfo.match(r'Sorry, cannot complete the request since `marker` '
r'points to a nonexistent snippet.')

async def test_get_filter_by_title(self):
result = await self.service.get(title='snippet #1')
assert result == [self.snippets[0]]

prefix_match = await self.service.get(title='snippet')
assert prefix_match == list(reversed(self.snippets))

regexes_are_escaped = await self.service.get(title='^snippet.*')
assert regexes_are_escaped == []

nonexistent = await self.service.get(title='non existing snippet')
assert nonexistent == []

async def test_get_filter_by_tag(self):
result = await self.service.get(tag='tag_c')
assert result == [self.snippets[1]]

with_multiple_tags = await self.service.get(tag='tag_a')
assert with_multiple_tags == [self.snippets[0]]

nonexistent = await self.service.get(tag='non_existing_tag')
assert nonexistent == []

async def test_get_filter_by_title_and_tag(self):
result = await self.service.get(title='snippet #2', tag='tag_c')
assert result == [self.snippets[1]]

nonexistent = await self.service.get(title='snippet #2', tag='tag_a')
assert nonexistent == []

async def test_get_filter_by_title_and_tag_with_pagination(self):
snippet = {
'title': 'snippet #1',
'content': '...',
'syntax': 'python',
'tags': ['tag_a']
}
created = await self.service.create(snippet)

one = await self.service.get(title='snippet #1', tag='tag_a', limit=1)
assert one == [created]

another = await self.service.get(title='snippet #1', tag='tag_a',
marker=one[0]['id'], limit=1)
assert another == [self.snippets[0]]

async def test_create(self):
snippet = {
'title': 'my snippet',
Expand Down
10 changes: 10 additions & 0 deletions xsnippet_api/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ async def setup(app):
db.snippets.create_index('author_id',
name='author_idx',
background=True),
db.snippets.create_index('title',
name='title_idx',
# create a partial index to skip null values -
# this is supposed to make the index smaller,
# so that there is a higher chance it's kept
# in the main memory
partialFilterExpression={
'title': {'$type': 'string'}
},
background=True),
db.snippets.create_index('tags',
name='tags_idx',
background=True),
Expand Down
10 changes: 10 additions & 0 deletions xsnippet_api/resources/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ async def get(self):
'min': 1,
'coerce': try_int,
},
'title': {
'type': 'string',
'empty': False,
},
'tag': {
'type': 'string',
'regex': '[\w_-]+',
}
})

if not v.validate(dict(self.request.GET)):
Expand All @@ -80,6 +88,8 @@ async def get(self):

try:
snippets = await services.Snippet(self.db).get(
title=self.request.GET.get('title'),
tag=self.request.GET.get('tag'),
# It's safe to have type cast here since those query parameters
# are guaranteed to be integer, thanks to validation above.
limit=int(self.request.GET.get('limit', 0)),
Expand Down
24 changes: 14 additions & 10 deletions xsnippet_api/services/snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

import datetime
import re

import pymongo

Expand All @@ -28,24 +29,27 @@ async def create(self, snippet):
snippet['id'] = snippet_id
return snippet

async def get(self, *, limit=100, marker=None):
async def get(self, *, title=None, tag=None, limit=100, marker=None):
condition = {}

if title is not None:
condition['title'] = {'$regex': '^' + re.escape(title) + '.*'}
if tag is not None:
condition['tags'] = tag

if marker:
specimen = await self.db.snippets.find_one({'_id': marker})
if not specimen:
raise exceptions.SnippetNotFound(
'Sorry, cannot complete the request since `marker` '
'points to a nonexistent snippet.')

query = self.db.snippets.find({
'$and': [
{'_id': {'$lt': specimen['id']}},
{'created_at': {'$lte': specimen['created_at']}},
]
})
else:
query = self.db.snippets.find()
condition['$and'] = [
{'_id': {'$lt': specimen['id']}},
{'created_at': {'$lte': specimen['created_at']}},
]

query = query.sort([
query = self.db.snippets.find(condition).sort([
('_id', pymongo.DESCENDING),
('created_at', pymongo.DESCENDING)
])
Expand Down

0 comments on commit 2baa46e

Please sign in to comment.