Merge lp:~pedronis/u1db/http-app into lp:u1db

Proposed by Samuele Pedroni
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
Reviewer Review Type Date Requested Status
John A Meinel (community) Approve
Review via email: mp+82290@code.launchpad.net

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-sync-testing.

To post a comment you must log in.
Revision history for this message
John A Meinel (jameinel) wrote :

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 TestFencedReader(testtools.TestCase):

I usually use

from u1db import tests

class TestFencedReader(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.

review: Needs Fixing
lp:~pedronis/u1db/http-app updated
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

Revision history for this message
Samuele Pedroni (pedronis) wrote :
Download full text (3.5 KiB)

> 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_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):
> ...

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_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.

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 TestFencedReader(testtoo...

Read more...

Revision history for this message
John A Meinel (jameinel) wrote :

Looks giod to me.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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]))

Subscribers

People subscribed via source and target branches