From ed7f25a2cc5961f059d52948c12c1143b1178ad7 Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 22 Jul 2014 16:46:10 -0700 Subject: [PATCH 01/17] First attempt at using Open Annotation Data Model Annotations can now be serialised into JSON-LD formatted RDF, following the data model spec: http://www.openannotation.org/spec/core/ --- annotator/annotation.py | 150 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/annotator/annotation.py b/annotator/annotation.py index 31e09af..fe47d98 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -64,6 +64,156 @@ def save(self, *args, **kwargs): super(Annotation, self).save(*args, **kwargs) + + @property + def jsonld(self): + """The JSON-LD formatted RDF representation of the annotation.""" + context = {} + context.update(self.jsonld_namespaces) + if self.jsonld_baseurl: + context['@base'] = self.jsonld_baseurl + + annotation = { + '@id': self['id'], + '@context': context, + '@type': 'oa:Annotation', + 'oa:hasBody': self.hasBody, + 'oa:hasTarget': self.hasTarget, + 'oa:annotatedBy': self.annotatedBy, + 'oa:annotatedAt': self.annotatedAt, + 'oa:serializedBy': self.serializedBy, + 'oa:serializedAt': self.serializedAt, + 'oa:motivatedBy': self.motivatedBy, + } + return annotation + + jsonld_namespaces = { + 'annotator': 'http://annotatorjs.org/ns/', + 'oa': 'http://www.w3.org/ns/oa#', + 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'cnt': 'http://www.w3.org/2011/content#', + 'dc': 'http://purl.org/dc/elements/1.1/', + 'dctypes': 'http://purl.org/dc/dcmitype/', + 'prov': 'http://www.w3.org/ns/prov#', + 'xsd': 'http://www.w3.org/2001/XMLSchema#', + } + + jsonld_baseurl = '' + + @property + def hasBody(self): + """Return all annotation bodies: the text comment and each tag""" + bodies = [] + bodies += self.textual_bodies + bodies += self.tags + return bodies + + @property + def textual_bodies(self): + """A list with a single text body or an empty list""" + if not 'text' in self or not self['text']: + # Note that we treat an empty text as not having text at all. + return [] + body = { + '@type': 'dctypes:Text', + '@type': 'cnt:ContentAsText', + 'dc:format': 'text/plain', + 'cnt:chars': self['text'], + } + return [body] + + @property + def tags(self): + """A list of oa:Tag items""" + if not 'tags' in self: + return [] + return [ + { + '@type': 'oa:Tag', + '@type': 'cnt:ContentAsText', + 'dc:format': 'text/plain', + 'cnt:chars': tag, + } + for tag in self['tags'] + ] + + @property + def motivatedBy(self): + """Motivations for the annotation. + + Currently any combination of commenting and/or tagging. + """ + motivations = [] + if self.textual_bodies: + motivations.append({'@id': 'oa:commenting'}) + if self.tags: + motivations.append({'@id': 'oa:tagging'}) + return motivations + + @property + def hasTarget(self): + """The targets of the annotation. + + Returns a selector for each range of the page content that was + selected, or if a range is absent the url of the page itself. + """ + targets = [] + if self.get('ranges') and self['ranges']: + # Build the selector for each quote + for rangeSelector in self['ranges']: + selector = { + '@type': 'annotator:TextRangeSelector', + 'annotator:startContainer': rangeSelector['start'], + 'annotator:endContainer': rangeSelector['end'], + 'annotator:startOffset': rangeSelector['startOffset'], + 'annotator:endOffset': rangeSelector['endOffset'], + } + target = { + '@type': 'oa:SpecificResource', + 'oa:hasSource': {'@id': self['uri']}, + 'oa:hasSelector': selector, + } + targets.append(target) + else: + # The annotation targets the page as a whole + targets.append({'@id': self['uri']}) + return targets + + @property + def annotatedBy(self): + """The user that created the annotation.""" + return self['user'] # todo: semantify, using foaf or so? + + @property + def annotatedAt(self): + """The annotation's creation date""" + return { + '@value': self['created'], + '@type': 'xsd:dateTime', + } + + @property + def serializedBy(self): + """The software used for serializing.""" + return { + '@id': 'annotator:annotator-store', + '@type': 'prov:Software-agent', + 'foaf:name': 'annotator-store', + 'foaf:homepage': {'@id': 'http://annotatorjs.org'}, + } # todo: add version number + + @property + def serializedAt(self): + """The last time the serialization changed.""" + # Following the spec[1], we do not use the current time, but the last + # time the annotation graph has been updated. + # [1]: https://hypothes.is/a/R6uHQyVTQYqBc4-1V9X56Q + return { + '@value': self['updated'], + '@type': 'xsd:dateTime', + } + + @classmethod def search_raw(cls, query=None, params=None, user=None, authorization_enabled=None, **kwargs): From 5f7d0bea523dec9deba728203355735b5012bc46 Mon Sep 17 00:00:00 2001 From: Gerben Date: Fri, 25 Jul 2014 13:32:27 -0700 Subject: [PATCH 02/17] Use OrderedDict for OA representation Because JSON-LD spec recommends keeping @context at the top. --- annotator/annotation.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index fe47d98..6438d2c 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + from annotator import authz, document, es TYPE = 'annotation' @@ -73,18 +75,19 @@ def jsonld(self): if self.jsonld_baseurl: context['@base'] = self.jsonld_baseurl - annotation = { - '@id': self['id'], - '@context': context, - '@type': 'oa:Annotation', - 'oa:hasBody': self.hasBody, - 'oa:hasTarget': self.hasTarget, - 'oa:annotatedBy': self.annotatedBy, - 'oa:annotatedAt': self.annotatedAt, - 'oa:serializedBy': self.serializedBy, - 'oa:serializedAt': self.serializedAt, - 'oa:motivatedBy': self.motivatedBy, - } + # The JSON-LD spec recommends to put @context at the top of the + # document, so we'll be nice and use and ordered dictionary. + annotation = OrderedDict() + annotation['@context'] = context, + annotation['@id'] = self['id'] + annotation['@type'] = 'oa:Annotation' + annotation['oa:hasBody'] = self.hasBody + annotation['oa:hasTarget'] = self.hasTarget + annotation['oa:annotatedBy'] = self.annotatedBy + annotation['oa:annotatedAt'] = self.annotatedAt + annotation['oa:serializedBy'] = self.serializedBy + annotation['oa:serializedAt'] = self.serializedAt + annotation['oa:motivatedBy'] = self.motivatedBy return annotation jsonld_namespaces = { From 06336d4550e33d8658d9c231ea6b332e61d5069f Mon Sep 17 00:00:00 2001 From: Gerben Date: Fri, 25 Jul 2014 17:38:14 -0700 Subject: [PATCH 03/17] Better checks for absent fields in creating jsonld --- annotator/annotation.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index 6438d2c..bcd5bed 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -114,7 +114,7 @@ def hasBody(self): @property def textual_bodies(self): """A list with a single text body or an empty list""" - if not 'text' in self or not self['text']: + if not self.get('text'): # Note that we treat an empty text as not having text at all. return [] body = { @@ -161,7 +161,9 @@ def hasTarget(self): selected, or if a range is absent the url of the page itself. """ targets = [] - if self.get('ranges') and self['ranges']: + if not 'uri' in self: + return targets + if self.get('ranges'): # Build the selector for each quote for rangeSelector in self['ranges']: selector = { @@ -185,15 +187,16 @@ def hasTarget(self): @property def annotatedBy(self): """The user that created the annotation.""" - return self['user'] # todo: semantify, using foaf or so? + return self.get('user') or [] # todo: semantify, using foaf or so? @property def annotatedAt(self): """The annotation's creation date""" - return { - '@value': self['created'], - '@type': 'xsd:dateTime', - } + if self.get('created'): + return { + '@value': self['created'], + '@type': 'xsd:dateTime', + } @property def serializedBy(self): @@ -211,10 +214,11 @@ def serializedAt(self): # Following the spec[1], we do not use the current time, but the last # time the annotation graph has been updated. # [1]: https://hypothes.is/a/R6uHQyVTQYqBc4-1V9X56Q - return { - '@value': self['updated'], - '@type': 'xsd:dateTime', - } + if self.get('updated'): + return { + '@value': self['updated'], + '@type': 'xsd:dateTime', + } @classmethod From 7cbbc0b3e6c457fd933d8d55687a6dcb8efd6c47 Mon Sep 17 00:00:00 2001 From: Gerben Date: Mon, 28 Jul 2014 18:13:37 -0700 Subject: [PATCH 04/17] Fall back to dict if OrderedDict unavailable --- annotator/annotation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index bcd5bed..420de62 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -1,4 +1,14 @@ -from collections import OrderedDict +import logging +log = logging.getLogger(__name__) +try: + from collections import OrderedDict +except ImportError: + try: + from ordereddict import OrderedDict + except ImportError: + log.warn("No OrderedDict available, JSON-LD content will be unordered. " + "Use Python>=2.7 or install ordereddict module to fix.") + OrderedDict = dict from annotator import authz, document, es From e59f3eb69e9f15e54b3ebf73f8a302fb032e9c29 Mon Sep 17 00:00:00 2001 From: Gerben Date: Mon, 28 Jul 2014 18:14:41 -0700 Subject: [PATCH 05/17] Code reordering and renaming --- annotator/annotation.py | 54 ++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index 420de62..5cf5fb9 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -53,6 +53,19 @@ class Annotation(es.Model): __type__ = TYPE __mapping__ = MAPPING + jsonld_baseurl = '' + + jsonld_namespaces = { + 'annotator': 'http://annotatorjs.org/ns/', + 'oa': 'http://www.w3.org/ns/oa#', + 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'cnt': 'http://www.w3.org/2011/content#', + 'dc': 'http://purl.org/dc/elements/1.1/', + 'dctypes': 'http://purl.org/dc/dcmitype/', + 'prov': 'http://www.w3.org/ns/prov#', + 'xsd': 'http://www.w3.org/2001/XMLSchema#', + } + def save(self, *args, **kwargs): _add_default_permissions(self) @@ -91,30 +104,17 @@ def jsonld(self): annotation['@context'] = context, annotation['@id'] = self['id'] annotation['@type'] = 'oa:Annotation' - annotation['oa:hasBody'] = self.hasBody - annotation['oa:hasTarget'] = self.hasTarget - annotation['oa:annotatedBy'] = self.annotatedBy - annotation['oa:annotatedAt'] = self.annotatedAt - annotation['oa:serializedBy'] = self.serializedBy - annotation['oa:serializedAt'] = self.serializedAt - annotation['oa:motivatedBy'] = self.motivatedBy + annotation['oa:hasBody'] = self.has_body + annotation['oa:hasTarget'] = self.has_target + annotation['oa:annotatedBy'] = self.annotated_by + annotation['oa:annotatedAt'] = self.annotated_at + annotation['oa:serializedBy'] = self.serialized_by + annotation['oa:serializedAt'] = self.serialized_at + annotation['oa:motivatedBy'] = self.motivated_by return annotation - jsonld_namespaces = { - 'annotator': 'http://annotatorjs.org/ns/', - 'oa': 'http://www.w3.org/ns/oa#', - 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', - 'cnt': 'http://www.w3.org/2011/content#', - 'dc': 'http://purl.org/dc/elements/1.1/', - 'dctypes': 'http://purl.org/dc/dcmitype/', - 'prov': 'http://www.w3.org/ns/prov#', - 'xsd': 'http://www.w3.org/2001/XMLSchema#', - } - - jsonld_baseurl = '' - @property - def hasBody(self): + def has_body(self): """Return all annotation bodies: the text comment and each tag""" bodies = [] bodies += self.textual_bodies @@ -151,7 +151,7 @@ def tags(self): ] @property - def motivatedBy(self): + def motivated_by(self): """Motivations for the annotation. Currently any combination of commenting and/or tagging. @@ -164,7 +164,7 @@ def motivatedBy(self): return motivations @property - def hasTarget(self): + def has_target(self): """The targets of the annotation. Returns a selector for each range of the page content that was @@ -195,12 +195,12 @@ def hasTarget(self): return targets @property - def annotatedBy(self): + def annotated_by(self): """The user that created the annotation.""" return self.get('user') or [] # todo: semantify, using foaf or so? @property - def annotatedAt(self): + def annotated_at(self): """The annotation's creation date""" if self.get('created'): return { @@ -209,7 +209,7 @@ def annotatedAt(self): } @property - def serializedBy(self): + def serialized_by(self): """The software used for serializing.""" return { '@id': 'annotator:annotator-store', @@ -219,7 +219,7 @@ def serializedBy(self): } # todo: add version number @property - def serializedAt(self): + def serialized_at(self): """The last time the serialization changed.""" # Following the spec[1], we do not use the current time, but the last # time the annotation graph has been updated. From 8c3be444afc33052d349724a3a7d9df9314cbe17 Mon Sep 17 00:00:00 2001 From: Gerben Date: Mon, 28 Jul 2014 19:21:10 -0700 Subject: [PATCH 06/17] Small edits&fixes --- annotator/annotation.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index 5cf5fb9..8d76a1f 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -101,7 +101,7 @@ def jsonld(self): # The JSON-LD spec recommends to put @context at the top of the # document, so we'll be nice and use and ordered dictionary. annotation = OrderedDict() - annotation['@context'] = context, + annotation['@context'] = context annotation['@id'] = self['id'] annotation['@type'] = 'oa:Annotation' annotation['oa:hasBody'] = self.has_body @@ -128,8 +128,7 @@ def textual_bodies(self): # Note that we treat an empty text as not having text at all. return [] body = { - '@type': 'dctypes:Text', - '@type': 'cnt:ContentAsText', + '@type': ['dctypes:Text', 'cnt:ContentAsText'], 'dc:format': 'text/plain', 'cnt:chars': self['text'], } @@ -142,8 +141,7 @@ def tags(self): return [] return [ { - '@type': 'oa:Tag', - '@type': 'cnt:ContentAsText', + '@type': ['oa:Tag', 'cnt:ContentAsText'], 'dc:format': 'text/plain', 'cnt:chars': tag, } @@ -158,9 +156,9 @@ def motivated_by(self): """ motivations = [] if self.textual_bodies: - motivations.append({'@id': 'oa:commenting'}) + motivations.append('oa:commenting') if self.tags: - motivations.append({'@id': 'oa:tagging'}) + motivations.append('oa:tagging') return motivations @property From 6b53f0e56de4fdef59f5e9c97acae0804865401a Mon Sep 17 00:00:00 2001 From: Gerben Date: Mon, 28 Jul 2014 19:22:22 -0700 Subject: [PATCH 07/17] Use OA context from w3.org --- annotator/annotation.py | 51 +++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index 8d76a1f..ee1d666 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -55,17 +55,6 @@ class Annotation(es.Model): jsonld_baseurl = '' - jsonld_namespaces = { - 'annotator': 'http://annotatorjs.org/ns/', - 'oa': 'http://www.w3.org/ns/oa#', - 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', - 'cnt': 'http://www.w3.org/2011/content#', - 'dc': 'http://purl.org/dc/elements/1.1/', - 'dctypes': 'http://purl.org/dc/dcmitype/', - 'prov': 'http://www.w3.org/ns/prov#', - 'xsd': 'http://www.w3.org/2001/XMLSchema#', - } - def save(self, *args, **kwargs): _add_default_permissions(self) @@ -93,10 +82,14 @@ def save(self, *args, **kwargs): @property def jsonld(self): """The JSON-LD formatted RDF representation of the annotation.""" - context = {} - context.update(self.jsonld_namespaces) + + context = [ + "http://www.w3.org/ns/oa-context-20130208.json", + {'annotator': 'http://annotatorjs.org/ns/'} + ] + if self.jsonld_baseurl: - context['@base'] = self.jsonld_baseurl + context.append({'@base': self.jsonld_baseurl}) # The JSON-LD spec recommends to put @context at the top of the # document, so we'll be nice and use and ordered dictionary. @@ -104,13 +97,13 @@ def jsonld(self): annotation['@context'] = context annotation['@id'] = self['id'] annotation['@type'] = 'oa:Annotation' - annotation['oa:hasBody'] = self.has_body - annotation['oa:hasTarget'] = self.has_target - annotation['oa:annotatedBy'] = self.annotated_by - annotation['oa:annotatedAt'] = self.annotated_at - annotation['oa:serializedBy'] = self.serialized_by - annotation['oa:serializedAt'] = self.serialized_at - annotation['oa:motivatedBy'] = self.motivated_by + annotation['hasBody'] = self.has_body + annotation['hasTarget'] = self.has_target + annotation['annotatedBy'] = self.annotated_by + annotation['annotatedAt'] = self.annotated_at + annotation['serializedBy'] = self.serialized_by + annotation['serializedAt'] = self.serialized_at + annotation['motivatedBy'] = self.motivated_by return annotation @property @@ -183,13 +176,13 @@ def has_target(self): } target = { '@type': 'oa:SpecificResource', - 'oa:hasSource': {'@id': self['uri']}, - 'oa:hasSelector': selector, + 'hasSource': self['uri'], + 'hasSelector': selector, } targets.append(target) else: # The annotation targets the page as a whole - targets.append({'@id': self['uri']}) + targets.append(self['uri']) return targets @property @@ -201,10 +194,7 @@ def annotated_by(self): def annotated_at(self): """The annotation's creation date""" if self.get('created'): - return { - '@value': self['created'], - '@type': 'xsd:dateTime', - } + return self['created'] @property def serialized_by(self): @@ -223,10 +213,7 @@ def serialized_at(self): # time the annotation graph has been updated. # [1]: https://hypothes.is/a/R6uHQyVTQYqBc4-1V9X56Q if self.get('updated'): - return { - '@value': self['updated'], - '@type': 'xsd:dateTime', - } + return self['updated'] @classmethod From 222951099c8cb7cf68a1ace439ed7774f5c21600 Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 29 Jul 2014 16:14:51 -0700 Subject: [PATCH 08/17] Set jsonld_baseurl default to None --- annotator/annotation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index ee1d666..5c63060 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -53,7 +53,7 @@ class Annotation(es.Model): __type__ = TYPE __mapping__ = MAPPING - jsonld_baseurl = '' + jsonld_baseurl = None def save(self, *args, **kwargs): _add_default_permissions(self) @@ -88,7 +88,7 @@ def jsonld(self): {'annotator': 'http://annotatorjs.org/ns/'} ] - if self.jsonld_baseurl: + if self.jsonld_baseurl is not None: context.append({'@base': self.jsonld_baseurl}) # The JSON-LD spec recommends to put @context at the top of the From ff2af84c94d864f0979f0daee7a8df7a4717eab6 Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 29 Jul 2014 18:58:59 -0700 Subject: [PATCH 09/17] Semantify user Let's keep the default very generic, for example we don't even know if users are Persons. Implementors can subclass Annotation to add more user info (as with any of the properties). --- annotator/annotation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/annotator/annotation.py b/annotator/annotation.py index 5c63060..59a89c8 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -188,7 +188,12 @@ def has_target(self): @property def annotated_by(self): """The user that created the annotation.""" - return self.get('user') or [] # todo: semantify, using foaf or so? + if not self.get('user'): + return [] + return { + '@type': 'foaf:Agent', # It could be either a person or a bot + 'foaf:name': self['user'], + } @property def annotated_at(self): From 82f1d0810ad54be5b36f8e8754e21e99d0d4b77f Mon Sep 17 00:00:00 2001 From: Gerben Date: Mon, 11 Aug 2014 16:43:08 -0700 Subject: [PATCH 10/17] Move json-ld attributes into openannotation.py --- annotator/annotation.py | 156 ----------------------------------- annotator/openannotation.py | 158 ++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 156 deletions(-) create mode 100644 annotator/openannotation.py diff --git a/annotator/annotation.py b/annotator/annotation.py index 59a89c8..ab004bf 100644 --- a/annotator/annotation.py +++ b/annotator/annotation.py @@ -1,15 +1,3 @@ -import logging -log = logging.getLogger(__name__) -try: - from collections import OrderedDict -except ImportError: - try: - from ordereddict import OrderedDict - except ImportError: - log.warn("No OrderedDict available, JSON-LD content will be unordered. " - "Use Python>=2.7 or install ordereddict module to fix.") - OrderedDict = dict - from annotator import authz, document, es TYPE = 'annotation' @@ -53,8 +41,6 @@ class Annotation(es.Model): __type__ = TYPE __mapping__ = MAPPING - jsonld_baseurl = None - def save(self, *args, **kwargs): _add_default_permissions(self) @@ -79,148 +65,6 @@ def save(self, *args, **kwargs): super(Annotation, self).save(*args, **kwargs) - @property - def jsonld(self): - """The JSON-LD formatted RDF representation of the annotation.""" - - context = [ - "http://www.w3.org/ns/oa-context-20130208.json", - {'annotator': 'http://annotatorjs.org/ns/'} - ] - - if self.jsonld_baseurl is not None: - context.append({'@base': self.jsonld_baseurl}) - - # The JSON-LD spec recommends to put @context at the top of the - # document, so we'll be nice and use and ordered dictionary. - annotation = OrderedDict() - annotation['@context'] = context - annotation['@id'] = self['id'] - annotation['@type'] = 'oa:Annotation' - annotation['hasBody'] = self.has_body - annotation['hasTarget'] = self.has_target - annotation['annotatedBy'] = self.annotated_by - annotation['annotatedAt'] = self.annotated_at - annotation['serializedBy'] = self.serialized_by - annotation['serializedAt'] = self.serialized_at - annotation['motivatedBy'] = self.motivated_by - return annotation - - @property - def has_body(self): - """Return all annotation bodies: the text comment and each tag""" - bodies = [] - bodies += self.textual_bodies - bodies += self.tags - return bodies - - @property - def textual_bodies(self): - """A list with a single text body or an empty list""" - if not self.get('text'): - # Note that we treat an empty text as not having text at all. - return [] - body = { - '@type': ['dctypes:Text', 'cnt:ContentAsText'], - 'dc:format': 'text/plain', - 'cnt:chars': self['text'], - } - return [body] - - @property - def tags(self): - """A list of oa:Tag items""" - if not 'tags' in self: - return [] - return [ - { - '@type': ['oa:Tag', 'cnt:ContentAsText'], - 'dc:format': 'text/plain', - 'cnt:chars': tag, - } - for tag in self['tags'] - ] - - @property - def motivated_by(self): - """Motivations for the annotation. - - Currently any combination of commenting and/or tagging. - """ - motivations = [] - if self.textual_bodies: - motivations.append('oa:commenting') - if self.tags: - motivations.append('oa:tagging') - return motivations - - @property - def has_target(self): - """The targets of the annotation. - - Returns a selector for each range of the page content that was - selected, or if a range is absent the url of the page itself. - """ - targets = [] - if not 'uri' in self: - return targets - if self.get('ranges'): - # Build the selector for each quote - for rangeSelector in self['ranges']: - selector = { - '@type': 'annotator:TextRangeSelector', - 'annotator:startContainer': rangeSelector['start'], - 'annotator:endContainer': rangeSelector['end'], - 'annotator:startOffset': rangeSelector['startOffset'], - 'annotator:endOffset': rangeSelector['endOffset'], - } - target = { - '@type': 'oa:SpecificResource', - 'hasSource': self['uri'], - 'hasSelector': selector, - } - targets.append(target) - else: - # The annotation targets the page as a whole - targets.append(self['uri']) - return targets - - @property - def annotated_by(self): - """The user that created the annotation.""" - if not self.get('user'): - return [] - return { - '@type': 'foaf:Agent', # It could be either a person or a bot - 'foaf:name': self['user'], - } - - @property - def annotated_at(self): - """The annotation's creation date""" - if self.get('created'): - return self['created'] - - @property - def serialized_by(self): - """The software used for serializing.""" - return { - '@id': 'annotator:annotator-store', - '@type': 'prov:Software-agent', - 'foaf:name': 'annotator-store', - 'foaf:homepage': {'@id': 'http://annotatorjs.org'}, - } # todo: add version number - - @property - def serialized_at(self): - """The last time the serialization changed.""" - # Following the spec[1], we do not use the current time, but the last - # time the annotation graph has been updated. - # [1]: https://hypothes.is/a/R6uHQyVTQYqBc4-1V9X56Q - if self.get('updated'): - return self['updated'] - - @classmethod def search_raw(cls, query=None, params=None, user=None, authorization_enabled=None, **kwargs): diff --git a/annotator/openannotation.py b/annotator/openannotation.py new file mode 100644 index 0000000..3a81fa4 --- /dev/null +++ b/annotator/openannotation.py @@ -0,0 +1,158 @@ +import logging +log = logging.getLogger(__name__) + +try: + from collections import OrderedDict +except ImportError: + try: + from ordereddict import OrderedDict + except ImportError: + log.warn("No OrderedDict available, JSON-LD content will be unordered. " + "Use Python>=2.7 or install ordereddict module to fix.") + OrderedDict = dict + +from annotator.annotation import Annotation + +class OAAnnotation(Annotation): + jsonld_baseurl = None + + @property + def jsonld(self): + """The JSON-LD formatted RDF representation of the annotation.""" + + context = [ + "http://www.w3.org/ns/oa-context-20130208.json", + {'annotator': 'http://annotatorjs.org/ns/'} + ] + + if self.jsonld_baseurl is not None: + context.append({'@base': self.jsonld_baseurl}) + + # The JSON-LD spec recommends to put @context at the top of the + # document, so we'll be nice and use and ordered dictionary. + annotation = OrderedDict() + annotation['@context'] = context + annotation['@id'] = self['id'] + annotation['@type'] = 'oa:Annotation' + annotation['hasBody'] = self.has_body + annotation['hasTarget'] = self.has_target + annotation['annotatedBy'] = self.annotated_by + annotation['annotatedAt'] = self.annotated_at + annotation['serializedBy'] = self.serialized_by + annotation['serializedAt'] = self.serialized_at + annotation['motivatedBy'] = self.motivated_by + return annotation + + @property + def has_body(self): + """Return all annotation bodies: the text comment and each tag""" + bodies = [] + bodies += self.textual_bodies + bodies += self.tags + return bodies + + @property + def textual_bodies(self): + """A list with a single text body or an empty list""" + if not self.get('text'): + # Note that we treat an empty text as not having text at all. + return [] + body = { + '@type': ['dctypes:Text', 'cnt:ContentAsText'], + 'dc:format': 'text/plain', + 'cnt:chars': self['text'], + } + return [body] + + @property + def tags(self): + """A list of oa:Tag items""" + if not 'tags' in self: + return [] + return [ + { + '@type': ['oa:Tag', 'cnt:ContentAsText'], + 'dc:format': 'text/plain', + 'cnt:chars': tag, + } + for tag in self['tags'] + ] + + @property + def motivated_by(self): + """Motivations for the annotation. + + Currently any combination of commenting and/or tagging. + """ + motivations = [] + if self.textual_bodies: + motivations.append('oa:commenting') + if self.tags: + motivations.append('oa:tagging') + return motivations + + @property + def has_target(self): + """The targets of the annotation. + + Returns a selector for each range of the page content that was + selected, or if a range is absent the url of the page itself. + """ + targets = [] + if not 'uri' in self: + return targets + if self.get('ranges'): + # Build the selector for each quote + for rangeSelector in self['ranges']: + selector = { + '@type': 'annotator:TextRangeSelector', + 'annotator:startContainer': rangeSelector['start'], + 'annotator:endContainer': rangeSelector['end'], + 'annotator:startOffset': rangeSelector['startOffset'], + 'annotator:endOffset': rangeSelector['endOffset'], + } + target = { + '@type': 'oa:SpecificResource', + 'hasSource': self['uri'], + 'hasSelector': selector, + } + targets.append(target) + else: + # The annotation targets the page as a whole + targets.append(self['uri']) + return targets + + @property + def annotated_by(self): + """The user that created the annotation.""" + if not self.get('user'): + return [] + return { + '@type': 'foaf:Agent', # It could be either a person or a bot + 'foaf:name': self['user'], + } + + @property + def annotated_at(self): + """The annotation's creation date""" + if self.get('created'): + return self['created'] + + @property + def serialized_by(self): + """The software used for serializing.""" + return { + '@id': 'annotator:annotator-store', + '@type': 'prov:Software-agent', + 'foaf:name': 'annotator-store', + 'foaf:homepage': {'@id': 'http://annotatorjs.org'}, + } # todo: add version number + + @property + def serialized_at(self): + """The last time the serialization changed.""" + # Following the spec[1], we do not use the current time, but the last + # time the annotation graph has been updated. + # [1]: https://hypothes.is/a/R6uHQyVTQYqBc4-1V9X56Q + if self.get('updated'): + return self['updated'] From 5756aa409d6f7a35fed7b2fe0f3a35d49599b2f2 Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 29 Jul 2014 16:19:54 -0700 Subject: [PATCH 11/17] Content negotiation: handle application/ld+json --- annotator/store.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/annotator/store.py b/annotator/store.py index 8bcb938..a11c2c1 100644 --- a/annotator/store.py +++ b/annotator/store.py @@ -144,6 +144,10 @@ def index(): user = None annotations = g.annotation_class.search(user=user) + + if _jsonld_requested(): + annotations = [annotation.jsonld for annotation in annotations] + return jsonify(annotations) # CREATE @@ -190,6 +194,9 @@ def read_annotation(id): if failure: return failure + if _jsonld_requested(): + annotation = annotation.jsonld + return jsonify(annotation) @@ -281,6 +288,9 @@ def search_annotations(): results = g.annotation_class.search(**kwargs) total = g.annotation_class.count(**kwargs) + if _jsonld_requested(): + results = [annotation.jsonld for annotation in results] + return jsonify({'total': total, 'rows': results}) @@ -418,3 +428,10 @@ def _update_query_raw(qo, params, k, v): elif k == 'search_type': params[k] = v + +def _jsonld_requested(): + # We prefer and default to plain json. + best_mimetype = request.accept_mimetypes.best_match( + ['application/json', 'application/ld+json'], + 'application/json') + return best_mimetype == 'application/ld+json' From 158b248013bfb5321b588047b1a3cfe881fa671c Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 29 Jul 2014 16:20:10 -0700 Subject: [PATCH 12/17] Add test for json and ld+json mimetype negotiation --- tests/test_store.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_store.py b/tests/test_store.py index a541ff1..23b23da 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,5 +1,6 @@ from . import TestCase from .helpers import MockUser +import functools from nose.tools import * from mock import patch @@ -341,6 +342,48 @@ def test_search_offset(self): assert_equal(len(res['rows']), 20) assert_equal(res['rows'][0], first) + def test_mimetypes(self): + """Test if correct responses are returned for given Accept headers. + + Tests each content-negotiating endpoint with several accept-header + values. + """ + kwargs = dict(text=u"Foo", id='123') + self._create_annotation(**kwargs) + accept_headers = { + 'no_accept': None, + 'pref_jsonld': 'application/ld+json,application/json;q=0.9', + 'pref_json': 'application/json,application/ld+json;q=0.9', + 'pref_either': 'application/ld+json,application/json', + 'eat_all': '*/*', + } + + endpoints = { + 'read': {'url': '/api/annotations/123', + 'get_ann': lambda res: res}, + 'search': {'url': '/api/search', + 'get_ann': lambda res: res['rows'][0]}, + 'index': {'url': '/api/annotations', + 'get_ann': lambda res: res[0]}, + } + + def returns_ld(endpoint, preference): + accept_header = accept_headers[preference] + headers = dict(self.headers, Accept=accept_header) + response = self.cli.get(endpoint['url'],headers=headers) + annotation = endpoint['get_ann'](json.loads(response.data)) + return '@id' in annotation + + for action, endpoint in endpoints.items(): + is_ld = functools.partial(returns_ld, endpoint) + # Currently, we only want JSON-LD if we explicitly ask for it. + assert is_ld('pref_jsonld'), "Expected JSON-LD response from %s" % action + assert not is_ld('pref_json'), "Expected plain JSON response from %s" % action + assert not is_ld('pref_either'), "Expected plain JSON response from %s" % action + assert not is_ld('eat_all'), "Plain JSON should be default (for %s)" % action + assert not is_ld('no_accept'), "Plain JSON should be default (for %s)" % action + + def _get_search_results(self, qs=''): res = self.cli.get('/api/search?{qs}'.format(qs=qs), headers=self.headers) return json.loads(res.data) From 5e4659c59c7492e23899fb199572bce5eebfd8b4 Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 29 Jul 2014 18:54:32 -0700 Subject: [PATCH 13/17] Set jsonld_baseurl in store.py --- annotator/store.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/annotator/store.py b/annotator/store.py index a11c2c1..125e46e 100644 --- a/annotator/store.py +++ b/annotator/store.py @@ -44,6 +44,12 @@ def before_request(): if not hasattr(g, 'annotation_class'): g.annotation_class = Annotation + if g.annotation_class.jsonld_baseurl is None: + # Make an annotation's URI equal to the location where it can be read + g.annotation_class.jsonld_baseurl = url_for('.read_annotation', + id='', + _external=True) + user = g.auth.request_user(request) if user is not None: g.user = user From 4ee3b4455b047b3df0d6f891cc9ab342ffabace5 Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 29 Jul 2014 18:55:37 -0700 Subject: [PATCH 14/17] Add test for JSON-LD content --- tests/test_store.py | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/test_store.py b/tests/test_store.py index 23b23da..b04795d 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -3,6 +3,7 @@ import functools from nose.tools import * from mock import patch +import re from flask import json, g from six.moves import xrange @@ -383,6 +384,88 @@ def returns_ld(endpoint, preference): assert not is_ld('eat_all'), "Plain JSON should be default (for %s)" % action assert not is_ld('no_accept'), "Plain JSON should be default (for %s)" % action + def test_jsonld(self): + """Test if the JSON-LD representation contains the correct @base and + check the basic fields. + """ + # Create an annotation + annotation_fields = { + "text": "blablabla", + "uri": "http://localhost:4000/dev.html", + "ranges": [ + { + "start": "/ul[1]/li[1]", + "end": "/ul[1]/li[1]", + "startOffset": 0, + "endOffset": 26 + } + ], + "user": "alice", + "quote": "Lorem ipsum dolor sit amet", + "consumer": "mockconsumer", + "permissions": { + "read": [], + "admin": [], + "update": [], + "delete": [] + } + } + ann_orig = self._create_annotation(**annotation_fields) + id = ann_orig['id'] + + # Fetch this annotation in JSON-LD format + headers = dict(self.headers, Accept='application/ld+json') + res = self.cli.get('/api/annotations/{0}'.format(id), + headers=headers) + ann_ld = json.loads(res.data) + + context_parts = ann_ld.get('@context') + assert context_parts is not None, "Expected a @context in JSON-LD" + # Merge all context parts + context = {} + for context_piece in context_parts: + if type(context_piece) == dict: + context.update(context_piece) + + # Check @base value (note it will be different for a real deployment) + base = context.get('@base') + assert base is not None, "Annotation should have a @base in @context" + assert base == 'http://localhost/api/annotations/', 'Base incorrect, found @base: "{0}"'.format(base) + + ldid = ann_ld['@id'] + assert ldid == id, "Incorrect annotation @id: {0}!={1}".format(ldid, id) + assert ann_ld['@type'] == 'oa:Annotation' + assert ann_ld['hasBody'] == [{ + "cnt:chars": "blablabla", + "@type": [ + "dctypes:Text", + "cnt:ContentAsText" + ], + "dc:format": "text/plain" + }], "Incorrect hasBody: {0}".format(ann_ld['hasBody']) + + assert ann_ld['hasTarget'] == [{ + "hasSource": "http://localhost:4000/dev.html", + "hasSelector": { + "annotator:endContainer": "/ul[1]/li[1]", + "annotator:startOffset": 0, + "annotator:startContainer": "/ul[1]/li[1]", + "@type": "annotator:TextRangeSelector", + "annotator:endOffset": 26 + }, + "@type": "oa:SpecificResource" + }], "Incorrect hasTarget: {0}".format(ann_ld['hasBody']) + + assert ann_ld['annotatedBy'] == { + '@type': 'foaf:Agent', + 'foaf:name': 'alice', + }, "Incorrect annotatedBy: {0}".format(ann_ld['annotatedBy']) + + date_str = "nnnn-nn-nnTnn:nn:nn(\.nnnnnn)?([+-]nn.nn|Z)" + date_regex = re.compile(date_str.replace("n","\d")) + assert date_regex.match(ann_ld['annotatedAt']), "Incorrect annotatedAt: {0}".format(ann_ld['annotatedAt']) + assert date_regex.match(ann_ld['serializedAt']), "Incorrect createdAt: {0}".format(ann_ld['annotatedAt']) + def _get_search_results(self, qs=''): res = self.cli.get('/api/search?{qs}'.format(qs=qs), headers=self.headers) From 9e11f53f57cf61a51046125031f6580d1565095a Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 12 Aug 2014 10:48:41 -0700 Subject: [PATCH 15/17] Create render(annotation), adapt to OAAnnotation Note that render does not return a string or response object, but an object that jsonify can process. This can be changed when we wish to support formats that are not JSON-based. Also the content-negotiation/rendering is still only done on endpoints that are read-only, because to use writable endpoints one needs to understand the annotator-specific format anyway. --- annotator/store.py | 55 ++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/annotator/store.py b/annotator/store.py index 125e46e..d7f13b7 100644 --- a/annotator/store.py +++ b/annotator/store.py @@ -1,6 +1,6 @@ """ -This module implements a Flask-based JSON API to talk with the annotation store via the -Annotation model. +This module implements a Flask-based JSON API to talk with the annotation store +via the Annotation model. It defines these routes: * Root * Index @@ -25,6 +25,7 @@ from annotator.atoi import atoi from annotator.annotation import Annotation +from annotator.openannotation import OAAnnotation store = Blueprint('store', __name__) @@ -39,17 +40,30 @@ def jsonify(obj, *args, **kwargs): return Response(res, mimetype='application/json', *args, **kwargs) +def render_jsonld(annotation): + """Returns a JSON-LD RDF representation of the annotation""" + oa_annotation = OAAnnotation(annotation) + oa_annotation.jsonld_baseurl = url_for('.read_annotation', + id='', _external=True) + return oa_annotation.jsonld + +renderers = { + 'application/ld+json': render_jsonld, + 'application/json': lambda annotation: annotation, +} +types_by_preference = ['application/json', 'application/ld+json'] + +def render(annotation, content_type=None): + if content_type is None: + content_type = preferred_content_type(types_by_preference) + return renderers[content_type](annotation) + + @store.before_request def before_request(): if not hasattr(g, 'annotation_class'): g.annotation_class = Annotation - if g.annotation_class.jsonld_baseurl is None: - # Make an annotation's URI equal to the location where it can be read - g.annotation_class.jsonld_baseurl = url_for('.read_annotation', - id='', - _external=True) - user = g.auth.request_user(request) if user is not None: g.user = user @@ -151,10 +165,8 @@ def index(): annotations = g.annotation_class.search(user=user) - if _jsonld_requested(): - annotations = [annotation.jsonld for annotation in annotations] + return jsonify(list(map(render, annotations))) - return jsonify(annotations) # CREATE @store.route('/annotations', methods=['POST']) @@ -200,10 +212,8 @@ def read_annotation(id): if failure: return failure - if _jsonld_requested(): - annotation = annotation.jsonld - return jsonify(annotation) + return jsonify(render(annotation)) # UPDATE @@ -294,11 +304,8 @@ def search_annotations(): results = g.annotation_class.search(**kwargs) total = g.annotation_class.count(**kwargs) - if _jsonld_requested(): - results = [annotation.jsonld for annotation in results] - return jsonify({'total': total, - 'rows': results}) + 'rows': list(map(render, results))}) # RAW ES SEARCH @@ -435,9 +442,9 @@ def _update_query_raw(qo, params, k, v): elif k == 'search_type': params[k] = v -def _jsonld_requested(): - # We prefer and default to plain json. - best_mimetype = request.accept_mimetypes.best_match( - ['application/json', 'application/ld+json'], - 'application/json') - return best_mimetype == 'application/ld+json' +def preferred_content_type(possible_types): + default = possible_types[0] + best_type = request.accept_mimetypes.best_match( + possible_types, + default) + return best_type From 040f87f534595018459769ab935261bda16b620e Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 12 Aug 2014 14:08:48 -0700 Subject: [PATCH 16/17] Add some documentation --- annotator/openannotation.py | 8 ++++++++ annotator/store.py | 24 +++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/annotator/openannotation.py b/annotator/openannotation.py index 3a81fa4..41dfe52 100644 --- a/annotator/openannotation.py +++ b/annotator/openannotation.py @@ -1,6 +1,7 @@ import logging log = logging.getLogger(__name__) +# Import OrderedDict if available try: from collections import OrderedDict except ImportError: @@ -14,6 +15,13 @@ from annotator.annotation import Annotation class OAAnnotation(Annotation): + """A helper class to represent an annotation according to the Open + Annotation Data Model: http://www.openannotation.org/spec/core/core.html + + Currently it only generates JSON-LD. + """ + + # The ID of the annotation will be relative to the base URL, if it is set. jsonld_baseurl = None @property diff --git a/annotator/store.py b/annotator/store.py index d7f13b7..9d48e6b 100644 --- a/annotator/store.py +++ b/annotator/store.py @@ -3,14 +3,18 @@ via the Annotation model. It defines these routes: * Root - * Index + * Index (OA) * Create - * Read + * Read (OA) * Update * Delete - * Search + * Search (OA) * Raw ElasticSearch search See their descriptions in `root`'s definition for more detail. + +Routes marked with OA (the read-only endpoints) will render the annotations in +JSON-LD following the Open Annotation Data Model if the user agent prefers this +(by accepting application/ld+json). """ from __future__ import absolute_import @@ -40,6 +44,11 @@ def jsonify(obj, *args, **kwargs): return Response(res, mimetype='application/json', *args, **kwargs) +""" +Define renderers that can be used for presenting the annotation. Note that we +currently only use JSON-based types. The renderer returns not a string but a +jsonifiable object. +""" def render_jsonld(annotation): """Returns a JSON-LD RDF representation of the annotation""" oa_annotation = OAAnnotation(annotation) @@ -54,6 +63,7 @@ def render_jsonld(annotation): types_by_preference = ['application/json', 'application/ld+json'] def render(annotation, content_type=None): + """Return the annotation in the given or negotiated content_type""" if content_type is None: content_type = preferred_content_type(types_by_preference) return renderers[content_type](annotation) @@ -443,6 +453,14 @@ def _update_query_raw(qo, params, k, v): params[k] = v def preferred_content_type(possible_types): + """Tells which content (MIME) type is preferred by the user agent. + + In case of ties (or absence of an Accept header) items earlier in the + sequence are chosen. + + Arguments: + possible_types -- Sequence of content types, in order of preference. + """ default = possible_types[0] best_type = request.accept_mimetypes.best_match( possible_types, From 1b62da858b96abbb1243bb4ec8dbefa348e62c2d Mon Sep 17 00:00:00 2001 From: Gerben Date: Tue, 12 Aug 2014 19:34:20 -0700 Subject: [PATCH 17/17] Create test_openannotation.py --- tests/test_openannotation.py | 93 ++++++++++++++++++++++++++++++++++++ tests/test_store.py | 64 ++----------------------- 2 files changed, 98 insertions(+), 59 deletions(-) create mode 100644 tests/test_openannotation.py diff --git a/tests/test_openannotation.py b/tests/test_openannotation.py new file mode 100644 index 0000000..2bd749f --- /dev/null +++ b/tests/test_openannotation.py @@ -0,0 +1,93 @@ +import re + +from annotator.annotation import Annotation +from annotator.openannotation import OAAnnotation +from annotator.elasticsearch import _add_created, _add_updated + +class TestOpenAnnotation(object): + + def _make_annotation(self): + annotation_fields = { + 'id': '1234', + 'text': 'blablabla', + 'uri': 'http://localhost:4000/dev.html', + 'ranges': [ + { + 'start': '/ul[1]/li[1]', + 'end': '/ul[1]/li[1]', + 'startOffset': 0, + 'endOffset': 26 + } + ], + 'user': 'alice', + 'quote': 'Lorem ipsum dolor sit amet', + 'consumer': 'mockconsumer', + 'permissions': { + 'read': [], + 'admin': [], + 'update': [], + 'delete': [] + } + } + annotation = OAAnnotation(annotation_fields) + _add_created(annotation) + _add_updated(annotation) + return annotation + + def test_basics(self): + ann = self._make_annotation() + + # Get the JSON-LD (as a dictionary) + ann_ld = ann.jsonld + + # Check the values of some basic fields + ldid = ann_ld['@id'] + assert ldid == '1234', "Incorrect annotation @id: {0}!={1}".format(ldid, id) + assert ann_ld['@type'] == 'oa:Annotation' + assert ann_ld['hasBody'] == [{ + "cnt:chars": "blablabla", + "@type": [ + "dctypes:Text", + "cnt:ContentAsText" + ], + "dc:format": "text/plain" + }], "Incorrect hasBody: {0}".format(ann_ld['hasBody']) + + assert ann_ld['hasTarget'] == [{ + "hasSource": "http://localhost:4000/dev.html", + "hasSelector": { + "annotator:endContainer": "/ul[1]/li[1]", + "annotator:startOffset": 0, + "annotator:startContainer": "/ul[1]/li[1]", + "@type": "annotator:TextRangeSelector", + "annotator:endOffset": 26 + }, + "@type": "oa:SpecificResource" + }], "Incorrect hasTarget: {0}".format(ann_ld['hasBody']) + + assert ann_ld['annotatedBy'] == { + '@type': 'foaf:Agent', + 'foaf:name': 'alice', + }, "Incorrect annotatedBy: {0}".format(ann_ld['annotatedBy']) + + date_str = "nnnn-nn-nnTnn:nn:nn(\.nnnnnn)?([+-]nn.nn|Z)" + date_regex = re.compile(date_str.replace("n","\d")) + assert date_regex.match(ann_ld['annotatedAt']), "Incorrect annotatedAt: {0}".format(ann_ld['annotatedAt']) + assert date_regex.match(ann_ld['serializedAt']), "Incorrect createdAt: {0}".format(ann_ld['annotatedAt']) + + +def assemble_context(context_value): + if isinstance(context_value, dict): + return context_value + elif isinstance(context_value, list): + # Merge all context parts + context = {} + for context_piece in context_value: + if isinstance(context_piece, dict): + context.update(context_piece) + return context + elif isinstance(context, str): + # XXX: we do not retrieve an externally defined context + raise NotImplementedError + else: + raise AssertionError("@context should be dict, list, or str") diff --git a/tests/test_store.py b/tests/test_store.py index b04795d..a47db9d 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -11,6 +11,7 @@ from annotator import auth, es from annotator.annotation import Annotation +from .test_openannotation import assemble_context class TestStore(TestCase): def setup(self): @@ -384,31 +385,15 @@ def returns_ld(endpoint, preference): assert not is_ld('eat_all'), "Plain JSON should be default (for %s)" % action assert not is_ld('no_accept'), "Plain JSON should be default (for %s)" % action - def test_jsonld(self): - """Test if the JSON-LD representation contains the correct @base and - check the basic fields. - """ + def test_jsonld_base(self): + """Test if the JSON-LD representation contains the correct @base""" # Create an annotation annotation_fields = { "text": "blablabla", "uri": "http://localhost:4000/dev.html", - "ranges": [ - { - "start": "/ul[1]/li[1]", - "end": "/ul[1]/li[1]", - "startOffset": 0, - "endOffset": 26 - } - ], "user": "alice", "quote": "Lorem ipsum dolor sit amet", "consumer": "mockconsumer", - "permissions": { - "read": [], - "admin": [], - "update": [], - "delete": [] - } } ann_orig = self._create_annotation(**annotation_fields) id = ann_orig['id'] @@ -419,53 +404,14 @@ def test_jsonld(self): headers=headers) ann_ld = json.loads(res.data) - context_parts = ann_ld.get('@context') - assert context_parts is not None, "Expected a @context in JSON-LD" - # Merge all context parts - context = {} - for context_piece in context_parts: - if type(context_piece) == dict: - context.update(context_piece) + assert '@context' in ann_ld, "Expected a @context in JSON-LD" + context = assemble_context(ann_ld['@context']) # Check @base value (note it will be different for a real deployment) base = context.get('@base') assert base is not None, "Annotation should have a @base in @context" assert base == 'http://localhost/api/annotations/', 'Base incorrect, found @base: "{0}"'.format(base) - ldid = ann_ld['@id'] - assert ldid == id, "Incorrect annotation @id: {0}!={1}".format(ldid, id) - assert ann_ld['@type'] == 'oa:Annotation' - assert ann_ld['hasBody'] == [{ - "cnt:chars": "blablabla", - "@type": [ - "dctypes:Text", - "cnt:ContentAsText" - ], - "dc:format": "text/plain" - }], "Incorrect hasBody: {0}".format(ann_ld['hasBody']) - - assert ann_ld['hasTarget'] == [{ - "hasSource": "http://localhost:4000/dev.html", - "hasSelector": { - "annotator:endContainer": "/ul[1]/li[1]", - "annotator:startOffset": 0, - "annotator:startContainer": "/ul[1]/li[1]", - "@type": "annotator:TextRangeSelector", - "annotator:endOffset": 26 - }, - "@type": "oa:SpecificResource" - }], "Incorrect hasTarget: {0}".format(ann_ld['hasBody']) - - assert ann_ld['annotatedBy'] == { - '@type': 'foaf:Agent', - 'foaf:name': 'alice', - }, "Incorrect annotatedBy: {0}".format(ann_ld['annotatedBy']) - - date_str = "nnnn-nn-nnTnn:nn:nn(\.nnnnnn)?([+-]nn.nn|Z)" - date_regex = re.compile(date_str.replace("n","\d")) - assert date_regex.match(ann_ld['annotatedAt']), "Incorrect annotatedAt: {0}".format(ann_ld['annotatedAt']) - assert date_regex.match(ann_ld['serializedAt']), "Incorrect createdAt: {0}".format(ann_ld['annotatedAt']) - def _get_search_results(self, qs=''): res = self.cli.get('/api/search?{qs}'.format(qs=qs), headers=self.headers)