diff --git a/src/test/term_info_queries_test.py b/src/test/term_info_queries_test.py index 3cbf901..f494c1e 100644 --- a/src/test/term_info_queries_test.py +++ b/src/test/term_info_queries_test.py @@ -17,6 +17,28 @@ def get_term_info_or_skip(self, term_id): except Exception as e: self.skipTest(f"SOLR server not available: {e}") + def test_label_stray_backslash_cleaned(self): + # Upstream Cypher / string-literal escaping can leave a stray backslash + # before an apostrophe in a label (e.g. "y5β\\'2a"). It must be + # stripped at ingestion so Name/Meta/links never carry it -- a backslash + # surviving into a downstream request target triggers a Tomcat + # "Invalid character found in the request target" 400. + terminfo_json = r''' + {"term": {"core": {"iri": "http://n2o.neo/individual/VFB_jrchk0e3", + "symbol": "", "types": ["Entity", "Individual", "Neuron"], + "short_form": "VFB_jrchk0e3", "unique_facets": ["Adult"], + "label": "MBON01(y5β\\'2a)_L"}, "description": [], "comment": []}, + "query": "Get JSON for Individual", "version": "t", "parents": [], + "relationships": [], "xrefs": [], "anatomy_channel_image": [], + "pub_syn": [], "def_pubs": []} + ''' + terminfo = deserialize_term_info(terminfo_json) + # backslash stripped, apostrophe and Greek beta preserved + self.assertEqual("MBON01(y5β'2a)_L", terminfo.term.core.label) + self.assertNotIn("\\", terminfo.term.core.label) + # and it flows clean into the internal link (used for Name/Meta rendering) + self.assertNotIn("\\", terminfo.term.core.get_int_link()) + def test_term_info_deserialization(self): terminfo_json = """ {"term": {"core": {"iri": "http://purl.obolibrary.org/obo/FBbt_00048514", "symbol": "BM-Taste", "types": ["Entity", "Adult", "Anatomy", "Cell", "Class", "Mechanosensory_system", "Nervous_system", "Neuron", "Sensory_neuron"], "short_form": "FBbt_00048514", "unique_facets": ["Adult", "Mechanosensory_system", "Nervous_system", "Sensory_neuron"], "label": "labial taste bristle mechanosensory neuron"}, "description": ["Any mechanosensory neuron (FBbt:00005919) that has sensory dendrite in some labellar taste bristle (FBbt:00004162)."], "comment": []}, "query": "Get JSON for Neuron Class", "version": "3d2a474", "parents": [{"symbol": "", "iri": "http://purl.obolibrary.org/obo/FBbt_00048508", "types": ["Entity", "Anatomy", "Cell", "Class", "Mechanosensory_system", "Nervous_system", "Neuron", "Sensory_neuron"], "short_form": "FBbt_00048508", "unique_facets": ["Mechanosensory_system", "Nervous_system", "Sensory_neuron"], "label": "mechanosensory neuron of chaeta"}, {"symbol": "", "iri": "http://purl.obolibrary.org/obo/FBbt_00051420", "types": ["Entity", "Adult", "Anatomy", "Cell", "Class", "Mechanosensory_system", "Nervous_system", "Neuron", "Sensory_neuron"], "short_form": "FBbt_00051420", "unique_facets": ["Adult", "Mechanosensory_system", "Nervous_system", "Sensory_neuron"], "label": "adult mechanosensory neuron"}, {"symbol": "", "iri": "http://purl.obolibrary.org/obo/FBbt_00048029", "types": ["Entity", "Adult", "Anatomy", "Cell", "Class", "Nervous_system", "Neuron", "Sensory_neuron"], "short_form": "FBbt_00048029", "unique_facets": ["Adult", "Nervous_system", "Sensory_neuron"], "label": "labellar taste bristle sensory neuron"}], "relationships": [{"relation": {"iri": "http://purl.obolibrary.org/obo/BFO_0000050", "label": "is part of", "type": "part_of"}, "object": {"symbol": "", "iri": "http://purl.obolibrary.org/obo/FBbt_00005892", "types": ["Entity", "Adult", "Anatomy", "Class", "Nervous_system"], "short_form": "FBbt_00005892", "unique_facets": ["Adult", "Nervous_system"], "label": "adult peripheral nervous system"}}], "xrefs": [], "anatomy_channel_image": [], "pub_syn": [{"synonym": {"scope": "has_exact_synonym", "label": "labellar taste bristle mechanosensitive neuron", "type": ""}, "pub": {"core": {"symbol": "", "iri": "http://flybase.org/reports/Unattributed", "types": ["Entity", "Individual", "pub"], "short_form": "Unattributed", "unique_facets": ["pub"], "label": ""}, "FlyBase": "", "PubMed": "", "DOI": ""}}, {"synonym": {"scope": "has_exact_synonym", "label": "labellar taste bristle mechanosensitive neuron", "type": ""}, "pub": {"core": {"symbol": "", "iri": "http://flybase.org/reports/Unattributed", "types": ["Entity", "Individual", "pub"], "short_form": "Unattributed", "unique_facets": ["pub"], "label": ""}, "FlyBase": "", "PubMed": "", "DOI": ""}}, {"synonym": {"scope": "has_exact_synonym", "label": "labial taste bristle mechanosensitive neuron", "type": ""}, "pub": {"core": {"symbol": "", "iri": "http://flybase.org/reports/Unattributed", "types": ["Entity", "Individual", "pub"], "short_form": "Unattributed", "unique_facets": ["pub"], "label": ""}, "FlyBase": "", "PubMed": "", "DOI": ""}}], "def_pubs": [{"core": {"symbol": "", "iri": "http://flybase.org/reports/FBrf0242472", "types": ["Entity", "Individual", "pub"], "short_form": "FBrf0242472", "unique_facets": ["pub"], "label": "Zhou et al., 2019, Sci. Adv. 5(5): eaaw5141"}, "FlyBase": "", "PubMed": "31131327", "DOI": "10.1126/sciadv.aaw5141"}], "targeting_splits": []} diff --git a/src/vfbquery/term_info_queries.py b/src/vfbquery/term_info_queries.py index 85100c2..98dc517 100644 --- a/src/vfbquery/term_info_queries.py +++ b/src/vfbquery/term_info_queries.py @@ -23,6 +23,23 @@ def default(self, obj): from dacite import from_dict +# Stray backslash-escaping of quotes/apostrophes leaks in from upstream +# Cypher / string-literal escaping (e.g. a label stored as "y5β\\'2a" +# arrives as y5β\'2a -- a literal backslash before the apostrophe). +# A backslash before a quote/apostrophe is never legitimate in a display +# label, and if it survives into a downstream request target it triggers +# Tomcat "Invalid character found in the request target" 400s. Strip it at +# ingestion so every consumer (Name, Meta, links, synonyms) sees the clean +# value. Idempotent and safe to call on already-clean text. +_STRAY_QUOTE_ESCAPE = re.compile(r"\\(['\"])") + + +def clean_label(text): + if not isinstance(text, str) or "\\" not in text: + return text + return _STRAY_QUOTE_ESCAPE.sub(r"\1", text) + + @dataclass_json @dataclass class Coordinates: @@ -67,9 +84,16 @@ class MinimalEntityInfo: unique_facets: Optional[List[str]] = None symbol: Optional[str] = "" + def __post_init__(self): + # Clean stray escaping at ingestion so Name/Meta/links/symbol are all + # consistent (the top-level termInfo["Name"] reads self.label/self.symbol + # directly and previously kept the backslash). + self.label = clean_label(self.label) + self.symbol = clean_label(self.symbol) + def get_int_link(self, show_types=False) -> str: if self.label: - result = get_link(self.label.replace("\\'", "'"), self.short_form) + " " + self.get_types_str(show_types) + result = get_link(self.label, self.short_form) + " " + self.get_types_str(show_types) else: result = get_link(self.short_form, self.short_form) + " " + self.get_types_str(show_types) return result.strip()