entries.rst 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. ******************
  2. Entry manipulation
  3. ******************
  4. Objects available through the web interface, such as cookbooks, have a
  5. readable interface which is available through direct attribute access.
  6. >>> from lazr.restfulclient.tests.example import CookbookWebServiceClient
  7. >>> service = CookbookWebServiceClient()
  8. >>> recipe = service.recipes[1]
  9. >>> print recipe.instructions
  10. You can always judge...
  11. These objects may have a number of attributes, as well as associated
  12. entries and collections.
  13. >>> cookbook = recipe.cookbook
  14. >>> print cookbook.name
  15. Mastering the Art of French Cooking
  16. >>> len(cookbook.recipes)
  17. 2
  18. The lp_* introspection methods let you know what you can do with an
  19. object. You can also use dir(), but it'll be cluttered with all sorts
  20. of other stuff.
  21. >>> sorted(dir(cookbook))
  22. [..., 'confirmed', 'copyright_date', 'cover', ... 'find_recipes',
  23. ..., 'recipes', ...]
  24. >>> sorted(cookbook.lp_attributes)
  25. ['confirmed', 'copyright_date', ..., 'resource_type_link', ...,
  26. 'self_link', 'web_link']
  27. >>> sorted(cookbook.lp_entries)
  28. ['cover']
  29. >>> sorted(cookbook.lp_collections)
  30. ['recipes']
  31. >>> sorted(cookbook.lp_operations)
  32. ['find_recipe_for', 'find_recipes', 'make_more_interesting',
  33. 'replace_cover']
  34. Some attributes can only take on certain values. The lp_values_for
  35. method will show you these values.
  36. >>> sorted(cookbook.lp_values_for('cuisine'))
  37. ['American', 'Dessert', u'Fran\xe7aise', 'General', 'Vegetarian']
  38. Some attributes don't have a predefined list of acceptable values. For
  39. them, lp_values_for() returns None.
  40. >>> print cookbook.lp_values_for('copyright_date')
  41. None
  42. Some of these attributes can be changed. For example, a client can
  43. change a recipe's preparation instructions. When changing attribute values
  44. though, the changes are not pushed to the web service until the entry
  45. is explicitly saved. This allows the client to batch the changes over
  46. the wire for efficiency.
  47. >>> recipe.instructions = 'Modified instructions'
  48. >>> print service.recipes[1].instructions
  49. You can always judge...
  50. Once the changes are saved though, they are propagated to the web
  51. service.
  52. >>> recipe.lp_save()
  53. >>> print service.recipes[1].instructions
  54. Modified instructions
  55. An entry object is a normal Python object like any other. Attributes
  56. of an entry, like 'cuisine' or 'cookbook', are available as attributes
  57. on the resource, and may be set. Random strings that are not
  58. attributes of the entry cannot be set or read as Python attributes.
  59. >>> recipe.instructions = 'Different instructions'
  60. >>> recipe.is_great = True
  61. Traceback (most recent call last):
  62. ...
  63. AttributeError: 'Entry' object has no attribute 'is_great'
  64. >>> recipe.is_great
  65. Traceback (most recent call last):
  66. ...
  67. AttributeError: http://cookbooks.dev/1.0/recipes/1 object has no attribute 'is_great'
  68. The client can set more than one attribute on an entry at a time:
  69. they'll all be changed when the entry is saved.
  70. >>> cookbook.cuisine
  71. u'Fran\xe7aise'
  72. >>> cookbook.description
  73. u''
  74. >>> cookbook.cuisine = 'Dessert'
  75. >>> cookbook.description = "A new description"
  76. >>> cookbook.lp_save()
  77. >>> cookbook = service.recipes[1].cookbook
  78. >>> print cookbook.cuisine
  79. Dessert
  80. >>> print cookbook.description
  81. A new description
  82. Some of an entry's attributes may take other resources as values.
  83. >>> old_cookbook = recipe.cookbook
  84. >>> other_cookbook = service.cookbooks['Everyday Greens']
  85. >>> print other_cookbook.name
  86. Everyday Greens
  87. >>> recipe.cookbook = other_cookbook
  88. >>> recipe.lp_save()
  89. >>> print recipe.cookbook.name
  90. Everyday Greens
  91. >>> recipe.cookbook = old_cookbook
  92. >>> recipe.lp_save()
  93. Refreshing data
  94. ---------------
  95. Here are two objects representing recipe #1. We'll fetch a
  96. representation for the first object right away...
  97. >>> recipe_copy = service.recipes[1]
  98. >>> print recipe_copy.instructions
  99. Different instructions
  100. ...but retrieve the second object in a way that doesn't fetch its
  101. representation.
  102. >>> recipe_copy_2 = service.recipes(1)
  103. An entry is automatically refreshed after saving.
  104. >>> recipe.instructions = 'Even newer instructions'
  105. >>> recipe.lp_save()
  106. >>> print recipe.instructions
  107. Even newer instructions
  108. If an old object representing that entry already has a representation,
  109. it will still show the old data.
  110. >>> print recipe_copy.instructions
  111. Different instructions
  112. If an old object representing that entry doesn't have a representation
  113. yet, it will show the new data.
  114. >>> print recipe_copy_2.instructions
  115. Even newer instructions
  116. You can also refresh a resource object manually.
  117. >>> recipe_copy.lp_refresh()
  118. >>> print recipe_copy.instructions
  119. Even newer instructions
  120. Bookmarking an entry
  121. --------------------
  122. You can get an entry's URL from the 'self_link' attribute, save the
  123. URL for a while, and retrieve the entry later using the load()
  124. function.
  125. >>> bookmark = recipe.self_link
  126. >>> new_recipe = service.load(bookmark)
  127. >>> print new_recipe.dish.name
  128. Roast chicken
  129. You can bookmark a URI relative to the version of the web service
  130. currently in use.
  131. >>> cookbooks = service.load("cookbooks")
  132. >>> assert isinstance(cookbooks._wadl_resource.url, basestring)
  133. >>> print cookbooks._wadl_resource.url
  134. http://cookbooks.dev/1.0/cookbooks
  135. >>> print cookbooks['The Joy of Cooking'].self_link
  136. http://cookbooks.dev/1.0/cookbooks/The%20Joy%20of%20Cooking
  137. >>> cookbook = service.load("/cookbooks/The%20Joy%20of%20Cooking")
  138. >>> assert isinstance(cookbook._wadl_resource.url, basestring)
  139. >>> print cookbook._wadl_resource.url
  140. http://cookbooks.dev/1.0/cookbooks/The%20Joy%20of%20Cooking
  141. >>> print cookbook.self_link
  142. http://cookbooks.dev/1.0/cookbooks/The%20Joy%20of%20Cooking
  143. >>> service_root = service.load("")
  144. >>> assert isinstance(service_root._wadl_resource.url, basestring)
  145. >>> print service_root._wadl_resource.url
  146. http://cookbooks.dev/1.0/
  147. >>> print service_root.cookbooks['The Joy of Cooking'].name
  148. The Joy of Cooking
  149. But you can't provide the web service version and bookmark a URI
  150. relative to the service root.
  151. >>> cookbooks = service.load("/1.0/cookbooks")
  152. Traceback (most recent call last):
  153. ...
  154. NotFound: HTTP Error 404: Not Found
  155. ...
  156. (That code attempts to load http://cookbooks.dev/1.0/1.0/cookbooks,
  157. which doesn't exist.)
  158. You can't bookmark an absolute or relative URI that has nothing to do
  159. with the web service.
  160. >>> bookmark = 'http://cookbooks.dev/'
  161. >>> service.load(bookmark)
  162. Traceback (most recent call last):
  163. ...
  164. NotFound: HTTP Error 404: Not Found
  165. ...
  166. >>> service.load("/no-such-url")
  167. Traceback (most recent call last):
  168. ...
  169. NotFound: HTTP Error 404: Not Found
  170. ...
  171. You can't bookmark the return value of a named operation. This is not
  172. really desirable, but that's how things work right now.
  173. >>> url_without_type = ('http://cookbooks.dev/1.0/cookbooks' +
  174. ... '?ws.op=find_recipes&search=a')
  175. >>> service.load(url_without_type)
  176. Traceback (most recent call last):
  177. ...
  178. ValueError: Couldn't determine the resource type of...
  179. Moving an entry
  180. ---------------
  181. Some entries will move to different URLs when a client changes their
  182. data attributes. For instance, a cookbook's URL is determined by its
  183. name.
  184. >>> cookbook = service.cookbooks['The Joy of Cooking']
  185. >>> print cookbook.name
  186. The Joy of Cooking
  187. >>> old_link = cookbook.self_link
  188. >>> print old_link
  189. http://cookbooks.dev/1.0/cookbooks/The%20Joy%20of%20Cooking
  190. >>> cookbook.name = "Another Name"
  191. >>> cookbook.lp_save()
  192. Change the name, and you change the URL.
  193. >>> new_link = cookbook.self_link
  194. >>> print new_link
  195. http://cookbooks.dev/1.0/cookbooks/Another%20Name
  196. Old bookmarks won't work anymore.
  197. >>> print service.load(old_link)
  198. Traceback (most recent call last):
  199. ...
  200. NotFound: HTTP Error 404: Not Found
  201. ...
  202. >>> print service.load(new_link).name
  203. Another Name
  204. Under the covers though, a refresh of the original object has been
  205. retrieved from the web service, so it's safe to continue using, and
  206. changing it.
  207. >>> cookbook.description = u'This cookbook was renamed'
  208. >>> cookbook.lp_save()
  209. >>> print service.load(new_link).description
  210. This cookbook was renamed
  211. It's just as easy to move this cookbook back to the old name.
  212. >>> cookbook.name = 'The Joy of Cooking'
  213. >>> cookbook.lp_save()
  214. Now the old bookmark works again, and the new bookmark no longer works.
  215. >>> print service.load(old_link).name
  216. The Joy of Cooking
  217. >>> print service.load(new_link)
  218. Traceback (most recent call last):
  219. ...
  220. NotFound: HTTP Error 404: Not Found
  221. ...
  222. Validation
  223. ----------
  224. Some attributes are subject to validation. For instance, a cookbook's
  225. cuisine is limited to one of a few selections.
  226. >>> from lazr.restfulclient.errors import HTTPError
  227. >>> def print_error_on_save(entry):
  228. ... try:
  229. ... entry.lp_save()
  230. ... except HTTPError, error:
  231. ... for line in sorted(error.content.splitlines()):
  232. ... print line.decode("utf-8")
  233. ... else:
  234. ... print 'Did not get expected HTTPError!'
  235. >>> cookbook.cuisine = 'No such cuisine'
  236. >>> print_error_on_save(cookbook)
  237. cuisine: Invalid value "No such cuisine". Acceptable values are: ...
  238. >>> cookbook.cuisine = 'General'
  239. Some attributes can't be modified at all.
  240. >>> cookbook.copyright_date = None
  241. >>> print_error_on_save(cookbook)
  242. copyright_date: You tried to modify a read-only attribute.
  243. If the client tries to save an entry that has more than one problem,
  244. it will get back an error message listing all the problems.
  245. >>> cookbook.cuisine = 'No such cuisine'
  246. >>> print_error_on_save(cookbook)
  247. copyright_date: You tried to modify a read-only attribute.
  248. cuisine: Invalid value "No such cuisine". Acceptable values are: ...
  249. Server-side data massage
  250. ------------------------
  251. Send bad data and your request will be rejected. But if you send data
  252. that's not quite what the server is expecting, the server may accept
  253. it while tweaking it. This means that the state of your object after
  254. you call lp_save() may be slightly different from the object before
  255. you called lp_save().
  256. >>> cookbook.lp_refresh()
  257. >>> cookbook.description = " Some extraneous whitespace "
  258. >>> cookbook.lp_save()
  259. >>> cookbook.description
  260. u'Some extraneous whitespace'
  261. Data types
  262. ----------
  263. Incoming data is serialized from JSON, and all the JSON data types
  264. appear to the end-user as native Python data types. But there's no
  265. standard serialization for JSON dates, so those are handled
  266. separately. From the perspective of the end-user, date and date-time
  267. fields always look like Python datetime objects or None.
  268. >>> cookbook.copyright_date
  269. datetime.datetime(1995, 1, 1,...)
  270. >>> from datetime import datetime
  271. >>> cookbook.last_printing = datetime(2009, 1, 1)
  272. >>> cookbook.lp_save()
  273. Avoiding conflicts
  274. ==================
  275. lazr.restful and lazr.restfulclient work together to try to avoid
  276. situations where one person unknowingly overwrites another's
  277. work. Here, two different clients are interested in the same
  278. lazr.restful object.
  279. >>> first_client = CookbookWebServiceClient()
  280. >>> first_cookbook = first_client.load(cookbook.self_link)
  281. >>> first_description = first_cookbook.description
  282. >>> second_client = CookbookWebServiceClient()
  283. >>> second_cookbook = second_client.load(cookbook.self_link)
  284. >>> second_cookbook.description == first_description
  285. True
  286. The first client decides to change the description.
  287. >>> first_cookbook.description = 'A description.'
  288. >>> first_cookbook.lp_save()
  289. The second client tries to make a conflicting change, but the server
  290. detects that the second client doesn't have the latest information,
  291. and rejects the request.
  292. >>> second_cookbook.description = 'A conflicting description.'
  293. >>> second_cookbook.lp_save()
  294. Traceback (most recent call last):
  295. ...
  296. PreconditionFailed: HTTP Error 412: Precondition Failed
  297. ...
  298. Now the second client has a chance to look at the changes that were
  299. made, before making their own changes.
  300. >>> second_cookbook.lp_refresh()
  301. >>> print second_cookbook.description
  302. A description.
  303. >>> second_cookbook.description = 'A conflicting description.'
  304. >>> second_cookbook.lp_save()
  305. Conflict detection works even when you operate on an object you
  306. retrieved from a collection.
  307. >>> first_cookbook = first_client.cookbooks[:10][0]
  308. >>> second_cookbook = second_client.cookbooks[:10][0]
  309. >>> first_cookbook.name == second_cookbook.name
  310. True
  311. >>> first_cookbook.description = "A description"
  312. >>> first_cookbook.lp_save()
  313. >>> second_cookbook.description = "A conflicting description"
  314. >>> second_cookbook.lp_save()
  315. Traceback (most recent call last):
  316. ...
  317. PreconditionFailed: HTTP Error 412: Precondition Failed
  318. ...
  319. >>> second_cookbook.lp_refresh()
  320. >>> print second_cookbook.description
  321. A description
  322. >>> second_cookbook.description = "A conflicting description"
  323. >>> second_cookbook.lp_save()
  324. >>> first_cookbook.lp_refresh()
  325. >>> print first_cookbook.description
  326. A conflicting description
  327. Comparing entries
  328. -----------------
  329. Two entries are equal if they represent the same state of the same
  330. server-side resource.
  331. >>> from lazr.restfulclient.tests.example import CookbookWebServiceClient
  332. >>> service = CookbookWebServiceClient()
  333. What does this mean? Well, two distinct objects that represent the
  334. same resource are equal.
  335. >>> recipe = service.recipes[1]
  336. >>> recipe_2 = service.load(recipe.self_link)
  337. >>> recipe is recipe_2
  338. False
  339. >>> recipe == recipe_2
  340. True
  341. >>> recipe != recipe_2
  342. False
  343. Two totally different entries are not equal.
  344. >>> another_recipe = service.recipes[2]
  345. >>> recipe == another_recipe
  346. False
  347. An entry can be compared to None, but the comparison never succeeds.
  348. >>> recipe == None
  349. False
  350. If one entry represents the current state of the server, and the other
  351. is out of date or has client-side modifications, they will not be
  352. considered equal.
  353. Here, 'recipe' has been modified and 'recipe_2' represents the current
  354. state of the server.
  355. >>> recipe.instructions = "Modified for equality testing."
  356. >>> recipe == recipe_2
  357. False
  358. After a save, 'recipe' is up to date, and 'recipe_2' is out of date.
  359. >>> recipe.lp_save()
  360. >>> recipe == recipe_2
  361. False
  362. Refreshing 'recipe_2' brings it up to date, and equality succeeds again.
  363. >>> recipe_2.lp_refresh()
  364. >>> recipe == recipe_2
  365. True
  366. If you make the *exact same* client-side modifications to two objects
  367. representing the same resource, the objects will be considered equal.
  368. >>> recipe.instructions = "Modified again."
  369. >>> recipe_2.instructions = recipe.instructions
  370. >>> recipe == recipe_2
  371. True
  372. If you then save one of the objects, they will stop being equal,
  373. because the saved object has a new ETag.
  374. >>> recipe.lp_save()
  375. >>> recipe == recipe_2
  376. False
  377. Server-side permissions
  378. -----------------------
  379. The server may hide some data from you because you lack the permission
  380. to see it. To avoid objects that are mysteriously missing fields, the
  381. server will serve a special "redacted" value that lets you know you
  382. don't have permission to see the data.
  383. >>> from lazr.restfulclient.tests.example import CookbookWebServiceClient
  384. >>> service = CookbookWebServiceClient()
  385. >>> cookbook = service.recipes[1].cookbook
  386. >>> print cookbook.confirmed
  387. tag:launchpad.net:2008:redacted
  388. If you try to make an HTTP request for the "redacted" value (usually
  389. by following a link that you don't know is redacted), you'll get a
  390. helpful error.
  391. >>> service.load("tag:launchpad.net:2008:redacted")
  392. Traceback (most recent call last):
  393. ...
  394. ValueError: You tried to access a resource that you don't have the
  395. server-side permission to see.
  396. Deleting an entry
  397. =================
  398. Some entries can be deleted with the lp_delete method.
  399. Before demonstrating this, let's acquire the underlying data model
  400. objects so that we can restore the entry later. This is a bit of a
  401. hack, but it's a lot less work than any alternative.
  402. >>> from lazr.restful.example.base.interfaces import IRecipeSet
  403. >>> from zope.component import getUtility
  404. >>> recipe_set = getUtility(IRecipeSet)
  405. >>> underlying_recipe = recipe_set.get(6)
  406. >>> underlying_cookbook = underlying_recipe.cookbook
  407. Now let's delete the entry.
  408. >>> recipe = service.recipes[6]
  409. >>> print recipe.lp_delete()
  410. None
  411. A deleted entry no longer exists.
  412. >>> recipe.lp_refresh()
  413. Traceback (most recent call last):
  414. ...
  415. NotFound: HTTP Error 404: Not Found
  416. ...
  417. Some entries can't be deleted.
  418. >>> cookbook.lp_delete()
  419. Traceback (most recent call last):
  420. ...
  421. MethodNotAllowed: HTTP Error 405: Method Not Allowed
  422. ...
  423. Cleanup: restore the deleted recipe.
  424. >>> recipe_set.recipes.append(underlying_recipe)
  425. >>> underlying_cookbook.recipes.append(underlying_recipe)
  426. When are representations fetched?
  427. =================================
  428. To avoid unnecessary HTTP requests, a representation of an entry is
  429. fetched at the last possible moment. Let's see what that means.
  430. >>> import httplib2
  431. >>> httplib2.debuglevel = 1
  432. >>> service = CookbookWebServiceClient()
  433. send: ...
  434. ...
  435. Here's an entry we got from a lookup operation on a top-level
  436. collection. The default top-level lookup operation fetches a
  437. representation of an entry immediately so as to immediately signal
  438. errors.
  439. >>> recipe = service.recipes[1]
  440. send: 'GET /1.0/recipes/1 ...'
  441. ...
  442. But there's also a lookup operation that only triggers an HTTP request
  443. when we try to get some data from the entry:
  444. >>> recipe1 = service.recipes(1)
  445. This gives a recipe object, because CookbookWebServiceClient happens
  446. to know that the 'recipes' collection contains recipe objects.
  447. Here's the dish associated with that original recipe entry. Traversing
  448. from one entry to another causes an HTTP request for the first entry
  449. (the recipe). Without this HTTP request, there's no way to know the
  450. URL of the dish.
  451. >>> dish = recipe1.dish
  452. send: 'GET /1.0/recipes/1 ...'
  453. ...
  454. Note that this request is a request for the _recipe_, not the dish. We
  455. don't need to know anything about the dish yet. And now that we have a
  456. representation of the recipe, we can traverse from the recipe to its
  457. cookbook without making another request.
  458. >>> cookbook = recipe1.cookbook
  459. Accessing any information about an entry we've traversed to _will_
  460. cause an HTTP request.
  461. >>> print dish.name
  462. send: 'GET /1.0/dishes/Roast%20chicken ...'
  463. ...
  464. Roast chicken
  465. Invoking a named operation also causes one (and only one) HTTP
  466. request.
  467. >>> recipes = cookbook.find_recipes(search="foo")
  468. send: 'GET /1.0/cookbooks/...ws.op=find_recipes...'
  469. ...
  470. Even dereferencing an entry from another entry and then invoking a
  471. named operation causes only one HTTP request.
  472. >>> recipes = recipe1.cookbook.find_recipes(search="bar")
  473. send: 'GET /1.0/cookbooks/...ws.op=find_recipes...'
  474. ...
  475. In all cases we are able to delay HTTP requests until the moment we
  476. need data that can only be found by making those HTTP requests (even
  477. if, as in the first example, that data is "does this object
  478. exist?). If it turns out we never need that data, we've eliminated a
  479. request entirely.
  480. If CookbookWebServiceClient didn't know that the 'recipes' collection
  481. contained recipe objects, then doing a lookup on that collection *would*
  482. trigger an HTTP request. There'd simply be no other way to know what
  483. kind of object was at the other end of the URL.
  484. >>> from lazr.restfulclient.tests.example import RecipeSet
  485. >>> old_collection_of = RecipeSet.collection_of
  486. >>> RecipeSet.collection_of = None
  487. >>> recipe1 = service.recipes[1]
  488. send: 'GET /1.0/recipes/1 ...'
  489. ...
  490. On the plus side, at least accessing this object's properties doesn't
  491. require _another_ HTTP request.
  492. >>> print recipe1.instructions
  493. Modified again.
  494. Cleanup.
  495. >>> RecipeSet.collection_of = old_collection_of
  496. >>> httplib2.debuglevel = 0