ldp.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. import logging
  2. from collections import defaultdict
  3. from pprint import pformat
  4. from uuid import uuid4
  5. import arrow
  6. from flask import (
  7. Blueprint, current_app, g, make_response, render_template,
  8. request, send_file)
  9. from rdflib.namespace import RDF, XSD
  10. from rdflib.term import Literal
  11. from lakesuperior.dictionaries.namespaces import ns_collection as nsc
  12. from lakesuperior.dictionaries.namespaces import ns_mgr as nsm
  13. from lakesuperior.exceptions import *
  14. from lakesuperior.model.ldpr import Ldpr
  15. from lakesuperior.model.ldp_nr import LdpNr
  16. from lakesuperior.model.ldp_rs import LdpRs
  17. from lakesuperior.toolbox import Toolbox
  18. logger = logging.getLogger(__name__)
  19. # Blueprint for LDP REST API. This is what is usually found under `/rest/` in
  20. # standard fcrepo4. Here, it is under `/ldp` but initially `/rest` can be kept
  21. # for backward compatibility.
  22. ldp = Blueprint(
  23. 'ldp', __name__, template_folder='templates',
  24. static_url_path='/static', static_folder='../../static')
  25. accept_patch = (
  26. 'application/sparql-update',
  27. )
  28. accept_rdf = (
  29. 'application/ld+json',
  30. 'application/n-triples',
  31. 'application/rdf+xml',
  32. #'application/x-turtle',
  33. #'application/xhtml+xml',
  34. #'application/xml',
  35. #'text/html',
  36. 'text/n3',
  37. #'text/plain',
  38. 'text/rdf+n3',
  39. 'text/turtle',
  40. )
  41. std_headers = {
  42. 'Accept-Patch' : ','.join(accept_patch),
  43. 'Accept-Post' : ','.join(accept_rdf),
  44. #'Allow' : ','.join(allow),
  45. }
  46. @ldp.url_defaults
  47. def bp_url_defaults(endpoint, values):
  48. url_prefix = getattr(g, 'url_prefix', None)
  49. if url_prefix is not None:
  50. values.setdefault('url_prefix', url_prefix)
  51. @ldp.url_value_preprocessor
  52. def bp_url_value_preprocessor(endpoint, values):
  53. g.url_prefix = values.pop('url_prefix')
  54. g.webroot = request.host_url + g.url_prefix
  55. @ldp.before_request
  56. def log_request_start():
  57. logger.info('\n\n** Start {} {} **'.format(request.method, request.url))
  58. @ldp.before_request
  59. def instantiate_toolbox():
  60. g.tbox = Toolbox()
  61. @ldp.before_request
  62. def request_timestamp():
  63. g.timestamp = arrow.utcnow()
  64. g.timestamp_term = Literal(g.timestamp, datatype=XSD.dateTime)
  65. @ldp.after_request
  66. def log_request_end(rsp):
  67. logger.info('** End {} {} **\n\n'.format(request.method, request.url))
  68. return rsp
  69. ## REST SERVICES ##
  70. @ldp.route('/<path:uuid>', methods=['GET'], strict_slashes=False)
  71. @ldp.route('/', defaults={'uuid': None}, methods=['GET'], strict_slashes=False)
  72. @ldp.route('/<path:uuid>/fcr:metadata', defaults={'force_rdf' : True},
  73. methods=['GET'])
  74. def get_resource(uuid, force_rdf=False):
  75. '''
  76. Retrieve RDF or binary content.
  77. @param uuid (string) UUID of resource to retrieve.
  78. @param force_rdf (boolean) Whether to retrieve RDF even if the resource is
  79. a LDP-NR. This is not available in the API but is used e.g. by the
  80. `*/fcr:metadata` endpoint. The default is False.
  81. '''
  82. out_headers = std_headers
  83. repr_options = defaultdict(dict)
  84. if 'prefer' in request.headers:
  85. prefer = g.tbox.parse_rfc7240(request.headers['prefer'])
  86. logger.debug('Parsed Prefer header: {}'.format(pformat(prefer)))
  87. if 'return' in prefer:
  88. repr_options = parse_repr_options(prefer['return'])
  89. try:
  90. rsrc = Ldpr.outbound_inst(uuid, repr_options)
  91. except ResourceNotExistsError as e:
  92. return str(e), 404
  93. except TombstoneError as e:
  94. return _tombstone_response(e, uuid)
  95. else:
  96. out_headers.update(rsrc.head())
  97. if isinstance(rsrc, LdpRs) \
  98. or is_accept_hdr_rdf_parsable() \
  99. or force_rdf:
  100. resp = rsrc.get()
  101. if request.accept_mimetypes.best == 'text/html':
  102. rsrc = resp.resource(request.path)
  103. return render_template('resource.html', rsrc=rsrc, nsm=nsm)
  104. else:
  105. return (resp.serialize(format='turtle'), out_headers)
  106. else:
  107. logger.info('Streaming out binary content.')
  108. rsp = make_response(send_file(rsrc.local_path, as_attachment=True,
  109. attachment_filename=rsrc.filename))
  110. rsp.headers['Link'] = '<{}/fcr:metadata>; rel="describedby"'\
  111. .format(rsrc.uri)
  112. return rsp
  113. @ldp.route('/<path:parent>', methods=['POST'], strict_slashes=False)
  114. @ldp.route('/', defaults={'parent': None}, methods=['POST'],
  115. strict_slashes=False)
  116. def post_resource(parent):
  117. '''
  118. Add a new resource in a new URI.
  119. '''
  120. out_headers = std_headers
  121. try:
  122. slug = request.headers['Slug']
  123. logger.info('Slug: {}'.format(slug))
  124. except KeyError:
  125. slug = None
  126. handling, disposition = set_post_put_params()
  127. stream, mimetype = bitstream_from_req()
  128. try:
  129. uuid = uuid_for_post(parent, slug)
  130. logger.debug('Generated UUID for POST: {}'.format(uuid))
  131. rsrc = Ldpr.inbound_inst(uuid, content_length=request.content_length,
  132. stream=stream, mimetype=mimetype, handling=handling,
  133. disposition=disposition)
  134. except ResourceNotExistsError as e:
  135. return str(e), 404
  136. except InvalidResourceError as e:
  137. return str(e), 409
  138. except TombstoneError as e:
  139. return _tombstone_response(e, uuid)
  140. try:
  141. rsrc.post()
  142. except ServerManagedTermError as e:
  143. return str(e), 412
  144. hdr = {
  145. 'Location' : rsrc.uri,
  146. }
  147. if isinstance(rsrc, LdpNr):
  148. hdr['Link'] = '<{0}/fcr:metadata>; rel="describedby"; anchor="<{0}>"'\
  149. .format(rsrc.uri)
  150. out_headers.update(hdr)
  151. return rsrc.uri, 201, out_headers
  152. @ldp.route('/<path:uuid>/fcr:versions', methods=['GET'])
  153. def get_version_info(uuid):
  154. '''
  155. Get version info (`fcr:versions`).
  156. '''
  157. try:
  158. rsp = Ldpr(uuid).get_version_info()
  159. except ResourceNotExistsError as e:
  160. return str(e), 404
  161. except InvalidResourceError as e:
  162. return str(e), 409
  163. except TombstoneError as e:
  164. return _tombstone_response(e, uuid)
  165. else:
  166. return rsp.serialize(format='turtle'), 200
  167. @ldp.route('/<path:uuid>/fcr:versions/<ver_uid>', methods=['GET'])
  168. def get_version(uuid, ver_uid):
  169. '''
  170. Get an individual resource version.
  171. @param uuid (string) Resource UUID.
  172. @param ver_uid (string) Version UID.
  173. '''
  174. try:
  175. rsp = Ldpr(uuid).get_version(ver_uid)
  176. except ResourceNotExistsError as e:
  177. return str(e), 404
  178. except InvalidResourceError as e:
  179. return str(e), 409
  180. except TombstoneError as e:
  181. return _tombstone_response(e, uuid)
  182. else:
  183. return rsp.serialize(format='turtle'), 200
  184. @ldp.route('/<path:uuid>/fcr:versions', methods=['POST'])
  185. def post_version(uuid):
  186. '''
  187. Create a new resource version.
  188. '''
  189. ver_uid = request.headers.get('slug', None)
  190. try:
  191. ver_uri = Ldpr.outbound_inst(uuid).create_version(ver_uid)
  192. except ResourceNotExistsError as e:
  193. return str(e), 404
  194. except InvalidResourceError as e:
  195. return str(e), 409
  196. except TombstoneError as e:
  197. return _tombstone_response(e, uuid)
  198. else:
  199. return '', 201, {'Location': ver_uri}
  200. @ldp.route('/<path:uuid>/fcr:versions/<ver_uid>', methods=['PATCH'])
  201. def patch_version(uuid, ver_uid):
  202. '''
  203. Revert to a previous version.
  204. NOTE: This creates a new version snapshot.
  205. @param uuid (string) Resource UUID.
  206. @param ver_uid (string) Version UID.
  207. '''
  208. try:
  209. Ldpr.outbound_inst(uuid).revert_to_version(ver_uid)
  210. except ResourceNotExistsError as e:
  211. return str(e), 404
  212. except InvalidResourceError as e:
  213. return str(e), 409
  214. except TombstoneError as e:
  215. return _tombstone_response(e, uuid)
  216. else:
  217. return '', 204
  218. @ldp.route('/<path:uuid>', methods=['PUT'], strict_slashes=False)
  219. @ldp.route('/<path:uuid>/fcr:metadata', defaults={'force_rdf' : True},
  220. methods=['PUT'])
  221. def put_resource(uuid):
  222. '''
  223. Add a new resource at a specified URI.
  224. '''
  225. # Parse headers.
  226. logger.info('Request headers: {}'.format(request.headers))
  227. rsp_headers = std_headers
  228. handling, disposition = set_post_put_params()
  229. stream, mimetype = bitstream_from_req()
  230. try:
  231. rsrc = Ldpr.inbound_inst(uuid, content_length=request.content_length,
  232. stream=stream, mimetype=mimetype, handling=handling,
  233. disposition=disposition)
  234. except InvalidResourceError as e:
  235. return str(e), 409
  236. except ServerManagedTermError as e:
  237. return str(e), 412
  238. except IncompatibleLdpTypeError as e:
  239. return str(e), 415
  240. try:
  241. ret = rsrc.put()
  242. except (InvalidResourceError, ResourceExistsError ) as e:
  243. return str(e), 409
  244. except TombstoneError as e:
  245. return _tombstone_response(e, uuid)
  246. if ret == Ldpr.RES_CREATED:
  247. rsp_code = 201
  248. rsp_headers['Location'] = rsp_body = rsrc.uri
  249. if isinstance(rsrc, LdpNr):
  250. rsp_headers['Link'] = '<{0}/fcr:metadata>; rel="describedby"'\
  251. .format(rsrc.uri)
  252. else:
  253. rsp_code = 204
  254. rsp_body = ''
  255. return rsp_body, rsp_code, rsp_headers
  256. @ldp.route('/<path:uuid>', methods=['PATCH'], strict_slashes=False)
  257. def patch_resource(uuid):
  258. '''
  259. Update an existing resource with a SPARQL-UPDATE payload.
  260. '''
  261. headers = std_headers
  262. rsrc = LdpRs(uuid)
  263. if request.mimetype != 'application/sparql-update':
  264. return 'Provided content type is not a valid parsable format: {}'\
  265. .format(request.mimetype), 415
  266. try:
  267. rsrc.patch(request.get_data().decode('utf-8'))
  268. except ResourceNotExistsError as e:
  269. return str(e), 404
  270. except TombstoneError as e:
  271. return _tombstone_response(e, uuid)
  272. except ServerManagedTermError as e:
  273. return str(e), 412
  274. return '', 204, headers
  275. @ldp.route('/<path:uuid>/fcr:metadata', methods=['PATCH'])
  276. def patch_resource_metadata(uuid):
  277. return patch_resource(uuid)
  278. @ldp.route('/<path:uuid>', methods=['DELETE'])
  279. def delete_resource(uuid):
  280. '''
  281. Delete a resource and optionally leave a tombstone.
  282. This behaves differently from FCREPO. A tombstone indicated that the
  283. resource is no longer available at its current location, but its historic
  284. snapshots still are. Also, deleting a resource with a tombstone creates
  285. one more version snapshot of the resource prior to being deleted.
  286. In order to completely wipe out all traces of a resource, the tombstone
  287. must be deleted as well, or the `Prefer:no-tombstone` header can be used.
  288. The latter will purge the resource immediately.
  289. '''
  290. headers = std_headers
  291. # If referential integrity is enforced, grab all inbound relationships
  292. # to break them.
  293. repr_opts = {'incl_inbound' : True} \
  294. if current_app.config['store']['ldp_rs']['referential_integrity'] \
  295. else {}
  296. if 'prefer' in request.headers:
  297. prefer = g.tbox.parse_rfc7240(request.headers['prefer'])
  298. leave_tstone = 'no-tombstone' not in prefer
  299. else:
  300. leave_tstone = True
  301. try:
  302. Ldpr.outbound_inst(uuid, repr_opts).delete(leave_tstone=leave_tstone)
  303. except ResourceNotExistsError as e:
  304. return str(e), 404
  305. except TombstoneError as e:
  306. return _tombstone_response(e, uuid)
  307. return '', 204, headers
  308. @ldp.route('/<path:uuid>/fcr:tombstone', methods=['GET', 'POST', 'PUT',
  309. 'PATCH', 'DELETE'])
  310. def tombstone(uuid):
  311. '''
  312. Handle all tombstone operations.
  313. The only allowed methods are POST and DELETE; any other verb will return a
  314. 405.
  315. '''
  316. logger.debug('Deleting tombstone for {}.'.format(uuid))
  317. rsrc = Ldpr(uuid)
  318. try:
  319. imr = rsrc.imr
  320. except TombstoneError as e:
  321. if request.method == 'DELETE':
  322. if e.uuid == uuid:
  323. rsrc.purge()
  324. return '', 204
  325. else:
  326. return _tombstone_response(e, uuid)
  327. elif request.method == 'POST':
  328. if e.uuid == uuid:
  329. rsrc_uri = rsrc.resurrect()
  330. headers = {'Location' : rsrc_uri}
  331. return rsrc_uri, 201, headers
  332. else:
  333. return _tombstone_response(e, uuid)
  334. else:
  335. return 'Method Not Allowed.', 405
  336. except ResourceNotExistsError as e:
  337. return str(e), 404
  338. else:
  339. return '', 404
  340. def uuid_for_post(parent_uuid=None, slug=None):
  341. '''
  342. Validate conditions to perform a POST and return an LDP resource
  343. UUID for using with the `post` method.
  344. This may raise an exception resulting in a 404 if the parent is not
  345. found or a 409 if the parent is not a valid container.
  346. '''
  347. def split_if_legacy(uuid):
  348. if current_app.config['store']['ldp_rs']['legacy_ptree_split']:
  349. uuid = g.tbox.split_uuid(uuid)
  350. return uuid
  351. # Shortcut!
  352. if not slug and not parent_uuid:
  353. uuid = split_if_legacy(str(uuid4()))
  354. return uuid
  355. parent = Ldpr.outbound_inst(parent_uuid, repr_opts={'incl_children' : False})
  356. if nsc['fcrepo'].Pairtree in parent.types:
  357. raise InvalidResourceError(parent.uuid,
  358. 'Resources cannot be created under a pairtree.')
  359. # Set prefix.
  360. if parent_uuid:
  361. parent_types = { t.identifier for t in \
  362. parent.imr.objects(RDF.type) }
  363. logger.debug('Parent types: {}'.format(pformat(parent_types)))
  364. if nsc['ldp'].Container not in parent_types:
  365. raise InvalidResourceError('Parent {} is not a container.'
  366. .format(parent_uuid))
  367. pfx = parent_uuid + '/'
  368. else:
  369. pfx = ''
  370. # Create candidate UUID and validate.
  371. if slug:
  372. cnd_uuid = pfx + slug
  373. if current_app.rdfly.ask_rsrc_exists(nsc['fcres'][cnd_uuid]):
  374. uuid = pfx + split_if_legacy(str(uuid4()))
  375. else:
  376. uuid = cnd_uuid
  377. else:
  378. uuid = pfx + split_if_legacy(str(uuid4()))
  379. return uuid
  380. def bitstream_from_req():
  381. '''
  382. Find how a binary file and its MIMEtype were uploaded in the request.
  383. '''
  384. logger.debug('Content type: {}'.format(request.mimetype))
  385. logger.debug('files: {}'.format(request.files))
  386. logger.debug('stream: {}'.format(request.stream))
  387. if request.mimetype == 'multipart/form-data':
  388. # This seems the "right" way to upload a binary file, with a
  389. # multipart/form-data MIME type and the file in the `file`
  390. # field. This however is not supported by FCREPO4.
  391. stream = request.files.get('file').stream
  392. mimetype = request.files.get('file').content_type
  393. # @TODO This will turn out useful to provide metadata
  394. # with the binary.
  395. #metadata = request.files.get('metadata').stream
  396. #provided_imr = [parse RDF here...]
  397. else:
  398. # This is a less clean way, with the file in the form body and
  399. # the request as application/x-www-form-urlencoded.
  400. # This is how FCREPO4 accepts binary uploads.
  401. stream = request.stream
  402. mimetype = request.mimetype
  403. return stream, mimetype
  404. def _get_bitstream(rsrc):
  405. out_headers = std_headers
  406. # @TODO This may change in favor of more low-level handling if the file
  407. # system is not local.
  408. return send_file(rsrc.local_path, as_attachment=True,
  409. attachment_filename=rsrc.filename)
  410. def _tombstone_response(e, uuid):
  411. headers = {
  412. 'Link' : '<{}/fcr:tombstone>; rel="hasTombstone"'.format(request.url),
  413. } if e.uuid == uuid else {}
  414. return str(e), 410, headers
  415. def set_post_put_params():
  416. '''
  417. Sets handling and content disposition for POST and PUT by parsing headers.
  418. '''
  419. handling = None
  420. if 'prefer' in request.headers:
  421. prefer = g.tbox.parse_rfc7240(request.headers['prefer'])
  422. logger.debug('Parsed Prefer header: {}'.format(prefer))
  423. if 'handling' in prefer:
  424. handling = prefer['handling']['value']
  425. try:
  426. disposition = g.tbox.parse_rfc7240(
  427. request.headers['content-disposition'])
  428. except KeyError:
  429. disposition = None
  430. return handling, disposition
  431. def is_accept_hdr_rdf_parsable():
  432. '''
  433. Check if any of the 'Accept' header values provided is a RDF parsable
  434. format.
  435. '''
  436. for mimetype in request.accept_mimetypes.values():
  437. if Ldpr.is_rdf_parsable(mimetype):
  438. return True
  439. return False
  440. def parse_repr_options(retr_opts):
  441. '''
  442. Set options to retrieve IMR.
  443. Ideally, IMR retrieval is done once per request, so all the options
  444. are set once in the `imr()` property.
  445. @param retr_opts (dict): Options parsed from `Prefer` header.
  446. '''
  447. logger.debug('Parsing retrieval options: {}'.format(retr_opts))
  448. imr_options = {}
  449. if retr_opts.get('value') == 'minimal':
  450. imr_options = {
  451. 'embed_children' : False,
  452. 'incl_children' : False,
  453. 'incl_inbound' : False,
  454. 'incl_srv_mgd' : False,
  455. }
  456. else:
  457. # Default.
  458. imr_options = {
  459. 'embed_children' : False,
  460. 'incl_children' : True,
  461. 'incl_inbound' : False,
  462. 'incl_srv_mgd' : True,
  463. }
  464. # Override defaults.
  465. if 'parameters' in retr_opts:
  466. include = retr_opts['parameters']['include'].split(' ') \
  467. if 'include' in retr_opts['parameters'] else []
  468. omit = retr_opts['parameters']['omit'].split(' ') \
  469. if 'omit' in retr_opts['parameters'] else []
  470. logger.debug('Include: {}'.format(include))
  471. logger.debug('Omit: {}'.format(omit))
  472. if str(Ldpr.EMBED_CHILD_RES_URI) in include:
  473. imr_options['embed_children'] = True
  474. if str(Ldpr.RETURN_CHILD_RES_URI) in omit:
  475. imr_options['incl_children'] = False
  476. if str(Ldpr.RETURN_INBOUND_REF_URI) in include:
  477. imr_options['incl_inbound'] = True
  478. if str(Ldpr.RETURN_SRV_MGD_RES_URI) in omit:
  479. imr_options['incl_srv_mgd'] = False
  480. logger.debug('Retrieval options: {}'.format(pformat(imr_options)))
  481. return imr_options