##// END OF EJS Templates
svn: Avoid chunked transfer for Subversion
johbo -
r282:5d91eb8d stable
parent child Browse files
Show More
@@ -1,130 +1,130 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from urlparse import urljoin
21 from urlparse import urljoin
22
22
23 import requests
23 import requests
24
24
25 import rhodecode
25 import rhodecode
26 from rhodecode.lib.middleware import simplevcs
26 from rhodecode.lib.middleware import simplevcs
27 from rhodecode.lib.utils import is_valid_repo
27 from rhodecode.lib.utils import is_valid_repo
28
28
29
29
30 class SimpleSvnApp(object):
30 class SimpleSvnApp(object):
31 IGNORED_HEADERS = [
31 IGNORED_HEADERS = [
32 'connection', 'keep-alive', 'content-encoding',
32 'connection', 'keep-alive', 'content-encoding',
33 'transfer-encoding']
33 'transfer-encoding']
34
34
35 def __init__(self, config):
35 def __init__(self, config):
36 self.config = config
36 self.config = config
37
37
38 def __call__(self, environ, start_response):
38 def __call__(self, environ, start_response):
39 request_headers = self._get_request_headers(environ)
39 request_headers = self._get_request_headers(environ)
40
40
41 data = environ['wsgi.input']
41 data = environ['wsgi.input']
42 # johbo: On Gunicorn, we end up with a 415 response if we pass data
42 # johbo: Avoid that we end up with sending the request in chunked
43 # to requests. I think the request is usually without payload, still
43 # transfer encoding (mainly on Gunicorn). If we know the content
44 # reading the data to be on the safe side.
44 # length, then we should transfer the payload in one request.
45 if environ['REQUEST_METHOD'] == 'MKCOL':
45 if environ['REQUEST_METHOD'] == 'MKCOL' or 'CONTENT_LENGTH' in environ:
46 data = data.read()
46 data = data.read()
47
47
48 response = requests.request(
48 response = requests.request(
49 environ['REQUEST_METHOD'], self._get_url(environ['PATH_INFO']),
49 environ['REQUEST_METHOD'], self._get_url(environ['PATH_INFO']),
50 data=data, headers=request_headers)
50 data=data, headers=request_headers)
51
51
52 response_headers = self._get_response_headers(response.headers)
52 response_headers = self._get_response_headers(response.headers)
53 start_response(
53 start_response(
54 '{} {}'.format(response.status_code, response.reason),
54 '{} {}'.format(response.status_code, response.reason),
55 response_headers)
55 response_headers)
56 return response.iter_content(chunk_size=1024)
56 return response.iter_content(chunk_size=1024)
57
57
58 def _get_url(self, path):
58 def _get_url(self, path):
59 return urljoin(
59 return urljoin(
60 self.config.get('subversion_http_server_url', ''), path)
60 self.config.get('subversion_http_server_url', ''), path)
61
61
62 def _get_request_headers(self, environ):
62 def _get_request_headers(self, environ):
63 headers = {}
63 headers = {}
64
64
65 for key in environ:
65 for key in environ:
66 if not key.startswith('HTTP_'):
66 if not key.startswith('HTTP_'):
67 continue
67 continue
68 new_key = key.split('_')
68 new_key = key.split('_')
69 new_key = [k.capitalize() for k in new_key[1:]]
69 new_key = [k.capitalize() for k in new_key[1:]]
70 new_key = '-'.join(new_key)
70 new_key = '-'.join(new_key)
71 headers[new_key] = environ[key]
71 headers[new_key] = environ[key]
72
72
73 if 'CONTENT_TYPE' in environ:
73 if 'CONTENT_TYPE' in environ:
74 headers['Content-Type'] = environ['CONTENT_TYPE']
74 headers['Content-Type'] = environ['CONTENT_TYPE']
75
75
76 if 'CONTENT_LENGTH' in environ:
76 if 'CONTENT_LENGTH' in environ:
77 headers['Content-Length'] = environ['CONTENT_LENGTH']
77 headers['Content-Length'] = environ['CONTENT_LENGTH']
78
78
79 return headers
79 return headers
80
80
81 def _get_response_headers(self, headers):
81 def _get_response_headers(self, headers):
82 return [
82 return [
83 (h, headers[h])
83 (h, headers[h])
84 for h in headers
84 for h in headers
85 if h.lower() not in self.IGNORED_HEADERS
85 if h.lower() not in self.IGNORED_HEADERS
86 ]
86 ]
87
87
88
88
89 class SimpleSvn(simplevcs.SimpleVCS):
89 class SimpleSvn(simplevcs.SimpleVCS):
90
90
91 SCM = 'svn'
91 SCM = 'svn'
92 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
92 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
93
93
94 def _get_repository_name(self, environ):
94 def _get_repository_name(self, environ):
95 """
95 """
96 Gets repository name out of PATH_INFO header
96 Gets repository name out of PATH_INFO header
97
97
98 :param environ: environ where PATH_INFO is stored
98 :param environ: environ where PATH_INFO is stored
99 """
99 """
100 path = environ['PATH_INFO'].split('!')
100 path = environ['PATH_INFO'].split('!')
101 repo_name = path[0].strip('/')
101 repo_name = path[0].strip('/')
102
102
103 # SVN includes the whole path in it's requests, including
103 # SVN includes the whole path in it's requests, including
104 # subdirectories inside the repo. Therefore we have to search for
104 # subdirectories inside the repo. Therefore we have to search for
105 # the repo root directory.
105 # the repo root directory.
106 if not is_valid_repo(repo_name, self.basepath, self.SCM):
106 if not is_valid_repo(repo_name, self.basepath, self.SCM):
107 current_path = ''
107 current_path = ''
108 for component in repo_name.split('/'):
108 for component in repo_name.split('/'):
109 current_path += component
109 current_path += component
110 if is_valid_repo(current_path, self.basepath, self.SCM):
110 if is_valid_repo(current_path, self.basepath, self.SCM):
111 return current_path
111 return current_path
112 current_path += '/'
112 current_path += '/'
113
113
114 return repo_name
114 return repo_name
115
115
116 def _get_action(self, environ):
116 def _get_action(self, environ):
117 return (
117 return (
118 'pull'
118 'pull'
119 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
119 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
120 else 'push')
120 else 'push')
121
121
122 def _create_wsgi_app(self, repo_path, repo_name, config):
122 def _create_wsgi_app(self, repo_path, repo_name, config):
123 return SimpleSvnApp(config)
123 return SimpleSvnApp(config)
124
124
125 def _create_config(self, extras, repo_name):
125 def _create_config(self, extras, repo_name):
126 server_url = rhodecode.CONFIG.get(
126 server_url = rhodecode.CONFIG.get(
127 'rhodecode_subversion_http_server_url', '')
127 'rhodecode_subversion_http_server_url', '')
128 extras['subversion_http_server_url'] = (
128 extras['subversion_http_server_url'] = (
129 server_url or 'http://localhost/')
129 server_url or 'http://localhost/')
130 return extras
130 return extras
@@ -1,185 +1,185 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from StringIO import StringIO
21 from StringIO import StringIO
22
22
23 import pytest
23 import pytest
24 from mock import patch, Mock
24 from mock import patch, Mock
25
25
26 import rhodecode
26 import rhodecode
27 from rhodecode.lib.middleware.simplesvn import SimpleSvn, SimpleSvnApp
27 from rhodecode.lib.middleware.simplesvn import SimpleSvn, SimpleSvnApp
28
28
29
29
30 class TestSimpleSvn(object):
30 class TestSimpleSvn(object):
31 @pytest.fixture(autouse=True)
31 @pytest.fixture(autouse=True)
32 def simple_svn(self, pylonsapp):
32 def simple_svn(self, pylonsapp):
33 self.app = SimpleSvn(
33 self.app = SimpleSvn(
34 application='None',
34 application='None',
35 config={'auth_ret_code': '',
35 config={'auth_ret_code': '',
36 'base_path': rhodecode.CONFIG['base_path']})
36 'base_path': rhodecode.CONFIG['base_path']})
37
37
38 def test_get_config(self):
38 def test_get_config(self):
39 extras = {'foo': 'FOO', 'bar': 'BAR'}
39 extras = {'foo': 'FOO', 'bar': 'BAR'}
40 config = self.app._create_config(extras, repo_name='test-repo')
40 config = self.app._create_config(extras, repo_name='test-repo')
41 assert config == extras
41 assert config == extras
42
42
43 @pytest.mark.parametrize(
43 @pytest.mark.parametrize(
44 'method', ['OPTIONS', 'PROPFIND', 'GET', 'REPORT'])
44 'method', ['OPTIONS', 'PROPFIND', 'GET', 'REPORT'])
45 def test_get_action_returns_pull(self, method):
45 def test_get_action_returns_pull(self, method):
46 environment = {'REQUEST_METHOD': method}
46 environment = {'REQUEST_METHOD': method}
47 action = self.app._get_action(environment)
47 action = self.app._get_action(environment)
48 assert action == 'pull'
48 assert action == 'pull'
49
49
50 @pytest.mark.parametrize(
50 @pytest.mark.parametrize(
51 'method', [
51 'method', [
52 'MKACTIVITY', 'PROPPATCH', 'PUT', 'CHECKOUT', 'MKCOL', 'MOVE',
52 'MKACTIVITY', 'PROPPATCH', 'PUT', 'CHECKOUT', 'MKCOL', 'MOVE',
53 'COPY', 'DELETE', 'LOCK', 'UNLOCK', 'MERGE'
53 'COPY', 'DELETE', 'LOCK', 'UNLOCK', 'MERGE'
54 ])
54 ])
55 def test_get_action_returns_push(self, method):
55 def test_get_action_returns_push(self, method):
56 environment = {'REQUEST_METHOD': method}
56 environment = {'REQUEST_METHOD': method}
57 action = self.app._get_action(environment)
57 action = self.app._get_action(environment)
58 assert action == 'push'
58 assert action == 'push'
59
59
60 @pytest.mark.parametrize(
60 @pytest.mark.parametrize(
61 'path, expected_name', [
61 'path, expected_name', [
62 ('/hello-svn', 'hello-svn'),
62 ('/hello-svn', 'hello-svn'),
63 ('/hello-svn/', 'hello-svn'),
63 ('/hello-svn/', 'hello-svn'),
64 ('/group/hello-svn/', 'group/hello-svn'),
64 ('/group/hello-svn/', 'group/hello-svn'),
65 ('/group/hello-svn/!svn/vcc/default', 'group/hello-svn'),
65 ('/group/hello-svn/!svn/vcc/default', 'group/hello-svn'),
66 ])
66 ])
67 def test_get_repository_name(self, path, expected_name):
67 def test_get_repository_name(self, path, expected_name):
68 environment = {'PATH_INFO': path}
68 environment = {'PATH_INFO': path}
69 name = self.app._get_repository_name(environment)
69 name = self.app._get_repository_name(environment)
70 assert name == expected_name
70 assert name == expected_name
71
71
72 def test_get_repository_name_subfolder(self, backend_svn):
72 def test_get_repository_name_subfolder(self, backend_svn):
73 repo = backend_svn.repo
73 repo = backend_svn.repo
74 environment = {
74 environment = {
75 'PATH_INFO': '/{}/path/with/subfolders'.format(repo.repo_name)}
75 'PATH_INFO': '/{}/path/with/subfolders'.format(repo.repo_name)}
76 name = self.app._get_repository_name(environment)
76 name = self.app._get_repository_name(environment)
77 assert name == repo.repo_name
77 assert name == repo.repo_name
78
78
79 def test_create_wsgi_app(self):
79 def test_create_wsgi_app(self):
80 with patch('rhodecode.lib.middleware.simplesvn.SimpleSvnApp') as (
80 with patch('rhodecode.lib.middleware.simplesvn.SimpleSvnApp') as (
81 wsgi_app_mock):
81 wsgi_app_mock):
82 config = Mock()
82 config = Mock()
83 wsgi_app = self.app._create_wsgi_app(
83 wsgi_app = self.app._create_wsgi_app(
84 repo_path='', repo_name='', config=config)
84 repo_path='', repo_name='', config=config)
85
85
86 wsgi_app_mock.assert_called_once_with(config)
86 wsgi_app_mock.assert_called_once_with(config)
87 assert wsgi_app == wsgi_app_mock()
87 assert wsgi_app == wsgi_app_mock()
88
88
89
89
90 class TestSimpleSvnApp(object):
90 class TestSimpleSvnApp(object):
91 data = '<xml></xml>'
91 data = '<xml></xml>'
92 path = '/group/my-repo'
92 path = '/group/my-repo'
93 wsgi_input = StringIO(data)
93 wsgi_input = StringIO(data)
94 environment = {
94 environment = {
95 'HTTP_DAV': (
95 'HTTP_DAV': (
96 'http://subversion.tigris.org/xmlns/dav/svn/depth,'
96 'http://subversion.tigris.org/xmlns/dav/svn/depth,'
97 ' http://subversion.tigris.org/xmlns/dav/svn/mergeinfo'),
97 ' http://subversion.tigris.org/xmlns/dav/svn/mergeinfo'),
98 'HTTP_USER_AGENT': 'SVN/1.8.11 (x86_64-linux) serf/1.3.8',
98 'HTTP_USER_AGENT': 'SVN/1.8.11 (x86_64-linux) serf/1.3.8',
99 'REQUEST_METHOD': 'OPTIONS',
99 'REQUEST_METHOD': 'OPTIONS',
100 'PATH_INFO': path,
100 'PATH_INFO': path,
101 'wsgi.input': wsgi_input,
101 'wsgi.input': wsgi_input,
102 'CONTENT_TYPE': 'text/xml',
102 'CONTENT_TYPE': 'text/xml',
103 'CONTENT_LENGTH': '130'
103 'CONTENT_LENGTH': '130'
104 }
104 }
105
105
106 def setup_method(self, method):
106 def setup_method(self, method):
107 self.host = 'http://localhost/'
107 self.host = 'http://localhost/'
108 self.app = SimpleSvnApp(
108 self.app = SimpleSvnApp(
109 config={'subversion_http_server_url': self.host})
109 config={'subversion_http_server_url': self.host})
110
110
111 def test_get_request_headers_with_content_type(self):
111 def test_get_request_headers_with_content_type(self):
112 expected_headers = {
112 expected_headers = {
113 'Dav': self.environment['HTTP_DAV'],
113 'Dav': self.environment['HTTP_DAV'],
114 'User-Agent': self.environment['HTTP_USER_AGENT'],
114 'User-Agent': self.environment['HTTP_USER_AGENT'],
115 'Content-Type': self.environment['CONTENT_TYPE'],
115 'Content-Type': self.environment['CONTENT_TYPE'],
116 'Content-Length': self.environment['CONTENT_LENGTH']
116 'Content-Length': self.environment['CONTENT_LENGTH']
117 }
117 }
118 headers = self.app._get_request_headers(self.environment)
118 headers = self.app._get_request_headers(self.environment)
119 assert headers == expected_headers
119 assert headers == expected_headers
120
120
121 def test_get_request_headers_without_content_type(self):
121 def test_get_request_headers_without_content_type(self):
122 environment = self.environment.copy()
122 environment = self.environment.copy()
123 environment.pop('CONTENT_TYPE')
123 environment.pop('CONTENT_TYPE')
124 expected_headers = {
124 expected_headers = {
125 'Dav': environment['HTTP_DAV'],
125 'Dav': environment['HTTP_DAV'],
126 'Content-Length': self.environment['CONTENT_LENGTH'],
126 'Content-Length': self.environment['CONTENT_LENGTH'],
127 'User-Agent': environment['HTTP_USER_AGENT'],
127 'User-Agent': environment['HTTP_USER_AGENT'],
128 }
128 }
129 request_headers = self.app._get_request_headers(environment)
129 request_headers = self.app._get_request_headers(environment)
130 assert request_headers == expected_headers
130 assert request_headers == expected_headers
131
131
132 def test_get_response_headers(self):
132 def test_get_response_headers(self):
133 headers = {
133 headers = {
134 'Connection': 'keep-alive',
134 'Connection': 'keep-alive',
135 'Keep-Alive': 'timeout=5, max=100',
135 'Keep-Alive': 'timeout=5, max=100',
136 'Transfer-Encoding': 'chunked',
136 'Transfer-Encoding': 'chunked',
137 'Content-Encoding': 'gzip',
137 'Content-Encoding': 'gzip',
138 'MS-Author-Via': 'DAV',
138 'MS-Author-Via': 'DAV',
139 'SVN-Supported-Posts': 'create-txn-with-props'
139 'SVN-Supported-Posts': 'create-txn-with-props'
140 }
140 }
141 expected_headers = [
141 expected_headers = [
142 ('MS-Author-Via', 'DAV'),
142 ('MS-Author-Via', 'DAV'),
143 ('SVN-Supported-Posts', 'create-txn-with-props')
143 ('SVN-Supported-Posts', 'create-txn-with-props')
144 ]
144 ]
145 response_headers = self.app._get_response_headers(headers)
145 response_headers = self.app._get_response_headers(headers)
146 assert sorted(response_headers) == sorted(expected_headers)
146 assert sorted(response_headers) == sorted(expected_headers)
147
147
148 def test_get_url(self):
148 def test_get_url(self):
149 url = self.app._get_url(self.path)
149 url = self.app._get_url(self.path)
150 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
150 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
151 assert url == expected_url
151 assert url == expected_url
152
152
153 def test_call(self):
153 def test_call(self):
154 start_response = Mock()
154 start_response = Mock()
155 response_mock = Mock()
155 response_mock = Mock()
156 response_mock.headers = {
156 response_mock.headers = {
157 'Content-Encoding': 'gzip',
157 'Content-Encoding': 'gzip',
158 'MS-Author-Via': 'DAV',
158 'MS-Author-Via': 'DAV',
159 'SVN-Supported-Posts': 'create-txn-with-props'
159 'SVN-Supported-Posts': 'create-txn-with-props'
160 }
160 }
161 response_mock.status_code = 200
161 response_mock.status_code = 200
162 response_mock.reason = 'OK'
162 response_mock.reason = 'OK'
163 with patch('rhodecode.lib.middleware.simplesvn.requests.request') as (
163 with patch('rhodecode.lib.middleware.simplesvn.requests.request') as (
164 request_mock):
164 request_mock):
165 request_mock.return_value = response_mock
165 request_mock.return_value = response_mock
166 self.app(self.environment, start_response)
166 self.app(self.environment, start_response)
167
167
168 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
168 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
169 expected_request_headers = {
169 expected_request_headers = {
170 'Dav': self.environment['HTTP_DAV'],
170 'Dav': self.environment['HTTP_DAV'],
171 'User-Agent': self.environment['HTTP_USER_AGENT'],
171 'User-Agent': self.environment['HTTP_USER_AGENT'],
172 'Content-Type': self.environment['CONTENT_TYPE'],
172 'Content-Type': self.environment['CONTENT_TYPE'],
173 'Content-Length': self.environment['CONTENT_LENGTH']
173 'Content-Length': self.environment['CONTENT_LENGTH']
174 }
174 }
175 expected_response_headers = [
175 expected_response_headers = [
176 ('SVN-Supported-Posts', 'create-txn-with-props'),
176 ('SVN-Supported-Posts', 'create-txn-with-props'),
177 ('MS-Author-Via', 'DAV')
177 ('MS-Author-Via', 'DAV')
178 ]
178 ]
179 request_mock.assert_called_once_with(
179 request_mock.assert_called_once_with(
180 self.environment['REQUEST_METHOD'], expected_url,
180 self.environment['REQUEST_METHOD'], expected_url,
181 data=self.wsgi_input, headers=expected_request_headers)
181 data=self.data, headers=expected_request_headers)
182 response_mock.iter_content.assert_called_once_with(chunk_size=1024)
182 response_mock.iter_content.assert_called_once_with(chunk_size=1024)
183 args, _ = start_response.call_args
183 args, _ = start_response.call_args
184 assert args[0] == '200 OK'
184 assert args[0] == '200 OK'
185 assert sorted(args[1]) == sorted(expected_response_headers)
185 assert sorted(args[1]) == sorted(expected_response_headers)
General Comments 0
You need to be logged in to leave comments. Login now