ldp.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848
  1. import logging
  2. import pdb
  3. from base64 import b64encode
  4. from collections import defaultdict
  5. from io import BytesIO
  6. from pprint import pformat
  7. from uuid import uuid4
  8. import arrow
  9. from flask import (
  10. Blueprint, Response, g, make_response, render_template,
  11. request, send_file)
  12. from rdflib import Graph, plugin, parser#, serializer
  13. from werkzeug.http import parse_date
  14. from lakesuperior import env
  15. from lakesuperior.api import resource as rsrc_api
  16. from lakesuperior.dictionaries.namespaces import ns_collection as nsc
  17. from lakesuperior.dictionaries.namespaces import ns_mgr as nsm
  18. from lakesuperior.exceptions import (ResourceNotExistsError, TombstoneError,
  19. ServerManagedTermError, InvalidResourceError, SingleSubjectError,
  20. ResourceExistsError, IncompatibleLdpTypeError)
  21. from lakesuperior.globals import RES_CREATED
  22. from lakesuperior.model.ldp_factory import LdpFactory
  23. from lakesuperior.model.ldp_nr import LdpNr
  24. from lakesuperior.model.ldp_rs import LdpRs
  25. from lakesuperior.model.ldpr import Ldpr
  26. from lakesuperior.toolbox import Toolbox
  27. DEFAULT_RDF_MIMETYPE = 'text/turtle'
  28. """
  29. Fallback serialization format used when no acceptable formats are specified.
  30. """
  31. logger = logging.getLogger(__name__)
  32. rdf_parsable_mimetypes = {
  33. mt.name for mt in plugin.plugins()
  34. if mt.kind is parser.Parser and '/' in mt.name
  35. }
  36. """MIMEtypes that can be parsed into RDF."""
  37. rdf_serializable_mimetypes = {
  38. #mt.name for mt in plugin.plugins()
  39. #if mt.kind is serializer.Serializer and '/' in mt.name
  40. 'application/ld+json',
  41. 'application/n-triples',
  42. 'application/rdf+xml',
  43. 'text/turtle',
  44. 'text/n3',
  45. }
  46. """
  47. MIMEtypes that RDF can be serialized into.
  48. These are not automatically derived from RDFLib because only triple
  49. (not quad) serializations are applicable.
  50. """
  51. accept_patch = (
  52. 'application/sparql-update',
  53. )
  54. std_headers = {
  55. 'Accept-Patch' : ','.join(accept_patch),
  56. 'Accept-Post' : ','.join(rdf_parsable_mimetypes),
  57. }
  58. """Predicates excluded by view."""
  59. vw_blacklist = {
  60. }
  61. ldp = Blueprint(
  62. 'ldp', __name__, template_folder='templates',
  63. static_url_path='/static', static_folder='templates/static')
  64. """
  65. Blueprint for LDP REST API. This is what is usually found under ``/rest/`` in
  66. standard fcrepo4. Here, it is under ``/ldp`` but initially ``/rest`` will be
  67. kept for backward compatibility.
  68. """
  69. ## ROUTE PRE- & POST-PROCESSING ##
  70. @ldp.url_defaults
  71. def bp_url_defaults(endpoint, values):
  72. url_prefix = getattr(g, 'url_prefix', None)
  73. if url_prefix is not None:
  74. values.setdefault('url_prefix', url_prefix)
  75. @ldp.url_value_preprocessor
  76. def bp_url_value_preprocessor(endpoint, values):
  77. g.url_prefix = values.pop('url_prefix')
  78. g.webroot = request.host_url + g.url_prefix
  79. # Normalize leading slashes for UID.
  80. if 'uid' in values:
  81. values['uid'] = '/' + values['uid'].lstrip('/')
  82. if 'parent_uid' in values:
  83. values['parent_uid'] = '/' + values['parent_uid'].lstrip('/')
  84. @ldp.before_request
  85. def log_request_start():
  86. logger.info('** Start {} {} **'.format(request.method, request.url))
  87. @ldp.before_request
  88. def instantiate_req_vars():
  89. g.tbox = Toolbox()
  90. @ldp.after_request
  91. def log_request_end(rsp):
  92. logger.info('** End {} {} **'.format(request.method, request.url))
  93. return rsp
  94. ## REST SERVICES ##
  95. @ldp.route('/<path:uid>', methods=['GET'], strict_slashes=False)
  96. @ldp.route('/', defaults={'uid': '/'}, methods=['GET'], strict_slashes=False)
  97. @ldp.route('/<path:uid>/fcr:metadata', defaults={'out_fmt' : 'rdf'},
  98. methods=['GET'])
  99. @ldp.route('/<path:uid>/fcr:content', defaults={'out_fmt' : 'non_rdf'},
  100. methods=['GET'])
  101. def get_resource(uid, out_fmt=None):
  102. r"""
  103. https://www.w3.org/TR/ldp/#ldpr-HTTP_GET
  104. Retrieve RDF or binary content.
  105. :param str uid: UID of resource to retrieve. The repository root has
  106. an empty string for UID.
  107. :param str out_fmt: Force output to RDF or non-RDF if the resource is
  108. a LDP-NR. This is not available in the API but is used e.g. by the
  109. ``\*/fcr:metadata`` and ``\*/fcr:content`` endpoints. The default is
  110. False.
  111. """
  112. out_headers = std_headers.copy()
  113. repr_options = defaultdict(dict)
  114. # Fist check if it's not a 404 or a 410.
  115. try:
  116. if not rsrc_api.exists(uid):
  117. return '', 404
  118. except TombstoneError as e:
  119. return _tombstone_response(e, uid)
  120. # Then process the condition headers.
  121. cond_ret = _process_cond_headers(uid, request.headers)
  122. if cond_ret:
  123. return cond_ret
  124. # Then, business as usual.
  125. # Evaluate which representation is requested.
  126. if 'prefer' in request.headers:
  127. prefer = g.tbox.parse_rfc7240(request.headers['prefer'])
  128. logger.debug('Parsed Prefer header: {}'.format(pformat(prefer)))
  129. if 'return' in prefer:
  130. repr_options = parse_repr_options(prefer['return'])
  131. rsrc = rsrc_api.get(uid, repr_options)
  132. if out_fmt is None:
  133. rdf_mimetype = _best_rdf_mimetype()
  134. out_fmt = (
  135. 'rdf'
  136. if isinstance(rsrc, LdpRs) or rdf_mimetype is not None
  137. else 'non_rdf')
  138. out_headers.update(_headers_from_metadata(rsrc, out_fmt))
  139. uri = g.tbox.uid_to_uri(uid)
  140. # RDF output.
  141. if out_fmt == 'rdf':
  142. if locals().get('rdf_mimetype', None) is None:
  143. rdf_mimetype = DEFAULT_RDF_MIMETYPE
  144. ggr = g.tbox.globalize_graph(rsrc.out_graph)
  145. ggr.namespace_manager = nsm
  146. return _negotiate_content(
  147. ggr, rdf_mimetype, out_headers, uid=uid, uri=uri)
  148. # Datastream.
  149. else:
  150. if not getattr(rsrc, 'local_path', False):
  151. return ('{} has no binary content.'.format(rsrc.uid), 404)
  152. logger.debug('Streaming out binary content.')
  153. rsp = make_response(send_file(
  154. rsrc.local_path, as_attachment=True,
  155. attachment_filename=rsrc.filename,
  156. mimetype=rsrc.mimetype), 200, out_headers)
  157. # This seems necessary to prevent Flask from setting an additional ETag.
  158. if 'ETag' in out_headers:
  159. rsp.set_etag(out_headers['ETag'])
  160. rsp.headers.add('Link',
  161. '<{}/fcr:metadata>; rel="describedby"'.format(uri))
  162. return rsp
  163. @ldp.route('/<path:uid>/fcr:versions', methods=['GET'])
  164. def get_version_info(uid):
  165. """
  166. Get version info (`fcr:versions`).
  167. :param str uid: UID of resource to retrieve versions for.
  168. """
  169. rdf_mimetype = _best_rdf_mimetype() or DEFAULT_RDF_MIMETYPE
  170. try:
  171. gr = rsrc_api.get_version_info(uid)
  172. except ResourceNotExistsError as e:
  173. return str(e), 404
  174. except InvalidResourceError as e:
  175. return str(e), 409
  176. except TombstoneError as e:
  177. return _tombstone_response(e, uid)
  178. else:
  179. return _negotiate_content(g.tbox.globalize_graph(gr), rdf_mimetype)
  180. @ldp.route('/<path:uid>/fcr:versions/<ver_uid>', methods=['GET'])
  181. def get_version(uid, ver_uid):
  182. """
  183. Get an individual resource version.
  184. :param str uid: Resource UID.
  185. :param str ver_uid: Version UID.
  186. """
  187. rdf_mimetype = _best_rdf_mimetype() or DEFAULT_RDF_MIMETYPE
  188. try:
  189. gr = rsrc_api.get_version(uid, ver_uid)
  190. except ResourceNotExistsError as e:
  191. return str(e), 404
  192. except InvalidResourceError as e:
  193. return str(e), 409
  194. except TombstoneError as e:
  195. return _tombstone_response(e, uid)
  196. else:
  197. return _negotiate_content(g.tbox.globalize_graph(gr), rdf_mimetype)
  198. @ldp.route('/<path:parent_uid>', methods=['POST'], strict_slashes=False)
  199. @ldp.route('/', defaults={'parent_uid': '/'}, methods=['POST'],
  200. strict_slashes=False)
  201. def post_resource(parent_uid):
  202. """
  203. https://www.w3.org/TR/ldp/#ldpr-HTTP_POST
  204. Add a new resource in a new URI.
  205. """
  206. rsp_headers = std_headers.copy()
  207. slug = request.headers.get('Slug')
  208. logger.debug('Slug: {}'.format(slug))
  209. handling, disposition = set_post_put_params()
  210. stream, mimetype = _bistream_from_req()
  211. if mimetype in rdf_parsable_mimetypes:
  212. # If the content is RDF, localize in-repo URIs.
  213. global_rdf = stream.read()
  214. rdf_data = g.tbox.localize_payload(global_rdf)
  215. rdf_fmt = mimetype
  216. stream = mimetype = None
  217. else:
  218. rdf_data = rdf_fmt = None
  219. try:
  220. rsrc = rsrc_api.create(
  221. parent_uid, slug, stream=stream, mimetype=mimetype,
  222. rdf_data=rdf_data, rdf_fmt=rdf_fmt, handling=handling,
  223. disposition=disposition)
  224. except ResourceNotExistsError as e:
  225. return str(e), 404
  226. except InvalidResourceError as e:
  227. return str(e), 409
  228. except TombstoneError as e:
  229. return _tombstone_response(e, uid)
  230. except ServerManagedTermError as e:
  231. return str(e), 412
  232. uri = g.tbox.uid_to_uri(rsrc.uid)
  233. rsp_headers.update(_headers_from_metadata(rsrc))
  234. rsp_headers['Location'] = uri
  235. if mimetype and rdf_fmt is None:
  236. rsp_headers['Link'] = (f'<{uri}/fcr:metadata>; rel="describedby"; '
  237. f'anchor="{uri}"')
  238. return uri, 201, rsp_headers
  239. @ldp.route('/<path:uid>', methods=['PUT'], strict_slashes=False)
  240. @ldp.route('/<path:uid>/fcr:metadata', defaults={'force_rdf' : True},
  241. methods=['PUT'])
  242. def put_resource(uid):
  243. """
  244. https://www.w3.org/TR/ldp/#ldpr-HTTP_PUT
  245. Add or replace a new resource at a specified URI.
  246. """
  247. # Parse headers.
  248. logger.debug('Request headers: {}'.format(request.headers))
  249. cond_ret = _process_cond_headers(uid, request.headers, False)
  250. if cond_ret:
  251. return cond_ret
  252. handling, disposition = set_post_put_params()
  253. stream, mimetype = _bistream_from_req()
  254. if mimetype in rdf_parsable_mimetypes:
  255. # If the content is RDF, localize in-repo URIs.
  256. global_rdf = stream.read()
  257. rdf_data = g.tbox.localize_payload(global_rdf)
  258. rdf_fmt = mimetype
  259. stream = mimetype = None
  260. else:
  261. rdf_data = rdf_fmt = None
  262. try:
  263. evt, rsrc = rsrc_api.create_or_replace(
  264. uid, stream=stream, mimetype=mimetype,
  265. rdf_data=rdf_data, rdf_fmt=rdf_fmt, handling=handling,
  266. disposition=disposition)
  267. except (InvalidResourceError, ResourceExistsError) as e:
  268. return str(e), 409
  269. except (ServerManagedTermError, SingleSubjectError) as e:
  270. return str(e), 412
  271. except IncompatibleLdpTypeError as e:
  272. return str(e), 415
  273. except TombstoneError as e:
  274. return _tombstone_response(e, uid)
  275. rsp_headers = _headers_from_metadata(rsrc)
  276. rsp_headers['Content-Type'] = 'text/plain; charset=utf-8'
  277. uri = g.tbox.uid_to_uri(uid)
  278. if evt == RES_CREATED:
  279. rsp_code = 201
  280. rsp_headers['Location'] = rsp_body = uri
  281. if mimetype and not rdf_data:
  282. rsp_headers['Link'] = f'<{uri}/fcr:metadata>; rel="describedby"'
  283. else:
  284. rsp_code = 204
  285. rsp_body = ''
  286. return rsp_body, rsp_code, rsp_headers
  287. @ldp.route('/<path:uid>', methods=['PATCH'], strict_slashes=False)
  288. @ldp.route('/', defaults={'uid': '/'}, methods=['PATCH'],
  289. strict_slashes=False)
  290. def patch_resource(uid, is_metadata=False):
  291. """
  292. https://www.w3.org/TR/ldp/#ldpr-HTTP_PATCH
  293. Update an existing resource with a SPARQL-UPDATE payload.
  294. """
  295. # Fist check if it's not a 404 or a 410.
  296. try:
  297. if not rsrc_api.exists(uid):
  298. return '', 404
  299. except TombstoneError as e:
  300. return _tombstone_response(e, uid)
  301. # Then process the condition headers.
  302. cond_ret = _process_cond_headers(uid, request.headers, False)
  303. if cond_ret:
  304. return cond_ret
  305. rsp_headers = {'Content-Type' : 'text/plain; charset=utf-8'}
  306. if request.mimetype != 'application/sparql-update':
  307. return 'Provided content type is not a valid parsable format: {}'\
  308. .format(request.mimetype), 415
  309. update_str = request.get_data().decode('utf-8')
  310. local_update_str = g.tbox.localize_ext_str(update_str, nsc['fcres'][uid])
  311. try:
  312. rsrc = rsrc_api.update(uid, local_update_str, is_metadata)
  313. except (ServerManagedTermError, SingleSubjectError) as e:
  314. return str(e), 412
  315. except InvalidResourceError as e:
  316. return str(e), 415
  317. else:
  318. rsp_headers.update(_headers_from_metadata(rsrc))
  319. return '', 204, rsp_headers
  320. @ldp.route('/<path:uid>/fcr:metadata', methods=['PATCH'])
  321. def patch_resource_metadata(uid):
  322. return patch_resource(uid, True)
  323. @ldp.route('/<path:uid>', methods=['DELETE'])
  324. def delete_resource(uid):
  325. """
  326. Delete a resource and optionally leave a tombstone.
  327. This behaves differently from FCREPO. A tombstone indicated that the
  328. resource is no longer available at its current location, but its historic
  329. snapshots still are. Also, deleting a resource with a tombstone creates
  330. one more version snapshot of the resource prior to being deleted.
  331. In order to completely wipe out all traces of a resource, the tombstone
  332. must be deleted as well, or the ``Prefer:no-tombstone`` header can be used.
  333. The latter will forget (completely delete) the resource immediately.
  334. """
  335. # Fist check if it's not a 404 or a 410.
  336. try:
  337. if not rsrc_api.exists(uid):
  338. return '', 404
  339. except TombstoneError as e:
  340. return _tombstone_response(e, uid)
  341. # Then process the condition headers.
  342. cond_ret = _process_cond_headers(uid, request.headers, False)
  343. if cond_ret:
  344. return cond_ret
  345. headers = std_headers.copy()
  346. if 'prefer' in request.headers:
  347. prefer = g.tbox.parse_rfc7240(request.headers['prefer'])
  348. leave_tstone = 'no-tombstone' not in prefer
  349. else:
  350. leave_tstone = True
  351. rsrc_api.delete(uid, leave_tstone)
  352. return '', 204, headers
  353. @ldp.route('/<path:uid>/fcr:tombstone', methods=['GET', 'POST', 'PUT',
  354. 'PATCH', 'DELETE'])
  355. def tombstone(uid):
  356. """
  357. Handle all tombstone operations.
  358. The only allowed methods are POST and DELETE; any other verb will return a
  359. 405.
  360. """
  361. try:
  362. rsrc = rsrc_api.get(uid)
  363. except TombstoneError as e:
  364. if request.method == 'DELETE':
  365. if e.uid == uid:
  366. rsrc_api.delete(uid, False)
  367. return '', 204
  368. else:
  369. return _tombstone_response(e, uid)
  370. elif request.method == 'POST':
  371. if e.uid == uid:
  372. rsrc_uri = rsrc_api.resurrect(uid)
  373. headers = {'Location' : rsrc_uri}
  374. return rsrc_uri, 201, headers
  375. else:
  376. return _tombstone_response(e, uid)
  377. else:
  378. return 'Method Not Allowed.', 405
  379. except ResourceNotExistsError as e:
  380. return str(e), 404
  381. else:
  382. return '', 404
  383. @ldp.route('/<path:uid>/fcr:versions', methods=['POST', 'PUT'])
  384. def post_version(uid):
  385. """
  386. Create a new resource version.
  387. """
  388. if request.method == 'PUT':
  389. return 'Method not allowed.', 405
  390. ver_uid = request.headers.get('slug', None)
  391. try:
  392. ver_uid = rsrc_api.create_version(uid, ver_uid)
  393. except ResourceNotExistsError as e:
  394. return str(e), 404
  395. except InvalidResourceError as e:
  396. return str(e), 409
  397. except TombstoneError as e:
  398. return _tombstone_response(e, uid)
  399. else:
  400. return '', 201, {'Location': g.tbox.uid_to_uri(ver_uid)}
  401. @ldp.route('/<path:uid>/fcr:versions/<ver_uid>', methods=['PATCH'])
  402. def patch_version(uid, ver_uid):
  403. """
  404. Revert to a previous version.
  405. NOTE: This creates a new version snapshot.
  406. :param str uid: Resource UID.
  407. :param str ver_uid: Version UID.
  408. """
  409. try:
  410. rsrc_api.revert_to_version(uid, rsrc_uid)
  411. except ResourceNotExistsError as e:
  412. return str(e), 404
  413. except InvalidResourceError as e:
  414. return str(e), 409
  415. except TombstoneError as e:
  416. return _tombstone_response(e, uid)
  417. else:
  418. return '', 204
  419. ## PRIVATE METHODS ##
  420. def _best_rdf_mimetype():
  421. """
  422. Check if any of the 'Accept' header values provided is a RDF parsable
  423. format.
  424. """
  425. for accept in request.accept_mimetypes:
  426. mimetype = accept[0]
  427. if mimetype in rdf_parsable_mimetypes:
  428. return mimetype
  429. return None
  430. def _negotiate_content(gr, rdf_mimetype, headers=None, **vw_kwargs):
  431. """
  432. Return HTML or serialized RDF depending on accept headers.
  433. """
  434. if request.accept_mimetypes.best == 'text/html':
  435. return render_template(
  436. 'resource.html', gr=gr, nsc=nsc, nsm=nsm,
  437. blacklist=vw_blacklist, arrow=arrow, **vw_kwargs)
  438. else:
  439. for p in vw_blacklist:
  440. gr.remove((None, p, None))
  441. return Response(
  442. gr.serialize(format=rdf_mimetype), 200, headers,
  443. mimetype=rdf_mimetype)
  444. def _bistream_from_req():
  445. """
  446. Find how a binary file and its MIMEtype were uploaded in the request.
  447. """
  448. #logger.debug('Content type: {}'.format(request.mimetype))
  449. #logger.debug('files: {}'.format(request.files))
  450. #logger.debug('stream: {}'.format(request.stream))
  451. if request.mimetype == 'multipart/form-data':
  452. # This seems the "right" way to upload a binary file, with a
  453. # multipart/form-data MIME type and the file in the `file`
  454. # field. This however is not supported by FCREPO4.
  455. stream = request.files.get('file').stream
  456. mimetype = request.files.get('file').content_type
  457. # @TODO This will turn out useful to provide metadata
  458. # with the binary.
  459. #metadata = request.files.get('metadata').stream
  460. else:
  461. # This is a less clean way, with the file in the form body and
  462. # the request as application/x-www-form-urlencoded.
  463. # This is how FCREPO4 accepts binary uploads.
  464. stream = request.stream
  465. # @FIXME Must decide what to do with this.
  466. mimetype = request.mimetype
  467. if mimetype == '' or mimetype == 'application/x-www-form-urlencoded':
  468. if getattr(stream, 'limit', 0) == 0:
  469. stream = mimetype = None
  470. else:
  471. mimetype = 'application/octet-stream'
  472. return stream, mimetype
  473. def _tombstone_response(e, uid):
  474. headers = {
  475. 'Link': '<{}/fcr:tombstone>; rel="hasTombstone"'.format(request.url),
  476. } if e.uid == uid else {}
  477. return str(e), 410, headers
  478. def set_post_put_params():
  479. """
  480. Sets handling and content disposition for POST and PUT by parsing headers.
  481. """
  482. handling = 'strict'
  483. if 'prefer' in request.headers:
  484. prefer = g.tbox.parse_rfc7240(request.headers['prefer'])
  485. logger.debug('Parsed Prefer header: {}'.format(prefer))
  486. if 'handling' in prefer:
  487. handling = prefer['handling']['value']
  488. try:
  489. disposition = g.tbox.parse_rfc7240(
  490. request.headers['content-disposition'])
  491. except KeyError:
  492. disposition = None
  493. return handling, disposition
  494. def parse_repr_options(retr_opts):
  495. """
  496. Set options to retrieve IMR.
  497. Ideally, IMR retrieval is done once per request, so all the options
  498. are set once in the `imr()` property.
  499. :param dict retr_opts:: Options parsed from `Prefer` header.
  500. """
  501. logger.debug('Parsing retrieval options: {}'.format(retr_opts))
  502. imr_options = {}
  503. if retr_opts.get('value') == 'minimal':
  504. imr_options = {
  505. 'embed_children' : False,
  506. 'incl_children' : False,
  507. 'incl_inbound' : False,
  508. 'incl_srv_mgd' : False,
  509. }
  510. else:
  511. # Default.
  512. imr_options = {
  513. 'embed_children' : False,
  514. 'incl_children' : True,
  515. 'incl_inbound' : False,
  516. 'incl_srv_mgd' : True,
  517. }
  518. # Override defaults.
  519. if 'parameters' in retr_opts:
  520. include = retr_opts['parameters']['include'].split(' ') \
  521. if 'include' in retr_opts['parameters'] else []
  522. omit = retr_opts['parameters']['omit'].split(' ') \
  523. if 'omit' in retr_opts['parameters'] else []
  524. logger.debug('Include: {}'.format(include))
  525. logger.debug('Omit: {}'.format(omit))
  526. if str(Ldpr.EMBED_CHILD_RES_URI) in include:
  527. imr_options['embed_children'] = True
  528. if str(Ldpr.RETURN_CHILD_RES_URI) in omit:
  529. imr_options['incl_children'] = False
  530. if str(Ldpr.RETURN_INBOUND_REF_URI) in include:
  531. imr_options['incl_inbound'] = True
  532. if str(Ldpr.RETURN_SRV_MGD_RES_URI) in omit:
  533. imr_options['incl_srv_mgd'] = False
  534. logger.debug('Retrieval options: {}'.format(pformat(imr_options)))
  535. return imr_options
  536. def _headers_from_metadata(rsrc, out_fmt='text/turtle'):
  537. """
  538. Create a dict of headers from a metadata graph.
  539. :param lakesuperior.model.ldpr.Ldpr rsrc: Resource to extract metadata
  540. from.
  541. """
  542. rsp_headers = defaultdict(list)
  543. digest_p = rsrc.metadata.value(nsc['premis'].hasMessageDigest)
  544. # Only add ETag and digest if output is not RDF.
  545. if digest_p:
  546. rsp_headers['ETag'], rsp_headers['Digest'] = (
  547. _digest_headers(digest_p))
  548. last_updated_term = rsrc.metadata.value(nsc['fcrepo'].lastModified)
  549. if last_updated_term:
  550. rsp_headers['Last-Modified'] = arrow.get(last_updated_term)\
  551. .format('ddd, D MMM YYYY HH:mm:ss Z')
  552. for t in rsrc.ldp_types:
  553. rsp_headers['Link'].append('{};rel="type"'.format(t.n3()))
  554. mimetype = rsrc.metadata.value(nsc['ebucore'].hasMimeType)
  555. if mimetype:
  556. rsp_headers['Content-Type'] = mimetype
  557. return rsp_headers
  558. def _digest_headers(digest):
  559. """
  560. Format ETag and Digest headers from resource checksum.
  561. :param str digest: Resource digest. For an extracted IMR, this is the
  562. value of the ``premis:hasMessageDigest`` property.
  563. """
  564. digest_components = digest.split(':')
  565. cksum_hex = digest_components[-1]
  566. cksum = bytearray.fromhex(cksum_hex)
  567. digest_algo = digest_components[-2]
  568. etag_str = cksum_hex
  569. digest_str = '{}={}'.format(
  570. digest_algo.upper(), b64encode(cksum).decode('ascii'))
  571. return etag_str, digest_str
  572. def _condition_hdr_match(uid, headers, safe=True):
  573. """
  574. Conditional header evaluation for HEAD, GET, PUT and DELETE requests.
  575. Determine whether any conditional headers, and which, is/are imposed in the
  576. request (``If-Match``, ``If-None-Match``, ``If-Modified-Since``,
  577. ``If-Unmodified-Since``, or none) and what the most relevant condition
  578. evaluates to (``True`` or ``False``).
  579. `RFC 7232 <https://tools.ietf.org/html/rfc7232#section-3.1>`__ does not
  580. indicate an exact condition precedence, except that the ETag
  581. matching conditions void the timestamp-based ones. This function
  582. adopts the following precedence:
  583. - ``If-Match`` is evaluated first if present;
  584. - Else, ``If-None-Match`` is evaluated if present;
  585. - Else, ``If-Modified-Since`` and ``If-Unmodified-Since``
  586. are evaluated if present. If both conditions are present they are
  587. both returned so they can be furher evaluated, e.g. using a logical AND
  588. to allow time-range conditions, where the two terms indicate the early
  589. and late boundary, respectively.
  590. Note that the above mentioned RFC mentions several cases in which these
  591. conditions are ignored, e.g. for a 404 in some cases, or for certain
  592. HTTP methods for ``If-Modified-Since``. This must be implemented by the
  593. calling function.
  594. :param str uid: UID of the resource requested.
  595. :param werkzeug.datastructures.EnvironHeaders headers: Incoming request
  596. headers.
  597. :param bool safe: Whether a "safe" method is being processed. Defaults to
  598. True.
  599. :rtype: dict (str, bool)
  600. :return: Dictionary whose keys are the conditional header names that
  601. have been evaluated, and whose boolean values indicate whether each
  602. condition is met. If no valid conditional header is found, an empty
  603. dict is returned.
  604. """
  605. # ETag-based conditions.
  606. # This ignores headers with empty values.
  607. if headers.get('if-match') or headers.get('if-none-match'):
  608. cond_hdr = 'if-match' if headers.get('if-match') else 'if-none-match'
  609. # Wildcard matching for unsafe methods. Cannot be part of a list of
  610. # ETags nor be enclosed in quotes.
  611. if not safe and headers.get(cond_hdr) == '*':
  612. return {cond_hdr: (cond_hdr == 'if-match') == rsrc_api.exists(uid)}
  613. req_etags = [
  614. et.strip('\'" ') for et in headers.get(cond_hdr).split(',')]
  615. try:
  616. rsrc_meta = rsrc_api.get_metadata(uid)
  617. except ResourceNotExistsError:
  618. rsrc_meta = Imr(nsc['fcres'][uid])
  619. digest_prop = rsrc_meta.value(nsc['premis'].hasMessageDigest)
  620. if digest_prop:
  621. etag, _ = _digest_headers(digest_prop)
  622. if cond_hdr == 'if-match':
  623. is_match = etag in req_etags
  624. else:
  625. is_match = etag not in req_etags
  626. else:
  627. is_match = cond_hdr == 'if-none-match'
  628. return {cond_hdr: is_match}
  629. # Timestmp-based conditions.
  630. ret = {}
  631. if headers.get('if-modified-since') or headers.get('if-unmodified-since'):
  632. try:
  633. rsrc_meta = rsrc_api.get_metadata(uid)
  634. except ResourceNotExistsError:
  635. return {
  636. 'if-modified-since': False,
  637. 'if-unmodified-since': False
  638. }
  639. lastmod_str = rsrc_meta.value(nsc['fcrepo'].lastModified)
  640. lastmod_date = arrow.get(lastmod_str)
  641. mod_since_date = parse_date(headers.get('if-modified-since'))
  642. # If date is not in a RFC 5322 format
  643. # (https://tools.ietf.org/html/rfc5322#section-3.3) it evaluates to
  644. # None.
  645. if mod_since_date:
  646. cond_hdr = 'if-modified-since'
  647. ret[cond_hdr] = lastmod_ts > mod_since_date
  648. unmod_since_date = parse_date(headers.get('if-unmodified-since'))
  649. if unmod_since_date:
  650. cond_hdr = 'if-unmodified-since'
  651. ret[cond_hdr] = lastmod_ts < unmod_since_date
  652. return ret
  653. def _process_cond_headers(uid, headers, safe=True):
  654. """
  655. Process the outcome of the evaluation of conditional headers.
  656. This yields different response between safe methods (``HEAD``, ``GET``,
  657. etc.) and unsafe ones (``PUT``, ``DELETE``, etc.
  658. :param str uid: Resource UID.
  659. :param werkzeug.datastructures.EnvironHeaders headers: Incoming request
  660. headers.
  661. :param bool safe: Whether a "safe" method is being processed. Defaults to
  662. True.
  663. """
  664. try:
  665. cond_match = _condition_hdr_match(uid, headers, safe)
  666. except TombstoneError as e:
  667. return _tombstone_response(e, uid)
  668. if cond_match:
  669. if safe:
  670. if 'if-match' in cond_match or 'if-none-match' in cond_match:
  671. # If an expected list of tags is not matched, the response is
  672. # "Precondition Failed". For all other cases, it's "Not Modified".
  673. if not cond_match.get('if-match', True):
  674. return '', 412
  675. if not cond_match.get('if-none-match', True):
  676. return '', 304
  677. # The presence of an Etag-based condition, whether satisfied or not,
  678. # voids the timestamp-based conditions.
  679. elif (
  680. not cond_match.get('if-modified-since', True) or
  681. not cond_match.get('if-unmodified-since', True)):
  682. return '', 304
  683. else:
  684. # Note that If-Modified-Since is only evaluated for safe methods.
  685. if 'if-match' in cond_match or 'if-none-match' in cond_match:
  686. if (
  687. not cond_match.get('if-match', True) or
  688. not cond_match.get('if-none-match', True)):
  689. return '', 412
  690. # The presence of an Etag-based condition, whether satisfied or not,
  691. # voids the timestamp-based conditions.
  692. elif not cond_match.get('if-unmodified-since', True):
  693. return '', 412