diff --git a/src/uco-json-ld-compaction.py b/src/uco-json-ld-compaction.py new file mode 100644 index 00000000..b3525be4 --- /dev/null +++ b/src/uco-json-ld-compaction.py @@ -0,0 +1,253 @@ +# +# Release Statement? +# + +""" +Purpose statement + +1) json-ld context to support compaction of all IRI base paths through defined + prefixes +2) json-ld context to support compaction of all property type assertions +3) json-ld context to support assertion of properties with potential + cardinalities >1 as set arrrays +4) json-ld context to support compaction of json-ld specific key strings @id, + @type, @value and @graph to simple json key strings id, type, value, and graph such that the body of content can be viewed as simple json and the context can be utilized to expand it into fully codified json-ld + +""" + +__version__ = "0.0.1" + +import argparse +import logging +from multiprocessing import context +import os +import typing +import pathlib +import sys +import re +import rdflib + +_logger = logging.getLogger(os.path.basename(__file__)) + +""" + 27 def main(): + 28 g = rdflib.Graph() + 29 for in_graph in args.in_graph: + 30 g.parse(in_graph, format="turtle") + 31 g.serialize(args.out_graph, format="turtle") +""" + +class context_builder: + def __init__(self): + self.ttl_file_list=None + self.prefix_dict=None + self.top_srcdir=None + self.iri_dict=None + self.datatype_properties_dict={} + + def get_ttl_files(self, subdirs=[]) -> list: + """ + Finds all turtle (.ttl) files in directory structure + @subdirs - Optional list used to restrict search to particular directories. + """ + if self.ttl_file_list is not None: + return self.ttl_file_list + + #Shamelessly stolen from populate_node_kind.py + # 0. Self-orient. + self.top_srcdir = pathlib.Path(os.path.dirname(__file__)) / ".." + top_srcdir=self.top_srcdir + # Sanity check. + assert (top_srcdir / ".git").exists(), "Hard-coded top_srcdir discovery is no longer correct." + + # 1. Load all ontology files into dictionary of graphs. + + # The extra filtering step loop to keep from picking up CI files. Path.glob returns dot files, unlike shell's glob. + # The uco.ttl file is also skipped because the Python output removes supplementary prefix statements. + ontology_filepaths : typing.List[pathlib.Path] = [] + + file_list=[] + _logger.debug(top_srcdir) + + if len(subdirs) < 1: + for x in (top_srcdir).rglob("*.ttl"): + if ".check-" in str(x): + continue + if "uco.ttl" in str(x): + continue + #_logger.debug(x) + file_list.append(x) + self.ttl_file_list=file_list + else: + for dir in subdirs: + for x in (top_srcdir / dir).rglob("*.ttl"): + if ".check-" in str(x): + continue + if "uco.ttl" in str(x): + continue + #_logger.debug(x) + file_list.append(x) + self.ttl_file_list=file_list + + return self.ttl_file_list + + def get_iris(self)->list: + """ + Returns sorted list of IRIs + """ + k_list=list(self.iri_dict.keys()) + #print(k_list) + k_list.sort() + irs_list=[] + for k in k_list: + #print(f"\"{k}\":{self.iri_dict[k]}") + irs_list.append(f"\"{k}\":{self.iri_dict[k]}") + return irs_list + + def __add_to_iri_dict(self, in_prefix): + """INTERNAL function: Adds unique key value pairs to dict + that will be used to generate context. Dies if inconsistent + key value pair is found. + @in_prefix - an input prefix triple + """ + if self.iri_dict is None: + self.iri_dict={} + + iri_dict = self.iri_dict + t_split=in_prefix.split() + #Taking the ':' off the end of the key + k=t_split[1][:-1] + v=t_split[2] + if k in iri_dict.keys(): + #_logger.debug(f"'{k}' already exists") + if iri_dict[k]!=v: + _logger.error(f"Mismatched values:\t{iri_dict[k]}!={v}") + sys.exit() + else: + iri_dict[k]=v + + def __process_DatatypePropertiesHelper(self, in_file=None): + """ + Does the actual work using rdflib + @in_file - ttl file to get object properties from + """ + graph = rdflib.Graph() + graph.parse(in_file, format="turtle") + "Make sure to do an itter that looks for rdflib.OWL.class" + #limit = 4 + #count = 0 + for triple in graph.triples((None,rdflib.RDF.type,rdflib.OWL.DatatypeProperty)): + print(triple) + print(triple[0].split('/')) + s_triple=triple[0].split('/') + root=s_triple[-1] + ns_prefix=f"{s_triple[-3]}-{s_triple[-2]}" + print(ns_prefix, root) + + if root in self.datatype_properties_dict.keys(): + print(f"None Unique Entry Found:\t {ns_prefix}:{root}") + self.datatype_properties_dict[root].append(ns_prefix) + else: + self.datatype_properties_dict[root]=[ns_prefix] + + return + #count += 1 + #if count >= limit: + # return + + def process_DatatypeProperties(self): + for ttl_file in self.ttl_file_list: + self.__process_DatatypePropertiesHelper(in_file=ttl_file) + + def get_prefixes(self): + """ + Finds all prefix lines in list of ttl files. Adds them to an + an internal dict + """ + ttl_file_list = self.get_ttl_files() + if len(ttl_file_list) < 1: + _logger.error("No ttls files to process") + sys.exit() + + for ttl_file in ttl_file_list: + with open(ttl_file,'r') as file: + for line in file: + if re.search("^\@prefix",line): + #_logger.debug(line.strip()) + self.__add_to_iri_dict(in_prefix=line.strip()) + + + +def main(): + argument_parser = argparse.ArgumentParser() + argument_parser.add_argument('--debug', action="store_true") + #argument_parser.add_argument('-i', '--in_graph', help="Input graph to be simplified") + args = argument_parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + + _logger.debug("Debug Mode enabled") + + cb = context_builder() + for i in (cb.get_ttl_files(subdirs=['ontology'])): + _logger.debug(f" Input ttl: {i}") + + cb.get_prefixes() + #for i in cb.get_iris(): + # print(i) + + cb.process_DatatypeProperties() + +""" +If we cannot find rdf range, skip +if rdf range is a blank node, skip +""" + dt_list = list(cb.datatype_properties_dict.keys()) + dt_list.sort() + for key in dt_list: + #Non-unique roots + if len(cb.datatype_properties_dict[key]) > 1: + print(f"{key}:{cb.datatype_properties_dict[key]}") + for ns in cb.datatype_properties_dict[key]: + con_str=f"\"{ns}:{key}\":{{" + con_str+="\n\t\"@id\":\"%s:%s\"," % (ns,key) + con_str+="\n\t\"@type\":\"@id\"" + con_str+="\n\t}," + print(con_str) + #Unique roots + else: + pass + + #from pprint import pprint + #pprint(cb.datatype_properties_dict) + graph = rdflib.Graph() + graph.parse("../tests/uco_monolithic.ttl", format="turtle") + graph.serialize("_uco_monolithic.json-ld", format="json-ld") + graph.serialize("_uco_monolithic.json-ld", format="json-ld") + sys.exit() + #context keyword in graph parse and graph serialize + #black formater FLAKE8 for isort + #check the case-uilities python + + + + graph = rdflib.Graph() + graph.parse("../tests/uco_monolithic.ttl", format="turtle") + "Make sure to do an itter that looks for rdflib.OWL.class" + limit = 4 + count = 0 + for triple in graph.triples((None,rdflib.RDF.type,rdflib.OWL.DatatypeProperty)): + print(triple[0].fragment) + print(triple) + count += 1 + if count >= limit: + sys.exit() + + #print(f"{args.in_graph}") + #g = rdflib.Graph() + #g.parse(args.in_graph, format="turtle") + #g.serialize("temp.json-ld", format="json-ld") + + +if __name__ == "__main__": + main() diff --git a/src/uco_jsonld_context_builder.py b/src/uco_jsonld_context_builder.py new file mode 100644 index 00000000..bcaeb620 --- /dev/null +++ b/src/uco_jsonld_context_builder.py @@ -0,0 +1,664 @@ +#!python +# +# NOTICE +# This software was produced for the U.S. Government under contract FA8702-22-C-0001, +# and is subject to the Rights in Data-General Clause 52.227-14, Alt. IV (DEC 2007) +# ©2022 The MITRE Corporation. All Rights Reserved. +# Released under PRS 18-4297. +# + +""" +Purpose statement + +1) json-ld context to support compaction of all IRI base paths through defined + prefixes +2) json-ld context to support compaction of all property type assertions +3) json-ld context to support assertion of properties with potential + cardinalities >1 as set arrrays +4) json-ld context to support compaction of json-ld specific key strings @id, + @type, @value and @graph to simple json key strings id, type, value, and + graph such that the body of content can be viewed as simple json and the + context can be utilized to expand it into fully codified json-ld + +""" + +__version__ = "0.0.1" + +import argparse +import logging +import os +import typing +import pathlib +import sys +import re + +import rdflib + +_logger = logging.getLogger(os.path.basename(__file__)) + + +class ObjectPropertyInfo: + """Class to hold ObjectProperty info which will be used to build + context""" + + def __init__(self) -> None: + self.ns_prefix: typing.Optional[str] = None + self.root_class_name: typing.Optional[str] = None + self.shacl_count_lte_1: typing.Optional[bool] = None + self.shacl_property_bnode = None + + def __get_json(self, hdr: str) -> str: + json_str = hdr + json_str += f'\t"@id":"{self.ns_prefix}:{self.root_class_name}",\n' + json_str += '\t"@type":"@id"' + if self.shacl_count_lte_1 is not True: + json_str += ',\n\t"@container":"@set"\n' + else: + json_str += "\n" + + json_str += "},\n" + return json_str + + def get_minimal_json(self) -> str: + hdr_str = f'"{self.ns_prefix}:{self.root_class_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + def get_concise_json(self) -> str: + hdr_str = f'"{self.root_class_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + +class DatatypePropertyInfo: + """Class to hold DatatypeProperty info which will be used to build + context""" + + def __init__(self) -> None: + self.ns_prefix: typing.Optional[str] = None + self.root_property_name: typing.Optional[str] = None + self.prefixed_datatype_name: typing.Optional[str] = None + self.shacl_count_lte_1: typing.Optional[bool] = None + self.shacl_property_bnode = None + + def __get_json(self, hdr: str) -> str: + json_str = hdr + json_str += f'\t"@id":"{self.ns_prefix}:{self.root_property_name}"' + if self.prefixed_datatype_name is not None: + json_str += ",\n" + json_str += f'\t"@type":"{self.prefixed_datatype_name}"' + if self.shacl_count_lte_1 is not True: + json_str += ',\n\t"@container":"@set"\n' + else: + json_str += "\n" + json_str += "},\n" + return json_str + + def get_minimal_json(self) -> str: + hdr_str = f'"{self.ns_prefix}:{self.root_property_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + def get_concise_json(self) -> str: + hdr_str = f'"{self.root_property_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + +class UCO_Class: + def __init__(self) -> None: + self.ns_prefix: typing.Optional[str] = None + self.root_class_name: typing.Optional[str] = None + # self.prefixed_datatype_name: typing.Optional[str] = None + # self.shacl_count_lte_1: typing.Optional[bool] = None + # self.shacl_property_bnode = None + + def __get_json(self, hdr: str) -> str: + json_str = hdr + json_str += f'\t"@id":"{self.ns_prefix}:{self.root_class_name}"' + json_str += "\n" + json_str += "},\n" + return json_str + + def get_minimal_json(self) -> str: + hdr_str = f'"{self.ns_prefix}:{self.root_class_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + def get_concise_json(self) -> str: + hdr_str = f'"{self.root_class_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + +class DataType: + def __init__(self) -> None: + self.ns_prefix: typing.Optional[str] = None + self.root_class_name: typing.Optional[str] = None + # self.prefixed_datatype_name: typing.Optional[str] = None + # self.shacl_count_lte_1: typing.Optional[bool] = None + # self.shacl_property_bnode = None + + def __get_json(self, hdr: str) -> str: + json_str = hdr + json_str += f'\t"@id":"{self.ns_prefix}:{self.root_class_name}"' + json_str += "\n" + json_str += "},\n" + return json_str + + def get_minimal_json(self) -> str: + hdr_str = f'"{self.ns_prefix}:{self.root_class_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + def get_concise_json(self) -> str: + hdr_str = f'"{self.root_class_name}":{{\n' + json_str = self.__get_json(hdr=hdr_str) + return json_str + + +class ContextBuilder: + def __init__(self) -> None: + self.ttl_file_list: typing.Optional[typing.List[pathlib.Path]] = None + self.prefix_dict = None + self.top_srcdir: typing.Optional[pathlib.Path] = None + self.iri_dict: typing.Optional[typing.Dict[str, str]] = None + # TODO ERROR MITIGATION: These two dicts should be keyed by IRI (str() cast) rather than IRI fragment. + self.datatype_properties_dict: typing.Dict[ + str, typing.List[DatatypePropertyInfo] + ] = dict() + self.object_properties_dict: typing.Dict[ + str, typing.List[ObjectPropertyInfo] + ] = dict() + self.classes_dict: typing.Dict[str, typing.List[UCO_Class]] = dict() + self.datatypes_dict: typing.Dict[str, typing.List[DataType]] = dict() + # The string that will hold the processed context + self.context_str = "" + + def init_context_str(self) -> None: + self.context_str = '{\n\t"@context":{\n' "" + + def close_context_str(self) -> None: + self.context_str = self.context_str.strip() + if self.context_str[-1] == ",": + self.context_str = self.context_str[:-1] + self.context_str += "\n\t}\n}" + + def get_ttl_files( + self, subdirs: typing.List[str] = [] + ) -> typing.List[pathlib.Path]: + """ + Finds all turtle (.ttl) files in directory structure + @subdirs - Optional list used to restrict search to particular + directories. + """ + # TODO - It seems some of the purpose of get_ttl_files() may be mooted by using tests/uco_monolithic.ttl, a temporary build artifact. + + if self.ttl_file_list is not None: + return self.ttl_file_list + + # Shamelessly stolen from populate_node_kind.py + # 0. Self-orient. + self.top_srcdir = pathlib.Path(os.path.dirname(__file__)) / ".." + top_srcdir = self.top_srcdir + # Sanity check. + assert ( + top_srcdir / ".git" + ).exists(), "Hard-coded top_srcdir discovery is no longer correct." + + # 1. Load all ontology files into dictionary of graphs. + + # The extra filtering step loop to keep from picking up CI files. + # Path.glob returns dot files, unlike shell's glob. + # The uco.ttl file is also skipped because the Python output removes + # supplementary prefix statements. + file_list = [] + _logger.debug(top_srcdir) + + if len(subdirs) < 1: + for x in (top_srcdir).rglob("*.ttl"): + if ".check-" in str(x): + continue + if "uco.ttl" in str(x): + continue + # _logger.debug(x) + file_list.append(x) + self.ttl_file_list = file_list + else: + for dir in subdirs: + for x in (top_srcdir / dir).rglob("*.ttl"): + if ".check-" in str(x): + continue + if "uco.ttl" in str(x): + continue + # _logger.debug(x) + file_list.append(x) + self.ttl_file_list = file_list + + return self.ttl_file_list + + def get_iris(self) -> typing.List[str]: + """ + Returns sorted list of IRIs as prefix:value strings + """ + assert self.iri_dict is not None + k_list = list(self.iri_dict.keys()) + # print(k_list) + k_list.sort() + irs_list = [] + for k in k_list: + # print(f"\"{k}\":{self.iri_dict[k]}") + # prepend "uco-" to specific IRIs + v = self.iri_dict[k] + # _logger.debug(v.split('/')) + if ("uco" in v.split("/")) and ( + "ontology.unifiedcyberontology.org" in v.split("/") + ): + irs_list.append(f'"uco-{k}":"{v}"') + else: + irs_list.append(f'"{k}":"{v}"') + return irs_list + + def add_prefixes_to_cntxt(self) -> None: + """Adds detected prefixes to the context string""" + for i in self.get_iris(): + self.context_str += f"{i},\n" + + def __add_to_iri_dict(self, in_prefix: str) -> None: + """INTERNAL function: Adds unique key value pairs to dict + that will be used to generate context. Dies if inconsistent + key value pair is found. + @in_prefix - an input prefix triple + """ + if self.iri_dict is None: + self.iri_dict = {} + + iri_dict = self.iri_dict + t_split = in_prefix.split() + # Taking the ':' off the end of the key + k = t_split[1][:-1] + v = t_split[2] + # Taking the angle brackets off the IRIs + v = v.strip()[1:-1] + if k in iri_dict.keys(): + # _logger.debug(f"'{k}' already exists") + if iri_dict[k] != v: + _logger.error(f"Mismatched values:\t{iri_dict[k]}!={v}") + sys.exit() + else: + iri_dict[k] = v + + def __process_DatatypePropertiesHelper(self, in_file: str) -> None: + """ + Does the actual work using rdflib + @in_file - ttl file to get object properties from + """ + graph = rdflib.Graph() + graph.parse(in_file, format="turtle") + "Make sure to do an itter that looks for rdflib.OWL.class" + # If we cannot find rdf range, skip + # If rdf range is a blank node, skip + + # Troubleshooting loop + for triple in graph.triples( + # (None, rdflib.RDF.type, rdflib.OWL.DatatypeProperty) + (None, rdflib.RDF.type, None) + ): + _logger.debug(f"Any: {triple}") + + # Troubleshooting loop + for triple in graph.triples((None, None, rdflib.OWL.DatatypeProperty)): + _logger.debug(f"Any Owl DatatypeProperty: {triple}") + + for triple in graph.triples( + (None, rdflib.RDF.type, rdflib.OWL.DatatypeProperty) + ): + dtp_obj = DatatypePropertyInfo() + _logger.debug(triple) + _logger.debug(triple[0].split("/")) + s_triple = triple[0].split("/") + # (rdflib calls this "fragment" rather than root) + # TODO LIKELY ERROR: This assumes fragments are unique within UCO, which is not true in UCO 0.9.0. + root = s_triple[-1] + ns_prefix = f"{s_triple[-3]}-{s_triple[-2]}" + # print(ns_prefix, root) + dtp_obj.ns_prefix = ns_prefix + dtp_obj.root_property_name = root + for triple2 in graph.triples((triple[0], rdflib.RDFS.range, None)): + # Testing for Blank Nodes + if isinstance(triple2[-1], rdflib.term.BNode): + _logger.debug(f"\tBlank: {triple2}\n") + continue + _logger.debug(f"\ttriple2: f{triple2}\n") + rdf_rang_str = str(triple2[-1].n3(graph.namespace_manager)) + dtp_obj.prefixed_datatype_name = rdf_rang_str + + for sh_triple in graph.triples((None, rdflib.SH.path, triple[0])): + _logger.debug(f"\t\t**sh_triple:{sh_triple}") + dtp_obj.shacl_property_bnode = sh_triple[0] + for sh_triple2 in graph.triples( + (dtp_obj.shacl_property_bnode, rdflib.SH.maxCount, None) + ): + _logger.debug(f"\t\t***sh_triple:{sh_triple2}") + _logger.debug(f"\t\t***sh_triple:{sh_triple2[2]}") + if int(sh_triple2[2]) <= 1: + if dtp_obj.shacl_count_lte_1 is not None: + _logger.debug( + f"\t\t\t**MaxCount Double Definition? {triple[0].n3(graph.namespace_manager)}" + ) + dtp_obj.shacl_count_lte_1 = True + else: + _logger.debug(f"\t\t\t***Large max_count: {sh_triple2[2]}") + + if root in self.datatype_properties_dict.keys(): + _logger.debug(f"None Unique Entry Found:\t {ns_prefix}:{root}") + self.datatype_properties_dict[root].append(dtp_obj) + else: + self.datatype_properties_dict[root] = [dtp_obj] + return + + def process_DatatypeProperties(self) -> None: + assert self.ttl_file_list is not None + for ttl_file in self.ttl_file_list: + _logger.debug(f"Datatype Processing for {str(ttl_file)}") + self.__process_DatatypePropertiesHelper(in_file=str(ttl_file)) + + def __process_ObjectPropertiesHelper(self, in_file: str) -> None: + """ + Does the actual work using rdflib + @in_file - ttl file to get object properties from + """ + graph = rdflib.Graph() + graph.parse(in_file, format="turtle") + # If we cannot find rdf range, skip + # If rdf range is a blank node, skip + for triple in graph.triples((None, rdflib.RDF.type, rdflib.OWL.ObjectProperty)): + op_obj = ObjectPropertyInfo() + _logger.debug((triple)) + # print(triple[0].split('/')) + s_triple = triple[0].split("/") + root = s_triple[-1] + ns_prefix = f"{s_triple[-3]}-{s_triple[-2]}" + # print(ns_prefix, root) + op_obj.ns_prefix = ns_prefix + op_obj.root_class_name = root + + for sh_triple in graph.triples((None, rdflib.SH.path, triple[0])): + _logger.debug(f"\t**obj_sh_triple:{sh_triple}") + op_obj.shacl_property_bnode = sh_triple[0] + for sh_triple2 in graph.triples( + (op_obj.shacl_property_bnode, rdflib.SH.maxCount, None) + ): + _logger.debug(f"\t\t***sh_triple:{sh_triple2}") + _logger.debug(f"\t\t***sh_triple:{sh_triple2[2]}") + if int(sh_triple2[2]) <= 1: + if op_obj.shacl_count_lte_1 is not None: + _logger.debug( + f"\t\t\t**MaxCount Double Definition? {triple[0].n3(graph.namespace_manager)}" + ) + op_obj.shacl_count_lte_1 = True + else: + _logger.debug(f"\t\t\t***Large max_count: {sh_triple2[2]}") + + if root in self.object_properties_dict.keys(): + _logger.debug(f"None Unique Entry Found:\t {ns_prefix}:{root}") + self.object_properties_dict[root].append(op_obj) + else: + self.object_properties_dict[root] = [op_obj] + return + + def __process_ClassesHelper(self, in_file: str) -> None: + graph = rdflib.Graph() + graph.parse(in_file, format="turtle") + # Populate with an iter that looks for rdflib.OWL.class, and then for participation in subclassing. + all_class_iris: typing.Set[rdflib.URIRef] = set() + for triple in graph.triples((None, rdflib.RDF.type, rdflib.OWL.Class)): + # Skip Blank Nodes + if isinstance(triple[0], rdflib.URIRef): + all_class_iris.add(triple[0]) + for triple in graph.triples((None, rdflib.RDFS.subClassOf, None)): + # Skip Blank Nodes + if isinstance(triple[0], rdflib.URIRef): + all_class_iris.add(triple[0]) + if isinstance(triple[2], rdflib.URIRef): + all_class_iris.add(triple[2]) + for class_iri in all_class_iris: + c_obj = UCO_Class() + _logger.debug((class_iri)) + # print(class_iri.split("/")) + s_triple = class_iri.split("/") + root = s_triple[-1] + ns_prefix = f"{s_triple[-3]}-{s_triple[-2]}" + # print(ns_prefix, root) + # print(root) + c_obj.ns_prefix = ns_prefix + c_obj.root_class_name = root + + if root in self.classes_dict.keys(): + _logger.debug(f"None Unique Entry Found:\t {ns_prefix}:{root}") + # print(f"None Unique Entry Found:\t {ns_prefix}:{root}") + self.classes_dict[root].append(c_obj) + else: + self.classes_dict[root] = [c_obj] + return + + def process_ObjectProperties(self) -> None: + assert self.ttl_file_list is not None + for ttl_file in self.ttl_file_list: + _logger.debug(f"ObjectProperty Processing for {str(ttl_file)}") + self.__process_ObjectPropertiesHelper(in_file=str(ttl_file)) + + def process_Classes(self) -> None: + assert self.ttl_file_list is not None + for ttl_file in self.ttl_file_list: + _logger.debug(f"Class Processing for {str(ttl_file)}") + self.__process_ClassesHelper(in_file=str(ttl_file)) + + def __process_DataTypesHelper(self, in_file: str) -> None: + graph = rdflib.Graph() + graph.parse(in_file, format="turtle") + # Make sure to do an iter that looks for rdflib.OWL.class" + # If we cannot find rdf range, skip + # If rdf range is a blank node, skip + for triple in graph.triples((None, rdflib.RDF.type, rdflib.RDFS.Datatype)): + # Skip Blank Nodes + if isinstance(triple[0], rdflib.term.BNode): + _logger.debug(f"\tBlank: {triple}\n") + continue + dt_obj = DataType() + # print(triple) + _logger.debug((triple)) + # print(triple[0].split("/")) + s_triple = triple[0].split("/") + root = s_triple[-1] + ns_prefix = f"{s_triple[-3]}-{s_triple[-2]}" + # print(ns_prefix, root) + # print(root) + dt_obj.ns_prefix = ns_prefix + dt_obj.root_class_name = root + + if root in self.datatypes_dict.keys(): + _logger.debug(f"None Unique Entry Found:\t {ns_prefix}:{root}") + self.datatypes_dict[root].append(dt_obj) + else: + self.datatypes_dict[root] = [dt_obj] + return + + def process_DataTypes(self) -> None: + assert self.ttl_file_list is not None + for ttl_file in self.ttl_file_list: + _logger.debug(f"DataType Processing for {str(ttl_file)}") + self.__process_DataTypesHelper(in_file=str(ttl_file)) + + def process_prefixes(self) -> None: + """ + Finds all prefix lines in list of ttl files. Adds them to an + an internal dict + """ + ttl_file_list = self.get_ttl_files() + if len(ttl_file_list) < 1: + _logger.error("No ttls files to process") + sys.exit() + + for ttl_file in ttl_file_list: + with open(ttl_file, "r") as file: + for line in file: + if re.search("^@prefix", line): + _logger.debug(f"Prefix: {ttl_file}\t{line.strip()}") + self.__add_to_iri_dict(in_prefix=line.strip()) + + def add_minimal_datatype_props_to_cntxt(self) -> None: + """Adds Datatype Properties to context string""" + dtp_str_sect = "" + dt_list = list(self.datatype_properties_dict.keys()) + dt_list.sort() + # last_dtp_obj = self.datatype_properties_dict[dt_list[-1]][-1] + for key in dt_list: + for dtp_obj in self.datatype_properties_dict[key]: + dtp_str_sect += dtp_obj.get_minimal_json() + self.context_str += dtp_str_sect + + def add_concise_datatype_props_to_cntxt(self) -> None: + """Adds Datatype Properties to context string""" + dtp_str_sect = "" + dtp_list = list(self.datatype_properties_dict.keys()) + dtp_list.sort() + for key in dtp_list: + if len(self.datatype_properties_dict[key]) > 1: + for dtp_obj in self.datatype_properties_dict[key]: + dtp_str_sect += dtp_obj.get_minimal_json() + else: + for dtp_obj in self.datatype_properties_dict[key]: + dtp_str_sect += dtp_obj.get_concise_json() + self.context_str += dtp_str_sect + + def add_minimal_object_props_to_cntxt(self) -> None: + """Adds Object Properties to context string""" + op_str_sect = "" + op_list = list(self.object_properties_dict.keys()) + op_list.sort() + for key in op_list: + for op_obj in self.object_properties_dict[key]: + op_str_sect += op_obj.get_minimal_json() + self.context_str += op_str_sect + + def add_concise_object_props_to_cntxt(self) -> None: + """Adds Object Properties to context string""" + op_str_sect = "" + op_list = list(self.object_properties_dict.keys()) + op_list.sort() + for key in op_list: + if len(self.object_properties_dict[key]) > 1: + for op_obj in self.object_properties_dict[key]: + # print(op_obj.ns_prefix, op_obj.root_class_name) + op_str_sect += op_obj.get_minimal_json() + else: + for op_obj in self.object_properties_dict[key]: + op_str_sect += op_obj.get_concise_json() + self.context_str += op_str_sect + + def add_key_strings_to_cntxt(self) -> None: + """Adds id, type, and graph key strings to context string""" + ks_str = "" + ks_str += '\t"id":"@id",\n' + ks_str += '\t"type":"@type",\n' + ks_str += '\t"value":"@value",\n' + ks_str += '\t"graph":"@graph",\n' + + self.context_str += ks_str + + def add_concise_classes_to_cntxt(self) -> None: + """Adds classes to context string""" + c_sect_str = "" + c_list = list(self.classes_dict.keys()) + c_list.sort() + + for key in c_list: + if len(self.classes_dict[key]) > 1: + # print(f"M:{self.classes_dict[key]}") + for c_obj in self.classes_dict[key]: + c_sect_str += c_obj.get_minimal_json() + else: + # print(f"S:{self.classes_dict[key]}") + for c_obj in self.classes_dict[key]: + c_sect_str += c_obj.get_concise_json() + self.context_str += c_sect_str + + def add_concise_datatypes_to_cntxt(self) -> None: + """Adds classes to context string""" + dt_sect_str = "" + dt_list = list(self.datatypes_dict.keys()) + dt_list.sort() + + for key in dt_list: + if len(self.datatypes_dict[key]) > 1: + # print(f"M:{self.classes_dict[key]}") + for dt_obj in self.datatypes_dict[key]: + dt_sect_str += dt_obj.get_minimal_json() + else: + # print(f"S:{self.classes_dict[key]}") + for dt_obj in self.datatypes_dict[key]: + dt_sect_str += dt_obj.get_concise_json() + self.context_str += dt_sect_str + + +def main() -> None: + argument_parser = argparse.ArgumentParser() + argument_parser.add_argument("--debug", action="store_true") + argument_parser.add_argument( + "--concise", + action="store_true", + help='Creates a "concise" context. This is more compact than the \ + default behavior which creates a "minimal" context', + ) + argument_parser.add_argument( + "-o", + "--output", + help="Output file for context.\ + Will print to stdout by default.", + ) + args = argument_parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + + _logger.debug("\t***Debug Mode enabled***") + + out_f = None + if args.output is not None: + out_f = open(args.output, "w") + + cb = ContextBuilder() + for i in cb.get_ttl_files(subdirs=["ontology"]): + _logger.debug(f" Input ttl: {i}") + + cb.process_prefixes() + cb.process_DatatypeProperties() + cb.process_ObjectProperties() + cb.init_context_str() + cb.add_prefixes_to_cntxt() + if args.concise: + # Note there is classes are not in minimal context + cb.process_Classes() + cb.process_DataTypes() + cb.add_concise_classes_to_cntxt() + cb.add_concise_datatypes_to_cntxt() + cb.add_concise_object_props_to_cntxt() + cb.add_concise_datatype_props_to_cntxt() + else: + cb.add_minimal_object_props_to_cntxt() + cb.add_minimal_datatype_props_to_cntxt() + cb.add_key_strings_to_cntxt() + cb.close_context_str() + + if out_f is not None: + out_f.write(cb.context_str) + out_f.flush() + out_f.close() + else: + print(cb.context_str) + + return + + +if __name__ == "__main__": + main() diff --git a/tests/Makefile b/tests/Makefile index ec5684ca..e2e42009 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -49,11 +49,15 @@ check: \ uco_monolithic.ttl source venv/bin/activate \ && pytest \ + --ignore context_builder \ --ignore examples \ --log-level=DEBUG $(MAKE) \ --directory examples \ check + $(MAKE) \ + --directory context_builder \ + check clean: @$(MAKE) \ diff --git a/tests/context_builder/Makefile b/tests/context_builder/Makefile new file mode 100644 index 00000000..131a9b24 --- /dev/null +++ b/tests/context_builder/Makefile @@ -0,0 +1,71 @@ +#!/usr/bin/make -f + +# This software was developed at the National Institute of Standards +# and Technology by employees of the Federal Government in the course +# of their official duties. Pursuant to title 17 Section 105 of the +# United States Code this software is not subject to copyright +# protection and is in the public domain. NIST assumes no +# responsibility whatsoever for its use by other parties, and makes +# no guarantees, expressed or implied, about its quality, +# reliability, or any other characteristic. +# +# We would appreciate acknowledgement if the software is used. + +SHELL := /bin/bash + +top_srcdir := $(shell cd ../.. ; pwd) + +tests_srcdir := $(top_srcdir)/tests + +all: + +.PHONY: \ + check-minimal \ + check-concise + +check: \ + check-minimal \ + check-concise + source $(tests_srcdir)/venv/bin/activate \ + && pytest \ + --log-level=DEBUG \ + --verbose \ + --verbose + +check-concise: \ + context-concise.json + source $(tests_srcdir)/venv/bin/activate \ + && python3 context_tester.py \ + --concise + +check-minimal: \ + context-minimal.json + source $(tests_srcdir)/venv/bin/activate \ + && python3 context_tester.py + +context-concise.json: \ + $(tests_srcdir)/.venv.done.log \ + $(top_srcdir)/src/uco_jsonld_context_builder.py + source $(tests_srcdir)/venv/bin/activate \ + && python3 $(top_srcdir)/src/uco_jsonld_context_builder.py \ + --concise \ + --output __$@ + # Normalize generated file. + python3 -m json.tool \ + __$@ \ + _$@ + rm __$@ + mv _$@ $@ + +context-minimal.json: \ + $(tests_srcdir)/.venv.done.log \ + $(top_srcdir)/src/uco_jsonld_context_builder.py + source $(tests_srcdir)/venv/bin/activate \ + && python3 $(top_srcdir)/src/uco_jsonld_context_builder.py \ + --output __$@ + # Normalize generated file. + python3 -m json.tool \ + __$@ \ + _$@ + rm __$@ + mv _$@ $@ diff --git a/tests/context_builder/action_result_NO_CONTEXT_concise.json b/tests/context_builder/action_result_NO_CONTEXT_concise.json new file mode 100644 index 00000000..865774a5 --- /dev/null +++ b/tests/context_builder/action_result_NO_CONTEXT_concise.json @@ -0,0 +1,80 @@ +{ + "@context": { + "kb": "http://example.org/kb/" + }, + "graph": [ + { + "id": "kb:action-1", + "type": "Action", + "rdfs:comment": "This node is some action that has some ObservableObjects as results. By the ontology, the results need to be some UcoObject or subclass of UcoObject. They are serialized here as ObservableObjects, and are redundantly assigned types of some of their superclasses. For completeness-tracking, let the id slug's number be a binary number tracking which superclasses are present, 2^0=core:UcoObject, 2^1=core:Item, 2^2=observable:Observable.", + "result": [ + "kb:node-0", + "kb:node-1", + "kb:node-2", + "kb:node-3", + "kb:node-4", + "kb:node-5", + "kb:node-6", + "kb:node-7" + ] + }, + { + "id": "kb:node-0", + "type": "ObservableObject" + }, + { + "id": "kb:node-1", + "type": [ + "UcoObject", + "ObservableObject" + ] + }, + { + "id": "kb:node-2", + "type": [ + "Item", + "ObservableObject" + ] + }, + { + "id": "kb:node-3", + "type": [ + "UcoObject", + "Item", + "ObservableObject" + ] + }, + { + "id": "kb:node-4", + "type": [ + "Observable", + "ObservableObject" + ] + }, + { + "id": "kb:node-5", + "type": [ + "UcoObject", + "Observable", + "ObservableObject" + ] + }, + { + "id": "kb:node-6", + "type": [ + "Item", + "Observable", + "ObservableObject" + ] + }, + { + "id": "kb:node-7", + "type": [ + "UcoObject", + "Item", + "Observable", + "ObservableObject" + ] + } + ] +} diff --git a/tests/context_builder/action_result_NO_CONTEXT_minimal.json b/tests/context_builder/action_result_NO_CONTEXT_minimal.json new file mode 100644 index 00000000..5c7e1a61 --- /dev/null +++ b/tests/context_builder/action_result_NO_CONTEXT_minimal.json @@ -0,0 +1,96 @@ +{ + "@context": { + "kb": "http://example.org/kb/" + }, + "graph": [ + { + "id": "kb:action-1", + "type": "uco-action:Action", + "rdfs:comment": "This node is some action that has some ObservableObjects as results. By the ontology, the results need to be some UcoObject or subclass of UcoObject. They are serialized here as ObservableObjects, and are redundantly assigned types of some of their superclasses. For completeness-tracking, let the id slug's number be a binary number tracking which superclasses are present, 2^0=core:UcoObject, 2^1=core:Item, 2^2=observable:Observable.", + "uco-action:result": [ + { + "id": "kb:node-0" + }, + { + "id": "kb:node-1" + }, + { + "id": "kb:node-2" + }, + { + "id": "kb:node-3" + }, + { + "id": "kb:node-4" + }, + { + "id": "kb:node-5" + }, + { + "id": "kb:node-6" + }, + { + "id": "kb:node-7" + } + ] + }, + { + "id": "kb:node-0", + "type": "uco-observable:ObservableObject" + }, + { + "id": "kb:node-1", + "type": [ + "uco-core:UcoObject", + "uco-observable:ObservableObject" + ] + }, + { + "id": "kb:node-2", + "type": [ + "uco-core:Item", + "uco-observable:ObservableObject" + ] + }, + { + "id": "kb:node-3", + "type": [ + "uco-core:UcoObject", + "uco-core:Item", + "uco-observable:ObservableObject" + ] + }, + { + "id": "kb:node-4", + "type": [ + "uco-observable:Observable", + "uco-observable:ObservableObject" + ] + }, + { + "id": "kb:node-5", + "type": [ + "uco-core:UcoObject", + "uco-observable:Observable", + "uco-observable:ObservableObject" + ] + }, + { + "id": "kb:node-6", + "type": [ + "uco-core:Item", + "uco-observable:Observable", + "uco-observable:ObservableObject" + ] + }, + { + "id": "kb:node-7", + "type": [ + "uco-core:UcoObject", + "uco-core:Item", + "uco-observable:Observable", + "uco-observable:ObservableObject" + ] + } + ] +} diff --git a/tests/context_builder/context_tester.py b/tests/context_builder/context_tester.py new file mode 100644 index 00000000..cb2c83c1 --- /dev/null +++ b/tests/context_builder/context_tester.py @@ -0,0 +1,72 @@ +#!python +# +# NOTICE +# This software was produced for the U.S. Government under contract +# FA8702-22-C-0001, and is subject to the Rights in Data-General +# Clause 52.227-14, Alt. IV (DEC 2007) +# +# ©2022 The MITRE Corporation. All Rights Reserved. +# Released under PRS 18-4297. +# + + +import argparse +import json +import rdflib +import subprocess +import os + + +def main() -> None: + + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument("--skip-clean", action="store_true", + help="Keeps intermediate test files instead of \ + automatic deletion") + arg_parser.add_argument("--input", default="action_result_NO_CONTEXT_minimal.json", + help="input file for testing") + arg_parser.add_argument('--concise', action="store_true", + help="Perform testing on \"concise\" context instead of \"minimal\"") + args = arg_parser.parse_args() + + # Test graph file in JSON format + # test_file = "action_result_NO_CONTEXT_minimal.json" + test_file = args.input + # File to which context will be written + output_file = "_temp_cntxt.json" + # Serialization of graph without using context + # no_cntxt_out = "_test_out_no_cntxt.json-ld" + no_cntxt_out = f"_out_no_cntxt_{test_file}" + # Serialization of graph using context + # cntxt_out = "_test_out_cntxt.json-ld" + cntxt_out = f"_out_ctxt_{test_file}" + # Execute Context builder + if args.concise: + cmd = "python ../../src/uco_jsonld_context_builder.py\ + --concise --output " + output_file + else: + cmd = "python ../../src/uco_jsonld_context_builder.py\ + --output " + output_file + + print(cmd) + subprocess.run(cmd.split()) + with open(output_file, 'r') as file: + tmp_c = json.load(file) + graph = rdflib.Graph() + graph.parse(test_file, format="json-ld") + graph.serialize(no_cntxt_out, format="json-ld") + graph2 = rdflib.Graph() + graph2.parse(test_file, format="json-ld", context_data=tmp_c) + graph.serialize(cntxt_out, context_data=tmp_c, + format="json-ld", auto_compact=True) + + # Clean up + if not args.skip_clean: + os.remove(output_file) + os.remove(no_cntxt_out) + os.remove(cntxt_out) + return + + +if __name__ == '__main__': + main() diff --git a/tests/context_builder/hash_NO_CONTEXT_concise.json b/tests/context_builder/hash_NO_CONTEXT_concise.json new file mode 100644 index 00000000..9a7187f3 --- /dev/null +++ b/tests/context_builder/hash_NO_CONTEXT_concise.json @@ -0,0 +1,16 @@ +{ + "@context": { + "kb": "http://example.org/kb/" + }, + "graph": [ + { + "id": "kb:hash-1", + "type": "Hash", + "hashMethod": { + "type": "HashNameVocab", + "value": "SHA1" + }, + "hashValue": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } + ] +} diff --git a/tests/context_builder/hash_NO_CONTEXT_minimal.json b/tests/context_builder/hash_NO_CONTEXT_minimal.json new file mode 100644 index 00000000..b64beabb --- /dev/null +++ b/tests/context_builder/hash_NO_CONTEXT_minimal.json @@ -0,0 +1,16 @@ +{ + "@context": { + "kb": "http://example.org/kb/" + }, + "graph": [ + { + "id": "kb:hash-1", + "type": "uco-types:Hash", + "uco-types:hashMethod": { + "type": "uco-vocabulary:HashNameVocab", + "value": "SHA1" + }, + "uco-types:hashValue": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } + ] +} diff --git a/tests/context_builder/hash_expanded.json b/tests/context_builder/hash_expanded.json new file mode 100644 index 00000000..9f0a1450 --- /dev/null +++ b/tests/context_builder/hash_expanded.json @@ -0,0 +1,16 @@ +{ + "@graph": [ + { + "@id": "http://example.org/kb/hash-1", + "@type": "https://ontology.unifiedcyberontology.org/uco/types/Hash", + "https://ontology.unifiedcyberontology.org/uco/types/hashMethod": { + "@type": "https://ontology.unifiedcyberontology.org/uco/vocabulary/HashNameVocab", + "@value": "SHA1" + }, + "https://ontology.unifiedcyberontology.org/uco/types/hashValue": { + "@type": "http://www.w3.org/2001/XMLSchema#hexBinary", + "@value": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } + } + ] +} diff --git a/tests/context_builder/test_context.py b/tests/context_builder/test_context.py new file mode 100644 index 00000000..4ba86b9d --- /dev/null +++ b/tests/context_builder/test_context.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +# This software was developed at the National Institute of Standards +# and Technology by employees of the Federal Government in the course +# of their official duties. Pursuant to title 17 Section 105 of the +# United States Code this software is not subject to copyright +# protection and is in the public domain. NIST assumes no +# responsibility whatsoever for its use by other parties, and makes +# no guarantees, expressed or implied, about its quality, +# reliability, or any other characteristic. +# +# We would appreciate acknowledgement if the software is used. + +import json +import logging +from typing import Any, Dict, Set + +from rdflib import Graph, RDF, RDFS +from rdflib.term import Node + + +def _test_action_graph_context_query(input_graph_file: str, input_context_file: str) -> None: + expected = 8 + computed = 0 + + context_object: Dict[str, Any] + with open(input_context_file, "r") as context_fh: + context_object = json.load(context_fh) + + graph = Graph() + graph.parse(input_graph_file, context=context_object) + + # The graph should at least include 8 statements of the form + # 'x uco-action:result y .' Actual length includes the rdfs:comment + # and type declarations, but is otherwise unimportant. + assert 8 < len(graph), "Graph failed to parse into triples." + + # The rdf:types must be supported by the context parse. + count_of_types = 0 + for triple in graph.triples((None, RDF.type, None)): + count_of_types += 1 + assert 0 < count_of_types, "Graph failed to parse non-UCO concept from RDF." + + # The rdfs:comment must be supported by the context parse. + count_of_comments = 0 + for triple in graph.triples((None, RDFS.comment, None)): + count_of_comments += 1 + assert 0 < count_of_comments, "Graph failed to parse non-UCO concept from RDFS." + + for result in graph.query("""\ +PREFIX uco-action: +SELECT ?nResult +WHERE { + ?nAction uco-action:result ?nResult . +} +"""): + computed += 1 + for triple in sorted(graph.triples((None, None, None))): + logging.debug(triple) + try: + assert expected == computed + except AssertionError: + # Provide a debug dump of the graph before forwarding assertion error. + for triple in sorted(graph.triples((None, None, None))): + logging.debug(triple) + raise + + +def test_action_context_concise() -> None: + _test_action_graph_context_query("action_result_NO_CONTEXT_concise.json", "context-concise.json") + + +def test_action_context_minimal() -> None: + _test_action_graph_context_query("action_result_NO_CONTEXT_minimal.json", "context-minimal.json") + + +def _test_graph_context_independent_match(input_dependent_graph_file: str, input_context_file: str, input_independent_graph_file: str) -> None: + """ + Run an exact-parse-match test, confirming that the triples found in a file that does not depend on a context dictionary matches a JSON-LD file that does depend on a context dictionary. + + :param input_dependent_graph_file: File that depends on externally-supplied context dictionary to function. + :param input_context_file: Context dictionary file. + :param input_independent_graph_file: File that does not depend on externally-supplied context dictionary to function. + """ + + expected: Set[Tuple[Node, Node, Nonde]] = set() + computed: Set[Tuple[Node, Node, Nonde]] = set() + + expected_graph = Graph() + computed_graph = Graph() + + expected_graph.parse(input_independent_graph_file) + + context_object: Dict[str, Any] + with open(input_context_file, "r") as context_fh: + context_object = json.load(context_fh) + + computed_graph.parse(input_dependent_graph_file, context=context_object) + + for expected_triple in expected_graph: + expected.add(expected_triple) + + for computed_triple in computed_graph: + computed.add(computed_triple) + + assert expected == computed + + +def test_hash_context_concise() -> None: + _test_graph_context_independent_match("hash_NO_CONTEXT_concise.json", "context-concise.json", "hash_expanded.json") + + +def test_hash_context_minimal() -> None: + _test_graph_context_independent_match("hash_NO_CONTEXT_minimal.json", "context-minimal.json", "hash_expanded.json") + + +def test_thread_context_concise() -> None: + _test_graph_context_independent_match("thread_NO_CONTEXT_concise.json", "context-concise.json", "../examples/thread_PASS.json") + + +def test_thread_context_minimal() -> None: + _test_graph_context_independent_match("thread_NO_CONTEXT_minimal.json", "context-minimal.json", "../examples/thread_PASS.json") + + +#def test_context_concise2() -> None: +# _test_graph_context_query("action_result_concise_NO_CONTEXT.json", "context-concise.json") + + +# def test_device_context_concise() -> None: +# _test_graph_context_query("device_NO_CONTEXT.json", "context-concise.json") + +# def test_device_context_minimal() -> None: +# _test_graph_context_query("device_NO_CONTEXT.json", "context-minimal.json") diff --git a/tests/context_builder/thread_NO_CONTEXT_concise.json b/tests/context_builder/thread_NO_CONTEXT_concise.json new file mode 100644 index 00000000..251875fe --- /dev/null +++ b/tests/context_builder/thread_NO_CONTEXT_concise.json @@ -0,0 +1,34 @@ +{ + "@context": { + "kb": "http://example.org/kb/" + }, + "graph": [ + { + "id": "kb:thread-1", + "type": "Thread", + "item": [ + "kb:thread-1-item-1", + "kb:thread-1-item-2", + "kb:thread-1-item-3" + ] + }, + { + "id": "kb:thread-1-item-1", + "type": "ThreadItem", + "threadNextItem": [ + "kb:thread-1-item-2", + "kb:thread-1-item-3" + ] + }, + { + "id": "kb:thread-1-item-2", + "type": "ThreadItem", + "threadPreviousItem": "kb:thread-1-item-1" + }, + { + "id": "kb:thread-1-item-3", + "type": "ThreadItem", + "threadPreviousItem": "kb:thread-1-item-1" + } + ] +} diff --git a/tests/context_builder/thread_NO_CONTEXT_minimal.json b/tests/context_builder/thread_NO_CONTEXT_minimal.json new file mode 100644 index 00000000..0014e0d2 --- /dev/null +++ b/tests/context_builder/thread_NO_CONTEXT_minimal.json @@ -0,0 +1,34 @@ +{ + "@context": { + "kb": "http://example.org/kb/" + }, + "graph": [ + { + "id": "kb:thread-1", + "type": "types:Thread", + "co:item": [ + "kb:thread-1-item-1", + "kb:thread-1-item-2", + "kb:thread-1-item-3" + ] + }, + { + "id": "kb:thread-1-item-1", + "type": "types:ThreadItem", + "types:threadNextItem": [ + "kb:thread-1-item-2", + "kb:thread-1-item-3" + ] + }, + { + "id": "kb:thread-1-item-2", + "type": "types:ThreadItem", + "types:threadPreviousItem": "kb:thread-1-item-1" + }, + { + "id": "kb:thread-1-item-3", + "type": "types:ThreadItem", + "types:threadPreviousItem": "kb:thread-1-item-1" + } + ] +}