/usr/share/pyshared/lazr/restful/example/base/tests/entry.txt is in python-lazr.restful 0.19.3-0ubuntu2.
This file is owned by root:root, with mode 0o644.
The actual contents of the file can be viewed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 | Entries
*******
Most objects published by a lazr.restful web service are entries:
self-contained data structures with an independent existence from any
other entry. Entries are distinguished from collections, which are
groupings of entries.
All entries in a web service work pretty much the same way. This
document illustrates the general features of entries, using the
example web service's dishes and recipes as examples.
>>> from lazr.restful.testing.webservice import WebServiceCaller
>>> webservice = WebServiceCaller(domain='cookbooks.dev')
=======
Reading
=======
It's possible to get a JSON 'representation' of an entry by sending a
GET request to the entry's URL.
Here we see that the cookbook 'Everyday Greens' is a vegetarian cookbook.
>>> from urllib import quote
>>> greens_url = quote("/cookbooks/Everyday Greens")
>>> webservice.get(greens_url).jsonBody()['cuisine']
u'Vegetarian'
Data is served encoded in UTF-8, and a good client will automatically
convert it into Unicode.
>>> construsions_url = quote("/cookbooks/Construsions un repas")
>>> webservice.get(construsions_url).jsonBody()['cuisine']
u'Fran\xe7aise'
Content negotiation
===================
By varying the 'Accept' header, the client can request either a JSON
or XHTML representation of an entry, or a WADL description of the
entry's capabilities.
>>> def negotiated_type(accept_header,
... uri='/cookbooks/Everyday%20Greens'):
... return webservice.get(
... uri, accept_header).getheader('Content-Type')
>>> negotiated_type('application/json')
'application/json'
>>> negotiated_type('application/xhtml+xml')
'application/xhtml+xml'
>>> negotiated_type('application/vnd.sun.wadl+xml')
'application/vnd.sun.wadl+xml'
>>> negotiated_type('')
'application/json'
>>> negotiated_type('text/html')
'application/json'
>>> negotiated_type('application/json, application/vnd.sun.wadl+xml')
'application/json'
>>> negotiated_type('application/json, application/xhtml+xml')
'application/json'
>>> negotiated_type('application/vnd.sun.wadl+xml, text/html, '
... 'application/json')
'application/vnd.sun.wadl+xml'
>>> negotiated_type('application/json;q=0.5, application/vnd.sun.wadl+xml')
'application/vnd.sun.wadl+xml'
>>> negotiated_type('application/json;q=0, application/xhtml+xml;q=0.05,'
... 'application/vd.sun.wadl+xml;q=0.1')
'application/vd.sun.wadl+xml'
The client can also set the 'ws.accept' query string variable, which
will take precedence over any value set for the Accept header.
>>> def qs_negotiated_type(query_string, header):
... uri = '/cookbooks/Everyday%20Greens?ws.accept=' + query_string
... return negotiated_type(header, uri)
>>> qs_negotiated_type('application/json', '')
'application/json'
>>> qs_negotiated_type('application/json', 'application/xhtml+xml')
'application/json'
>>> negotiated_type('application/json;q=0, application/xhtml+xml;q=0.5,'
... 'application/json;q=0.5, application/xhtml+xml;q=0,')
'application/xhtml+xml'
Earlier versions of lazr.restful served a misspelling of the WADL
media type. For purposes of backwards compatibility, lazr.restful
will still serve this media type if it's requested.
>>> negotiated_type('application/vd.sun.wadl+xml')
'application/vd.sun.wadl+xml'
XHTML representations
=====================
Every entry has an XHTML representation. The default representation is
a simple definition list.
>>> print webservice.get(greens_url, 'application/xhtml+xml')
HTTP/1.1 200 Ok
...
<dl ...>
...
</dl>
Getting the XHTML representation works correctly even when some of the fields
have non-ascii values.
>>> print webservice.get(construsions_url, 'application/xhtml+xml')
HTTP/1.1 200 Ok
...
<dl ...>
...
<BLANKLINE>
<dt>cuisine</dt>
<dd>Française</dd>
<BLANKLINE>
...
</dl>
But it's possible to define a custom HTML view for a particular object
type. Here's a simple view that serves some hard-coded HTML.
>>> class DummyView:
...
... def __init__(*args):
... pass
...
... def __call__(*args):
... return "<html>foo</html>"
Register the view as the IWebServiceClientRequest view for an
ICookbook entry...
>>> from lazr.restful.interfaces import IWebServiceClientRequest
>>> from lazr.restful.example.base.interfaces import ICookbook
>>> from zope.interface.interfaces import IInterface
>>> view_name = "lazr.restful.EntryResource"
>>> from zope.component import getGlobalSiteManager
>>> manager = getGlobalSiteManager()
>>> manager.registerAdapter(
... factory=DummyView,
... required=[ICookbook, IWebServiceClientRequest],
... provided=IInterface, name=view_name)
...and the XHTML representation of an ICookbook will be the result of
calling a DummyView object.
>>> print webservice.get(greens_url, 'application/xhtml+xml')
HTTP/1.1 200 Ok
...
<html>foo</html>
Before we continue, here's some cleanup code to remove the custom view
we just defined.
>>> from zope.component import getGlobalSiteManager
>>> ignored = getGlobalSiteManager().unregisterAdapter(
... factory=DummyView,
... required=[ICookbook, IWebServiceClientRequest],
... provided=IInterface, name=view_name)
>>> print webservice.get(greens_url, 'application/xhtml+xml')
HTTP/1.1 200 Ok
...
<dl ...>
...
</dl>
Visibility
==========
There are two recipes in "James Beard's American Cookery", but one of
them has been marked private. The private one cannot be retrieved.
>>> print webservice.get('/recipes/3')
HTTP/1.1 200 Ok
...
>>> print webservice.get('/recipes/5')
HTTP/1.1 401 Unauthorized
...
If a resource itself is visible to the client, but it contains
information that's not visible, the information will be
redacted. Here, a cookbook resource is visible to our client, but its
value for the 'confirmed' field is not visible.
>>> cookbook = webservice.get(greens_url).jsonBody()
>>> print cookbook['name']
Everyday Greens
>>> print cookbook['confirmed']
tag:launchpad.net:2008:redacted
Named operations
================
Some entries support custom operations through GET. The custom
operation to be invoked is named in the query string's 'ws.op'
argument. You can search a cookbook's recipes by specifying
the 'find_recipes' operation.
>>> joy_url = quote("/cookbooks/The Joy of Cooking")
>>> recipes = webservice.get(
... "%s?ws.op=find_recipes&search=e" % joy_url).jsonBody()
>>> sorted([r['self_link'] for r in recipes['entries']])
[u'.../recipes/2', u'.../recipes/4']
A named operation can take as an argument the URL to another
object. Here the 'dish' argument is the URL to a dish, and the named
operation finds a recipe for making that dish.
>>> def find_recipe_in_joy(dish_url):
... """Look up a dish in 'The Joy of Cooking'."""
... return webservice.get("%s?ws.op=find_recipe_for&dish=%s" %
... (joy_url, quote(dish_url))).jsonBody()
>>> dish_url = webservice.get("/recipes/2").jsonBody()['dish_link']
>>> find_recipe_in_joy(dish_url)['instructions']
u'Draw, singe, stuff, and truss...'
The URL passed in to a named operation may be an absolute URL, or it
may be relative to the versioned service root. This is for developer
convenience only, as lazr.restful never serves relative URLs.
>>> print dish_url
http://cookbooks.dev/devel/dishes/Roast%20chicken
>>> relative_url = quote("/dishes/Roast chicken")
>>> find_recipe_in_joy(relative_url)['instructions']
u'Draw, singe, stuff, and truss...'
A URL relative to the unversioned service root will not work.
>>> relative_url = quote("/devel/dishes/Roast chicken")
>>> find_recipe_in_joy(relative_url)
Traceback (most recent call last):
...
ValueError: dish: No such object "/devel/dishes/Roast%20chicken".
Some entries support custom operations through POST. You can invoke a
custom operation to modify a cookbook's name, making it seem more
interesting.
>>> print webservice.get(joy_url).jsonBody()['cuisine']
General
>>> print webservice.named_post(joy_url, 'make_more_interesting', {})
HTTP/1.1 200 Ok
...
>>> new_joy_url = quote("/cookbooks/The New The Joy of Cooking")
>>> print webservice.get(new_joy_url).jsonBody()['name']
The New The Joy of Cooking
Custom operations may have error handling.
>>> print webservice.named_post(new_joy_url, 'make_more_interesting', {})
HTTP/1.1 400 Bad Request
...
The 'New' trick can't be used on this cookbook because its
name already starts with 'The New'.
>>> import simplejson
>>> ignore = webservice.patch(
... new_joy_url, 'application/json',
... simplejson.dumps({"name": "The Joy of Cooking"}))
Trying to invoke a nonexistent custom operation yields an error.
>>> print webservice.get("%s?ws.op=no_such_operation" % joy_url)
HTTP/1.1 400 Bad Request
...
No such operation: no_such_operation
============
Modification
============
It's possible to modify an entry by sending to the server a document
asserting what the entry should look like. The document may only
describe part of the entry's new state, in which case the client
should use the PATCH HTTP method. Or it may completely describe the
entry's state, in which case the client should use PUT.
>>> def modify_entry(url, representation, method, headers=None):
... "A helper function to PUT or PATCH an entry."
... new_headers = {'Content-type': 'application/json'}
... if headers is not None:
... new_headers.update(headers)
... return webservice(
... url, method, simplejson.dumps(representation), headers)
>>> def modify_cookbook(cookbook, representation, method, headers=None):
... "A helper function to PUT or PATCH a cookbook."
... return modify_entry(
... '/cookbooks/' + quote(cookbook), representation,
... method, headers)
Here we use the web service to change the cuisine of the "Everyday
Greens" cookbook. The data returned is the new JSON representation of
the object.
>>> print webservice.get(greens_url).jsonBody()['revision_number']
0
>>> result = modify_cookbook('Everyday Greens', {'cuisine' : 'American'},
... 'PATCH')
>>> print result
HTTP/1.1 209 Content Returned
...
Content-Type: application/json
...
{...}
>>> greens = result.jsonBody()
>>> print greens['cuisine']
American
Whenever a client modifies a cookbook, the revision_number is
incremented behind the scenes.
>>> print greens['revision_number']
1
A modification may cause one of en entry's links to point to another
object. Here, we change the 'dish_link' field of a roast chicken
recipe, turning it into a recipe for baked beans.
>>> old_dish = webservice.get("/recipes/1").jsonBody()['dish_link']
>>> print old_dish
http://.../dishes/Roast%20chicken
>>> new_dish = webservice.get("/recipes/4").jsonBody()['dish_link']
>>> print new_dish
http://.../dishes/Baked%20beans
>>> new_entry = modify_entry(
... "/recipes/2", {'dish_link' : new_dish}, 'PATCH').jsonBody()
>>> print new_entry['dish_link']
http://.../dishes/Baked%20beans
When changing one of an entry's links, you can use an absolute URL (as
seen above) or a URL relative to the versioned service root. Let's use
a relative URL to change the baked beans recipe back to a roast
chicken recipe.
>>> relative_old_dish = quote('/dishes/Roast chicken')
>>> new_entry = modify_entry(
... "/recipes/2", {'dish_link' : relative_old_dish},
... 'PATCH').jsonBody()
>>> print new_entry['dish_link']
http://.../dishes/Roast%20chicken
A modification might cause an entry's address to change. Here we use
the web service to change the cookbook's name to 'Everyday Greens 2'.
>>> print modify_cookbook('Everyday Greens',
... {'name' : 'Everyday Greens 2'}, 'PATCH')
HTTP/1.1 301 Moved Permanently
...
Location: http://.../Everyday%20Greens%202
...
At this point we can no longer manipulate this cookbook by sending
HTTP requests to http://cookbooks.dev/1.0/cookbooks/Everyday%20Greens,
because that cookbook now 'lives' at
http://cookbooks.dev/1.0/cookbooks/Everyday%20Greens%202. To change
the cookbook name back, we need to send a PATCH request to the new
address.
>>> print modify_cookbook('Everyday Greens 2',
... {'name' : 'Everyday Greens'}, 'PATCH')
HTTP/1.1 301 Moved Permanently
...
Location: http://.../cookbooks/Everyday%20Greens
...
The PATCH HTTP method is useful for simple changes, but not all HTTP
clients support PATCH. It's possible to fake a PATCH request with
POST, by setting the X-HTTP-Method-Override header to "PATCH". Because
Firefox 3 mangles the Content-Type header for POST requests, you may
also set the X-Content-Type-Override header, which will override the
value of Content-Type.
>>> print modify_cookbook('Everyday Greens',
... {'cuisine' : 'General'}, 'POST',
... {'X-HTTP-Method-Override' : 'PATCH',
... 'Content-Type': 'not-a-valid-content/type',
... 'X-Content-Type-Override': 'application/json'})
HTTP/1.1 209 Content Returned
...
Here, the use of a nonexistent HTTP method causes an error.
>>> print modify_cookbook('Everyday Greens',
... {'cuisine' : 'General'}, 'POST',
... {'X-HTTP-Method-Override' : 'NOSUCHMETHOD'})
HTTP/1.1 405 Method Not Allowed
...
X-HTTP-Method-Override is only respected when the underlying HTTP
method is POST. If you use X-HTTP-Method-Override with any other HTTP
method, your value is ignored. Here, a nonexistent HTTP method is
ignored in favor of HTTP GET.
>>> print webservice('/cookbooks/Everyday%20Greens', 'GET',
... headers={'X-HTTP-Method-Override' : 'NOSUCHMETHOD'})
HTTP/1.1 200 Ok
...
Content-Type: application/json
...
Even if a client supports PATCH, sometimes it's easier to GET a
document, modify it, and send it back. If you have the full document
at hand, you can use the PUT method.
We happen to have a full document from when we sent a GET request to
the 'Everday Greens' cookbook. Modifying that document and PUTting it
back is less work than constructing a new document and sending it with
PATCH. As with PATCH, a successful PUT serve the new representation of
the object that was modified.
>>> greens = webservice.get(greens_url).jsonBody()
>>> print greens['cuisine']
General
>>> greens['cuisine'] = 'Vegetarian'
>>> print modify_cookbook('Everyday Greens', greens, 'PUT')
HTTP/1.1 209 Content Returned
...
{...}
>>> greens = webservice.get(greens_url).jsonBody()
>>> print greens['cuisine']
Vegetarian
Because our patch format is the same as our representation format (a
JSON hash), any document that works with a PUT request will also work
with a PATCH request.
>>> print modify_cookbook('Everyday Greens', greens, 'PATCH')
HTTP/1.1 209 Content Returned
...
Content negotiation during modification
=======================================
When making a PATCH, you don't have to get a JSON representation
back. You can also get an HTML representation.
>>> print modify_cookbook('Everyday Greens', {}, 'PATCH',
... headers={'Accept': 'application/xhtml+xml'})
HTTP/1.1 209 Content Returned
...
Content-Type: application/xhtml+xml
...
<?xml version="1.0"?>
...
You can even get a WADL representation, though that's pretty useless.
>>> headers = {'Accept':'application/vd.sun.wadl+xml'}
>>> print modify_cookbook('Everyday Greens', {}, 'PATCH',
... headers=headers)
HTTP/1.1 209 Content Returned
...
Content-Type: application/vd.sun.wadl+xml
...
Server-side modification
========================
Sometimes the server will transparently modify a value sent by the
client, to clean it up or put it into a canonical form. For this
purpose, the response to a PUT or PATCH request includes a brand new
JSON representation of the object, so that the client can know whether
and which changes were made.
Here's an example. If a cookbook's description contains leading or trailing
whitespace, the whitespace will be stripped.
>>> greens = webservice.get(greens_url).jsonBody()
>>> greens['description']
u''
>>> first_etag = greens['http_etag']
Send in a name with leading or trailing whitespace and it'll be
transparently trimmed. The document returned from the POST request
will be the new representation, modified by both client and server.
>>> greens = webservice(
... greens_url, "PATCH",
... simplejson.dumps({'description' : ' A description '}),
... {'Content-type': 'application/json'}).jsonBody()
>>> greens['description']
u'A description'
>>> greens['http_etag'] == first_etag
False
The canonicalization works for PUT requests as well.
>>> greens['description'] = " Another description "
>>> greens = webservice(greens_url, "PUT", simplejson.dumps(greens),
... {'Content-type': 'application/json'}).jsonBody()
>>> greens['description']
u'Another description'
Conditional GET, PUT and PATCH
==============================
When you GET an entry you're given an ETag; an opaque string that
changes whenever the entry changes.
>>> response = webservice.get(greens_url)
>>> greens_etag = response.getheader('ETag')
>>> greens = response.jsonBody()
The ETag is present in the HTTP response headers when you GET an
entry, but it's also present in the representation of the entry
itself.
>>> greens['http_etag'] == greens_etag
True
This is so you can get the ETags for all the entries in a collection
at once, without making a separate HTTP request for each.
>>> cookbooks = webservice.get('/cookbooks').jsonBody()
>>> etags = [book['http_etag'] for book in cookbooks['entries']]
The ETag provided with an entry of a collection is the same as the
ETag you'd get if you got that entry individually.
>>> first_book = cookbooks['entries'][0]
>>> first_book_2 = webservice.get(first_book['self_link']).jsonBody()
>>> first_book['http_etag'] == first_book_2['http_etag']
True
When you make a GET request, you can provide the ETag as the
If-None-Match header. This lets you save time when the resource hasn't
changed.
>>> print webservice.get(greens_url,
... headers={'If-None-Match': greens_etag})
HTTP/1.1 304 Not Modified
...
Conditional GET works the same way whether the request goes through
the web service's virtual host or through the website-level interface
designed for Ajax.
>>> from lazr.restful.testing.webservice import WebServiceAjaxCaller
>>> ajax = WebServiceAjaxCaller(domain='cookbooks.dev')
>>> etag = 'dummy-etag'
>>> response = ajax.get(greens_url, headers={'If-None-Match' : etag})
>>> etag = response.getheader("Etag")
>>> print ajax.get(greens_url, headers={'If-None-Match' : etag})
HTTP/1.1 304 Not Modified
...
When you make a PUT or PATCH request, you can provide the ETag as the
If-Match header. This lets you detect changes that other people made
to the entry, so your changes don't overwrite theirs.
If the ETag you provide in If-Match matches the entry's current ETag,
your request goes through.
>>> print modify_cookbook('Everyday Greens', greens, 'PATCH',
... {'If-Match' : greens_etag})
HTTP/1.1 209 Content Returned
...
If the ETags don't match, it's because somebody modified the entry
after you got your copy of it. Your request will fail with status code
412.
>>> print modify_cookbook('Everyday Greens', greens, 'PATCH',
... {'If-Match' : '"an-old-etag"'})
HTTP/1.1 412 Precondition Failed
...
If you specify a number of ETags, and any of them match, your request
will go through.
>>> greens = webservice.get(greens_url).jsonBody()
>>> match = '"an-old-etag", %s' % greens['http_etag']
>>> print modify_cookbook('Everyday Greens', greens, 'PATCH',
... {'If-Match' : match})
HTTP/1.1 209 Content Returned
...
Both PUT and PATCH requests work this way.
>>> print modify_cookbook('Everyday Greens', greens, 'PUT',
... {'If-Match' : 'an-old-etag'})
HTTP/1.1 412 Precondition Failed
...
Conditional writes are a little more complicated
************************************************
OK, but consider the 'copyright_date' field of a cookbook. This is
published as a read-only field; the client can't change it. But it's
not read-only on the server side. What if this value changes on the
server-side? What happens to the ETag then? Does it change, causing a
conditional PATCH to fail, even if the PATCH doesn't touch that
read-only field?
>>> greens = webservice.get(greens_url).jsonBody()
>>> print greens['copyright_date']
2003-01-01
>>> etag_before_server_modification = greens['http_etag']
>>> from lazr.restful.example.base.root import C4
>>> greens_object = C4
>>> old_date = greens_object.copyright_date
>>> old_date
datetime.date(2003, 1, 1)
Let's change the server-side value and find out.
>>> import datetime
>>> greens_object.copyright_date = datetime.date(2005, 12, 12)
>>> new_greens = webservice.get(greens_url).jsonBody()
>>> print new_greens['copyright_date']
2005-12-12
>>> etag_after_server_modification = new_greens['http_etag']
The ETag has indeed changed.
>>> etag_before_server_modification == etag_after_server_modification
False
So if we try to modify the cookbook using the old ETag, it should
fail, right?
>>> body = {'description' : 'New description.'}
>>> print modify_cookbook('Everyday Greens', body, 'PATCH',
... {'If-Match' : etag_before_server_modification})
HTTP/1.1 209 Content Returned
...
Actually, it succeeds! How does that work? Well, the ETag consists of
two parts, separated by a dash.
>>> read_before, write_before = etag_before_server_modification.split('-')
>>> read_after, write_after = etag_after_server_modification.split('-')
The first part of the ETag only changes when a field the client can't
modify changes. This is the part of the ETag that changed when
copyright_date changed.
>>> read_before == read_after
False
The second part only changes when a field changes that the client can
modify. This part of the ETag hasn't changed.
>>> write_before == write_after
True
When you make a conditional write, the second part of the ETag you
provide is checked against the second part of the ETag generated on
the server. The first part of the ETag is ignored.
The point of checking the ETag is to avoid conflicts where two clients
modify the same resource. But no client can modify any of those
read-only fields, so changes to them don't matter for purposes of
avoiding conflicts.
If you hack the ETag to something that's not "two parts, separated by
a dash", lazr.restful will still handle it. (Of course, since that
ETag will never match anything, you'll always get a 412 error.)
>>> body = {'description' : 'New description.'}
>>> print modify_cookbook('Everyday Greens', body, 'PATCH',
... {'If-Match' : "Weird etag"})
HTTP/1.1 412 Precondition Failed
...
Conditional PUT fails where a PATCH would succeed, but not because
lazr.restful rejects an old ETag. To verify this, let's change the
cookbook's copyright_date again, behind the scenes.
>>> greens = webservice.get(greens_url).jsonBody()
>>> old_etag = greens['http_etag']
>>> greens_object.copyright_date = datetime.date(2005, 11, 11)
A totally bogus ETag fails with a 412 error.
>>> greens['description'] = 'Another new description'
>>> print modify_cookbook('Everyday Greens', greens, 'PUT',
... {'If-Match' : "Not the old ETag"})
HTTP/1.1 412 Precondition Failed
...
When we use the original ETag, we don't cause a 412 error, but the PUT
request fails anyway.
>>> print modify_cookbook('Everyday Greens', greens, 'PUT',
... {'If-Match' : old_etag})
HTTP/1.1 400 Bad Request
...
http_etag: You tried to modify a read-only attribute.
copyright_date: You tried to modify a read-only attribute.
Rather, it's because a PUT request includes a representation of the
entire resource, and lazr.restful thinks the client is trying to
modify the fields that changed behind the scenes--in this case,
copyright_date and http_etag.
Conditional reads are *not* more complicated
********************************************
The point of the two-part ETag is to avoid spurious 412 errors when
doing conditional writes. When making a conditional _read_ request,
the condition will fail if _any_ part of the ETag is different.
>>> new_etag = webservice.get(greens_url).jsonBody()['http_etag']
>>> print webservice.get(
... greens_url,
... headers={'If-None-Match': new_etag})
HTTP/1.1 304 Not Modified
...
>>> print webservice.get(
... greens_url,
... headers={'If-None-Match': "changed" + new_etag})
HTTP/1.1 200 Ok
...
lazr.restful checks the entire ETag on conditional GET because the
purpose of a conditional read is to avoid getting data that hasn't
changed. A server-side change to a read-only field like copyright_date
doesn't affect future client writes, but it _is_ a change to the
representation.
A bit of cleanup: restore the old value for the cookbook's
copyright_date.
>>> greens_object.copyright_date = old_date
Changing object relationships
=============================
In addition to changing an object's data fields, you can change its
relationships to other objects. Here we change which dish a recipe is
for.
>>> recipe_url = '/recipes/3'
>>> recipe = webservice.get(recipe_url).jsonBody()
>>> print recipe['dish_link']
http://.../dishes/Roast%20chicken
>>> def modify_dish(url, recipe, new_dish_url):
... recipe['dish_link'] = new_dish_url
... return webservice.put(
... url, 'application/json', simplejson.dumps(recipe))
>>> new_dish = webservice.get(quote('/dishes/Baked beans')).jsonBody()
>>> new_dish_url = new_dish['self_link']
>>> recipe['dish_link'] = new_dish_url
>>> print modify_dish(recipe_url, recipe, new_dish_url)
HTTP/1.1 209 Content Returned
...
>>> recipe = webservice.get(recipe_url).jsonBody()
>>> print recipe['dish_link']
http://.../dishes/Baked%20beans
Identification of the dish is done by specifying a URL; a random
string won't work.
>>> print modify_dish(recipe_url, recipe, 'A random string')
HTTP/1.1 400 Bad Request
...
dish_link: "A random string" is not a valid URI.
But not just any URL will do. It has to identify an object in the web
service.
>>> print modify_dish(recipe_url, recipe, 'http://www.canonical.com')
HTTP/1.1 400 Bad Request
...
dish_link: No such object "http://www.canonical.com".
>>> print modify_dish(
... recipe_url, recipe,
... 'http://www.canonical.com/dishes/Baked%20beans')
HTTP/1.1 400 Bad Request
...
dish_link: No such object "http://www.canonical.com/dishes/Baked%20beans".
This URL would be valid, but it uses the wrong protocol (HTTPS instead
of HTTP).
>>> https_link = recipe['dish_link'].replace('http:', 'https:')
>>> print modify_dish(recipe_url, recipe, https_link)
HTTP/1.1 400 Bad Request
...
dish_link: No such object "https://.../Baked%20beans".
Even a URL that identifies an object in the web service won't work, if
the object isn't the right kind of object. A recipe must be for a
dish, not a cookbook:
>>> print modify_dish(recipe_url, recipe, recipe['cookbook_link'])
HTTP/1.1 400 Bad Request
...
dish_link: Your value points to the wrong kind of object
Date formats
============
lazr.restful web services serve and parse dates in ISO 8601
format. Only UTC dates are allowed.
The tests that follow make a number of PATCH requests that include
values for a cookbook's 'copyright_date' attribute.
>>> greens = webservice.get(greens_url).jsonBody()
>>> greens['copyright_date']
u'2003-01-01'
>>> def patch_greens_copyright_date(date):
... "A helper method to try and change a date field."
... return modify_cookbook(
... 'Everyday Greens', {'copyright_date' : date}, 'PATCH')
These requests aren't actually trying to modify 'copyright_date', which
is read-only. They're asserting that 'copyright_date' is a certain
value. If the assertion succeeds (because 'copyright_date' does in fact
have that value), the response code is 200. If the assertion could not
be understood (because the date is in the wrong format), the response
code is 400, and the body is an error message about the date
format. If the assertion _fails_ (because 'copyright_date' happens to be
read-only), the response code is also 400, but the error message talks
about an attempt to modify a read-only attribute.
The two 400 error codes below are caused by a failure to understand
the assertion. The string used in the assertion might not be a date.
>>> print patch_greens_copyright_date('dummy')
HTTP/1.1 400 Bad Request
...
copyright_date: Value doesn't look like a date.
Or it might be a date that's not in UTC.
>>> print patch_greens_copyright_date(u'2005-06-06T00:00:00.000000+05:00')
HTTP/1.1 400 Bad Request
...
copyright_date: Time not in UTC.
There are five ways to specify UTC:
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000Z')
HTTP/1.1 209 Content Returned
...
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000+00:00')
HTTP/1.1 209 Content Returned
...
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000+0000')
HTTP/1.1 209 Content Returned
...
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000-00:00')
HTTP/1.1 209 Content Returned
...
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000-0000')
HTTP/1.1 209 Content Returned
...
A value with a missing timezone is treated as UTC.
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000')
HTTP/1.1 209 Content Returned
...
Less precise time measurements may also be acceptable.
>>> print patch_greens_copyright_date(u'2003-01-01T00:00:00Z')
HTTP/1.1 209 Content Returned
...
>>> print patch_greens_copyright_date(u'2003-01-01')
HTTP/1.1 209 Content Returned
...
What you can't do
=================
A document that would be acceptable as the payload of a PATCH request
might not be acceptable as the payload of a PUT request.
>>> print modify_cookbook('Everyday Greens', {'name' : 'Greens'}, 'PUT')
HTTP/1.1 400 Bad Request
...
You didn't specify a value for the attribute 'cuisine'.
A document that's not a valid JSON document is also unacceptable.
>>> print webservice.patch(greens_url, "application/json", "{")
HTTP/1.1 400 Bad Request
...
Entity-body was not a well-formed JSON document.
A document that's valid JSON but is not a JSON hash is unacceptable.
>>> print modify_cookbook('Everyday Greens', 'name=Greens', 'PATCH')
HTTP/1.1 400 Bad Request
...
Expected a JSON hash.
An entry's read-only attributes can't be modified.
>>> print modify_cookbook(
... 'Everyday Greens',
... {'copyright_date' : u'2001-01-01T01:01:01+00:00Z'}, 'PATCH')
HTTP/1.1 400 Bad Request
...
copyright_date: You tried to modify a read-only attribute.
You can send a document that includes a value for a read-only
attribute, but it has to be the same as the current value.
>>> print modify_cookbook(
... 'Everyday Greens',
... {'copyright_date' : greens['copyright_date']}, 'PATCH')
HTTP/1.1 209 Content Returned
...
You can't change the link to an entry's associated collection.
>>> print modify_cookbook('Everyday Greens',
... {'recipes_collection_link' : 'dummy'},
... 'PATCH')
HTTP/1.1 400 Bad Request
...
recipes_collection_link: You tried to modify a collection...
Again, you can send a document that includes a value for an associated
collection link; you just can't _change_ the value.
>>> print modify_cookbook(
... 'Everyday Greens',
... {'recipes_collection_link' : greens['recipes_collection_link']},
... 'PATCH')
HTTP/1.1 209 Content Returned
...
You can't directly change an entry's URL address.
>>> print modify_cookbook('Everyday Greens',
... {'self_link' : 'dummy'}, 'PATCH')
HTTP/1.1 400 Bad Request
...
self_link: You tried to modify a read-only attribute.
You can't directly change an entry's ETag.
>>> print modify_cookbook('Everyday Greens',
... {'http_etag' : 'dummy'}, 'PATCH')
HTTP/1.1 400 Bad Request
...
http_etag: You tried to modify a read-only attribute.
You can't change an entry's resource type.
>>> print modify_cookbook('Everyday Greens',
... {'resource_type_link' : 'dummy'}, 'PATCH')
HTTP/1.1 400 Bad Request
...
resource_type_link: You tried to modify a read-only attribute.
You can't refer to a link to an associated object or collection as
though it were the actual object. A cookbook has a
'recipes_collection_link', but it doesn't have 'recipes' directly.
>>> print modify_cookbook(
... 'Everyday Greens', {'recipes' : 'dummy'}, 'PATCH')
HTTP/1.1 400 Bad Request
...
recipes: You tried to modify a nonexistent attribute.
A recipe has a 'dish_link', but it doesn't have a 'dish' directly.
>>> url = quote('/cookbooks/The Joy of Cooking/Roast chicken')
>>> print webservice.patch(url, 'application/json',
... simplejson.dumps({'dish' : 'dummy'}))
HTTP/1.1 400 Bad Request
...
dish: You tried to modify a nonexistent attribute.
You can't set values that violate data integrity rules. For instance,
you can't set a required value to None.
>>> print modify_cookbook('Everyday Greens',
... {'name' : None}, 'PATCH')
HTTP/1.1 400 Bad Request
...
name: Missing required value.
And of course you can't modify attributes that don't exist.
>>> print modify_cookbook(
... 'Everyday Greens', {'nonesuch' : 'dummy'}, 'PATCH')
HTTP/1.1 400 Bad Request
...
nonesuch: You tried to modify a nonexistent attribute.
Deletion
========
Some entries may be deleted with the HTTP DELETE method. In the
example web service, recipes can be deleted.
>>> recipe_url = "/recipes/6"
>>> print webservice.get(recipe_url)
HTTP/1.1 200 Ok
...
>>> print webservice.delete(recipe_url)
HTTP/1.1 200 Ok
...
>>> print webservice.get(recipe_url)
HTTP/1.1 404 Not Found
...
|