Status: | Merged |
---|---|
Approved by: | John A Meinel |
Approved revision: | 121 |
Merged at revision: | 109 |
Proposed branch: | lp:~pedronis/u1db/http-app |
Merge into: | lp:u1db |
Diff against target: |
794 lines (+785/-0) 2 files modified
u1db/remote/http_app.py (+319/-0) u1db/tests/test_http_app.py (+466/-0) |
To merge this branch: | bzr merge lp:~pedronis/u1db/http-app |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
John A Meinel (community) | Approve | ||
Review via email: mp+82290@code.launchpad.net |
Commit message
Description of the change
Start of a WSGI application to expose access to a collection of u1dbs, sync and variants of put docs are implemented.
To centralize handling of deserialisation and matching argument introduce and use a http_method decorator. The goal is that http method implementation shouldn't need to deal with finding arguments or deserializing json on their own, in order to ensure proper BadRequest to be generated as needed in a uniform way.
Still to do on future branches:
- pick a proper approach to lookup *Resource classes
- more operations
- (more) error handling
Integration testing and a start of a HTTP client side are started in http-app-
- 116. By Samuele Pedroni
-
switch to simplejson which is faster
- 117. By Samuele Pedroni
-
use our own TestCase
- 118. By Samuele Pedroni
-
content_
unserialized_ as_args -> content_as_args, fix doc - 119. By Samuele Pedroni
-
an empty body is not valid json
- 120. By Samuele Pedroni
-
clarification, make _FencedReader and ._kept protected
- 121. By Samuele Pedroni
-
clarify test
Samuele Pedroni (pedronis) wrote : | # |
> Does it matter that we don't check the number of bytes in 'self.kept' during
> read_chunk?
it's going and is meant to be a subchunk from a previous call result, so ignoring atmost is ok,
and it's not worth trying to read a bit more at that point.
I renamed things though, because they are really internal helpers, and added a comment about this.
>
> http_method seems a bit too magical, and I'm having some trouble understanding
> what it is trying to do just from its docstring.
>
> I think you're trying to convert a call that passes args and content dict into
> actually calling arguments of a function.
>
> I wonder if it would be less magical if you didn't have it also have options
> itself. like 'content_as_args'. Would it be better to just have "http_method"
> and "http_content_
>
> I like that the decorator makes writing the other functions cleaner, it is
> just hard to understand the decorator itself.
>
> Maybe a separate type conversion decorator? So you would have
>
> @arg_conversion
> @http_method()
> def f(self, a, b):
> ...
that's hard to do without either having order matter, or adding further complexity
>
> It may not be worthwhile, I'm just brainstorming since I still find
> http_method a bit hard to follow. It does seem to have good test coverage,
> though.
>
I understand the concern, I want to use it a bit more before refactoring. I think the idea of having different decorators for the special parameters would be nice, not sure how to do that without duplication or more complexity though.
>
>
> test_args_no_query seems like the exception could be raised because f()
> doesn't take 'a' as a parameter, rather than because we explicitly requested
> no_query=True. Maybe use a default argument a=N, and assert that we still pass
> when we think we should, and fail appropriately?
changed the test along those lines
>
>
> content_
> At least, I found the 'unserialized' bit more confusing than helpful. I'm
> certainly open to discussion on it, though.
renamed
>
>
> If content-length isn't sent, it seems to set the total length to 0 for
> FencedReader, which looks like the read() will get called and always return
> the empty string. Is that intentional? Would it be cleaner to just fail as
> soon as we notice it is not set?
>
as we discussed an empty body is never valid for the content types we expect so changed the code to raise a BadRequest directly.
>
> I wonder what we'll want the routing logic to look like. Since I think we'll
> want ~user, and maybe even nesting in the api. (path/to/database, vs only
> supporting top-level databases). Maybe not, though. Short term this certainly
> works fine.
we don't want to support ~user I think for the ref implementation. supporting path/to/database with this kind of interface involves some level of DWIM or we need to go the couch %2F route but it adds a lot of complexity and it's error prone, especially when you throw oauth in the mix. I think I would like to not do path/to/database unless if have a strong use case for that.
>
>
> class TestFencedReade
John A Meinel (jameinel) wrote : | # |
Looks giod to me.
Preview Diff
1 | === added file 'u1db/remote/http_app.py' |
2 | --- u1db/remote/http_app.py 1970-01-01 00:00:00 +0000 |
3 | +++ u1db/remote/http_app.py 2011-11-16 15:30:08 +0000 |
4 | @@ -0,0 +1,319 @@ |
5 | +# Copyright 2011 Canonical Ltd. |
6 | +# |
7 | +# This program is free software: you can redistribute it and/or modify it |
8 | +# under the terms of the GNU General Public License version 3, as published |
9 | +# by the Free Software Foundation. |
10 | +# |
11 | +# This program is distributed in the hope that it will be useful, but |
12 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
13 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
14 | +# PURPOSE. See the GNU General Public License for more details. |
15 | +# |
16 | +# You should have received a copy of the GNU General Public License along |
17 | +# with this program. If not, see <http://www.gnu.org/licenses/>. |
18 | + |
19 | +"""HTTP Application exposing U1DB.""" |
20 | + |
21 | +import functools |
22 | +import httplib |
23 | +import inspect |
24 | +import simplejson |
25 | +import sys |
26 | +import urlparse |
27 | + |
28 | + |
29 | +class _FencedReader(object): |
30 | + """Read and get lines from a file but not past a given length.""" |
31 | + |
32 | + MAXCHUNK = 8192 |
33 | + |
34 | + # xxx do checks for maximum admissible total size |
35 | + # and maximum admissible line size |
36 | + |
37 | + def __init__(self, rfile, total): |
38 | + self.rfile = rfile |
39 | + self.remaining = total |
40 | + self._kept = None |
41 | + |
42 | + def read_chunk(self, atmost): |
43 | + if self._kept is not None: |
44 | + # ignore atmost, kept data should be a subchunk anyway |
45 | + return self._kept |
46 | + if self.remaining == 0: |
47 | + return '' |
48 | + data = self.rfile.read(min(self.remaining, atmost)) |
49 | + self.remaining -= len(data) |
50 | + return data |
51 | + |
52 | + def getline(self): |
53 | + line_parts = [] |
54 | + while True: |
55 | + chunk = self.read_chunk(self.MAXCHUNK) |
56 | + if chunk == '': |
57 | + break |
58 | + nl = chunk.find("\n") |
59 | + if nl != -1: |
60 | + line_parts.append(chunk[:nl+1]) |
61 | + rest = chunk[nl+1:] |
62 | + self._kept = rest or None |
63 | + break |
64 | + else: |
65 | + line_parts.append(chunk) |
66 | + return ''.join(line_parts) |
67 | + |
68 | + |
69 | +class BadRequest(Exception): |
70 | + """Bad request.""" |
71 | + |
72 | + |
73 | +def http_method(**control): |
74 | + """Decoration for handling of query arguments and content for a HTTP method. |
75 | + |
76 | + args and content here are the query arguments and body of the incoming |
77 | + HTTP requests. |
78 | + |
79 | + Match query arguments to python method arguments: |
80 | + w = http_method()(f) |
81 | + w(self, args, content) => args["content"]=content; |
82 | + f(self, **args) |
83 | + |
84 | + JSON deserialize content to arguments: |
85 | + w = http_method(content_as_args=True,...)(f) |
86 | + w(self, args, content) => args.update(simplejson.loads(content)); |
87 | + f(self, **args) |
88 | + |
89 | + Support conversions (e.g int): |
90 | + w = http_method(Arg=Conv,...)(f) |
91 | + w(self, args, content) => args["Arg"]=Conv(args["Arg"]); |
92 | + f(self, **args) |
93 | + |
94 | + Enforce no use of query arguments: |
95 | + w = http_method(no_query=True,...)(f) |
96 | + w(self, args, content) raises BadRequest if args is not empty |
97 | + |
98 | + Argument mismatches, deserialisation failures produce BadRequest. |
99 | + """ |
100 | + content_as_args = control.pop('content_as_args', False) |
101 | + no_query = control.pop('no_query', False) |
102 | + conversions = control.items() |
103 | + def wrap(f): |
104 | + argspec = inspect.getargspec(f) |
105 | + assert argspec.args[0] == "self" |
106 | + nargs = len(argspec.args) |
107 | + ndefaults = len(argspec.defaults or ()) |
108 | + required_args = set(argspec.args[1:nargs-ndefaults]) |
109 | + all_args = set(argspec.args) |
110 | + @functools.wraps(f) |
111 | + def wrapper(self, args, content): |
112 | + if no_query and args: |
113 | + raise BadRequest() |
114 | + if content is not None: |
115 | + if content_as_args: |
116 | + try: |
117 | + args.update(simplejson.loads(content)) |
118 | + except ValueError: |
119 | + raise BadRequest() |
120 | + else: |
121 | + args["content"] = content |
122 | + if not (required_args <= set(args) <= all_args): |
123 | + raise BadRequest() |
124 | + for name, conv in conversions: |
125 | + try: |
126 | + args[name] = conv(args[name]) |
127 | + except ValueError: |
128 | + raise BadRequest() |
129 | + return f(self, **args) |
130 | + return wrapper |
131 | + return wrap |
132 | + |
133 | + |
134 | +class DocResource(object): |
135 | + """Document resource.""" |
136 | + |
137 | + def __init__(self, dbname, id, state, responder): |
138 | + self.id = id |
139 | + self.responder = responder |
140 | + self.db = state.open_database(dbname) |
141 | + |
142 | + @http_method() |
143 | + def put(self, content, old_rev=None): |
144 | + doc_rev = self.db.put_doc(self.id, old_rev, content) |
145 | + if old_rev is None: |
146 | + status = 201 # created |
147 | + else: |
148 | + status = 200 |
149 | + self.responder.send_response(status, rev=doc_rev) |
150 | + |
151 | + |
152 | +class SyncResource(object): |
153 | + """Sync endpoint resource.""" |
154 | + |
155 | + def __init__(self, dbname, from_replica_uid, state, responder): |
156 | + self.from_replica_uid = from_replica_uid |
157 | + self.responder = responder |
158 | + self.target = state.open_database(dbname).get_sync_target() |
159 | + |
160 | + @http_method() |
161 | + def get(self): |
162 | + result = self.target.get_sync_info(self.from_replica_uid) |
163 | + self.responder.send_response(this_replica_uid=result[0], |
164 | + this_replica_generation=result[1], |
165 | + other_replica_uid=self.from_replica_uid, |
166 | + other_replica_generation=result[2]) |
167 | + |
168 | + @http_method(generation=int, |
169 | + content_as_args=True, no_query=True) |
170 | + def put(self, generation): |
171 | + self.target.record_sync_info(self.from_replica_uid, generation) |
172 | + self.responder.send_response(ok=True) |
173 | + |
174 | + # Implements the same logic as LocalSyncTarget.sync_exchange |
175 | + |
176 | + @http_method(from_replica_generation=int, last_known_generation=int, |
177 | + content_as_args=True) |
178 | + def post_args(self, last_known_generation, from_replica_generation): |
179 | + self.from_replica_generation = from_replica_generation |
180 | + self.last_known_generation = last_known_generation |
181 | + self.sync_exch = self.target.get_sync_exchange() |
182 | + |
183 | + @http_method(content_as_args=True) |
184 | + def post_stream_entry(self, id, rev, doc): |
185 | + self.sync_exch.insert_doc_from_source(id, rev, doc) |
186 | + |
187 | + def post_end(self): |
188 | + def send_doc(doc_id, doc_rev, doc): |
189 | + entry = dict(id=doc_id, rev=doc_rev, doc=doc) |
190 | + self.responder.stream_entry(entry) |
191 | + new_gen = self.sync_exch.find_docs_to_return(self.last_known_generation) |
192 | + self.responder.content_type = 'application/x-u1db-multi-json' |
193 | + self.responder.start_response(new_generation=new_gen) |
194 | + new_gen = self.sync_exch.return_docs_and_record_sync( |
195 | + self.from_replica_uid, |
196 | + self.from_replica_generation, |
197 | + send_doc) |
198 | + self.responder.finish_response() |
199 | + |
200 | + |
201 | +OK = 200 |
202 | + |
203 | +class HTTPResponder(object): |
204 | + """Encode responses from the server back to the client.""" |
205 | + |
206 | + # a multi document response will put args and documents |
207 | + # each on one line of the response body |
208 | + |
209 | + def __init__(self, start_response): |
210 | + self._started = False |
211 | + self.sent_response = False |
212 | + self._start_response = start_response |
213 | + self._write = None |
214 | + self.content_type = 'application/json' |
215 | + |
216 | + def start_response(self, status=OK, **kwargs): |
217 | + """start sending response: header and args.""" |
218 | + if self._started: |
219 | + return |
220 | + self._started = True |
221 | + status_text = httplib.responses[status] |
222 | + self._write = self._start_response('%d %s' % (status, status_text), |
223 | + [('content-type', self.content_type), |
224 | + ('cache-control', 'no-cache')]) |
225 | + # xxx version in headers |
226 | + if kwargs: |
227 | + self._write(simplejson.dumps(kwargs)+"\r\n") |
228 | + |
229 | + def finish_response(self): |
230 | + """finish sending response.""" |
231 | + self.sent_response = True |
232 | + |
233 | + def send_response(self, status=OK, **kwargs): |
234 | + """send and finish response in one go.""" |
235 | + self.start_response(status, **kwargs) |
236 | + self.finish_response() |
237 | + |
238 | + def stream_entry(self, entry): |
239 | + "send stream entry as part of the response." |
240 | + assert self._started |
241 | + self._write(simplejson.dumps(entry)+"\r\n") |
242 | + |
243 | + |
244 | +class HTTPInvocationByMethodWithBody(object): |
245 | + """Invoke methods on a resource.""" |
246 | + |
247 | + def __init__(self, resource, environ): |
248 | + self.resource = resource |
249 | + self.environ = environ |
250 | + |
251 | + def _lookup(self, method): |
252 | + try: |
253 | + return getattr(self.resource, method) |
254 | + except AttributeError: |
255 | + raise BadRequest() |
256 | + |
257 | + def __call__(self): |
258 | + args = urlparse.parse_qsl(self.environ['QUERY_STRING'], |
259 | + strict_parsing=False) |
260 | + try: |
261 | + args = dict((k.decode('utf-8'), v.decode('utf-8')) for k,v in args) |
262 | + except ValueError: |
263 | + raise BadRequest() |
264 | + method = self.environ['REQUEST_METHOD'].lower() |
265 | + if method in ('get', 'delete'): |
266 | + meth = self._lookup(method) |
267 | + return meth(args, None) |
268 | + else: |
269 | + # we expect content-length > 0, reconsider if we move |
270 | + # to support chunked enconding |
271 | + try: |
272 | + content_length = int(self.environ['CONTENT_LENGTH']) |
273 | + except (ValueError, KeyError), e: |
274 | + raise BadRequest |
275 | + if content_length <= 0: |
276 | + raise BadRequest |
277 | + reader = _FencedReader(self.environ['wsgi.input'], content_length) |
278 | + content_type = self.environ.get('CONTENT_TYPE') |
279 | + if content_type == 'application/json': |
280 | + meth = self._lookup(method) |
281 | + body = reader.read_chunk(sys.maxint) |
282 | + return meth(args, body) |
283 | + elif content_type == 'application/x-u1db-multi-json': |
284 | + meth_args = self._lookup('%s_args' % method) |
285 | + meth_entry = self._lookup('%s_stream_entry' % method) |
286 | + meth_end = self._lookup('%s_end' % method) |
287 | + body_getline = reader.getline |
288 | + meth_args(args, body_getline()) |
289 | + while True: |
290 | + line = body_getline() |
291 | + if not line: |
292 | + break |
293 | + entry = line.strip() |
294 | + meth_entry({}, entry) |
295 | + return meth_end() |
296 | + else: |
297 | + raise BadRequest() |
298 | + |
299 | + |
300 | +class HTTPApp(object): |
301 | + |
302 | + def __init__(self, state): |
303 | + self.state = state |
304 | + |
305 | + def _lookup_resource(self, environ, responder): |
306 | + # xxx proper dispatch logic |
307 | + parts = environ['PATH_INFO'].split('/') |
308 | + if len(parts) == 4 and parts[2] == 'doc': |
309 | + resource = DocResource(parts[1], parts[3], self.state, responder) |
310 | + elif len(parts) == 4 and parts[2] == 'sync-from': |
311 | + resource = SyncResource(parts[1], parts[3], self.state, responder) |
312 | + else: |
313 | + raise BadRequest() |
314 | + return resource |
315 | + |
316 | + def __call__(self, environ, start_response): |
317 | + responder = HTTPResponder(start_response) |
318 | + try: |
319 | + resource = self._lookup_resource(environ, responder) |
320 | + HTTPInvocationByMethodWithBody(resource, environ)() |
321 | + except BadRequest: |
322 | + responder.send_response(400) |
323 | + return [] |
324 | |
325 | === added file 'u1db/tests/test_http_app.py' |
326 | --- u1db/tests/test_http_app.py 1970-01-01 00:00:00 +0000 |
327 | +++ u1db/tests/test_http_app.py 2011-11-16 15:30:08 +0000 |
328 | @@ -0,0 +1,466 @@ |
329 | +# Copyright 2011 Canonical Ltd. |
330 | +# |
331 | +# This program is free software: you can redistribute it and/or modify it |
332 | +# under the terms of the GNU General Public License version 3, as published |
333 | +# by the Free Software Foundation. |
334 | +# |
335 | +# This program is distributed in the hope that it will be useful, but |
336 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
337 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
338 | +# PURPOSE. See the GNU General Public License for more details. |
339 | +# |
340 | +# You should have received a copy of the GNU General Public License along |
341 | +# with this program. If not, see <http://www.gnu.org/licenses/>. |
342 | + |
343 | +"""Test the WSGI app.""" |
344 | + |
345 | +import paste.fixture |
346 | +import simplejson |
347 | +import StringIO |
348 | + |
349 | +from u1db import ( |
350 | + tests, |
351 | + ) |
352 | + |
353 | +from u1db.remote import ( |
354 | + http_app, |
355 | + ) |
356 | + |
357 | + |
358 | +class TestFencedReader(tests.TestCase): |
359 | + |
360 | + def test_init(self): |
361 | + reader = http_app._FencedReader(StringIO.StringIO(""), 25) |
362 | + self.assertEqual(25, reader.remaining) |
363 | + |
364 | + def test_read_chunk(self): |
365 | + inp = StringIO.StringIO("abcdef") |
366 | + reader = http_app._FencedReader(inp, 5) |
367 | + data = reader.read_chunk(2) |
368 | + self.assertEqual("ab", data) |
369 | + self.assertEqual(2, inp.tell()) |
370 | + self.assertEqual(3, reader.remaining) |
371 | + |
372 | + def test_read_chunk_remaining(self): |
373 | + inp = StringIO.StringIO("abcdef") |
374 | + reader = http_app._FencedReader(inp, 4) |
375 | + data = reader.read_chunk(9999) |
376 | + self.assertEqual("abcd", data) |
377 | + self.assertEqual(4, inp.tell()) |
378 | + self.assertEqual(0, reader.remaining) |
379 | + |
380 | + def test_read_chunk_nothing_left(self): |
381 | + inp = StringIO.StringIO("abc") |
382 | + reader = http_app._FencedReader(inp, 2) |
383 | + reader.read_chunk(2) |
384 | + self.assertEqual(2, inp.tell()) |
385 | + self.assertEqual(0, reader.remaining) |
386 | + data = reader.read_chunk(2) |
387 | + self.assertEqual("", data) |
388 | + self.assertEqual(2, inp.tell()) |
389 | + self.assertEqual(0, reader.remaining) |
390 | + |
391 | + def test_read_chunk_kept(self): |
392 | + inp = StringIO.StringIO("abcde") |
393 | + reader = http_app._FencedReader(inp, 4) |
394 | + reader._kept = "xyz" |
395 | + data = reader.read_chunk(2) # atmost ignored |
396 | + self.assertEqual("xyz", data) |
397 | + self.assertEqual(0, inp.tell()) |
398 | + self.assertEqual(4, reader.remaining) |
399 | + |
400 | + def test_getline(self): |
401 | + inp = StringIO.StringIO("abc\r\nde") |
402 | + reader = http_app._FencedReader(inp, 6) |
403 | + reader.MAXCHUNK = 6 |
404 | + line = reader.getline() |
405 | + self.assertEqual("abc\r\n", line) |
406 | + self.assertEqual("d", reader._kept) |
407 | + |
408 | + def test_getline_exact(self): |
409 | + inp = StringIO.StringIO("abcd\r\nef") |
410 | + reader = http_app._FencedReader(inp, 6) |
411 | + reader.MAXCHUNK = 6 |
412 | + line = reader.getline() |
413 | + self.assertEqual("abcd\r\n", line) |
414 | + self.assertIs(None, reader._kept) |
415 | + |
416 | + def test_getline_no_newline(self): |
417 | + inp = StringIO.StringIO("abcd") |
418 | + reader = http_app._FencedReader(inp, 4) |
419 | + reader.MAXCHUNK = 6 |
420 | + line = reader.getline() |
421 | + self.assertEqual("abcd", line) |
422 | + |
423 | + def test_getline_many_chunks(self): |
424 | + inp = StringIO.StringIO("abcde\r\nf") |
425 | + reader = http_app._FencedReader(inp, 8) |
426 | + reader.MAXCHUNK = 4 |
427 | + line = reader.getline() |
428 | + self.assertEqual("abcde\r\n", line) |
429 | + self.assertEqual("f", reader._kept) |
430 | + |
431 | + def test_getline_empty(self): |
432 | + inp = StringIO.StringIO("") |
433 | + reader = http_app._FencedReader(inp, 0) |
434 | + reader.MAXCHUNK = 4 |
435 | + line = reader.getline() |
436 | + self.assertEqual("", line) |
437 | + line = reader.getline() |
438 | + self.assertEqual("", line) |
439 | + |
440 | + def test_getline_just_newline(self): |
441 | + inp = StringIO.StringIO("\r\n") |
442 | + reader = http_app._FencedReader(inp, 2) |
443 | + reader.MAXCHUNK = 4 |
444 | + line = reader.getline() |
445 | + self.assertEqual("\r\n", line) |
446 | + line = reader.getline() |
447 | + self.assertEqual("", line) |
448 | + |
449 | + |
450 | +class TestHTTPMethodDecorator(tests.TestCase): |
451 | + |
452 | + def test_args(self): |
453 | + @http_app.http_method() |
454 | + def f(self, a, b): |
455 | + return self, a, b |
456 | + res = f("self", {"a": "x", "b": "y"}, None) |
457 | + self.assertEqual(("self", "x", "y"), res) |
458 | + |
459 | + def test_args_missing(self): |
460 | + @http_app.http_method() |
461 | + def f(self, a, b): |
462 | + return a, b |
463 | + self.assertRaises(http_app.BadRequest, f, "self", {"a": "x"}, None) |
464 | + |
465 | + def test_args_unexpected(self): |
466 | + @http_app.http_method() |
467 | + def f(self, a): |
468 | + return a |
469 | + self.assertRaises(http_app.BadRequest, f, "self", |
470 | + {"a": "x", "c": "z"}, None) |
471 | + |
472 | + def test_args_default(self): |
473 | + @http_app.http_method() |
474 | + def f(self, a, b="z"): |
475 | + return a, b |
476 | + res = f("self", {"a": "x"}, None) |
477 | + self.assertEqual(("x", "z"), res) |
478 | + |
479 | + def test_args_conversion(self): |
480 | + @http_app.http_method(b=int) |
481 | + def f(self, a, b): |
482 | + return self, a, b |
483 | + res = f("self", {"a": "x", "b": "2"}, None) |
484 | + self.assertEqual(("self", "x", 2), res) |
485 | + |
486 | + self.assertRaises(http_app.BadRequest, f, "self", |
487 | + {"a": "x", "b": "foo"}, None) |
488 | + |
489 | + def test_args_content(self): |
490 | + @http_app.http_method() |
491 | + def f(self, a, content): |
492 | + return a, content |
493 | + res = f(self, {"a": "x"}, "CONTENT") |
494 | + self.assertEqual(("x", "CONTENT"), res) |
495 | + |
496 | + def test_args_content_as_args(self): |
497 | + @http_app.http_method(b=int, content_as_args=True) |
498 | + def f(self, a, b): |
499 | + return self, a, b |
500 | + res = f("self", {"a": "x"}, '{"b": "2"}') |
501 | + self.assertEqual(("self", "x", 2), res) |
502 | + |
503 | + self.assertRaises(http_app.BadRequest, f, "self", {}, 'not-json') |
504 | + |
505 | + def test_args_content_no_query(self): |
506 | + @http_app.http_method(no_query=True, |
507 | + content_as_args=True) |
508 | + def f(self, a='a', b='b'): |
509 | + return a, b |
510 | + res = f("self", {}, '{"b": "y"}') |
511 | + self.assertEqual(('a', 'y'), res) |
512 | + |
513 | + self.assertRaises(http_app.BadRequest, f, "self", {'a': 'x'}, |
514 | + '{"b": "y"}') |
515 | + |
516 | +class TestResource(object): |
517 | + |
518 | + @http_app.http_method() |
519 | + def get(self, a, b): |
520 | + self.args = dict(a=a, b=b) |
521 | + return 'Get' |
522 | + |
523 | + @http_app.http_method() |
524 | + def put(self, a, content): |
525 | + self.args = dict(a=a) |
526 | + self.content = content |
527 | + return 'Put' |
528 | + |
529 | + @http_app.http_method(content_as_args=True) |
530 | + def put_args(self, a, b): |
531 | + self.args = dict(a=a, b=b) |
532 | + self.order = ['a'] |
533 | + self.entries = [] |
534 | + |
535 | + @http_app.http_method() |
536 | + def put_stream_entry(self, content): |
537 | + self.entries.append(content) |
538 | + self.order.append('s') |
539 | + |
540 | + def put_end(self): |
541 | + self.order.append('e') |
542 | + return "Put/end" |
543 | + |
544 | +class TestHTTPInvocationByMethodWithBody(tests.TestCase): |
545 | + |
546 | + def test_get(self): |
547 | + resource = TestResource() |
548 | + environ = {'QUERY_STRING': 'a=1&b=2', 'REQUEST_METHOD': 'GET'} |
549 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
550 | + res = invoke() |
551 | + self.assertEqual('Get', res) |
552 | + self.assertEqual({'a': '1', 'b': '2'}, resource.args) |
553 | + |
554 | + def test_put_json(self): |
555 | + resource = TestResource() |
556 | + body = '{"body": true}' |
557 | + environ = {'QUERY_STRING': 'a=1', 'REQUEST_METHOD': 'PUT', |
558 | + 'wsgi.input': StringIO.StringIO(body), |
559 | + 'CONTENT_LENGTH': str(len(body)), |
560 | + 'CONTENT_TYPE': 'application/json'} |
561 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
562 | + res = invoke() |
563 | + self.assertEqual('Put', res) |
564 | + self.assertEqual({'a': '1'}, resource.args) |
565 | + self.assertEqual('{"body": true}', resource.content) |
566 | + |
567 | + def test_put_multi_json(self): |
568 | + resource = TestResource() |
569 | + body = ( |
570 | + '{"b": 2}\r\n' # args |
571 | + '{"entry": "x"}\r\n' # stream entry |
572 | + '{"entry": "y"}\r\n' # stream entry |
573 | + ) |
574 | + environ = {'QUERY_STRING': 'a=1', 'REQUEST_METHOD': 'PUT', |
575 | + 'wsgi.input': StringIO.StringIO(body), |
576 | + 'CONTENT_LENGTH': str(len(body)), |
577 | + 'CONTENT_TYPE': 'application/x-u1db-multi-json'} |
578 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
579 | + res = invoke() |
580 | + self.assertEqual('Put/end', res) |
581 | + self.assertEqual({'a': '1', 'b': 2}, resource.args) |
582 | + self.assertEqual(['{"entry": "x"}', '{"entry": "y"}'], resource.entries) |
583 | + self.assertEqual(['a', 's', 's', 'e'], resource.order) |
584 | + |
585 | + def test_bad_request_decode_failure(self): |
586 | + resource = TestResource() |
587 | + environ = {'QUERY_STRING': 'a=\xff', 'REQUEST_METHOD': 'PUT', |
588 | + 'wsgi.input': StringIO.StringIO('{}'), |
589 | + 'CONTENT_LENGTH': '2', |
590 | + 'CONTENT_TYPE': 'application/json'} |
591 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
592 | + self.assertRaises(http_app.BadRequest, invoke) |
593 | + |
594 | + def test_bad_request_unsupported_content_type(self): |
595 | + resource = TestResource() |
596 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', |
597 | + 'wsgi.input': StringIO.StringIO('{}'), |
598 | + 'CONTENT_LENGTH': '2', |
599 | + 'CONTENT_TYPE': 'text/plain'} |
600 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
601 | + self.assertRaises(http_app.BadRequest, invoke) |
602 | + |
603 | + def test_bad_request_no_content_length(self): |
604 | + resource = TestResource() |
605 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', |
606 | + 'wsgi.input': StringIO.StringIO('a'), |
607 | + 'CONTENT_TYPE': 'application/json'} |
608 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
609 | + self.assertRaises(http_app.BadRequest, invoke) |
610 | + |
611 | + def test_bad_request_invalid_content_length(self): |
612 | + resource = TestResource() |
613 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', |
614 | + 'wsgi.input': StringIO.StringIO('abc'), |
615 | + 'CONTENT_LENGTH': '1unk', |
616 | + 'CONTENT_TYPE': 'application/json'} |
617 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
618 | + self.assertRaises(http_app.BadRequest, invoke) |
619 | + |
620 | + def test_bad_request_empty_body(self): |
621 | + resource = TestResource() |
622 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', |
623 | + 'wsgi.input': StringIO.StringIO(''), |
624 | + 'CONTENT_LENGTH': '0', |
625 | + 'CONTENT_TYPE': 'application/json'} |
626 | + invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ) |
627 | + self.assertRaises(http_app.BadRequest, invoke) |
628 | + |
629 | + def test_bad_request_unsupported_method_get_like(self): |
630 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'DELETE'} |
631 | + invoke = http_app.HTTPInvocationByMethodWithBody(None, environ) |
632 | + self.assertRaises(http_app.BadRequest, invoke) |
633 | + |
634 | + def test_bad_request_unsupported_method_put_like(self): |
635 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', |
636 | + 'wsgi.input': StringIO.StringIO('{}'), |
637 | + 'CONTENT_LENGTH': '2', |
638 | + 'CONTENT_TYPE': 'application/json'} |
639 | + invoke = http_app.HTTPInvocationByMethodWithBody(None, environ) |
640 | + self.assertRaises(http_app.BadRequest, invoke) |
641 | + |
642 | + def test_bad_request_unsupported_method_put_like_multi_json(self): |
643 | + body = '{}\r\n{}\r\n' |
644 | + environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'POST', |
645 | + 'wsgi.input': StringIO.StringIO(body), |
646 | + 'CONTENT_LENGTH': str(len(body)), |
647 | + 'CONTENT_TYPE': 'application/x-u1db-multi-json'} |
648 | + invoke = http_app.HTTPInvocationByMethodWithBody(None, environ) |
649 | + self.assertRaises(http_app.BadRequest, invoke) |
650 | + |
651 | + |
652 | +class TestHTTPResponder(tests.TestCase): |
653 | + |
654 | + def start_response(self, status, headers): |
655 | + self.status = status |
656 | + self.headers = dict(headers) |
657 | + self.response_body = [] |
658 | + def write(data): |
659 | + self.response_body.append(data) |
660 | + return write |
661 | + |
662 | + def test_send_response(self): |
663 | + responder = http_app.HTTPResponder(self.start_response) |
664 | + responder.send_response(value='success') |
665 | + self.assertEqual('200 OK', self.status) |
666 | + self.assertEqual({'content-type': 'application/json', |
667 | + 'cache-control': 'no-cache'}, self.headers) |
668 | + self.assertEqual(['{"value": "success"}\r\n'], self.response_body) |
669 | + |
670 | + def test_send_response_status_fail(self): |
671 | + responder = http_app.HTTPResponder(self.start_response) |
672 | + responder.send_response(400) |
673 | + self.assertEqual('400 Bad Request', self.status) |
674 | + self.assertEqual({'content-type': 'application/json', |
675 | + 'cache-control': 'no-cache'}, self.headers) |
676 | + self.assertEqual([], self.response_body) |
677 | + |
678 | + def test_start_finish_response_status_fail(self): |
679 | + responder = http_app.HTTPResponder(self.start_response) |
680 | + responder.start_response(404, error='not found') |
681 | + responder.finish_response() |
682 | + self.assertEqual('404 Not Found', self.status) |
683 | + self.assertEqual({'content-type': 'application/json', |
684 | + 'cache-control': 'no-cache'}, self.headers) |
685 | + self.assertEqual(['{"error": "not found"}\r\n'], self.response_body) |
686 | + |
687 | + def test_send_stream_entry(self): |
688 | + responder = http_app.HTTPResponder(self.start_response) |
689 | + responder.content_type = "application/x-u1db-multi-json" |
690 | + responder.start_response(one=1) |
691 | + responder.stream_entry({'entry': True}) |
692 | + responder.finish_response() |
693 | + self.assertEqual('200 OK', self.status) |
694 | + self.assertEqual({'content-type': 'application/x-u1db-multi-json', |
695 | + 'cache-control': 'no-cache'}, self.headers) |
696 | + self.assertEqual(['{"one": 1}\r\n', |
697 | + '{"entry": true}\r\n'], self.response_body) |
698 | + |
699 | + |
700 | +class TestHTTPApp(tests.TestCase): |
701 | + |
702 | + def setUp(self): |
703 | + super(TestHTTPApp, self).setUp() |
704 | + self.state = tests.ServerStateForTests() |
705 | + application = http_app.HTTPApp(self.state) |
706 | + self.app = paste.fixture.TestApp(application) |
707 | + self.db0 = self.state._create_database('db0') |
708 | + |
709 | + def test_bad_request_broken(self): |
710 | + resp = self.app.put('/db0/doc/doc1', params='{"x": 1}', |
711 | + headers={'content-type': 'application/foo'}, |
712 | + expect_errors=True) |
713 | + self.assertEqual(400, resp.status) |
714 | + |
715 | + def test_bad_request_dispatch(self): |
716 | + resp = self.app.put('/db0/foo/doc1', params='{"x": 1}', |
717 | + headers={'content-type': 'application/json'}, |
718 | + expect_errors=True) |
719 | + self.assertEqual(400, resp.status) |
720 | + |
721 | + def test_put_doc_create(self): |
722 | + resp = self.app.put('/db0/doc/doc1', params='{"x": 1}', |
723 | + headers={'content-type': 'application/json'}) |
724 | + doc_rev, doc, _ = self.db0.get_doc('doc1') |
725 | + self.assertEqual(201, resp.status) # created |
726 | + self.assertEqual('{"x": 1}', doc) |
727 | + self.assertEqual('application/json', resp.header('content-type')) |
728 | + self.assertEqual({'rev': doc_rev}, simplejson.loads(resp.body)) |
729 | + |
730 | + def test_put_doc(self): |
731 | + doc_id, orig_rev = self.db0.create_doc('doc1', '{"x": 1}') |
732 | + resp = self.app.put('/db0/doc/doc1?old_rev=%s' % orig_rev, |
733 | + params='{"x": 2}', |
734 | + headers={'content-type': 'application/json'}) |
735 | + doc_rev, doc, _ = self.db0.get_doc('doc1') |
736 | + self.assertEqual(200, resp.status) |
737 | + self.assertEqual('{"x": 2}', doc) |
738 | + self.assertEqual('application/json', resp.header('content-type')) |
739 | + self.assertEqual({'rev': doc_rev}, simplejson.loads(resp.body)) |
740 | + |
741 | + def test_get_sync_info(self): |
742 | + self.db0.set_sync_generation('other-id', 1) |
743 | + resp = self.app.get('/db0/sync-from/other-id') |
744 | + self.assertEqual(200, resp.status) |
745 | + self.assertEqual('application/json', resp.header('content-type')) |
746 | + self.assertEqual(dict(this_replica_uid='db0', |
747 | + this_replica_generation=0, |
748 | + other_replica_uid='other-id', |
749 | + other_replica_generation=1), |
750 | + simplejson.loads(resp.body)) |
751 | + |
752 | + def test_record_sync_info(self): |
753 | + resp = self.app.put('/db0/sync-from/other-id', |
754 | + params='{"generation": 2}', |
755 | + headers={'content-type': 'application/json'}) |
756 | + self.assertEqual(200, resp.status) |
757 | + self.assertEqual('application/json', resp.header('content-type')) |
758 | + self.assertEqual({'ok': True}, simplejson.loads(resp.body)) |
759 | + self.assertEqual(self.db0.get_sync_generation('other-id'), 2) |
760 | + |
761 | + def test_sync_exchange_send(self): |
762 | + entry = {'id': 'doc-here', 'rev': 'replica:1', 'doc': |
763 | + '{"value": "here"}'} |
764 | + args = dict(from_replica_generation=10, last_known_generation=0) |
765 | + body = ("%s\r\n" % simplejson.dumps(args) + |
766 | + "%s\r\n" % simplejson.dumps(entry)) |
767 | + resp = self.app.post('/db0/sync-from/replica', |
768 | + params=body, |
769 | + headers={'content-type': |
770 | + 'application/x-u1db-multi-json'}) |
771 | + self.assertEqual(200, resp.status) |
772 | + self.assertEqual('application/x-u1db-multi-json', |
773 | + resp.header('content-type')) |
774 | + self.assertEqual({'new_generation': 1}, simplejson.loads(resp.body)) |
775 | + self.assertEqual(('replica:1', '{"value": "here"}', False), |
776 | + self.db0.get_doc('doc-here')) |
777 | + |
778 | + def test_sync_exchange_receive(self): |
779 | + doc_id, doc_rev = self.db0.create_doc('{"value": "there"}') |
780 | + args = dict(from_replica_generation=10, last_known_generation=0) |
781 | + body = "%s\r\n" % simplejson.dumps(args) |
782 | + resp = self.app.post('/db0/sync-from/replica', |
783 | + params=body, |
784 | + headers={'content-type': |
785 | + 'application/x-u1db-multi-json'}) |
786 | + self.assertEqual(200, resp.status) |
787 | + self.assertEqual('application/x-u1db-multi-json', |
788 | + resp.header('content-type')) |
789 | + parts = resp.body.splitlines() |
790 | + self.assertEqual(2, len(parts)) |
791 | + self.assertEqual({'new_generation': 1}, simplejson.loads(parts[0])) |
792 | + self.assertEqual({'doc': '{"value": "there"}', |
793 | + 'rev': doc_rev, 'id': doc_id}, |
794 | + simplejson.loads(parts[1])) |
Does it matter that we don't check the number of bytes in 'self.kept' during read_chunk?
http_method seems a bit too magical, and I'm having some trouble understanding what it is trying to do just from its docstring.
I think you're trying to convert a call that passes args and content dict into actually calling arguments of a function.
I wonder if it would be less magical if you didn't have it also have options itself. like 'content_as_args'. Would it be better to just have "http_method" and "http_content_ method" or something like that.
I like that the decorator makes writing the other functions cleaner, it is just hard to understand the decorator itself.
Maybe a separate type conversion decorator? So you would have
@arg_conversion (b=int)
@http_method()
def f(self, a, b):
...
It may not be worthwhile, I'm just brainstorming since I still find http_method a bit hard to follow. It does seem to have good test coverage, though.
test_args_no_query seems like the exception could be raised because f() doesn't take 'a' as a parameter, rather than because we explicitly requested no_query=True. Maybe use a default argument a=N, and assert that we still pass when we think we should, and fail appropriately?
content_ unserialized_ as_args I would probably rename to just content_as_args. At least, I found the 'unserialized' bit more confusing than helpful. I'm certainly open to discussion on it, though.
If content-length isn't sent, it seems to set the total length to 0 for FencedReader, which looks like the read() will get called and always return the empty string. Is that intentional? Would it be cleaner to just fail as soon as we notice it is not set?
I wonder what we'll want the routing logic to look like. Since I think we'll want ~user, and maybe even nesting in the api. (path/to/database, vs only supporting top-level databases). Maybe not, though. Short term this certainly works fine.
class TestFencedReade r(testtools. TestCase) :
I usually use
from u1db import tests
class TestFencedReade r(tests. TestCase) :
that way we can change our base TestCase class, without updating all the tests themselves. (I've already used that once to switch from unittest.TestCase to testtools. TestCase. )
We want to be using "simplejson" and not just "json". At least from my benchmarks:
$ TIMEIT "[json.dumps(o) for o in objs]"
10 loops, best of 3: 5.33 sec per loop
vs
$ TIMEIT -s "import json" "[simplejson. dumps(o) for o in objs]"
10 loops, best of 3: 1.15 sec per loop
It appears that the stdlib's "json" module is pure-python, and about 5x slower than simplejson's C version.
If we want to make it a soft dependency we can do something like:
try:
import simplejson as json
except ImportError:
import json
But the performance impact is pretty significant.