소스 검색

Simple store strategy and basic LDP interaction.

Stefano Cossu 6 년 전
부모
커밋
3ac5e2d7e5

+ 3 - 0
etc.skeleton/application.yml

@@ -22,6 +22,9 @@ store:
     # The semantic store used for persisting LDP-RS (RDF Source) resources.
     # MUST support SPARQL 1.1 query and update.
     ldp_rs:
+        # Store strategy. This corresponds to a sub-class of the
+        # `lakesuperior.connectors.graph_store_connector.BaseGraphStoreConnector`.
+        strategy: simple_strategy
         webroot: http://localhost:9999/namespace/fcrepo/
         query_ep: sparql
         update_ep: sparql

+ 1 - 1
lakesuperior/connectors/filesystem_connector.py

@@ -1,4 +1,4 @@
 import logging
 
 class FilesystemConnector:
-
+    pass

+ 67 - 27
lakesuperior/connectors/graph_store_connector.py

@@ -1,70 +1,110 @@
 import logging
+import uuid
 
+from flask import request
 from rdflib import Dataset
-from rdflib.plugins.sparql import prepareQuery
 from rdflib.plugins.stores.sparqlstore import SPARQLUpdateStore
+from rdflib.term import URIRef
 
 from lakesuperior.config_parser import config
+from lakesuperior.core.namespaces import ns_collection as nsc
+from lakesuperior.core.namespaces import ns_mgr as nsm
+
 
 class GraphStoreConnector:
     '''Connector for LDP-RS (RDF Source) resources. Connects to a
-    triplestore.'''
+    triplestore.
+    '''
 
     _conf = config['application']['store']['ldp_rs']
     _logger = logging.getLogger(__module__)
 
+    query_ep = _conf['webroot'] + _conf['query_ep']
+    update_ep = _conf['webroot'] + _conf['update_ep']
+
 
     ## MAGIC METHODS ##
 
-    def __init__(self, method=POST):
-        '''Initialize the graph store.
+    @property
+    def store(self):
+        if not hasattr(self, '_store') or not self._store:
+            self._store = SPARQLUpdateStore(
+                    queryEndpoint=self.query_ep,
+                    update_endpoint=self.update_ep,
+                    autocommit=False)
 
-        @param method (string) HTTP method to use for the query. POST is the
-        default and recommended value since it places virtually no limitation
-        on the query string length.
+        return self._store
+
+
+    def __init__(self):
+        '''Initialize the graph store.
 
         NOTE: `rdflib.Dataset` requires a RDF 1.1 compliant store with support
         for Graph Store HTTP protocol
-        (https://www.w3.org/TR/sparql11-http-rdf-update/). This may not be
-        viable with the current version of Blazegraph. It would with Fuseki,
+        (https://www.w3.org/TR/sparql11-http-rdf-update/). Blazegraph supports
+        this only in the (currently) unreleased 2.2 branch. It works with Jena,
         but other considerations would have to be made (e.g. Jena has no REST
         API for handling transactions).
-        '''
-
-        self._store = SPARQLUpdateStore(queryEnpdpoint=self._conf['query_ep'],
-                update_endpoint=self._conf['update_ep'])
-        try:
-            self._store.open(self._conf['base_url'])
-        except:
-            raise RuntimeError('Error opening remote graph store.')
-        self.dataset = Dataset(self._store)
 
+        In a more advanced development phase it could be possible to extend the
+        SPARQLUpdateStore class to add non-standard interaction with specific
+        SPARQL implementations in order to support ACID features provided
+        by them; e.g. Blazegraph's RESTful transaction handling methods.
+        '''
+        self.ds = Dataset(self.store, default_union=True)
+        self.ds.namespace_manager = nsm
 
     def __del__(self):
         '''Commit pending transactions and close connection.'''
-        self._store.close(True)
+        self.store.close(True)
 
 
     ## PUBLIC METHODS ##
 
-    def query(self, q, initNs=None, initBindings=None):
-        '''Query the triplestore.
+    def query(self, q, initBindings=None, nsc=nsc):
+        '''Perform a custom query on the triplestore.
 
         @param q (string) SPARQL query.
 
         @return rdflib.query.Result
         '''
         self._logger.debug('Querying SPARQL endpoint: {}'.format(q))
-        return self.dataset.query(q, initNs=initNs or nsc,
-                initBindings=initBindings)
+        return self.ds.query(q, initBindings=initBindings, initNs=nsc)
 
 
-    def find_by_type(self, type):
-        '''Find all resources by RDF type.
+    ## PROTECTED METHODS ##
 
-        @param type (rdflib.term.URIRef) RDF type to query.
+    def _rdf_cksum(self, g):
+        '''Generate a checksum for a graph.
+
+        This is not straightforward because a graph is derived from an
+        unordered data structure (RDF).
+
+        What this method does is ordering the graph by subject, predicate,
+        object, then creating a pickle string and a checksum of it.
+
+        N.B. The context of the triples is ignored, so isomorphic graphs would
+        have the same checksum regardless of the context(s) they are found in.
+
+        @TODO This can be later reworked to use a custom hashing algorithm.
+
+        @param rdflib.Graph g The graph to be hashed.
+
+        @return string SHA1 checksum.
         '''
-        return self.query('SELECT ?s {{?s a {} . }}'.format(type.n3()))
+        # Remove the messageDigest property, which at this point is very likely
+        # old.
+        g.remove((Variable('s'), nsc['premis'].messageDigest, Variable('o')))
+
+        ord_g = sorted(list(g), key=lambda x : (x[0], x[1], x[2]))
+        hash = sha1(pickle.dumps(ord_g)).hexdigest()
 
+        return hash
 
 
+    def _non_rdf_checksum(self, data):
+        '''Generate a checksum of non-RDF content.
+
+        @TODO This can be later reworked to use a custom hashing algorithm.
+        '''
+        return sha1(data).hexdigest()

+ 0 - 0
lakesuperior/config/README → lakesuperior/core/README


+ 9 - 6
lakesuperior/config/namespaces.py → lakesuperior/core/namespaces.py

@@ -3,7 +3,7 @@ import rdflib
 from rdflib import Graph
 from rdflib.namespace import Namespace, NamespaceManager
 
-from lakesuperior.configparser import config
+from lakesuperior.config_parser import config
 
 # Core namespace prefixes. These add to and override any user-defined prefixes.
 # @TODO Some of these have been copy-pasted from FCREPO4 and may be deprecated.
@@ -11,27 +11,30 @@ core_namespaces = {
     'authz' : Namespace('http://fedora.info/definitions/v4/authorization#'),
     'cnt' : Namespace('http://www.w3.org/2011/content#'),
     'dc' : rdflib.namespace.DC,
-    'dcterms' : namespace.DCTERMS,
+    'dcterms' : rdflib.namespace.DCTERMS,
     'ebucore' : Namespace('http://www.ebu.ch/metadata/ontologies/ebucore/ebucore#'),
     'fedora' : Namespace('http://fedora.info/definitions/v4/repository#'),
     'fedoraconfig' : Namespace('http://fedora.info/definitions/v4/config#'), # fcrepo =< 4.7
     'gen' : Namespace('http://www.w3.org/2006/gen/ont#'),
     'iana' : Namespace('http://www.iana.org/assignments/relation/'),
+    'lake' : Namespace('http://definitions.artic.edu/ontology/lake#'),
+    'lakemeta' : Namespace('http://definitions.artic.edu/ontology/lake/metadata#'),
     'ldp' : Namespace('http://www.w3.org/ns/ldp#'),
     'owl' : rdflib.namespace.OWL,
     'premis' : Namespace('http://www.loc.gov/premis/rdf/v1#'),
     'rdf' : rdflib.namespace.RDF,
     'rdfs' : rdflib.namespace.RDFS,
-    'res' : Namespace('http://definitions.artic.edu/lake/resource#'),
-    'snap' : Namespace('http://definitions.artic.edu/lake/snapshot#'),
+    'fcg' : Namespace('urn:fcgraph:'),
+    'fcres' : Namespace('urn:fcres:'),
+    'fcstate' : Namespace('urn:fcstate:'),
     'webac' : Namespace('http://www.w3.org/ns/auth/acl#'),
     'xml' : Namespace('http://www.w3.org/XML/1998/namespace'),
     'xsd' : rdflib.namespace.XSD,
     'xsi' : Namespace('http://www.w3.org/2001/XMLSchema-instance'),
 }
 
-ns_collection = config['namespaces'][:]
-ns_collection.update(core_namespaces)
+ns_collection = core_namespaces.copy()
+ns_collection.update(config['namespaces'])
 
 ns_mgr = NamespaceManager(Graph())
 ns_pfx_sparql = dict()

+ 248 - 0
lakesuperior/ldp/ldpr.py

@@ -0,0 +1,248 @@
+from abc import ABCMeta
+from importlib import import_module
+
+import arrow
+
+from rdflib import Graph
+from rdflib.namespace import XSD
+from rdflib.term import Literal
+
+from lakesuperior.config_parser import config
+from lakesuperior.connectors.filesystem_connector import FilesystemConnector
+from lakesuperior.core.namespaces import ns_collection as nsc
+
+
+class Ldpr(metaclass=ABCMeta):
+    '''LDPR (LDP Resource).
+
+    Definition: https://www.w3.org/TR/ldp/#ldpr-resource
+
+    This class and related subclasses contain the implementation pieces of
+    the vanilla LDP specifications. This is extended by the
+    `lakesuperior.fcrepo.Resource` class.
+
+    Inheritance graph: https://www.w3.org/TR/ldp/#fig-ldpc-types
+
+    Note: Even though LdpNr (which is a subclass of Ldpr) handles binary files,
+    it still has an RDF representation in the triplestore. Hence, some of the
+    RDF-related methods are defined in this class rather than in the LdpRs
+    class.
+
+    Convention notes:
+
+    All the methods in this class handle internal UUIDs (URN). Public-facing
+    URIs are converted from URNs and passed by these methods to the methods
+    handling HTTP negotiation.
+
+    The data passed to the store strategy for processing should be in a graph.
+    All conversion from request payload strings is done here.
+    '''
+
+    LDP_NR_TYPE = nsc['ldp'].NonRDFSource
+    LDP_RS_TYPE = nsc['ldp'].RDFSource
+
+    store_strategy = config['application']['store']['ldp_rs']['strategy']
+
+
+    ## MAGIC METHODS ##
+
+    def __init__(self, uuid):
+        '''Instantiate an in-memory LDP resource that can be loaded from and
+        persisted to storage.
+
+        @param uuid (string) UUID of the resource.
+        '''
+        # Dynamically load the store strategy indicated in the configuration.
+        store_mod = import_module(
+                'lakesuperior.store_strategies.rdf.{}'.format(
+                        self.store_strategy))
+        store_cls = getattr(store_mod, self._camelcase(self.store_strategy))
+        self.gs = store_cls()
+
+        # Same thing coud be done for the filesystem store strategy, but we
+        # will keep it simple for now.
+        self.fs = FilesystemConnector()
+
+        self.uuid = uuid
+
+
+    @property
+    def urn(self):
+        return nsc['fcres'][self.uuid]
+
+
+    @property
+    def uri(self):
+        return self.gs.uuid_to_uri(self.uuid)
+
+
+    @property
+    def types(self):
+        '''The LDP types.
+
+        @return tuple(rdflib.term.URIRef)
+        '''
+        return self.gs.list_types(self.uuid)
+
+
+    ## LDP METHODS ##
+
+    def get(self):
+        '''
+        https://www.w3.org/TR/ldp/#ldpr-HTTP_GET
+        '''
+        ret = self.gs.get_rsrc(self.uuid)
+
+        return ret
+
+
+    def post(self, data, format='text/turtle'):
+        '''
+        https://www.w3.org/TR/ldp/#ldpr-HTTP_POST
+        '''
+        # @TODO Use gunicorn to get request timestamp.
+        ts = Literal(arrow.utcnow(), datatype=XSD.dateTime)
+
+        g = Graph()
+        g.parse(data=data, format=format, publicID=self.urn)
+
+        data.add((self.urn, nsc['fedora'].lastUpdated, ts))
+        data.add((self.urn, nsc['fedora'].lastUpdatedBy,
+                Literal('BypassAdmin')))
+
+        self.gs.create_rsrc(self.urn, g, ts)
+
+        self.gs.conn.store.commit()
+
+
+    def put(self, data, format='text/turtle'):
+        '''
+        https://www.w3.org/TR/ldp/#ldpr-HTTP_PUT
+        '''
+        # @TODO Use gunicorn to get request timestamp.
+        ts = Literal(arrow.utcnow(), datatype=XSD.dateTime)
+
+        g = Graph()
+        g.parse(data=data, format=format, publicID=self.urn)
+
+        g.add((self.urn, nsc['fedora'].lastUpdated, ts))
+        g.add((self.urn, nsc['fedora'].lastUpdatedBy,
+                Literal('BypassAdmin')))
+
+        self.gs.create_or_replace_rsrc(self.urn, g, ts)
+
+        self.gs.conn.store.commit()
+
+
+    def delete(self):
+        '''
+        https://www.w3.org/TR/ldp/#ldpr-HTTP_DELETE
+        '''
+        self.gs.delete_rsrc(self.urn, commit=True)
+
+
+    ## PROTECTED METHODS ##
+
+    def _create_containment_rel(self):
+        '''Find the closest parent in the path indicated by the UUID and
+        establish a containment triple.
+
+        E.g. If ONLY urn:res:a exist:
+
+        - If a/b is being created, a becomes container of a/b.
+        - If a/b/c/d is being created, a becomes container of a/b/c/d.
+        - If e is being created, the root node becomes container of e.
+          (verify if this is useful or necessary in any way).
+        - If only a and a/b/c/d exist, and therefore a contains a/b/c/d, and
+        a/b is created:
+          - a ceases to be the container of a/b/c/d
+          - a becomes container of a/b
+          - a/b becomes container of a/b/c/d.
+        '''
+        if self.gs.list_containment_statements(self.urn):
+            pass
+
+
+    def _camelcase(self, word):
+        '''
+        Convert a string with underscores with a camel-cased one.
+
+        Ripped from https://stackoverflow.com/a/6425628
+        '''
+        return ''.join(x.capitalize() or '_' for x in word.split('_'))
+
+
+
+class LdpRs(Ldpr):
+    '''LDP-RS (LDP RDF source).
+
+    Definition: https://www.w3.org/TR/ldp/#ldprs
+    '''
+    base_types = {
+        nsc['ldp'].RDFSource
+    }
+
+    def patch(self, data):
+        '''
+        https://www.w3.org/TR/ldp/#ldpr-HTTP_PATCH
+        '''
+        ts = Literal(arrow.utcnow(), datatype=XSD.dateTime)
+
+        self.gs.patch_rsrc(self.urn, data, ts)
+
+        self.gs.ds.add((self.urn, nsc['fedora'].lastUpdated, ts))
+        self.gs.ds.add((self.urn, nsc['fedora'].lastUpdatedBy,
+                Literal('BypassAdmin')))
+
+        self.gs.conn.store.commit()
+
+
+class LdpNr(LdpRs):
+    '''LDP-NR (Non-RDF Source).
+
+    Definition: https://www.w3.org/TR/ldp/#ldpnr
+    '''
+    pass
+
+
+
+class Ldpc(LdpRs):
+    '''LDPC (LDP Container).'''
+
+    def __init__(self, uuid):
+        super().__init__(uuid)
+        self.base_types.update({
+            nsc['ldp'].Container,
+        })
+
+
+
+
+class LdpBc(Ldpc):
+    '''LDP-BC (LDP Basic Container).'''
+    pass
+
+
+
+class LdpDc(Ldpc):
+    '''LDP-DC (LDP Direct Container).'''
+
+    def __init__(self, uuid):
+        super().__init__(uuid)
+        self.base_types.update({
+            nsc['ldp'].DirectContainer,
+        })
+
+
+
+class LdpIc(Ldpc):
+    '''LDP-IC (LDP Indirect Container).'''
+
+    def __init__(self, uuid):
+        super().__init__(uuid)
+        self.base_types.update({
+            nsc['ldp'].IndirectContainer,
+        })
+
+
+

+ 0 - 12
lakesuperior/ldp/resource.py

@@ -1,12 +0,0 @@
-from lakesuperior.connectors.graph_store_connector import GraphStoreConnector
-
-class Resource:
-    '''Model for LDP resources.'''
-
-    def __init__(self):
-        self.ds = GraphStoreConnector()
-
-    def get(uuid):
-        g = ds
-
-

+ 216 - 0
lakesuperior/store_strategies/rdf/base_rdf_strategy.py

@@ -0,0 +1,216 @@
+import logging
+
+from abc import ABCMeta, abstractmethod
+
+from flask import request
+from rdflib import Graph
+from rdflib.resource import Resource
+from rdflib.term import URIRef
+
+from lakesuperior.connectors.graph_store_connector import GraphStoreConnector
+from lakesuperior.core.namespaces import ns_collection as nsc
+from lakesuperior.core.namespaces import ns_mgr as nsm
+
+
+class ResourceExistsError(RuntimeError):
+    '''Thrown when a resource is being created for an existing URN.'''
+    pass
+
+
+
+class BaseRdfStrategy(metaclass=ABCMeta):
+    '''
+    This class exposes an interface to build graph store strategies.
+
+    Some store strategies are provided. New ones aimed at specific uses
+    and optimizations of the repository may be developed by extending this
+    class and implementing all its abstract methods.
+
+    A strategy is implemented via application configuration. However, once
+    contents are ingested in a repository, changing a strategy will most likely
+    require a migration.
+
+    The custom strategy must be in the lakesuperior.store_strategies.rdf
+    package and the class implementing the strategy must be called
+    `StoreStrategy`. The module name is the one defined in the app
+    configuration.
+
+    E.g. if the configuration indicates `simple_strategy` the application will
+    look for
+    `lakesuperior.store_strategies.rdf.simple_strategy.SimpleStrategy`.
+
+    Some method naming conventions:
+
+    - Methods starting with `get_` return a graph.
+    - Methods starting with `list_` return a list, tuple or set of URIs.
+    - Methods starting with `select_` return a list or tuple with table-like
+      data such as from a SELECT statement.
+    - Methods starting with `ask_` return a boolean value.
+    '''
+
+    UNION_GRAPH_URI = URIRef('urn:x-arq:UnionGraph') # This is Fuseki-specific
+
+    _logger = logging.getLogger(__module__)
+
+
+    ## MAGIC METHODS ##
+
+    def __init__(self):
+        self.conn = GraphStoreConnector()
+        self.ds = self.conn.ds
+
+
+    ## PUBLIC METHODS ##
+
+    @abstractmethod
+    def ask_rsrc_exists(self, urn):
+        '''Return whether the resource exists.
+
+        @param uuid Resource UUID.
+
+        @retrn boolean
+        '''
+        pass
+
+
+    @abstractmethod
+    def get_rsrc(self, urn):
+        '''Get the copy of a resource graph.
+
+        @param uuid Resource UUID.
+
+        @retrn rdflib.resource.Resource
+        '''
+        pass
+
+
+    @abstractmethod
+    def create_or_replace_rsrc(self, urn, data, commit=True):
+        '''Create a resource graph in the main graph if it does not exist.
+
+        If it exists, replace the existing one retaining some special
+        properties.
+        '''
+        pass
+
+
+    @abstractmethod
+    def create_rsrc(self, urn, data, commit=True):
+        '''Create a resource graph in the main graph.
+
+        If the resource exists, raise an exception.
+        '''
+        pass
+
+
+    @abstractmethod
+    def patch_rsrc(self, urn, data, commit=False):
+        '''
+        Perform a SPARQL UPDATE on a resource.
+        '''
+        pass
+
+
+    @abstractmethod
+    def delete_rsrc(self, urn, commit=True):
+        pass
+
+
+    def list_containment_statements(self, urn):
+        q = '''
+        SELECT ?container ?contained {
+          {
+            ?s ldp:contains ?contained .
+          } UNION {
+            ?container ldp:contains ?s .
+          }
+        }
+        '''
+        return self.ds.query(q, initBindings={'s' : urn})
+
+
+    def uuid_to_uri(self, uuid):
+        '''Convert a UUID to a URI.
+
+        @return URIRef
+        '''
+        return URIRef('{}rest/{}'.format(request.host_url, uuid))
+
+
+    def localize_string(self, s):
+        '''Convert URIs into URNs in a string using the application base URI.
+
+        @param string s Input string.
+
+        @return string
+        '''
+        return s.replace(
+            request.host_url + 'rest/',
+            str(nsc['fcres'])
+        )
+
+
+    def globalize_string(self, s):
+        '''Convert URNs into URIs in a string using the application base URI.
+
+        @param string s Input string.
+
+        @return string
+        '''
+        return s.replace(
+            str(nsc['fcres']),
+            request.host_url + 'rest/'
+        )
+
+
+    def globalize_term(self, urn):
+        '''Convert an URN into an URI using the application base URI.
+
+        @param rdflib.term.URIRef urn Input URN.
+
+        @return rdflib.term.URIRef
+        '''
+        return URIRef(self.globalize_string(str(urn)))
+
+
+    def globalize_triples(self, g):
+        '''Convert all URNs in a resource or graph into URIs using the
+        application base URI.
+
+        @param rdflib.Graph | rdflib.resource.Resource g Input graph.
+
+        @return rdflib.Graph | rdflib.resource.Resource The same class as the
+        input value.
+        '''
+        if isinstance(g, Resource):
+            return self._globalize_graph(g.graph).resource(
+                    self.globalize_term(g.identifier))
+        elif isinstance (g, Graph):
+            return self._globalize_graph(g)
+        else:
+            raise TypeError('Not a valid input type: {}'.format(g))
+
+
+    def _globalize_graph(self, g):
+        '''Globalize a graph.'''
+        q = '''
+        CONSTRUCT {{ ?s ?p ?o . }} WHERE {{
+          ?s ?p ?o .
+          {{ FILTER STRSTARTS(str(?s), "{0}") . }}
+          UNION
+          {{ FILTER STRSTARTS(str(?o), "{0}") . }}
+        }}'''.format(nsc['fcres'])
+        flt_g = g.query(q)
+
+        for t in flt_g:
+            print('Triple: {}'.format(t))
+            global_s = self.globalize_term(t[0])
+            global_o = self.globalize_term(t[2]) \
+                    if isinstance(t[2], URIRef) \
+                    else t[2]
+            g.remove(t)
+            g.add((global_s, t[1], global_o))
+
+        return g
+
+

+ 117 - 0
lakesuperior/store_strategies/rdf/simple_strategy.py

@@ -0,0 +1,117 @@
+from copy import deepcopy
+
+from rdflib import Graph
+from rdflib.namespace import XSD
+from rdflib.term import Literal, URIRef, Variable
+
+from lakesuperior.core.namespaces import ns_collection as nsc
+from lakesuperior.core.namespaces import ns_mgr as nsm
+from lakesuperior.store_strategies.rdf.base_rdf_strategy import \
+        BaseRdfStrategy, ResourceExistsError
+
+
+class SimpleStrategy(BaseRdfStrategy):
+    '''
+    This is the simplest strategy.
+
+    It uses a flat triple structure without named graphs aimed at performance.
+    In theory it could be used on top of a triplestore instead of a quad-store
+    for (possible) improved speed and reduced storage.
+    '''
+
+    def ask_rsrc_exists(self, urn):
+        '''
+        See base_rdf_strategy.ask_rsrc_exists.
+        '''
+        return (urn, Variable('p'), Variable('o')) in self.ds
+
+
+    def get_rsrc(self, urn, globalize=True):
+        '''
+        See base_rdf_strategy.get_rsrc.
+        '''
+        res = self.ds.query(
+            'CONSTRUCT WHERE { ?s ?p ?o }',
+            initBindings={'s' : urn}
+        )
+
+        g = Graph()
+        g += res
+
+        return self.globalize_triples(g) if globalize else g
+
+
+    def create_or_replace_rsrc(self, urn, g, ts, format='text/turtle',
+            base_types=None, commit=False):
+        '''
+        See base_rdf_strategy.create_or_replace_rsrc.
+        '''
+        if self.ask_rsrc_exists(urn):
+            print('Resource exists. Removing.')
+            old_rsrc = deepcopy(self.get_rsrc(urn, False)).resource(urn)
+
+            self.delete_rsrc(urn)
+            g.add((urn, nsc['fedora'].created,
+                    old_rsrc.value(nsc['fedora'].created)))
+            g.add((urn, nsc['fedora'].createdBy,
+                    old_rsrc.value(nsc['fedora'].createdBy)))
+
+        else:
+            print('New resource.')
+            g.add((urn, nsc['fedora'].created, ts))
+            g.add((urn, nsc['fedora'].createdBy, Literal('BypassAdmin')))
+
+        for s, p, o in g:
+            self.ds.add((s, p, o))
+
+        if commit:
+            self.conn.store.commit()
+
+
+    def create_rsrc(self, urn, g, ts, base_types=None, commit=False):
+        '''
+        See base_rdf_strategy.create_rsrc.
+        '''
+        if self.ask_rsrc_exists(urn):
+            raise ResourceExistsError(
+                'Resource #{} already exists. It cannot be re-created with '
+                'this method.'.format(urn))
+
+        g.add((self.urn, nsc['fedora'].created, ts))
+        g.add((self.urn, nsc['fedora'].createdBy, Literal('BypassAdmin')))
+
+        for s, p, o in g:
+            self.ds.add((s, p, o))
+
+        if commit:
+            self.conn.store.commit()
+
+
+    def patch_rsrc(self, urn, data, ts, commit=False):
+        '''
+        Perform a SPARQL UPDATE on a resource.
+        '''
+        q = self.localize_string(data).replace('<>', urn.n3())
+        self.ds.update(q)
+
+        if commit:
+            self.conn.store.commit()
+
+
+    def delete_rsrc(self, urn, inbound=False, commit=False):
+        '''
+        Delete a resource. If `inbound` is specified, delete all inbound
+        relationships as well.
+        '''
+        print('Removing resource {}.'.format(urn))
+
+        self.ds.remove((urn, Variable('p'), Variable('o')))
+        if inbound:
+            self.ds.remove((Variable('s'), Variable('p'), urn))
+
+        if commit:
+            self.conn.store.commit()
+
+
+    ## PROTECTED METHODS ##
+

+ 74 - 18
server.py

@@ -1,39 +1,95 @@
 import io
 import json
 import os.path
+import pickle
 
-from flask import Flask
+import arrow
+
+from hashlib import sha1
+from uuid import  uuid4
+
+from flask import Flask, request, url_for
 
 from lakesuperior.config_parser import config
-from lakesuperior.ldp.resource import Resource
+from lakesuperior.ldp.ldpr import Ldpr, Ldpc, LdpNr
 
 app = Flask(__name__)
 app.config.update(config['flask'])
 
+
+## ROUTES ##
+
 @app.route('/', methods=['GET'])
 def index():
-    '''Homepage'''
+    '''
+    Homepage.
+    '''
     return 'Hello. This is LAKEsuperior.'
 
 
-@app.route('/<uuid>', methods=['GET'])
-def get_resource():
-    '''Add a new resource in a new URI.'''
-    rsrc = Resource.get(uuid)
-    return rsrc.path
+@app.route('/debug', methods=['GET'])
+def debug():
+    '''
+    Debug page.
+    '''
+    raise RuntimeError()
+
+
+## REST SERVICES ##
+
+@app.route('/rest/<path:uuid>', methods=['GET'])
+@app.route('/rest/', defaults={'uuid': None}, methods=['GET'])
+def get_resource(uuid):
+    '''
+    Retrieve RDF or binary content.
+    '''
+    rsrc = LdpRs(uuid).get()
+    if rsrc:
+        headers = {
+            #'ETag' : 'W/"{}"'.format(ret.value(nsc['premis
+        }
+        return (rsrc.graph.serialize(format='turtle'), headers)
+    else:
+        return ('Resource not found in repository: {}'.format(uuid), 404)
+
+
+@app.route('/rest/<path:parent>', methods=['POST'])
+@app.route('/rest/', defaults={'parent': None}, methods=['POST'])
+def post_resource(parent):
+    '''
+    Add a new resource in a new URI.
+    '''
+    uuid = uuid4()
+
+    uuid = '{}/{}'.format(parent, uuid) \
+            if path else uuid
+    rsrc = Ldpc(path).post(request.get_data().decode('utf-8'))
+
+    return rsrc.uri, 201
 
 
-@app.route('/<path>', methods=['POST'])
-def post_resource():
-    '''Add a new resource in a new URI.'''
-    rsrc = Resource.post(path)
-    return rsrc.path
+@app.route('/rest/<path:uuid>', methods=['PUT'])
+def put_resource(uuid):
+    '''
+    Add a new resource at a specified URI.
+    '''
+    rsrc = Ldpc(uuid).put(request.get_data().decode('utf-8'))
+    return '', 204
 
 
-@app.route('/<path>', methods=['PUT'])
-def put_resource():
-    '''Add a new resource in a new URI.'''
-    rsrc = Resource.put(path)
-    return rsrc.path
+@app.route('/rest/<path:uuid>', methods=['PATCH'])
+def patch_resource(uuid):
+    '''
+    Add a new resource at a specified URI.
+    '''
+    rsrc = Ldpc(uuid).patch(request.get_data().decode('utf-8'))
+    return '', 204
 
 
+@app.route('/rest/<path:uuid>', methods=['DELETE'])
+def delete_resource(uuid):
+    '''
+    Delete a resource.
+    '''
+    rsrc = Ldpc(uuid).delete()
+    return '', 204

+ 43 - 53
tests/initial_tests.py

@@ -41,29 +41,21 @@ def clear():
 def insert(report=False):
     '''Add a resource.'''
 
-    res1 = ds.graph(URIRef('urn:res:001'))
-    res1.add((URIRef('urn:lake:12873624'), RDF.type, URIRef('http://example.edu#Blah')))
-
-    # Add to default graph. This allows to do something like:
-    # CONSTRUCT {?s ?p ?o}
-    # WHERE {
-    #   ?g1 a <http://example.edu#Resource>  .
-    #   GRAPH ?g1 {
-    #     ?s ?p ?o .
-    #   }
-    # }
-    ds.add((URIRef('urn:res:001'), RDF.type, URIRef('http://example.edu#Resource')))
+    res1 = ds.graph(URIRef('urn:res:12873624'))
+    meta1 = ds.graph(URIRef('urn:meta:12873624'))
+    res1.add((URIRef('urn:state:001'), RDF.type, URIRef('http://example.edu#Blah')))
+
+    meta1.add((URIRef('urn:state:001'), RDF.type, URIRef('http://example.edu#ActiveState')))
     store.commit()
 
     if report:
         print('Inserted resource:')
         query('''
-            SELECT *
-            WHERE {
-              ?g1 a <http://example.edu#Resource>  .
-              GRAPH ?g1 {
+            SELECT ?s ?p ?o
+            FROM <urn:res:12873624>
+            FROM <urn:meta:12873624> {
+                ?s a <http://example.edu#ActiveState> .
                 ?s ?p ?o .
-              }
             }'''
         )
 
@@ -71,34 +63,33 @@ def insert(report=False):
 def update(report=False):
     '''Update resource and create a historic snapshot.'''
 
-    res2 = ds.graph(URIRef('urn:res:002'))
-    res2.add((URIRef('urn:lake:12873624'), RDF.type, URIRef('http://example.edu#Boo')))
+    res1 = ds.graph(URIRef('urn:res:12873624'))
+    meta1 = ds.graph(URIRef('urn:meta:12873624'))
+    res1.add((URIRef('urn:state:002'), RDF.type, URIRef('http://example.edu#Boo')))
 
-    ds.remove((URIRef('urn:res:001'), RDF.type, URIRef('http://example.edu#Resource')))
-    ds.add((URIRef('urn:res:001'), RDF.type, URIRef('http://example.edu#Snapshot')))
-    ds.add((URIRef('urn:res:002'), RDF.type, URIRef('http://example.edu#Resource')))
-    ds.add((URIRef('urn:res:002'), URIRef('http://example.edu#hasVersion'), URIRef('urn:res:001')))
+    meta1.remove((URIRef('urn:state:001'), RDF.type, URIRef('http://example.edu#ActiveState')))
+    meta1.add((URIRef('urn:state:001'), RDF.type, URIRef('http://example.edu#Snapshot')))
+    meta1.add((URIRef('urn:state:002'), RDF.type, URIRef('http://example.edu#ActiveState')))
+    meta1.add((URIRef('urn:state:002'), URIRef('http://example.edu#prevState'), URIRef('urn:state:001')))
     store.commit()
 
     if report:
         print('Updated resource:')
         query('''
-            SELECT *
-            WHERE {
-              ?g1 a <http://example.edu#Resource>  .
-              GRAPH ?g1 {
+            SELECT ?s ?p ?o
+            FROM <urn:res:12873624>
+            FROM <urn:meta:12873624> {
+                ?s a <http://example.edu#ActiveState> .
                 ?s ?p ?o .
-              }
             }'''
         )
         print('Version snapshot:')
         query('''
-            SELECT *
-            WHERE {
-              ?g1 a <http://example.edu#Snapshot>  .
-              GRAPH ?g1 {
+            SELECT ?s ?p ?o
+            FROM <urn:res:12873624>
+            FROM <urn:meta:12873624> {
+                ?s a <http://example.edu#Snapshot> .
                 ?s ?p ?o .
-              }
             }'''
         )
 
@@ -106,19 +97,19 @@ def update(report=False):
 def delete(report=False):
     '''Delete resource and leave a tombstone.'''
 
-    ds.remove((URIRef('urn:res:002'), RDF.type, URIRef('http://example.edu#Resource')))
-    ds.add((URIRef('urn:res:002'), RDF.type, URIRef('http://example.edu#Tombstone')))
+    meta1 = ds.graph(URIRef('urn:meta:12873624'))
+    meta1.remove((URIRef('urn:state:002'), RDF.type, URIRef('http://example.edu#ActiveState')))
+    meta1.add((URIRef('urn:state:002'), RDF.type, URIRef('http://example.edu#Tombstone')))
     store.commit()
 
     if report:
         print('Deleted resource (tombstone):')
         query('''
-            SELECT *
-            WHERE {
-              ?g1 a <http://example.edu#Tombstone>  .
-              GRAPH ?g1 {
+            SELECT ?s ?p ?o
+            FROM <urn:res:12873624>
+            FROM <urn:meta:12873624> {
+                ?s a <http://example.edu#Tombstone> .
                 ?s ?p ?o .
-              }
             }'''
         )
 
@@ -126,19 +117,19 @@ def delete(report=False):
 def undelete(report=False):
     '''Resurrect resource from a tombstone.'''
 
-    ds.remove((URIRef('urn:res:002'), RDF.type, URIRef('http://example.edu#Tombstone')))
-    ds.add((URIRef('urn:res:002'), RDF.type, URIRef('http://example.edu#Resource')))
+    meta1 = ds.graph(URIRef('urn:meta:12873624'))
+    meta1.remove((URIRef('urn:state:002'), RDF.type, URIRef('http://example.edu#Tombstone')))
+    meta1.add((URIRef('urn:state:002'), RDF.type, URIRef('http://example.edu#ActiveState')))
     store.commit()
 
     if report:
         print('Undeleted resource:')
         query('''
-            SELECT *
-            WHERE {
-              ?g1 a <http://example.edu#Resource>  .
-              GRAPH ?g1 {
+            SELECT ?s ?p ?o
+            FROM <urn:res:12873624>
+            FROM <urn:meta:12873624> {
+                ?s a <http://example.edu#ActiveState> .
                 ?s ?p ?o .
-              }
             }'''
         )
 
@@ -147,7 +138,7 @@ def abort_tx(report=False):
     '''Abort an operation in the middle of a transaction and roll back.'''
 
     try:
-        res2 = ds.graph(URIRef('urn:res:002'))
+        res2 = ds.graph(URIRef('urn:state:002'))
         res2.add((URIRef('urn:lake:12873624'), RDF.type, URIRef('http://example.edu#Werp')))
         raise RuntimeError('Something awful happened!')
         store.commit()
@@ -158,12 +149,11 @@ def abort_tx(report=False):
     if report:
         print('Failed operation (no updates):')
         query('''
-            SELECT *
-            WHERE {
-              ?g1 a <http://example.edu#Resource>  .
-              GRAPH ?g1 {
+            SELECT ?s ?p ?o
+            FROM <urn:res:12873624>
+            FROM <urn:meta:12873624> {
+                ?s a <http://example.edu#ActiveState> .
                 ?s ?p ?o .
-              }
             }'''
         )