From ee3522c5164440691421ea4cf5653a20bc219f6d Mon Sep 17 00:00:00 2001 From: AlanSimmons Date: Mon, 16 Dec 2024 17:18:04 -0500 Subject: [PATCH 1/4] 1. fix to bug with citations returning null values 2. sab is not required parameter for sources in YAML files --- dd-api-spec.yaml | 2 +- src/ubkg_api/cypher/sources.cypher | 6 ++++-- ubkg-api-spec.yaml | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dd-api-spec.yaml b/dd-api-spec.yaml index 31069b4..6f09fa3 100644 --- a/dd-api-spec.yaml +++ b/dd-api-spec.yaml @@ -974,7 +974,7 @@ paths: parameters: - name: sab in: query - required: true + required: false description: A source (SAB) to which to limit the response. schema: type: string diff --git a/src/ubkg_api/cypher/sources.cypher b/src/ubkg_api/cypher/sources.cypher index b0f2af6..42e82b2 100644 --- a/src/ubkg_api/cypher/sources.cypher +++ b/src/ubkg_api/cypher/sources.cypher @@ -62,11 +62,13 @@ CALL CALL { WITH CUISource - OPTIONAL MATCH (pSource:Concept)-[:has_citation]->(p:Concept)-[:CODE]->(c:Code)-[r:PT]->(t:Term) + MATCH (pSource:Concept)-[:has_citation]->(p:Concept)-[:CODE]->(c:Code)-[r:PT]->(t:Term) WHERE pSource.CUI=CUISource AND r.CUI = p.CUI - RETURN COLLECT(DISTINCT {PMID:split(t.name,':')[1], url:'https://pubmed.ncbi.nlm.nih.gov/'+split(t.name,':')[1]}) AS citations + RETURN COLLECT({pmid:CASE WHEN split(t.name,':')[0]='PMID' THEN split(t.name,':')[1] END, + url:CASE WHEN split(t.name,':')[0]<>'PMID' THEN t.name ELSE 'https://pubmed.ncbi.nlm.nih.gov/'+split(t.name,':')[1] END}) AS citations } + // ETL command CALL { diff --git a/ubkg-api-spec.yaml b/ubkg-api-spec.yaml index 5a82ae4..07df504 100644 --- a/ubkg-api-spec.yaml +++ b/ubkg-api-spec.yaml @@ -972,7 +972,7 @@ paths: parameters: - name: sab in: query - required: true + required: false description: A source (SAB) to which to limit the response. schema: type: string From fdb8664826e29c15791975876cb2d3ff21dffb64 Mon Sep 17 00:00:00 2001 From: AlanSimmons Date: Mon, 16 Dec 2024 17:28:41 -0500 Subject: [PATCH 2/4] Description of URL encoding for term_id. --- dd-api-spec.yaml | 4 ++-- ubkg-api-spec.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dd-api-spec.yaml b/dd-api-spec.yaml index 6f09fa3..ecc8ecc 100644 --- a/dd-api-spec.yaml +++ b/dd-api-spec.yaml @@ -925,7 +925,7 @@ paths: - name: term_id in: path required: true - description: The string to match. Subject to timeout. + description: The string to match. Subject to timeout. Should be URL-encoded for spaces (%20). schema: type: string example: Breast cancer @@ -950,7 +950,7 @@ paths: - name: term_id in: path required: true - description: The string to match + description: The string to match. Should be URL-encoded for spaces (%20). schema: type: string example: Breast cancer diff --git a/ubkg-api-spec.yaml b/ubkg-api-spec.yaml index 07df504..16e0152 100644 --- a/ubkg-api-spec.yaml +++ b/ubkg-api-spec.yaml @@ -923,7 +923,7 @@ paths: - name: term_id in: path required: true - description: The string to match. Subject to timeout. + description: The string to match. Subject to timeout. Should be URL-encoded for spaces (%20). schema: type: string example: Breast cancer @@ -948,7 +948,7 @@ paths: - name: term_id in: path required: true - description: The string to match + description: The string to match. Should be URL-encoded for spaces (%20). schema: type: string example: Breast cancer From 3444ab0d1a09f477a97ce151761ce66cca1c4360 Mon Sep 17 00:00:00 2001 From: AlanSimmons Date: Tue, 17 Dec 2024 01:10:50 -0500 Subject: [PATCH 3/4] Return error messages in JSON instead of as strings or in HTTP --- src/ubkg_api/app.py | 11 ++++++ .../concepts/concepts_controller.py | 6 +-- src/ubkg_api/utils/http_error_string.py | 39 +++++++++++++------ 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/ubkg_api/app.py b/src/ubkg_api/app.py index d8b4078..66724e0 100644 --- a/src/ubkg_api/app.py +++ b/src/ubkg_api/app.py @@ -23,6 +23,8 @@ from common_routes.sabs.sabs_controller import sabs_blueprint from common_routes.sources.sources_controller import sources_blueprint +from utils.http_error_string import wrap_message + logging.basicConfig(format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s', level=logging.DEBUG, datefmt='%Y-%m-%d %H:%M:%S') logger = logging.getLogger(__name__) @@ -102,6 +104,15 @@ def __init__(self, config, package_base_dir): def index(): return "Hello! This is UBKG-API service :)" + @self.app.errorhandler(404) + # Custom 404 error handler. + def servererror(error): + return wrap_message(key='message', msg=error.description) + + @self.app.errorhandler(500) + # Custom 500 error handler. + def servererror(error): + return wrap_message(key='error', msg=error.description) #################################################################################################### ## For local development/testing diff --git a/src/ubkg_api/common_routes/concepts/concepts_controller.py b/src/ubkg_api/common_routes/concepts/concepts_controller.py index fe3efda..7e1fef9 100644 --- a/src/ubkg_api/common_routes/concepts/concepts_controller.py +++ b/src/ubkg_api/common_routes/concepts/concepts_controller.py @@ -9,7 +9,7 @@ from utils.http_error_string import get_404_error_string, validate_query_parameter_names, \ validate_parameter_value_in_enum, validate_required_parameters, validate_parameter_is_numeric, \ validate_parameter_is_nonnegative, validate_parameter_range_order, check_payload_size, \ - check_neo4j_version_compatibility,check_max_mindepth + check_neo4j_version_compatibility,check_max_mindepth,wrap_message # Functions to format query parameters for use in Cypher queries from utils.http_parameter import parameter_as_list, set_default_minimum, set_default_maximum # Functions common to paths routes @@ -489,8 +489,8 @@ def concepts_paths_subraphs_sequential_get(concept_id=None): relsabs = [] for rs in relsequence: if not ':' in rs: - err = f'Invalid parameter value: {rs}. Format relationships as :' - return make_response(err, 400) + err = f'Invalid parameter value for \'relsequence\': {rs}. Format relationships as :' + return make_response(wrap_message(key="message", msg=err), 400) relsabs.append(rs.split(':')[0].upper()) reltypes.append(rs.split(':')[1]) diff --git a/src/ubkg_api/utils/http_error_string.py b/src/ubkg_api/utils/http_error_string.py index 9cb3cdd..8e9da71 100644 --- a/src/ubkg_api/utils/http_error_string.py +++ b/src/ubkg_api/utils/http_error_string.py @@ -3,6 +3,12 @@ from flask import request +def wrap_message(key:str, msg:str) ->dict: + """ + Wraps a return message string in JSON format. + """ + + return {key: msg} def format_request_path(custom_err=None): """ @@ -83,10 +89,10 @@ def get_404_error_string(prompt_string=None, custom_request_path=None, timeout=N if timeout > 1: errtimeout = errtimeout + "s" - err = err + f". Note that this endpoint is limited to an execution time of {errtimeout} " \ + err = err + f". Note that this endpoint is limited to an execution time of {errtimeout}" \ f" to prevent timeout errors." - return err + return wrap_message(key="message", msg=err) def get_number_agreement(list_items=None): @@ -127,8 +133,9 @@ def validate_query_parameter_names(parameter_name_list=None) -> str: if req not in parameter_name_list: namelist = list_as_single_quoted_string(list_elements=parameter_name_list) prompt = get_number_agreement(list_items=parameter_name_list) - return f"Invalid query parameter: '{req}'. The possible parameter name{prompt}: {namelist}. " \ + err = f"Invalid query parameter: '{req}'. The possible parameter name{prompt}: {namelist}. " \ f"Refer to the SmartAPI documentation for this endpoint for more information." + return wrap_message(key="message", msg=err) return "ok" @@ -150,8 +157,9 @@ def validate_required_parameters(required_parameter_list=None) -> str: if param not in request.args: namelist = list_as_single_quoted_string(list_elements=required_parameter_list) prompt = get_number_agreement(list_items=required_parameter_list) - return f"Missing query parameter: '{param}'. The required parameter{prompt}: {namelist}. " \ + err = f"Missing query parameter: '{param}'. The required parameter{prompt}: {namelist}. " \ f"Refer to the SmartAPI documentation for this endpoint for more information." + return wrap_message(key="message", msg=err) return "ok" @@ -176,8 +184,9 @@ def validate_parameter_value_in_enum(param_name=None, param_value=None, enum_lis if param_value not in enum_list: namelist = list_as_single_quoted_string(list_elements=enum_list) prompt = get_number_agreement(enum_list) - return f"Invalid value for parameter: '{param_name}'. The possible parameter value{prompt}: {namelist}. " \ + err = f"Invalid value for parameter: '{param_name}'. The possible parameter value{prompt}: {namelist}. " \ f"Refer to the SmartAPI documentation for this endpoint for more information." + return wrap_message(key="message", msg=err) return "ok" @@ -193,7 +202,8 @@ def validate_parameter_is_numeric(param_name=None, param_value: str = '') -> str """ if not param_value.lstrip('-').isnumeric(): - return f"Invalid value ({param_value}) for parameter '{param_name}'. The parameter must be numeric." + err = f"Invalid value ({param_value}) for parameter '{param_name}'. The parameter must be numeric." + return wrap_message(key="message", msg=err) return "ok" @@ -215,8 +225,8 @@ def validate_parameter_is_nonnegative(param_name=None, param_value: str = '') -> return err if int(param_value) < 0: - return f"Invalid value ({param_value}) for parameter '{param_name}'. The parameter cannot be negative." - + err = f"Invalid value ({param_value}) for parameter '{param_name}'. The parameter cannot be negative." + return wrap_message(key="message", msg=err) return "ok" @@ -233,7 +243,8 @@ def validate_parameter_range_order(min_name: str, min_value: str, max_name: str, """ if int(min_value) > int(max_value): - return f"Invalid parameter values: '{min_name}' ({min_value}) greater than '{max_name}' ({max_value}). " + err = f"Invalid parameter values: '{min_name}' ({min_value}) greater than '{max_name}' ({max_value}). " + return wrap_message(key="message", msg=err) return "ok" @@ -247,9 +258,10 @@ def check_payload_size(payload: str, max_payload_size: int) -> str: payload_size = len(str(payload)) if payload_size > max_payload_size: - return f"The size of the response to the endpoint with the specified parameters " \ + err = f"The size of the response to the endpoint with the specified parameters " \ f"({int(payload_size)/1024} MB) exceeds the payload limit" \ f" of {int(max_payload_size)/1024} MB." + return wrap_message(key="message", msg=err) return "ok" @@ -269,7 +281,8 @@ def check_neo4j_version_compatibility(query_version: str, instance_version: str) int_query_version = int(query_version.replace('.', '')) if int_instance_version < int_query_version: - return f"This functionality requires at least version {query_version} of neo4j." + err = f"This functionality requires at least version {query_version} of neo4j." + return wrap_message(key="message", msg=err) return "ok" @@ -281,6 +294,8 @@ def check_max_mindepth(mindepth: int, max_mindepth: int) -> str: """ if mindepth > max_mindepth: - return f"The maximum value of 'mindepth' for this endpoint is {max_mindepth}. " \ + err = f"The maximum value of 'mindepth' for this endpoint is {max_mindepth}. " \ f"Larger values of 'mindepth' result in queries that will exceed the server timeout." + return wrap_message(key="message", msg=err) + return "ok" From d2115ddbc4e89be725417ba33c15debd694f7fc9 Mon Sep 17 00:00:00 2001 From: AlanSimmons Date: Tue, 17 Dec 2024 11:26:16 -0500 Subject: [PATCH 4/4] codes/{code_id}/concepts endpoint now only returns concepts with linked preferred terms. --- .../common_routes/common_neo4j_logic.py | 24 ++++++++++++------- .../cypher/codes_code_id_concepts.cypher | 9 +++++++ 2 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 src/ubkg_api/cypher/codes_code_id_concepts.cypher diff --git a/src/ubkg_api/common_routes/common_neo4j_logic.py b/src/ubkg_api/common_routes/common_neo4j_logic.py index 52b90c6..1f17153 100644 --- a/src/ubkg_api/common_routes/common_neo4j_logic.py +++ b/src/ubkg_api/common_routes/common_neo4j_logic.py @@ -174,15 +174,23 @@ def codes_code_id_codes_get_logic(neo4j_instance, code_id: str, sab: List[str]) def codes_code_id_concepts_get_logic(neo4j_instance, code_id: str) -> List[ConceptDetail]: conceptdetails: List[ConceptDetail] = [] - query: str = \ - 'WITH [$code_id] AS query' \ - ' MATCH (a:Code)<-[:CODE]-(b:Concept)' \ - ' WHERE a.CodeID IN query' \ - ' OPTIONAL MATCH (b)-[:PREF_TERM]->(c:Term)' \ - ' RETURN DISTINCT a.CodeID AS Code, b.CUI AS Concept, c.name as Prefterm' \ - ' ORDER BY Code ASC, Concept' + # Dec 2024 - replace in-line Cypher with loaded file. + # Load Cypher query from file. + query: str = loadquerystring(filename='codes_code_id_concepts.cypher') + + # Filter by code_id. + query = query.replace('$code_id', f"'{code_id}'") + + #query: str = \ + #'WITH [$code_id] AS query' \ + #' MATCH (a:Code)<-[:CODE]-(b:Concept)' \ + #' WHERE a.CodeID IN query' \ + #' OPTIONAL MATCH (b)-[:PREF_TERM]->(c:Term)' \ + #' RETURN DISTINCT a.CodeID AS Code, b.CUI AS Concept, c.name as Prefterm' \ + #' ORDER BY Code ASC, Concept' + with neo4j_instance.driver.session() as session: - recds: neo4j.Result = session.run(query, code_id=code_id) + recds: neo4j.Result = session.run(query) for record in recds: try: conceptdetail: ConceptDetail = ConceptDetail(record.get('Concept'), diff --git a/src/ubkg_api/cypher/codes_code_id_concepts.cypher b/src/ubkg_api/cypher/codes_code_id_concepts.cypher new file mode 100644 index 0000000..f142242 --- /dev/null +++ b/src/ubkg_api/cypher/codes_code_id_concepts.cypher @@ -0,0 +1,9 @@ +// Used in the codes/{code_id}/concepts endpoint + +// December 2024 - Changed to return only those concepts with a linked preferred term. + +WITH $code_id AS query +MATCH (:Term)<-[d]-(a:Code)<-[:CODE]-(b:Concept)-[:PREF_TERM]->(c:Term) +WHERE ((a.CodeID = query) AND (b.CUI = d.CUI)) +RETURN DISTINCT a.CodeID AS Code, b.CUI AS Concept, c.name as Prefterm +ORDER BY Code ASC, Concept \ No newline at end of file