Skip to content

Commit 2692339

Browse files
kodek16accek
authored andcommitted
Separated server into /files and /list endpoints. (#40)
* Separated server into /files and /list endpoints. Also implemented /list endpoint and added tests. * Increased wait time for server in migration tests. * Refactored URL handling code.
1 parent 0cf6ca8 commit 2692339

File tree

8 files changed

+292
-64
lines changed

8 files changed

+292
-64
lines changed

MIGRATING.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ it's more involved.
4545
You should have read and become comfortable with the section above, this section only
4646
describes the differences.
4747

48-
The trick is based on the new `--fallback-url` parameter for the filetracker server. After upgrading the filetracker server, you should configure two servers: the main server which
48+
The trick is based on the new `--fallback-url` parameter for the filetracker server.
49+
After upgrading the filetracker server, you should configure two servers: the main server which
4950
uses the new filetracker code, and the fallback server which is plain lighttpd.
5051

5152
Before starting the new server and the rest of the SIO2 infrastructure, start a lighttpd
52-
server with a simple configuration for serving static files from `ft_root/files`
53+
server with a simple configuration for serving static files from `ft_root`
5354
(port and log path are arbitrary):
5455

5556
```
5657
server.tag = "filetracker-old"
57-
server.document-root = "/path/to/ft_root/files"
58+
server.document-root = "/path/to/ft_root"
5859
server.port = 59999
5960
server.bind = "0.0.0.0"
6061
server.modules = ( "mod_accesslog", "mod_status" )

filetracker/client/remote_data_store.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def __init__(self, base_url):
5959
def _parse_name(self, name):
6060
check_name(name)
6161
name, version = split_name(name)
62-
url = self.base_url + pathname2url(name)
62+
url = self.base_url + '/files' + pathname2url(name)
6363
return url, version
6464

6565
def _parse_last_modified(self, response):
@@ -155,7 +155,7 @@ def exists(self, name):
155155

156156
@_verbose_http_errors
157157
def file_version(self, name):
158-
url, version = self._parse_name(name)
158+
url, _ = self._parse_name(name)
159159
response = requests.head(url, allow_redirects=True)
160160
response.raise_for_status()
161161
return self._parse_last_modified(response)

filetracker/migration_test.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def setUpClass(cls):
3737
args=(cls.server_dir, fallback_url))
3838
cls.server_process.start()
3939

40-
time.sleep(1) # give servers some time to start
40+
time.sleep(2) # give servers some time to start
4141

4242
cls.client = Client(
4343
cache_dir=cls.cache_dir1,
@@ -105,6 +105,17 @@ def test_migration_server_should_redirect_to_fallback(self):
105105
f = self.client.get_stream('/fallback.txt')[0]
106106
self.assertEqual(f.read(), b'remote hello')
107107

108+
def test_file_version_should_return_version_from_fallback(self):
109+
temp_file = os.path.join(self.temp_dir, 'fallback_version.txt')
110+
with open(temp_file, 'w') as tf:
111+
tf.write('fallback version')
112+
113+
timestamp = int(time.time())
114+
self.fallback_client.put_file('/fallback_version.txt', temp_file)
115+
116+
self.assertGreaterEqual(
117+
self.client.file_version('/fallback_version.txt'), timestamp)
118+
108119
def test_file_version_of_not_existent_file_should_return_404(self):
109120
with self.assertRaisesRegexp(FiletrackerError, "404"):
110121
self.client.get_stream('/nonexistent.txt')

filetracker/protocol_test.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Similar to interaction tests, but tests the HTTP API itself."""
2+
3+
from __future__ import absolute_import
4+
from __future__ import division
5+
from __future__ import print_function
6+
7+
from multiprocessing import Process
8+
import os
9+
import shutil
10+
import tempfile
11+
import time
12+
import unittest
13+
14+
import requests
15+
import six
16+
17+
from filetracker.client import Client
18+
from filetracker.servers.run import main as server_main
19+
20+
_TEST_PORT_NUMBER = 45775
21+
22+
23+
class ProtocolTest(unittest.TestCase):
24+
@classmethod
25+
def setUpClass(cls):
26+
cls.cache_dir = tempfile.mkdtemp()
27+
cls.server_dir = tempfile.mkdtemp()
28+
cls.temp_dir = tempfile.mkdtemp()
29+
30+
cls.server_process = Process(
31+
target=_start_server, args=(cls.server_dir,))
32+
cls.server_process.start()
33+
time.sleep(1) # give server some time to start
34+
35+
# We use a client to set up test environments.
36+
cls.client = Client(
37+
cache_dir=cls.cache_dir,
38+
remote_url='http://127.0.0.1:{}'.format(_TEST_PORT_NUMBER))
39+
40+
@classmethod
41+
def tearDownClass(cls):
42+
cls.server_process.terminate()
43+
shutil.rmtree(cls.cache_dir)
44+
shutil.rmtree(cls.server_dir)
45+
shutil.rmtree(cls.temp_dir)
46+
47+
def setUp(self):
48+
# Shortcuts for convenience
49+
self.cache_dir = ProtocolTest.cache_dir
50+
self.server_dir = ProtocolTest.server_dir
51+
self.temp_dir = ProtocolTest.temp_dir
52+
self.client = ProtocolTest.client
53+
54+
def test_list_files_in_root_should_work(self):
55+
src_file = os.path.join(self.temp_dir, 'list.txt')
56+
with open(src_file, 'wb') as sf:
57+
sf.write(b'hello list')
58+
59+
self.client.put_file('/list_a.txt', src_file)
60+
self.client.put_file('/list_b.txt', src_file)
61+
62+
res = requests.get(
63+
'http://127.0.0.1:{}/list/'.format(_TEST_PORT_NUMBER))
64+
self.assertEqual(res.status_code, 200)
65+
66+
lines = [l for l in res.text.split('\n') if l]
67+
68+
self.assertEqual(lines.count('list_a.txt'), 1)
69+
self.assertEqual(lines.count('list_b.txt'), 1)
70+
71+
def test_list_files_in_subdirectory_should_work(self):
72+
src_file = os.path.join(self.temp_dir, 'list_sub.txt')
73+
with open(src_file, 'wb') as sf:
74+
sf.write(b'hello list sub')
75+
76+
self.client.put_file('/sub/direct/ory/list_a.txt', src_file)
77+
self.client.put_file('/sub/direct/ory/list_b.txt', src_file)
78+
self.client.put_file('/should_not_be_listed', src_file)
79+
80+
res = requests.get(
81+
'http://127.0.0.1:{}/list/sub/direct/'
82+
.format(_TEST_PORT_NUMBER))
83+
self.assertEqual(res.status_code, 200)
84+
85+
lines = [l for l in res.text.split('\n') if l]
86+
expected = [
87+
'ory/list_a.txt',
88+
'ory/list_b.txt',
89+
]
90+
six.assertCountEqual(self, lines, expected)
91+
92+
93+
def _start_server(server_dir):
94+
server_main(['-p', str(_TEST_PORT_NUMBER), '-d', server_dir, '-D',
95+
'--workers', '4'])

filetracker/servers/base.py

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,89 @@
1+
"""This module contains an utility superclass for creating WSGI servers."""
2+
13
from __future__ import absolute_import
4+
from __future__ import division
25
from __future__ import print_function
6+
37
import socket
48
import errno
59
import os
610
import sys
711
import traceback
812

913

14+
class HttpError(Exception):
15+
def __init__(self, status, description):
16+
# status should be a string of form '404 Not Found'
17+
self.status = status
18+
self.description = description
19+
20+
1021
class Server(object):
1122
"""A base WSGI-compatible class, which delegates request handling to
1223
``handle_<HTTP-method-name>`` methods."""
1324

1425
def __call__(self, environ, start_response):
1526
try:
16-
return getattr(self, 'handle_' + environ['REQUEST_METHOD']) \
17-
(environ, start_response)
27+
if environ['REQUEST_METHOD'] == 'HEAD':
28+
environ['REQUEST_METHOD'] = 'GET'
29+
_body_iter = self.__call__(environ, start_response)
30+
# Server implementations should return closeable iterators
31+
# from handle_GET to avoid resource leaks.
32+
_body_iter.close()
33+
34+
return []
35+
else:
36+
handler = getattr(
37+
self, 'handle_{}'.format(environ['REQUEST_METHOD']))
38+
return handler(environ, start_response)
39+
40+
except HttpError as e:
41+
response_headers = [
42+
('Content-Type', 'text/plain'),
43+
('X-Exception', e.description)
44+
]
45+
start_response(e.status, response_headers, sys.exc_info())
46+
return [traceback.format_exc().encode()]
1847
except Exception as e:
1948
status = '500 Oops'
2049
response_headers = [
21-
('Content-Type', 'text/plain'),
22-
('X-Exception', str(e))
23-
]
50+
('Content-Type', 'text/plain'),
51+
('X-Exception', str(e))
52+
]
2453
start_response(status, response_headers, sys.exc_info())
2554
return [traceback.format_exc().encode()]
2655

2756

57+
def get_endpoint_and_path(environ):
58+
"""Extracts "endpoint" and "path" from the request URL.
59+
60+
Endpoint is the first path component, and path is the rest. Both
61+
of them are without leading slashes.
62+
"""
63+
path = environ['PATH_INFO']
64+
if '..' in path:
65+
raise HttpError('400 Bad Request', 'Path cannot contain "..".')
66+
67+
components = path.split('/')
68+
69+
# Strip closing slash
70+
if components and components[-1] == '':
71+
components.pop()
72+
73+
# If path contained '//', get the segment after the last occurence
74+
try:
75+
first = _rindex(components, '') + 1
76+
except ValueError:
77+
first = 0
78+
79+
components = components[first:]
80+
81+
if len(components) == 0:
82+
return '', ''
83+
else:
84+
return components[0], '/'.join(components[1:])
85+
86+
2887
def start_cgi(server):
2988
from flup.server.cgi import WSGIServer
3089
WSGIServer(server).run()
@@ -59,3 +118,8 @@ def main(server):
59118
start_fcgi(server)
60119

61120
start_standalone(server)
121+
122+
123+
def _rindex(l, value):
124+
"""Same as str.rindex, but for lists."""
125+
return len(l) - l[::-1].index(value) - 1

0 commit comments

Comments
 (0)