From 53cfad27ac6e48085ea9219384680662010127ca Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 5 Oct 2026 19:08:12 -0400 Subject: [PATCH 01/25] Rewrite the OpenAPI document to describe the Elasticsearch service The document was copied from NodeNorm Redis and never pruned, so it advertised TRAPI 1.5 (info.x-trapi), carried 145 schemas no path referenced, named RENCI's developer as the contact and sent readers to /get_curie_prefixes, which this service has never served. It also failed openapi-spec-validator: the /get_allowed_conflations response had no description, and FilterKgraphTopNParameters.max_edges declared maximum 0 (NCATSTranslator/NodeNormalization#409). Rewritten from scratch against build_handlers(): the six routes this service registers, nothing else. Responses that were `schema: {}` now have schemas (NormalizedNodes, SetIDResponse), the FastAPI 422 responses are replaced by the 400 the Tornado handlers raise, and the examples are taken from the live CI instance. The servers block lists the ci and test deployments at ITRB instead of RENCI. Contact is Will Liu and the team is DOGSURF (biothings/NodeNormalizationAPI#35); infores and termsOfService are unchanged for now. info.version is bumped to 1.1.0 because /status reports it and the response shapes change in the next commit. handlers/curie_prefix.py was an unregistered copy of the semantic-types handler (its class is even named SemanticTypeHandler), so it goes too. Refs biothings/NodeNormalizationAPI#35, #36 Co-Authored-By: Claude Fable 5.1 --- src/nodenorm/handlers/curie_prefix.py | 63 - src/nodenorm/webapp/openapi.json | 3360 +++---------------------- 2 files changed, 385 insertions(+), 3038 deletions(-) delete mode 100644 src/nodenorm/handlers/curie_prefix.py diff --git a/src/nodenorm/handlers/curie_prefix.py b/src/nodenorm/handlers/curie_prefix.py deleted file mode 100644 index e4a4dc6..0000000 --- a/src/nodenorm/handlers/curie_prefix.py +++ /dev/null @@ -1,63 +0,0 @@ -from biothings.web.handlers import BaseHandler -from tornado.web import HTTPError - -from nodenorm.biolink import toolkit - - -class SemanticTypeHandler(BaseHandler): - """ - Mirror implementation to the renci implementation found at - https://nodenormalization-sri.renci.org/docs - - We intend to mirror the /get_curie_prefixes endpoint - """ - - name = "curie_prefixes" - - async def get(self) -> dict: - type_aggregation = {"unique_types": {"terms": {"field": "type", "size": 100}}} - source_fields = ["type"] - try: - search_indices = self.biothings.elasticsearch.indices - type_aggregation_result = await self.biothings.elasticsearch.async_client.search( - aggregations=type_aggregation, index=search_indices, size=0, source_includes=source_fields - ) - except Exception as gen_exc: - network_error = HTTPError( - detail="Unable to access the elasticsearch index for type information", status_code=500 - ) - raise network_error from gen_exc - - semantic_types = set() - for bucket in type_aggregation_result.body["aggregations"]["unique_types"]["buckets"]: - biolink_type = bucket["key"] - semantic_types.add(biolink_type) - for ancestor in toolkit.get_ancestors(biolink_type): - semantic_types.add(toolkit.get_element(ancestor)["class_uri"].lower()) - - semantic_type_response = {"semantic_types": {"types": list(semantic_types)}} - self.finish(semantic_type_response) - - async def post(self) -> dict: - type_aggregation = {"unique_types": {"terms": {"field": "type", "size": 100}}} - source_fields = ["type"] - try: - search_indices = self.biothings.elasticsearch.indices - type_aggregation_result = await self.biothings.elasticsearch.async_client.search( - aggregations=type_aggregation, index=search_indices, size=0, source_includes=source_fields - ) - except Exception as gen_exc: - network_error = HTTPError( - detail="Unable to access the elasticsearch index for type information", status_code=500 - ) - raise network_error from gen_exc - - semantic_types = set() - for bucket in type_aggregation_result.body["aggregations"]["unique_types"]["buckets"]: - biolink_type = bucket["key"] - semantic_types.add(biolink_type) - for ancestor in toolkit.get_ancestors(biolink_type): - semantic_types.add(toolkit.get_element(ancestor)["class_uri"].lower()) - - semantic_type_response = {"semantic_types": {"types": list(semantic_types)}} - self.finish(semantic_type_response) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index 72e4d8a..e36e50f 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -2,44 +2,104 @@ "openapi": "3.1.0", "info": { "title": "Node Normalization", - "version": "1.0.0", + "version": "1.1.0", "x-translator": { "component": "Utility", "team": [ - "Standards Reference Implementation Team" + "DOGSURF" ], "infores": "infores:sri-node-normalizer" }, - "x-trapi": { - "version": "1.5.0", - "operations": [ - "annotate_nodes" - ] - }, "contact": { - "email": "bizon@renci.org", - "name": "Chris Bizon", - "x-id": "https://github.com/cbizon", + "email": "shuchenliu2014@gmail.com", + "name": "Will Liu", + "x-id": "https://github.com/shuchenliu", "x-role": "responsible developer" }, "termsOfService": "https://toss.apps.renci.org/tos?service_long=Node+Normalization&provider_long=the+Translator+Consortium", - "description": "Node Normalization takes a CURIE, and returns:
  1. The preferred CURIE for this entity
  2. All other known equivalent identifiers for the entity
  3. Semantic types for the entity as defined by the BioLink Model
The data served by Node Normalization is created by Babel, which attempts to find identifier equivalences, and makes sure that CURIE prefixes are BioLink Model Compliant. To determine whether Node Normalization is likely to be useful, check /get_semantic_types, which lists the BioLink semantic types for which normalization has been attempted, and /get_curie_prefixes, which lists the number of times each prefix is used for a semantic type." + "description": "Node Normalization takes a CURIE, and returns:
  1. The preferred CURIE for this entity
  2. All other known equivalent identifiers for the entity
  3. Semantic types for the entity as defined by the Biolink Model
This is the Elasticsearch-backed implementation of the Node Normalization API, maintained by the BioThings group at Scripps Research (source code). The data it serves is created by Babel, which finds identifier equivalences and makes sure that CURIE prefixes are Biolink Model compliant. To determine whether Node Normalization is likely to be useful, check /get_semantic_types, which lists the Biolink semantic types for which normalization has been attempted. The semantics of each endpoint are described in the Node Normalization API documentation. To learn more about how Babel creates cliques of equivalent identifiers, read its documentation." }, + "servers": [ + { + "description": "Continuous integration", + "url": "https://nodenorm-es.ci.transltr.io/", + "x-maturity": "staging", + "x-location": "ITRB" + }, + { + "description": "Test", + "url": "https://nodenorm-es.test.transltr.io/", + "x-maturity": "testing", + "x-location": "ITRB" + }, + { + "description": "Localhost", + "url": "http://localhost:8000/", + "x-maturity": "development" + } + ], + "tags": [ + { + "name": "translator" + }, + { + "name": "health" + }, + { + "name": "metadata" + } + ], "paths": { "/status": { "get": { "summary": "Status information on this NodeNorm instance", "tags": ["health"], - "description": "Returns information about this NodeNorm instance and the elasticsearch instance backing it.", + "description": "Returns information about this NodeNorm instance, the Babel release it serves and the Elasticsearch cluster backing it. `status` is `error` when the Elasticsearch cluster cannot be reached.", "operationId": "status", "responses": { "200": { - "description": "Successful Response", + "description": "Status of this NodeNorm instance", "content": { "application/json": { "schema": { "type": "object", - "title": "Response Status Get" + "properties": { + "status": {"type": "string", "enum": ["running", "error"]}, + "version": {"type": "string", "description": "The `info.version` of this OpenAPI document"}, + "babel_version": {"type": "string", "description": "The Babel release loaded into the index"}, + "babel_version_url": {"type": "string", "format": "uri", "description": "Release notes for that Babel release"}, + "backend": {"type": "string", "enum": ["elasticsearch"]}, + "biolink_model_toolkit_version": {"type": "string"}, + "elasticsearch": {"type": "object", "description": "Per-node statistics from the cluster's `_cat/nodes`"} + }, + "required": ["status", "version", "babel_version", "babel_version_url", "backend"] + }, + "example": { + "status": "running", + "version": "1.1.0", + "babel_version": "2025sep1", + "babel_version_url": "https://github.com/NCATSTranslator/Babel/blob/main/releases/2025sep1/README.md", + "backend": "elasticsearch", + "biolink_model_toolkit_version": "v4.4.3", + "elasticsearch": { + "nodes": { + "es-0": { + "name": "es-0", + "cpu": "0", + "disk.avail": "334.2gb", + "disk.total": "786.3gb", + "disk.used": "452.1gb", + "disk.used_percent": "57.50", + "heap.current": "5.8gb", + "heap.max": "16gb", + "load_1m": "0.16", + "load_5m": "0.16", + "load_15m": "0.19", + "uptime": "81.2d", + "version": "9.3.1" + } + } + } } } } @@ -47,136 +107,119 @@ } } }, + "/version": { + "get": { + "summary": "The git commit this instance was built from", + "tags": ["health"], + "description": "Returns the git commit hash of the deployed code. For the API version, see `version` in /status.", + "operationId": "version", + "responses": { + "200": { + "description": "The deployed commit", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": {"type": "string"} + }, + "required": ["version"] + }, + "example": {"version": "fa7d74dc9537373fb36a7bef0d29cdaf4e83b897"} + } + } + } + } + } + }, "/get_normalized_nodes": { "get": { "summary": "Get the equivalent identifiers and semantic types for the curie(s) entered.", "tags": ["translator"], - "description": "Returns the equivalent identifiers and semantic types for the curie(s)", - "operationId": "get_normalized_node_handler_get_normalized_nodes_get", + "description": "Returns the equivalent identifiers and semantic types for the curie(s). Each input CURIE maps to a normalized node, or to `null` if it is unknown.", + "operationId": "get_normalized_nodes_get", "parameters": [ { + "name": "curie", + "in": "query", "description": "List of curies to normalize", - "required": false, + "required": true, "schema": { - "items": { - "type": "string" - }, "type": "array", - "minItems": 1, - "title": "Curie", - "description": "List of curies to normalize", - "default": [] + "items": {"type": "string"}, + "minItems": 1 }, - "example": [ - "MESH:D014867", - "NCIT:C34373" - ], - "name": "curie", - "in": "query" + "example": ["MESH:D014867", "NCIT:C34373"] }, { + "name": "conflate", + "in": "query", "description": "Whether to apply gene/protein conflation", "required": false, - "schema": { - "type": "boolean", - "title": "Conflate", - "description": "Whether to apply gene/protein conflation", - "default": true - }, - "name": "conflate", - "in": "query" + "schema": {"type": "boolean", "default": true} }, { + "name": "drug_chemical_conflate", + "in": "query", "description": "Whether to apply drug/chemical conflation", "required": false, - "schema": { - "type": "boolean", - "title": "Drug Chemical Conflate", - "description": "Whether to apply drug/chemical conflation", - "default": false - }, - "name": "drug_chemical_conflate", - "in": "query" + "schema": {"type": "boolean", "default": false} }, { + "name": "description", + "in": "query", "description": "Whether to return curie descriptions when possible", "required": false, - "schema": { - "type": "boolean", - "title": "Description", - "description": "Whether to return curie descriptions when possible", - "default": false - }, - "name": "description", - "in": "query" + "schema": {"type": "boolean", "default": false} }, { + "name": "individual_types", + "in": "query", "description": "Whether to return individual types for equivalent identifiers", "required": false, - "schema": { - "type": "boolean", - "title": "Individual Types", - "description": "Whether to return individual types for equivalent identifiers", - "default": false - }, - "name": "individual_types", - "in": "query" + "schema": {"type": "boolean", "default": false} } ], "responses": { "200": { - "description": "Successful Response", + "description": "A normalized node (or `null`) for every input CURIE", "content": { "application/json": { - "schema": {} + "schema": {"$ref": "#/components/schemas/NormalizedNodes"}, + "examples": {"default": {"$ref": "#/components/examples/NormalizedNodes"}} } } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } + "400": { + "description": "No `curie` parameter was supplied" } } }, "post": { "summary": "Get the equivalent identifiers and semantic types for the curie(s) entered.", "tags": ["translator"], - "description": "Returns the equivalent identifiers and semantic types for the curie(s). Use the `conflate` flag to choose whether to apply conflation.", - "operationId": "get_normalized_node_handler_post_get_normalized_nodes_post", + "description": "Returns the equivalent identifiers and semantic types for the curie(s). Use the `conflate` and `drug_chemical_conflate` flags to choose which conflations to apply.", + "operationId": "get_normalized_nodes_post", "requestBody": { + "required": true, "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/CurieList" - } + "schema": {"$ref": "#/components/schemas/CurieList"} } - }, - "required": true + } }, "responses": { "200": { - "description": "Successful Response", + "description": "A normalized node (or `null`) for every input CURIE", "content": { "application/json": { - "schema": {} + "schema": {"$ref": "#/components/schemas/NormalizedNodes"}, + "examples": {"default": {"$ref": "#/components/examples/NormalizedNodes"}} } } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } + "400": { + "description": "The body has no `curies`" } } } @@ -185,107 +228,67 @@ "get": { "summary": "Normalize and deduplicate a set of identifiers and return a single hash that represents this set.", "tags": ["translator"], - "operationId": "get_setid_get_setid_get", + "description": "Returns the set ID for a given set of CURIEs. A set ID is an identifier that can be used to identify this set of CURIEs going forward. It is currently impossible to recreate a set of CURIEs from a set ID, but the same set of CURIEs given to the same version of Node Normalization will always return the same set ID.", + "operationId": "get_setid_get", "parameters": [ { + "name": "curie", + "in": "query", "description": "Set of curies to normalize", - "required": false, + "required": true, "schema": { - "items": { - "type": "string" - }, "type": "array", - "minItems": 1, - "title": "Curie", - "description": "Set of curies to normalize", - "default": [] + "items": {"type": "string"}, + "minItems": 1 }, - "example": [ - "MESH:D014867", - "NCIT:C34373", - "UNII:63M8RYN44N", - "RUBBISH:1234" - ], - "name": "curie", - "in": "query" + "example": ["MESH:D014867", "NCIT:C34373", "UNII:63M8RYN44N", "RUBBISH:1234"] }, { - "description": "Set of conflations to apply", + "name": "conflation", + "in": "query", + "description": "Set of conflations to apply; see /get_allowed_conflations", "required": false, "schema": { - "items": { - "type": "string" - }, "type": "array", - "title": "Conflation", - "description": "Set of conflations to apply", + "items": {"type": "string"}, "default": [] }, - "example": [ - "GeneProtein", - "DrugChemical" - ], - "name": "conflation", - "in": "query" + "example": ["GeneProtein", "DrugChemical"] } ], "responses": { "200": { - "description": "Successful Response", + "description": "The set ID for the input CURIEs", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/SetIDResponse" - } + "schema": {"$ref": "#/components/schemas/SetIDResponse"} } } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } + "400": { + "description": "No `curie` parameter was supplied" } } }, "post": { - "summary": "Normalize and deduplicate a set of identifiers and return a single hash that represents this set.", + "summary": "Normalize and deduplicate several sets of identifiers and return a hash for each set.", "tags": ["translator"], - "operationId": "get_setid_get_setid_post", + "description": "Returns one set ID per input set, keyed by the set's position in the request as a string (\"0\", \"1\", ...).", + "operationId": "get_setid_post", "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/SetIDQuery" - }, "type": "array", - "title": "Sets", - "description": "Set of identifiers to normalize", - "default": [] + "items": {"$ref": "#/components/schemas/SetIDQuery"}, + "minItems": 1 }, "example": [ + {"curies": ["MESH:D014867", "NCIT:C34373"]}, { - "curies": [ - "MESH:D014867", - "NCIT:C34373" - ] - }, - { - "curies": [ - "NCIT:C34373", - "MESH:D014867", - "UNII:63M8RYN44N", - "RUBBISH:1234" - ], - "conflations": [ - "GeneProtein", - "DrugChemical" - ] + "curies": ["NCIT:C34373", "MESH:D014867", "UNII:63M8RYN44N", "RUBBISH:1234"], + "conflations": ["GeneProtein", "DrugChemical"] } ] } @@ -293,45 +296,45 @@ }, "responses": { "200": { - "description": "Successful Response", + "description": "One set ID per input set, keyed by its index", "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/SetIDResponse" - }, - "type": "array", - "title": "Response Get Setid Get Setid Post" + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/SetIDResponse"} } } } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } + "400": { + "description": "The body is empty" } } } }, "/get_semantic_types": { "get": { - "summary": "Return a list of BioLink semantic types for which normalization has been attempted.", + "summary": "Return a list of Biolink semantic types for which normalization has been attempted.", "tags": ["metadata"], - "description": "Returns a distinct set of the semantic types discovered in the compendium data.", - "operationId": "get_semantic_types_handler_get_semantic_types_get", + "description": "Returns the distinct Biolink classes found in the index, together with all of their ancestors.", + "operationId": "get_semantic_types", "responses": { "200": { - "description": "Successful Response", + "description": "The semantic types in this instance", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/SemanticTypes" + "schema": {"$ref": "#/components/schemas/SemanticTypes"}, + "example": { + "semantic_types": { + "types": [ + "biolink:NamedThing", + "biolink:ChemicalEntity", + "biolink:SmallMolecule", + "biolink:Disease", + "biolink:Gene", + "biolink:Protein" + ] + } } } } @@ -341,26 +344,17 @@ }, "/get_allowed_conflations": { "get": { + "summary": "Return a list of named conflations.", "tags": ["metadata"], - "description": "Returns a list of allowed conflation options", + "description": "Returns the conflations this instance can apply. Conflation combines cliques on the fly:
  1. GeneProtein merges protein-coding genes with the proteins they encode. The gene(s) always appear first in the combined clique.
  2. DrugChemical merges chemicals based on their active ingredient. The active ingredient appears before any formulations in the combined clique.
You can read more about conflation in the Babel documentation.", + "operationId": "get_allowed_conflations", "responses": { "200": { + "description": "The conflations this instance supports", "content": { "application/json": { - "schema": { - "example": { - "conflations": [ - "GeneProtein", - "DrugChemical" - ], - "type": "object", - "description": "Results", - "summary": "Return a list of named conflations.", - "tags": [ - "Interfaces" - ] - } - } + "schema": {"$ref": "#/components/schemas/ConflationList"}, + "example": {"conflations": ["GeneProtein", "DrugChemical"]} } } } @@ -370,2839 +364,255 @@ }, "components": { "schemas": { - "AboveOrBelowEnum": { - "type": "string", - "enum": [ - "above", - "below" - ], - "title": "AboveOrBelowEnum", - "description": "'above' or 'below'." - }, - "Analysis": { + "CurieList": { + "title": "CurieList", + "description": "Curie list input model", + "type": "object", + "required": ["curies"], "properties": { - "resource_id": { - "type": "string", - "title": "resource infores" - }, - "edge_bindings": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableMapping_str__HashableSet_EdgeBinding__" - } - ], - "title": "list of edge bindings" + "curies": { + "title": "List of CURIEs to normalize", + "type": "array", + "items": {"type": "string"}, + "minItems": 1 }, - "score": { - "type": "number", - "format": "float", - "title": "Score" + "conflate": { + "title": "Whether to apply gene/protein conflation", + "type": "boolean", + "default": true }, - "support_graphs": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_str_" - } - ], - "title": "Support Graphs", - "nullable": true + "drug_chemical_conflate": { + "title": "Whether to apply drug/chemical conflation", + "type": "boolean", + "default": false }, - "scoring_method": { - "type": "string", - "title": "Scoring Method", - "nullable": true + "description": { + "title": "Whether to return CURIE descriptions when possible", + "type": "boolean", + "default": false }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Attribute_" - } - ], - "title": "Attributes", - "nullable": true + "individual_types": { + "title": "Whether to return individual types for equivalent identifiers", + "type": "boolean", + "default": false } }, - "type": "object", - "required": [ - "resource_id", - "edge_bindings" - ], - "title": "analysis", - "description": "Analysis." + "example": { + "curies": ["MESH:D014867", "NCIT:C34373"], + "conflate": true, + "drug_chemical_conflate": false, + "description": false, + "individual_types": false + } }, - "AnnotateEdgesParameters": { - "properties": { - "attributes": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, + "Identifier": { + "title": "Identifier", + "description": "A CURIE with its label, and optionally its description and Biolink type", "type": "object", - "title": "AnnotateEdgesParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "AnnotateNodesParameters": { + "required": ["identifier"], "properties": { - "attributes": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, - "type": "object", - "title": "AnnotateNodesParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "AscOrDescEnum": { - "type": "string", - "enum": [ - "ascending", - "descending" - ], - "title": "AscOrDescEnum", - "description": "'ascending' or 'descending'." + "identifier": {"type": "string"}, + "label": {"type": "string"}, + "description": {"type": "string", "description": "Only when `description` was requested"}, + "type": {"type": "string", "description": "Only when `individual_types` was requested"} + } }, - "AsyncQuery": { - "properties": { - "callback": { - "type": "string", - "pattern": "^https?://", - "format": "uri", - "title": "Callback" - }, - "message": { - "allOf": [ - { - "$ref": "#/components/schemas/Message" - } - ], - "title": "message" - }, - "log_level": { - "allOf": [ - { - "$ref": "#/components/schemas/LogLevel" - } - ], - "title": "log_level", - "nullable": true - }, - "workflow": { - "$ref": "#/components/schemas/Workflow" - } - }, + "NormalizedNode": { + "title": "NormalizedNode", + "description": "The clique an input CURIE belongs to", "type": "object", - "required": [ - "callback", - "message" - ], - "title": "query", - "description": "AsyncQuery.", - "x-body-name": "request_body" - }, - "Attribute": { + "required": ["id", "equivalent_identifiers", "type", "taxa"], "properties": { - "attribute_type_id": { - "type": "string", - "title": "type" - }, - "value": { - "title": "value" - }, - "value_type_id": { - "type": "string", - "title": "value_type_id", - "nullable": true + "id": { + "description": "The preferred identifier for this clique", + "$ref": "#/components/schemas/Identifier" }, - "original_attribute_name": { - "type": "string", - "title": "Original Attribute Name", - "nullable": true + "equivalent_identifiers": { + "type": "array", + "items": {"$ref": "#/components/schemas/Identifier"} }, - "value_url": { - "type": "string", - "title": "Value Url", - "nullable": true + "type": { + "description": "Biolink classes for this clique, most specific first", + "type": "array", + "items": {"type": "string"} }, - "attribute_source": { - "type": "string", - "title": "Attribute Source", - "nullable": true + "information_content": { + "type": "number", + "description": "Omitted when Babel has no information content for this clique" }, - "description": { - "type": "string", - "title": "Description", - "nullable": true + "descriptions": { + "type": "array", + "items": {"type": "string"}, + "description": "Only when `description` was requested" }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_SubAttribute_" - } - ], - "title": "Attributes", - "nullable": true + "taxa": { + "type": "array", + "items": {"type": "string"}, + "description": "NCBITaxon CURIEs this clique is associated with" } - }, - "additionalProperties": false, + } + }, + "NormalizedNodes": { + "title": "NormalizedNodes", + "description": "One normalized node per input CURIE, keyed by the input CURIE; `null` when the CURIE is unknown", "type": "object", - "required": [ - "attribute_type_id", - "value" - ], - "title": "Attribute", - "description": "Node/edge attribute." + "additionalProperties": { + "oneOf": [ + {"$ref": "#/components/schemas/NormalizedNode"}, + {"type": "null"} + ] + } }, - "AttributeConstraint": { + "SetIDQuery": { + "title": "SetIDQuery", + "type": "object", + "required": ["curies"], "properties": { - "name": { - "type": "string", - "title": "name", - "nullable": false - }, - "id": { - "type": "string", - "title": "id", - "nullable": false - }, - "not": { - "type": "boolean", - "title": "not", - "default": false - }, - "operator": { - "allOf": [ - { - "$ref": "#/components/schemas/Operator" - } - ], - "title": "operator" - }, - "value": { - "title": "value" - }, - "unit_id": { - "title": "unit_id" + "curies": { + "title": "Set of CURIEs", + "type": "array", + "items": {"type": "string"}, + "minItems": 1 }, - "unit_name": { - "title": "unit_name" + "conflations": { + "title": "Conflations to apply; see /get_allowed_conflations", + "type": "array", + "items": {"type": "string"}, + "default": [] } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "name", - "id", - "operator", - "value" - ], - "title": "AttributeConstraint", - "description": "QNode or QEdge attribute constraint." + } }, - "AuxiliaryGraph": { + "SetIDResponse": { + "title": "SetIDResponse", + "type": "object", + "required": ["curies", "conflations", "error", "normalized_curies", "normalized_string", "setid"], "properties": { - "edges": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_str_" - } - ], - "title": "edges in auxiliary graph" + "curies": {"type": "array", "items": {"type": "string"}}, + "conflations": {"type": "array", "items": {"type": "string"}}, + "error": {"type": ["string", "null"], "description": "Set when the conflations are not all allowed"}, + "normalized_curies": { + "type": ["array", "null"], + "items": {"type": "string"}, + "description": "The preferred identifiers, deduplicated and sorted; unknown CURIEs pass through unchanged" }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Attribute_" - } - ], - "title": "Attributes", - "nullable": true - } + "normalized_string": {"type": ["string", "null"], "description": "`normalized_curies` joined with `||`"}, + "setid": {"type": ["string", "null"], "description": "UUID v5 of `normalized_string`, prefixed `uuid:`"} }, - "type": "object", - "required": [ - "edges" - ], - "title": "auxiliary graph", - "description": "Auxiliary Graph" + "example": { + "curies": ["MESH:D014867", "NCIT:C34373", "UNII:63M8RYN44N", "RUBBISH:1234"], + "conflations": ["GeneProtein", "DrugChemical"], + "error": null, + "normalized_curies": ["CHEBI:15377", "MONDO:0004976", "RUBBISH:1234"], + "normalized_string": "CHEBI:15377||MONDO:0004976||RUBBISH:1234", + "setid": "uuid:771d3c09-9a8c-5c46-8b85-97f481a90d40" + } }, - "AuxiliaryGraphs": { - "$ref": "#/components/schemas/HashableMapping_str__AuxiliaryGraph_", - "title": "auxiliary graphs", - "description": "Auxiliary Graphs" + "SemanticTypes": { + "title": "SemanticTypes", + "type": "object", + "required": ["semantic_types"], + "properties": { + "semantic_types": { + "type": "object", + "required": ["types"], + "properties": { + "types": { + "type": "array", + "items": {"type": "string"} + } + } + } + } }, "ConflationList": { + "title": "ConflationList", + "type": "object", + "required": ["conflations"], "properties": { "conflations": { - "items": {}, "type": "array", - "title": "Conflations" + "items": {"type": "string"} } - }, - "type": "object", - "required": [ - "conflations" - ], - "title": "ConflationList" - }, - "CurieList": { - "properties": { - "curies": { - "items": { - "type": "string" - }, - "type": "array", - "minItems": 1, - "title": "List of CURIEs to normalize" - }, - "conflate": { - "type": "boolean", - "title": "Whether to apply gene/protein conflation", - "default": true - }, - "description": { - "type": "boolean", - "title": "Whether to return CURIE descriptions when possible", - "default": false - }, - "drug_chemical_conflate": { - "type": "boolean", - "title": "Whether to apply drug/chemical conflation", - "default": false - }, - "individual_types": { - "type": "boolean", - "title": "Whether to return individual types for equivalent identifiers", - "default": false - } - }, - "type": "object", - "required": [ - "curies" - ], - "title": "CurieList", - "description": "Curie list input model", - "example": { - "curies": [ - "MESH:D014867", - "NCIT:C34373" - ], - "conflate": true, - "description": false, - "drug_chemical_conflate": false } - }, - "CuriePivot": { - "properties": { - "curie_prefix": { - "additionalProperties": { - "type": "string" + } + }, + "examples": { + "NormalizedNodes": { + "summary": "Water, ALS and an unknown CURIE, with descriptions and individual types requested", + "value": { + "MESH:D014867": { + "id": { + "identifier": "CHEBI:15377", + "label": "Water", + "description": "An oxygen hydride consisting of an oxygen atom that is covalently bonded to two hydrogen atoms" }, - "type": "object", - "title": "Curie Prefix" - } - }, - "type": "object", - "required": [ - "curie_prefix" - ], - "title": "CuriePivot" - }, - "Edge": { - "properties": { - "subject": { - "type": "string", - "title": "subject node id" - }, - "object": { - "type": "string", - "title": "object node id" - }, - "predicate": { - "type": "string", - "pattern": "^biolink:[a-z][a-z_]*$", - "title": "edge predicate" - }, - "sources": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_RetrievalSource_" - } - ], - "title": "list of source retrievals" - }, - "qualifiers": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Qualifier_" - } - ], - "title": "Qualifiers", - "nullable": true - }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Attribute_" - } - ], - "title": "Attributes", - "nullable": true - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "subject", - "object", - "predicate", - "sources" - ], - "title": "knowledge-graph edge", - "description": "Knowledge graph edge." - }, - "EdgeBinding": { - "properties": { - "id": { - "type": "string", - "title": "knowledge graph id" - }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Attribute_" - } - ], - "title": "Attributes", - "nullable": true - } - }, - "type": "object", - "required": [ - "id" - ], - "title": "edge binding", - "description": "Edge binding.", - "example": { - "id": "string" - } - }, - "EnrichResultsParameters": { - "properties": { - "pvalue_threshold": { - "type": "number", - "maximum": 1.0, - "minimum": 0.0, - "title": "Pvalue Threshold", - "default": 0.000001 - }, - "qnode_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "predicates_to_exclude": { - "$ref": "#/components/schemas/HashableSequence_BiolinkPredicate_" - } - }, - "type": "object", - "title": "EnrichResultsParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FillAllowParameters": { - "properties": { - "allowlist": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, - "additionalProperties": false, - "type": "object", - "title": "FillAllowParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FillDenyParameters": { - "properties": { - "denylist": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, - "additionalProperties": false, - "type": "object", - "title": "FillDenyParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FillParameters": { - "anyOf": [ - { - "$ref": "#/components/schemas/FillAllowParameters" - }, - { - "$ref": "#/components/schemas/FillDenyParameters" - } - ], - "title": "FillParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterKgraphContinuousKedgeAttributeParameters": { - "properties": { - "edge_attribute": { - "type": "string", - "title": "Edge Attribute" - }, - "threshold": { - "type": "number", - "title": "Threshold" - }, - "remove_above_or_below": { - "$ref": "#/components/schemas/AboveOrBelowEnum" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "qnode_keys": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_str_" - } + "descriptions": [ + "An oxygen hydride consisting of an oxygen atom that is covalently bonded to two hydrogen atoms" ], - "title": "Qnode Keys", - "default": [] - } - }, - "type": "object", - "required": [ - "edge_attribute", - "threshold", - "remove_above_or_below" - ], - "title": "FilterKgraphContinuousKedgeAttributeParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterKgraphDiscreteKedgeAttributeParameters": { - "properties": { - "edge_attribute": { - "type": "string", - "title": "Edge Attribute" - }, - "remove_value": { - "title": "Remove Value" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "qnode_keys": { - "allOf": [ + "equivalent_identifiers": [ { - "$ref": "#/components/schemas/HashableSequence_str_" - } - ], - "title": "Qnode Keys", - "default": [] - } - }, - "type": "object", - "required": [ - "edge_attribute" - ], - "title": "FilterKgraphDiscreteKedgeAttributeParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterKgraphDiscreteKnodeAttributeParameters": { - "properties": { - "node_attribute": { - "type": "string", - "title": "Node Attribute" - }, - "remove_value": { - "title": "Remove Value" - }, - "qnode_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, - "type": "object", - "required": [ - "node_attribute" - ], - "title": "FilterKgraphDiscreteKnodeAttributeParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterKgraphPercentileParameters": { - "properties": { - "edge_attribute": { - "type": "string", - "title": "Edge Attribute" - }, - "threshold": { - "type": "number", - "maximum": 100.0, - "minimum": 0.0, - "title": "Threshold", - "default": 95 - }, - "remove_above_or_below": { - "allOf": [ + "identifier": "CHEBI:15377", + "label": "water", + "description": "An oxygen hydride consisting of an oxygen atom that is covalently bonded to two hydrogen atoms", + "type": "biolink:SmallMolecule" + }, { - "$ref": "#/components/schemas/AboveOrBelowEnum" + "identifier": "CHEBI:44701", + "label": "", + "type": "biolink:SmallMolecule" } ], - "default": "below" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "qnode_keys": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_str_" - } + "type": [ + "biolink:SmallMolecule", + "biolink:MolecularEntity", + "biolink:OntologyClass", + "biolink:ChemicalEntity", + "biolink:PhysicalEssence", + "biolink:ChemicalOrDrugOrTreatment", + "biolink:ChemicalEntityOrGeneOrGeneProduct", + "biolink:ChemicalEntityOrProteinOrPolypeptide", + "biolink:NamedThing", + "biolink:PhysicalEssenceOrOccurrent" ], - "title": "Qnode Keys", - "default": [] - } - }, - "type": "object", - "required": [ - "edge_attribute" - ], - "title": "FilterKgraphPercentileParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterKgraphStdDevParameters": { - "properties": { - "edge_attribute": { - "type": "string", - "title": "Edge Attribute" + "information_content": 47.5, + "taxa": [] }, - "plus_or_minus_std_dev": { - "allOf": [ - { - "$ref": "#/components/schemas/PlusOrMinusEnum" - } + "NCIT:C34373": { + "id": { + "identifier": "MONDO:0004976", + "label": "amyotrophic lateral sclerosis", + "description": "Amyotrophic lateral sclerosis (ALS) is a neurodegenerative disease characterized by progressive muscular paralysis reflecting degeneration of motor neurons in the primary motor cortex, corticospinal tracts, brainstem and spinal cord." + }, + "descriptions": [ + "Amyotrophic lateral sclerosis (ALS) is a neurodegenerative disease characterized by progressive muscular paralysis reflecting degeneration of motor neurons in the primary motor cortex, corticospinal tracts, brainstem and spinal cord." ], - "default": "plus" - }, - "num_sigma": { - "type": "number", - "minimum": 0.0, - "title": "Num Sigma", - "default": 1 - }, - "remove_above_or_below": { - "allOf": [ + "equivalent_identifiers": [ { - "$ref": "#/components/schemas/AboveOrBelowEnum" - } - ], - "default": "below" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "qnode_keys": { - "allOf": [ + "identifier": "MONDO:0004976", + "label": "amyotrophic lateral sclerosis", + "description": "Amyotrophic lateral sclerosis (ALS) is a neurodegenerative disease characterized by progressive muscular paralysis reflecting degeneration of motor neurons in the primary motor cortex, corticospinal tracts, brainstem and spinal cord.", + "type": "biolink:Disease" + }, { - "$ref": "#/components/schemas/HashableSequence_str_" + "identifier": "DOID:332", + "label": "amyotrophic lateral sclerosis", + "type": "biolink:Disease" } ], - "title": "Qnode Keys", - "default": [] - } - }, - "type": "object", - "required": [ - "edge_attribute" - ], - "title": "FilterKgraphStdDevParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterKgraphTopNParameters": { - "properties": { - "edge_attribute": { - "type": "string", - "title": "Edge Attribute" - }, - "max_edges": { - "type": "integer", - "maximum": 0.0, - "title": "Max Edges", - "default": 50 - }, - "remove_top_or_bottom": { - "allOf": [ - { - "$ref": "#/components/schemas/TopOrBottomEnum" - } + "type": [ + "biolink:Disease", + "biolink:DiseaseOrPhenotypicFeature", + "biolink:OntologyClass", + "biolink:BiologicalEntity", + "biolink:ThingWithTaxon", + "biolink:NamedThing" ], - "default": "top" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" + "information_content": 74.9, + "taxa": [] }, - "qnode_keys": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_str_" - } - ], - "title": "Qnode Keys", - "default": [] - } - }, - "type": "object", - "required": [ - "edge_attribute" - ], - "title": "FilterKgraphTopNParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "FilterResultsTopNParameters": { - "properties": { - "max_results": { - "type": "integer", - "minimum": 0.0, - "title": "Max Results" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "max_results" - ], - "title": "FilterResultsTopNParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "HashableMapping_CURIE__Node_": { - "additionalProperties": { - "$ref": "#/components/schemas/Node" - }, - "type": "object", - "title": "HashableMapping[CURIE, Node]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableMapping_EdgeIdentifier__Edge_": { - "additionalProperties": { - "$ref": "#/components/schemas/Edge" - }, - "type": "object", - "title": "HashableMapping[EdgeIdentifier, Edge]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableMapping_str__AuxiliaryGraph_": { - "additionalProperties": { - "$ref": "#/components/schemas/AuxiliaryGraph" - }, - "type": "object", - "title": "HashableMapping[str, AuxiliaryGraph]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableMapping_str__HashableSet_EdgeBinding__": { - "additionalProperties": { - "$ref": "#/components/schemas/HashableSet_EdgeBinding_" - }, - "type": "object", - "title": "HashableMapping[str, HashableSet[EdgeBinding]]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableMapping_str__HashableSet_NodeBinding__": { - "additionalProperties": { - "$ref": "#/components/schemas/HashableSet_NodeBinding_" - }, - "type": "object", - "title": "HashableMapping[str, HashableSet[NodeBinding]]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableMapping_str__QEdge_": { - "additionalProperties": { - "$ref": "#/components/schemas/QEdge" - }, - "type": "object", - "title": "HashableMapping[str, QEdge]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableMapping_str__QNode_": { - "additionalProperties": { - "$ref": "#/components/schemas/QNode" - }, - "type": "object", - "title": "HashableMapping[str, QNode]", - "description": "Custom class that implements MutableMapping and is hashable", - "default": {} - }, - "HashableSequence_AttributeConstraint_": { - "items": { - "$ref": "#/components/schemas/AttributeConstraint" - }, - "type": "array", - "title": "HashableSequence[AttributeConstraint]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_BiolinkEntity_": { - "items": { - "type": "string", - "pattern": "^biolink:[A-Z][a-zA-Z]*$" - }, - "type": "array", - "title": "HashableSequence[BiolinkEntity]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_BiolinkPredicate_": { - "items": { - "type": "string", - "pattern": "^biolink:[a-z][a-z_]*$" - }, - "type": "array", - "title": "HashableSequence[BiolinkPredicate]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_CURIE_": { - "items": { - "type": "string" - }, - "type": "array", - "title": "HashableSequence[CURIE]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_Operation_": { - "items": { - "$ref": "#/components/schemas/Operation" - }, - "type": "array", - "title": "HashableSequence[Operation]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_QualifierConstraint_": { - "items": { - "$ref": "#/components/schemas/QualifierConstraint" - }, - "type": "array", - "title": "HashableSequence[QualifierConstraint]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_Qualifier_": { - "items": { - "$ref": "#/components/schemas/Qualifier" - }, - "type": "array", - "title": "HashableSequence[Qualifier]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_RecursiveAttribute_": { - "items": { - "$ref": "#/components/schemas/RecursiveAttribute" - }, - "type": "array", - "title": "HashableSequence[RecursiveAttribute]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_Result_": { - "items": { - "$ref": "#/components/schemas/Result" - }, - "type": "array", - "title": "HashableSequence[Result]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_SubAttribute_": { - "items": { - "$ref": "#/components/schemas/SubAttribute" - }, - "type": "array", - "title": "HashableSequence[SubAttribute]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSequence_str_": { - "items": { - "type": "string" - }, - "type": "array", - "title": "HashableSequence[str]", - "description": "Custom class that implements MutableSequence and is hashable", - "default": [] - }, - "HashableSet_Analysis_": { - "items": { - "$ref": "#/components/schemas/Analysis" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[Analysis]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_Attribute_": { - "items": { - "$ref": "#/components/schemas/Attribute" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[Attribute]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_BiolinkEntity_": { - "items": { - "type": "string", - "pattern": "^biolink:[A-Z][a-zA-Z]*$" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[BiolinkEntity]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_CURIE_": { - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[CURIE]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_EdgeBinding_": { - "items": { - "$ref": "#/components/schemas/EdgeBinding" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[EdgeBinding]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_NodeBinding_": { - "items": { - "$ref": "#/components/schemas/NodeBinding" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[NodeBinding]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_Qualifier_": { - "items": { - "$ref": "#/components/schemas/Qualifier" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[Qualifier]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_RetrievalSource_": { - "items": { - "$ref": "#/components/schemas/RetrievalSource" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[RetrievalSource]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "HashableSet_str_": { - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true, - "title": "HashableSet[str]", - "description": "Custom class that implements MutableSet and is hashable", - "default": [] - }, - "KnowledgeGraph": { - "properties": { - "nodes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableMapping_CURIE__Node_" - } - ], - "title": "nodes" - }, - "edges": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableMapping_EdgeIdentifier__Edge_" - } - ], - "title": "edges" - } - }, - "type": "object", - "required": [ - "nodes", - "edges" - ], - "title": "knowledge graph", - "description": "Knowledge graph." - }, - "KnowledgeType": { - "type": "string", - "enum": [ - "lookup", - "inferred" - ], - "title": "KnowledgeType", - "description": "Knowledge Type." - }, - "LogLevel": { - "$ref": "#/components/schemas/LogLevelEnum", - "title": "LogLevel", - "description": "Log level." - }, - "LogLevelEnum": { - "type": "string", - "enum": [ - "ERROR", - "WARNING", - "INFO", - "DEBUG" - ], - "title": "LogLevelEnum", - "description": "Log level." - }, - "Message": { - "properties": { - "query_graph": { - "allOf": [ - { - "$ref": "#/components/schemas/QueryGraph" - } - ], - "title": "query graph", - "nullable": true - }, - "knowledge_graph": { - "allOf": [ - { - "$ref": "#/components/schemas/KnowledgeGraph" - } - ], - "title": "knowledge graph", - "nullable": true - }, - "results": { - "allOf": [ - { - "$ref": "#/components/schemas/Results" - } - ], - "title": "list of results", - "nullable": true - }, - "auxiliary_graphs": { - "allOf": [ - { - "$ref": "#/components/schemas/AuxiliaryGraphs" - } - ], - "title": "dict of auxiliary graphs", - "nullable": true - } - }, - "additionalProperties": false, - "type": "object", - "title": "message", - "description": "Message." - }, - "Node": { - "properties": { - "categories": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_BiolinkEntity_" - } - ], - "title": "categories", - "nullable": true - }, - "name": { - "type": "string", - "title": "Name", - "nullable": true - }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Attribute_" - } - ], - "title": "Attributes", - "nullable": true - } - }, - "additionalProperties": false, - "type": "object", - "title": "knowledge-graph node", - "description": "Knowledge graph node.", - "example": { - "category": "string" - } - }, - "NodeBinding": { - "properties": { - "id": { - "type": "string", - "title": "knowledge graph id" - }, - "query_id": { - "type": "string", - "title": "query graph id" - }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Attribute_" - } - ], - "title": "Attributes", - "nullable": true - } - }, - "type": "object", - "required": [ - "id" - ], - "title": "node binding", - "description": "Node binding.", - "example": { - "id": "x:string" - } - }, - "Operation": { - "anyOf": [ - { - "$ref": "#/components/schemas/OperationAnnotate" - }, - { - "$ref": "#/components/schemas/OperationAnnotateEdges" - }, - { - "$ref": "#/components/schemas/OperationAnnotateNodes" - }, - { - "$ref": "#/components/schemas/OperationBind" - }, - { - "$ref": "#/components/schemas/OperationCompleteResults" - }, - { - "$ref": "#/components/schemas/OperationEnrichResults" - }, - { - "$ref": "#/components/schemas/OperationFill" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraph" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphContinuousKedgeAttribute" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphDiscreteKedgeAttribute" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphDiscreteKnodeAttribute" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphOrphans" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphPercentile" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphStdDev" - }, - { - "$ref": "#/components/schemas/OperationFilterKgraphTopN" - }, - { - "$ref": "#/components/schemas/OperationFilterResults" - }, - { - "$ref": "#/components/schemas/OperationFilterResultsTopN" - }, - { - "$ref": "#/components/schemas/OperationLookup" - }, - { - "$ref": "#/components/schemas/OperationOverlay" - }, - { - "$ref": "#/components/schemas/OperationOverlayComputeJaccard" - }, - { - "$ref": "#/components/schemas/OperationOverlayComputeNgd" - }, - { - "$ref": "#/components/schemas/OperationOverlayConnectKnodes" - }, - { - "$ref": "#/components/schemas/OperationOverlayFisherExactTest" - }, - { - "$ref": "#/components/schemas/OperationRestate" - }, - { - "$ref": "#/components/schemas/OperationScore" - }, - { - "$ref": "#/components/schemas/OperationSortResults" - }, - { - "$ref": "#/components/schemas/OperationSortResultsEdgeAttribute" - }, - { - "$ref": "#/components/schemas/OperationSortResultsNodeAttribute" - }, - { - "$ref": "#/components/schemas/OperationSortResultsScore" - } - ], - "title": "Operation", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationAnnotate": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/annotate" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationAnnotate", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationAnnotateEdges": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/annotate_edges" - }, - "parameters": { - "$ref": "#/components/schemas/AnnotateEdgesParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationAnnotateEdges", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationAnnotateNodes": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/annotate_nodes" - }, - "parameters": { - "$ref": "#/components/schemas/AnnotateNodesParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationAnnotateNodes", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationBind": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/bind" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationBind", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationCompleteResults": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/complete_results" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationCompleteResults", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationEnrichResults": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/enrich_results" - }, - "parameters": { - "$ref": "#/components/schemas/EnrichResultsParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationEnrichResults", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFill": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/fill" - }, - "parameters": { - "$ref": "#/components/schemas/FillParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationFill", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraph": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationFilterKgraph", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphContinuousKedgeAttribute": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_continuous_kedge_attribute" - }, - "parameters": { - "$ref": "#/components/schemas/FilterKgraphContinuousKedgeAttributeParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationFilterKgraphContinuousKedgeAttribute", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphDiscreteKedgeAttribute": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_discrete_kedge_attribute" - }, - "parameters": { - "$ref": "#/components/schemas/FilterKgraphDiscreteKedgeAttributeParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationFilterKgraphDiscreteKedgeAttribute", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphDiscreteKnodeAttribute": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_discrete_knode_attribute" - }, - "parameters": { - "$ref": "#/components/schemas/FilterKgraphDiscreteKnodeAttributeParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationFilterKgraphDiscreteKnodeAttribute", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphOrphans": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_orphans" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationFilterKgraphOrphans", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphPercentile": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_percentile" - }, - "parameters": { - "$ref": "#/components/schemas/FilterKgraphPercentileParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationFilterKgraphPercentile", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphStdDev": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_std_dev" - }, - "parameters": { - "$ref": "#/components/schemas/FilterKgraphStdDevParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationFilterKgraphStdDev", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterKgraphTopN": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_kgraph_top_n" - }, - "parameters": { - "$ref": "#/components/schemas/FilterKgraphTopNParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationFilterKgraphTopN", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterResults": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_results" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationFilterResults", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationFilterResultsTopN": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/filter_results_top_n" - }, - "parameters": { - "$ref": "#/components/schemas/FilterResultsTopNParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationFilterResultsTopN", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationLookup": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/lookup" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationLookup", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationOverlay": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/overlay" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationOverlay", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationOverlayComputeJaccard": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/overlay_compute_jaccard" - }, - "parameters": { - "$ref": "#/components/schemas/OverlayComputeJaccardParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationOverlayComputeJaccard", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationOverlayComputeNgd": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/overlay_compute_ngd" - }, - "parameters": { - "$ref": "#/components/schemas/OverlayComputeNgdParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationOverlayComputeNgd", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationOverlayConnectKnodes": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/overlay_connect_knodes" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationOverlayConnectKnodes", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationOverlayFisherExactTest": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/overlay_fisher_exact_test" - }, - "parameters": { - "$ref": "#/components/schemas/OverlayFisherExactTestParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationOverlayFisherExactTest", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationRestate": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/restate" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationRestate", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationScore": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/score" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationScore", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationSortResults": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/sort_results" - }, - "parameters": { - "title": "Parameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id" - ], - "title": "OperationSortResults", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationSortResultsEdgeAttribute": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/sort_results_edge_attribute" - }, - "parameters": { - "$ref": "#/components/schemas/SortResultsEdgeAttributeParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationSortResultsEdgeAttribute", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationSortResultsNodeAttribute": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/sort_results_node_attribute" - }, - "parameters": { - "$ref": "#/components/schemas/SortResultsNodeAttributeParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationSortResultsNodeAttribute", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OperationSortResultsScore": { - "properties": { - "runner_parameters": { - "$ref": "#/components/schemas/RunnerParameters" - }, - "id": { - "$ref": "#/components/schemas/sort_results_score" - }, - "parameters": { - "$ref": "#/components/schemas/SortResultsScoreParameters" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "parameters" - ], - "title": "OperationSortResultsScore", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "Operator": { - "type": "string", - "enum": [ - "==", - "===", - ">", - "<", - "matches" - ], - "title": "Operator", - "description": "Operator." - }, - "OverlayComputeJaccardParameters": { - "properties": { - "intermediate_node_key": { - "type": "string", - "title": "Intermediate Node Key" - }, - "end_node_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "virtual_relation_label": { - "type": "string", - "title": "Virtual Relation Label" - } - }, - "type": "object", - "required": [ - "intermediate_node_key", - "end_node_keys", - "virtual_relation_label" - ], - "title": "OverlayComputeJaccardParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OverlayComputeNgdParameters": { - "properties": { - "qnode_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "virtual_relation_label": { - "type": "string", - "title": "Virtual Relation Label" - } - }, - "type": "object", - "required": [ - "qnode_keys", - "virtual_relation_label" - ], - "title": "OverlayComputeNgdParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "OverlayFisherExactTestParameters": { - "properties": { - "subject_qnode_key": { - "type": "string", - "title": "Subject Qnode Key" - }, - "object_qnode_key": { - "type": "string", - "title": "Object Qnode Key" - }, - "virtual_relation_label": { - "type": "string", - "title": "Virtual Relation Label" - }, - "rel_edge_key": { - "type": "string", - "title": "Rel Edge Key" - } - }, - "type": "object", - "required": [ - "subject_qnode_key", - "object_qnode_key", - "virtual_relation_label" - ], - "title": "OverlayFisherExactTestParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "PlusOrMinusEnum": { - "type": "string", - "enum": [ - "plus", - "minus" - ], - "title": "PlusOrMinusEnum", - "description": "'plus' or 'minus'." - }, - "QEdge": { - "properties": { - "subject": { - "type": "string", - "title": "subject node id" - }, - "object": { - "type": "string", - "title": "object node id" - }, - "knowledge_type": { - "allOf": [ - { - "$ref": "#/components/schemas/KnowledgeType" - } - ], - "title": "knowledge type" - }, - "predicates": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_BiolinkPredicate_" - } - ], - "title": "predicates", - "nullable": true - }, - "attribute_constraints": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_AttributeConstraint_" - } - ], - "title": "attribute constraints", - "default": { - "__root__": [] - } - }, - "qualifier_constraints": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_QualifierConstraint_" - } - ], - "title": "qualifier constraint", - "default": { - "__root__": [] - } - } - }, - "type": "object", - "required": [ - "subject", - "object" - ], - "title": "query-graph edge", - "description": "Query edge." - }, - "QNode": { - "properties": { - "ids": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_CURIE_" - } - ], - "title": "ids", - "nullable": true - }, - "categories": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_BiolinkEntity_" - } - ], - "title": "categories", - "nullable": true - }, - "is_set": { - "type": "boolean", - "title": "Is Set", - "default": false - }, - "constraints": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_AttributeConstraint_" - } - ], - "title": "attribute constraints", - "default": { - "__root__": [] - } - } - }, - "type": "object", - "title": "query-graph node", - "description": "Query node." - }, - "Qualifier": { - "properties": { - "qualifier_type_id": { - "type": "string", - "pattern": "^biolink:[a-z][a-z_]*$", - "title": "type" - }, - "qualifier_value": { - "type": "string", - "title": "value" - } - }, - "type": "object", - "required": [ - "qualifier_type_id", - "qualifier_value" - ], - "title": "Qualifier", - "description": "Edge qualifier." - }, - "QualifierConstraint": { - "properties": { - "qualifier_set": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_Qualifier_" - } - ], - "title": "qualifier set", - "default": { - "__root__": [] - } - } - }, - "type": "object", - "title": "QualifierConstraint", - "description": "QEdge Qualifier constraint." - }, - "Query": { - "properties": { - "message": { - "allOf": [ - { - "$ref": "#/components/schemas/Message" - } - ], - "title": "message" - }, - "log_level": { - "allOf": [ - { - "$ref": "#/components/schemas/LogLevel" - } - ], - "title": "log_level", - "nullable": true - }, - "workflow": { - "$ref": "#/components/schemas/Workflow" - } - }, - "type": "object", - "required": [ - "message" - ], - "title": "query", - "description": "Request.", - "x-body-name": "request_body" - }, - "QueryGraph": { - "properties": { - "nodes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableMapping_str__QNode_" - } - ], - "title": "dict of nodes" - }, - "edges": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableMapping_str__QEdge_" - } - ], - "title": "dict of edges" - } - }, - "type": "object", - "required": [ - "nodes", - "edges" - ], - "title": "simple query graph", - "description": "Query graph." - }, - "RecursiveAttribute": { - "properties": { - "attribute_type_id": { - "type": "string", - "title": "type" - }, - "value": { - "title": "value" - }, - "value_type_id": { - "type": "string", - "title": "value_type_id", - "nullable": true - }, - "original_attribute_name": { - "type": "string", - "title": "Original Attribute Name", - "nullable": true - }, - "value_url": { - "type": "string", - "title": "Value Url", - "nullable": true - }, - "attribute_source": { - "type": "string", - "title": "Attribute Source", - "nullable": true - }, - "description": { - "type": "string", - "title": "Description", - "nullable": true - }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_RecursiveAttribute_" - } - ], - "title": "Attributes", - "nullable": true - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "attribute_type_id", - "value" - ], - "title": "RecursiveAttribute", - "description": "Attribute subattribute." - }, - "ResourceRoleEnum": { - "type": "string", - "enum": [ - "aggregator_knowledge_source", - "primary_knowledge_source", - "supporting_data_source" - ], - "title": "ResourceRoleEnum", - "description": "Types of resources" - }, - "Result": { - "properties": { - "node_bindings": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableMapping_str__HashableSet_NodeBinding__" - } - ], - "title": "list of node bindings" - }, - "analyses": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_Analysis_" - } - ], - "title": "list of anlysis blocks" - } - }, - "type": "object", - "required": [ - "node_bindings", - "analyses" - ], - "title": "result", - "description": "Result." - }, - "Results": { - "$ref": "#/components/schemas/HashableSequence_Result_", - "title": "results", - "description": "Results." - }, - "RetrievalSource": { - "properties": { - "resource_id": { - "type": "string", - "title": "infores for source" - }, - "resource_role": { - "allOf": [ - { - "$ref": "#/components/schemas/ResourceRoleEnum" - } - ], - "title": "source type" - }, - "upstream_resource_ids": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_CURIE_" - } - ], - "title": "Upstream Resource Ids", - "nullable": true - }, - "source_record_urls": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSet_str_" - } - ], - "title": "Source Record Urls", - "nullable": true - } - }, - "type": "object", - "required": [ - "resource_id", - "resource_role" - ], - "title": "RetrievalSource", - "description": "A component of source retrieval provenance" - }, - "RunnerAllowList": { - "properties": { - "allowlist": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "timeout": { - "type": "number", - "title": "Timeout" - } - }, - "additionalProperties": false, - "type": "object", - "title": "RunnerAllowList", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "RunnerDenyList": { - "properties": { - "denylist": { - "$ref": "#/components/schemas/HashableSequence_str_" - }, - "timeout": { - "type": "number", - "title": "Timeout" - } - }, - "additionalProperties": false, - "type": "object", - "title": "RunnerDenyList", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "RunnerParameters": { - "anyOf": [ - { - "$ref": "#/components/schemas/RunnerAllowList" - }, - { - "$ref": "#/components/schemas/RunnerDenyList" - }, - { - "$ref": "#/components/schemas/RunnerTimeout" - } - ], - "title": "RunnerParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "RunnerTimeout": { - "properties": { - "timeout": { - "type": "number", - "title": "Timeout" - } - }, - "additionalProperties": false, - "type": "object", - "title": "RunnerTimeout", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "SemanticTypes": { - "properties": { - "semantic_types": { - "additionalProperties": { - "items": {}, - "type": "array" - }, - "type": "object", - "title": "Semantic Types" - } - }, - "type": "object", - "required": [ - "semantic_types" - ], - "title": "SemanticTypes", - "example": { - "semantic_types": { - "types": [ - "biolink:CellularComponent", - "biolink:NamedThing", - "etc." - ] - } - } - }, - "SemanticTypesInput": { - "properties": { - "semantic_types": { - "items": { - "type": "string" - }, - "type": "array", - "title": "list of semantic types" - } - }, - "type": "object", - "required": [ - "semantic_types" - ], - "title": "SemanticTypesInput", - "description": "Semantic type input model", - "example": { - "semantic_types": [ - "biolink:ChemicalEntity", - "biolink:AnatomicalEntity" - ] + "RUBBISH:1234": null } - }, - "SetIDQuery": { - "properties": { - "curies": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Curies", - "description": "Set of curies to normalize", - "example": [ - "MESH:D014867", - "NCIT:C34373" - ] - }, - "conflations": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Conflations", - "description": "Set of conflations to apply", - "default": [], - "example": [ - "GeneProtein", - "DrugChemical" - ] - } - }, - "type": "object", - "required": [ - "curies" - ], - "title": "SetIDQuery", - "description": "Query for a single SetID. Includes a set of CURIEs as well as a set of conflations to apply. " - }, - "SetIDResponse": { - "properties": { - "curies": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Curies" - }, - "conflations": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Conflations" - }, - "error": { - "type": "string", - "title": "Error" - }, - "normalized_curies": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Normalized Curies" - }, - "normalized_string": { - "type": "string", - "title": "Normalized String" - }, - "setid": { - "type": "string", - "title": "Setid" - } - }, - "type": "object", - "required": [ - "curies", - "conflations" - ], - "title": "SetIDResponse" - }, - "SortResultsEdgeAttributeParameters": { - "properties": { - "edge_attribute": { - "type": "string", - "title": "Edge Attribute" - }, - "ascending_or_descending": { - "$ref": "#/components/schemas/AscOrDescEnum" - }, - "qedge_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, - "type": "object", - "required": [ - "edge_attribute", - "ascending_or_descending" - ], - "title": "SortResultsEdgeAttributeParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "SortResultsNodeAttributeParameters": { - "properties": { - "node_attribute": { - "type": "string", - "title": "Node Attribute" - }, - "ascending_or_descending": { - "$ref": "#/components/schemas/AscOrDescEnum" - }, - "qnode_keys": { - "$ref": "#/components/schemas/HashableSequence_str_" - } - }, - "type": "object", - "required": [ - "node_attribute", - "ascending_or_descending" - ], - "title": "SortResultsNodeAttributeParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "SortResultsScoreParameters": { - "properties": { - "ascending_or_descending": { - "$ref": "#/components/schemas/AscOrDescEnum" - } - }, - "type": "object", - "required": [ - "ascending_or_descending" - ], - "title": "SortResultsScoreParameters", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "SubAttribute": { - "properties": { - "attribute_type_id": { - "type": "string", - "title": "type" - }, - "value": { - "title": "value" - }, - "value_type_id": { - "type": "string", - "title": "value_type_id", - "nullable": true - }, - "original_attribute_name": { - "type": "string", - "title": "Original Attribute Name", - "nullable": true - }, - "value_url": { - "type": "string", - "title": "Value Url", - "nullable": true - }, - "attribute_source": { - "type": "string", - "title": "Attribute Source", - "nullable": true - }, - "description": { - "type": "string", - "title": "Description", - "nullable": true - }, - "attributes": { - "allOf": [ - { - "$ref": "#/components/schemas/HashableSequence_RecursiveAttribute_" - } - ], - "title": "Attributes", - "nullable": true - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "attribute_type_id", - "value" - ], - "title": "SubAttribute", - "description": "Attribute subattribute." - }, - "TopOrBottomEnum": { - "type": "string", - "enum": [ - "top", - "bottom" - ], - "title": "TopOrBottomEnum", - "description": "'top' or 'bottom'." - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - } - }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" - }, - "Workflow": { - "$ref": "#/components/schemas/HashableSequence_Operation_", - "title": "Workflow", - "description": "Custom base model for all classes\n\nThis provides hash and equality methods." - }, - "annotate": { - "type": "string", - "enum": [ - "annotate" - ], - "title": "annotate", - "description": "An enumeration." - }, - "annotate_edges": { - "type": "string", - "enum": [ - "annotate_edges" - ], - "title": "annotate_edges", - "description": "An enumeration." - }, - "annotate_nodes": { - "type": "string", - "enum": [ - "annotate_nodes" - ], - "title": "annotate_nodes", - "description": "An enumeration." - }, - "bind": { - "type": "string", - "enum": [ - "bind" - ], - "title": "bind", - "description": "An enumeration." - }, - "complete_results": { - "type": "string", - "enum": [ - "complete_results" - ], - "title": "complete_results", - "description": "An enumeration." - }, - "enrich_results": { - "type": "string", - "enum": [ - "enrich_results" - ], - "title": "enrich_results", - "description": "An enumeration." - }, - "fill": { - "type": "string", - "enum": [ - "fill" - ], - "title": "fill", - "description": "An enumeration." - }, - "filter_kgraph": { - "type": "string", - "enum": [ - "filter_kgraph" - ], - "title": "filter_kgraph", - "description": "An enumeration." - }, - "filter_kgraph_continuous_kedge_attribute": { - "type": "string", - "enum": [ - "filter_kgraph_continuous_kedge_attribute" - ], - "title": "filter_kgraph_continuous_kedge_attribute", - "description": "An enumeration." - }, - "filter_kgraph_discrete_kedge_attribute": { - "type": "string", - "enum": [ - "filter_kgraph_discrete_kedge_attribute" - ], - "title": "filter_kgraph_discrete_kedge_attribute", - "description": "An enumeration." - }, - "filter_kgraph_discrete_knode_attribute": { - "type": "string", - "enum": [ - "filter_kgraph_discrete_knode_attribute" - ], - "title": "filter_kgraph_discrete_knode_attribute", - "description": "An enumeration." - }, - "filter_kgraph_orphans": { - "type": "string", - "enum": [ - "filter_kgraph_orphans" - ], - "title": "filter_kgraph_orphans", - "description": "An enumeration." - }, - "filter_kgraph_percentile": { - "type": "string", - "enum": [ - "filter_kgraph_percentile" - ], - "title": "filter_kgraph_percentile", - "description": "An enumeration." - }, - "filter_kgraph_std_dev": { - "type": "string", - "enum": [ - "filter_kgraph_std_dev" - ], - "title": "filter_kgraph_std_dev", - "description": "An enumeration." - }, - "filter_kgraph_top_n": { - "type": "string", - "enum": [ - "filter_kgraph_top_n" - ], - "title": "filter_kgraph_top_n", - "description": "An enumeration." - }, - "filter_results": { - "type": "string", - "enum": [ - "filter_results" - ], - "title": "filter_results", - "description": "An enumeration." - }, - "filter_results_top_n": { - "type": "string", - "enum": [ - "filter_results_top_n" - ], - "title": "filter_results_top_n", - "description": "An enumeration." - }, - "lookup": { - "type": "string", - "enum": [ - "lookup" - ], - "title": "lookup", - "description": "An enumeration." - }, - "overlay": { - "type": "string", - "enum": [ - "overlay" - ], - "title": "overlay", - "description": "An enumeration." - }, - "overlay_compute_jaccard": { - "type": "string", - "enum": [ - "overlay_compute_jaccard" - ], - "title": "overlay_compute_jaccard", - "description": "An enumeration." - }, - "overlay_compute_ngd": { - "type": "string", - "enum": [ - "overlay_compute_ngd" - ], - "title": "overlay_compute_ngd", - "description": "An enumeration." - }, - "overlay_connect_knodes": { - "type": "string", - "enum": [ - "overlay_connect_knodes" - ], - "title": "overlay_connect_knodes", - "description": "An enumeration." - }, - "overlay_fisher_exact_test": { - "type": "string", - "enum": [ - "overlay_fisher_exact_test" - ], - "title": "overlay_fisher_exact_test", - "description": "An enumeration." - }, - "restate": { - "type": "string", - "enum": [ - "restate" - ], - "title": "restate", - "description": "An enumeration." - }, - "score": { - "type": "string", - "enum": [ - "score" - ], - "title": "score", - "description": "An enumeration." - }, - "sort_results": { - "type": "string", - "enum": [ - "sort_results" - ], - "title": "sort_results", - "description": "An enumeration." - }, - "sort_results_edge_attribute": { - "type": "string", - "enum": [ - "sort_results_edge_attribute" - ], - "title": "sort_results_edge_attribute", - "description": "An enumeration." - }, - "sort_results_node_attribute": { - "type": "string", - "enum": [ - "sort_results_node_attribute" - ], - "title": "sort_results_node_attribute", - "description": "An enumeration." - }, - "sort_results_score": { - "type": "string", - "enum": [ - "sort_results_score" - ], - "title": "sort_results_score", - "description": "An enumeration." } } - }, - "tags": [ - { - "name": "translator" - }, - { - "name": "health" - }, - { - "name": "metadata" - } - ], - "servers": [ - { - "description": "Default server", - "url": "https://nodenorm-es.ci.transltr.io/", - "x-maturity": "development", - "x-location": "RENCI" - }, - { - "description": "Localhost", - "url": "http://localhost:8000/", - "x-maturity": "development", - "x-location": "RENCI" - } - ] + } } From 35c16a37a5052da3373d31a081608a574745e56c Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 12 Oct 2026 00:42:04 -0400 Subject: [PATCH 02/25] Make two endpoints answer the way the OpenAPI document says GET /get_allowed_conflations finished a bare list, and Tornado's write() refuses a top-level list ("Lists not accepted for security reasons"), so it returned HTTP 500 on every deployment. It now returns {"conflations": [...]}, which is also what NodeNorm Redis returns. GET /get_semantic_types lower-cased every class ("biolink:namedthing"); NodeNorm Redis returns the Biolink class URI as is ("biolink:NamedThing"). /openapi.json now redirects to /webapp/openapi.json so the FastAPI default address used by NodeNorm Redis, SmartAPI and Babel Validation works here too; it is excluded from OpenTelemetry like the other static paths. The POST set-ID test posted a single object where the handler expects a list of sets, and the normalized-nodes docstring showed a "curie" key where the handler reads "curies"; both corrected. POST /get_setid still answers with an object keyed "0", "1", ... (the same Tornado limitation); returning a list as NodeNorm Redis does is a breaking change, so it is left to its own pull request. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- docker/configuration/config.json | 2 +- src/nodenorm/config/config.default.json | 2 +- src/nodenorm/handlers/__init__.py | 4 ++++ src/nodenorm/handlers/conflations.py | 11 ++++------ src/nodenorm/handlers/normalized_nodes.py | 2 +- src/nodenorm/handlers/semantic_types.py | 2 +- tests/test_set_identifiers.py | 25 ++++++++++++++--------- 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docker/configuration/config.json b/docker/configuration/config.json index a92fe45..627acb7 100644 --- a/docker/configuration/config.json +++ b/docker/configuration/config.json @@ -28,6 +28,6 @@ "OPENTELEMETRY_SERVICE_NAME": "NodeNorm", "OPENTELEMETRY_JAEGER_HOST": "http://jaeger-otel-collector.sri", "OPENTELEMETRY_JAEGER_PORT": 4318, - "OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/favicon\\.ico$"] + "OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/openapi\\.json$", "^/favicon\\.ico$"] } } diff --git a/src/nodenorm/config/config.default.json b/src/nodenorm/config/config.default.json index 54668f4..b071288 100644 --- a/src/nodenorm/config/config.default.json +++ b/src/nodenorm/config/config.default.json @@ -28,6 +28,6 @@ "OPENTELEMETRY_SERVICE_NAME": "NodeNorm", "OPENTELEMETRY_JAEGER_HOST": "http://localhost", "OPENTELEMETRY_JAEGER_PORT": 4318, - "OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/favicon\\.ico$"] + "OPENTELEMETRY_EXCLUDED_URLS": ["^/$", "^/status$", "^/version$", "^/webapp", "^/openapi\\.json$", "^/favicon\\.ico$"] } } diff --git a/src/nodenorm/handlers/__init__.py b/src/nodenorm/handlers/__init__.py index d30a44c..d2cfa7a 100644 --- a/src/nodenorm/handlers/__init__.py +++ b/src/nodenorm/handlers/__init__.py @@ -45,5 +45,9 @@ def build_handlers() -> dict[str, tuple[str, Callable]]: favicon_handler = (r"/favicon.ico", tornado.web.RedirectHandler, {"url": "/webapp/swaggerui/favicon-32x32.png"}) handler_collection.append(favicon_handler) + # NodeNorm Redis (FastAPI) serves its OpenAPI document at the root; keep that address working + openapi_handler = (r"/openapi.json", tornado.web.RedirectHandler, {"url": "/webapp/openapi.json"}) + handler_collection.append(openapi_handler) + handlers = {handler[0]: handler for handler in handler_collection} return handlers diff --git a/src/nodenorm/handlers/conflations.py b/src/nodenorm/handlers/conflations.py index 62cb57e..cebc8ba 100644 --- a/src/nodenorm/handlers/conflations.py +++ b/src/nodenorm/handlers/conflations.py @@ -1,17 +1,14 @@ -import logging - from nodenorm.handlers.base import NodeNormalizationBaseHandler -logger = logging.getLogger(__name__) +ALLOWED_CONFLATIONS = ["GeneProtein", "DrugChemical"] class ValidConflationsHandler(NodeNormalizationBaseHandler): name = "allowed-conflations" async def get(self): - conflations = ["GeneProtein", "DrugChemical"] - self.finish(conflations) + # Wrapped in an object, as NodeNorm Redis does. Tornado also refuses to write a bare list. + self.finish({"conflations": ALLOWED_CONFLATIONS}) async def head(self): - conflations = ["GeneProtein", "DrugChemical"] - self.finish(conflations) + await self.get() diff --git a/src/nodenorm/handlers/normalized_nodes.py b/src/nodenorm/handlers/normalized_nodes.py index 18835aa..284e610 100644 --- a/src/nodenorm/handlers/normalized_nodes.py +++ b/src/nodenorm/handlers/normalized_nodes.py @@ -76,7 +76,7 @@ async def post(self): Example body { - "curie": [ + "curies": [ "MESH:D014867", "NCIT:C34373" ] diff --git a/src/nodenorm/handlers/semantic_types.py b/src/nodenorm/handlers/semantic_types.py index 3201e1e..df0a5b8 100644 --- a/src/nodenorm/handlers/semantic_types.py +++ b/src/nodenorm/handlers/semantic_types.py @@ -33,7 +33,7 @@ async def get(self) -> dict: biolink_type = bucket["key"] semantic_types.add(biolink_type) for ancestor in toolkit.get_ancestors(biolink_type): - semantic_types.add(toolkit.get_element(ancestor)["class_uri"].lower()) + semantic_types.add(toolkit.get_element(ancestor)["class_uri"]) semantic_type_response = {"semantic_types": {"types": list(semantic_types)}} self.finish(semantic_type_response) diff --git a/tests/test_set_identifiers.py b/tests/test_set_identifiers.py index ea96679..2d82983 100644 --- a/tests/test_set_identifiers.py +++ b/tests/test_set_identifiers.py @@ -82,10 +82,12 @@ def test_post_endpoint(self): url = self.get_url(normalized_nodes_endpoint) body = json.dumps( - { - "curies": ["MESH:D014867", "NCIT:C34373", "UNII:63M8RYN44N", "RUBBISH:1234"], - "conflations": ["GeneProtein", "DrugChemical"], - } + [ + { + "curies": ["MESH:D014867", "NCIT:C34373", "UNII:63M8RYN44N", "RUBBISH:1234"], + "conflations": ["GeneProtein", "DrugChemical"], + } + ] ) headers = {"Content-Type": "application/json"} @@ -93,12 +95,15 @@ def test_post_endpoint(self): response = yield http_client.fetch(url, self.stop, method="POST", headers=headers, body=body, request_timeout=0) body = json.loads(response.body.decode("utf-8")) + # One SetIDResponse per input set, keyed by the set's index in the request. expected_body = { - "curies": ["MESH:D014867", "NCIT:C34373", "UNII:63M8RYN44N", "RUBBISH:1234"], - "conflations": ["GeneProtein", "DrugChemical"], - "error": None, - "normalized_curies": ["CHEBI:15377", "MONDO:0004976", "RUBBISH:1234"], - "normalized_string": "CHEBI:15377||MONDO:0004976||RUBBISH:1234", - "setid": "uuid:771d3c09-9a8c-5c46-8b85-97f481a90d40", + "0": { + "curies": ["MESH:D014867", "NCIT:C34373", "UNII:63M8RYN44N", "RUBBISH:1234"], + "conflations": ["GeneProtein", "DrugChemical"], + "error": None, + "normalized_curies": ["CHEBI:15377", "MONDO:0004976", "RUBBISH:1234"], + "normalized_string": "CHEBI:15377||MONDO:0004976||RUBBISH:1234", + "setid": "uuid:771d3c09-9a8c-5c46-8b85-97f481a90d40", + } } assert json.loads(response.body.decode("utf-8")) == expected_body From 8f6bc15a2f0c9598f8cf43ba1d04a407aadf4f98 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 5 Oct 2026 19:10:20 -0400 Subject: [PATCH 03/25] Test that the OpenAPI document matches the handlers The document is hand-written and served as a static file, so these tests are what stops it drifting: the spec validates; its paths are exactly the routes build_handlers() registers; each path documents the GET/POST methods its handler defines; each GET documents the query parameters its handler reads (found by scanning the handler source for get_argument calls); and the SetIDResponse schema lists the dataclass's fields. Each was checked to fail when the spec is mutated. Also a regression test for /get_allowed_conflations, which returned HTTP 500 until the previous commit: it mounts just that handler and validates the body against the documented ConflationList schema. Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 1 + tests/test_openapi.py | 76 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/test_openapi.py diff --git a/pyproject.toml b/pyproject.toml index 5b38ada..e81b7ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ tests = [ "pytest >= 8.1.1", "pytest-asyncio >= 0.23.8", "pytest-randomly", + "openapi-spec-validator", ] telemetry = [ diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..50ee53b --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,76 @@ +""" +Keep src/nodenorm/webapp/openapi.json in step with the handlers. + +The OpenAPI document is written by hand and served as a static file (Tornado has nothing like +FastAPI's schema generation), so nothing stops it drifting from the code except these tests. +If one fails after a change to a handler, edit the JSON; if it fails after a change to the +JSON, the document now promises something the code doesn't do. +""" + +import dataclasses +import importlib.resources +import inspect +import json +import re + +import jsonschema +import tornado.web +from openapi_spec_validator import validate +from tornado.testing import AsyncHTTPTestCase + +import nodenorm +from nodenorm.handlers import build_handlers +from nodenorm.handlers.base import NodeNormalizationBaseHandler +from nodenorm.handlers.conflations import ValidConflationsHandler +from nodenorm.handlers.set_identifiers import SetIDResponse + +SPEC = json.loads((importlib.resources.files(nodenorm) / "webapp" / "openapi.json").read_text(encoding="utf-8")) + +# Only the handlers that answer the API itself: not the static files, index or redirects. +API_HANDLERS = { + pattern.rstrip("?"): handler + for pattern, handler, *_ in build_handlers().values() + if isinstance(handler, type) and issubclass(handler, NodeNormalizationBaseHandler) +} + + +def test_document_is_valid_openapi(): + validate(SPEC) + + +def test_every_route_is_documented_and_every_documented_path_is_routed(): + assert set(SPEC["paths"]) == set(API_HANDLERS) + + +def test_documented_methods_match_the_handlers(): + for path, handler in API_HANDLERS.items(): + documented = {method for method in SPEC["paths"][path] if method in ("get", "post")} + implemented = {method for method in ("get", "post") if method in vars(handler)} + assert documented == implemented, path + + +def test_documented_query_parameters_match_the_handlers(): + for path, handler in API_HANDLERS.items(): + documented = {p["name"] for p in SPEC["paths"][path]["get"].get("parameters", []) if p["in"] == "query"} + implemented = set(re.findall(r'get_arguments?\("(\w+)"', inspect.getsource(handler.get))) + assert documented == implemented, path + + +def test_setid_response_schema_matches_the_dataclass(): + documented = set(SPEC["components"]["schemas"]["SetIDResponse"]["properties"]) + assert documented == {field.name for field in dataclasses.fields(SetIDResponse)} + + +class TestAllowedConflations(AsyncHTTPTestCase): + """Regression: this endpoint returned HTTP 500 because it finished a bare list.""" + + def get_app(self) -> tornado.web.Application: + return tornado.web.Application([(r"/get_allowed_conflations", ValidConflationsHandler)]) + + def test_response_matches_the_documented_schema(self): + response = self.fetch("/get_allowed_conflations") + + assert response.code == 200 + body = json.loads(response.body) + jsonschema.validate(body, SPEC["components"]["schemas"]["ConflationList"]) + assert {"GeneProtein", "DrugChemical"} <= set(body["conflations"]) From 5de9ee9359a3a68d88512fb91146f97e0312f8f7 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 5 Oct 2026 19:10:21 -0400 Subject: [PATCH 04/25] Document where the OpenAPI document lives and how it is kept in sync Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index de20ce8..fd79bac 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,12 @@ https://github.com/biothings/biothings_explorer/blob/main/docs/README-maintainin The documentation is maintained at the [biothings_explorer](https://github.com/biothings/biothings_explorer) repository, as each knowledgebase API will be integrated into the [BioThings Explorer application](https://explorer.biothings.io) become a Translator's standard KP (Knowledge Provider) API. + +## OpenAPI document + +The API is described by `src/nodenorm/webapp/openapi.json`, which is written by hand and served +verbatim at `/webapp/openapi.json` (with `/openapi.json` redirecting there); the Swagger UI at `/` +renders it. `/status` reports its `info.version` as the API version, so bump that when the API +changes. `tests/test_openapi.py` checks that the document validates and that its paths, methods, +query parameters and the `SetIDResponse` schema match the handlers, so a handler change that +isn't reflected in the JSON fails the test suite. From 10df39853c593c81fca1363afa9a4080280793cd Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 12 Oct 2026 00:42:31 -0400 Subject: [PATCH 05/25] Unit-test the semantic-type casing fix It ran only against the su10 Elasticsearch host before. This mounts the handler alone with a fake Elasticsearch client and validates the body against the documented schema. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- tests/test_openapi.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 50ee53b..c5a9515 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -12,6 +12,7 @@ import inspect import json import re +from types import SimpleNamespace import jsonschema import tornado.web @@ -22,6 +23,7 @@ from nodenorm.handlers import build_handlers from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.handlers.conflations import ValidConflationsHandler +from nodenorm.handlers.semantic_types import SemanticTypeHandler from nodenorm.handlers.set_identifiers import SetIDResponse SPEC = json.loads((importlib.resources.files(nodenorm) / "webapp" / "openapi.json").read_text(encoding="utf-8")) @@ -74,3 +76,31 @@ def test_response_matches_the_documented_schema(self): body = json.loads(response.body) jsonschema.validate(body, SPEC["components"]["schemas"]["ConflationList"]) assert {"GeneProtein", "DrugChemical"} <= set(body["conflations"]) + + +class FakeElasticsearch: + """Just enough of AsyncElasticsearch for the semantic-types aggregation.""" + + async def search(self, **kwargs): + return SimpleNamespace(body={"aggregations": {"unique_types": {"buckets": [{"key": "biolink:Disease"}]}}}) + + +class TestSemanticTypes(AsyncHTTPTestCase): + """Regression: the handler lower-cased every class ("biolink:namedthing").""" + + def get_app(self) -> tornado.web.Application: + app = tornado.web.Application([(r"/get_semantic_types", SemanticTypeHandler)]) + app.biothings = SimpleNamespace( + elasticsearch=SimpleNamespace(indices="nodenorm", async_client=FakeElasticsearch()) + ) + return app + + def test_types_are_biolink_class_curies(self): + response = self.fetch("/get_semantic_types") + + assert response.code == 200 + body = json.loads(response.body) + jsonschema.validate(body, SPEC["components"]["schemas"]["SemanticTypes"]) + types = body["semantic_types"]["types"] + assert "biolink:Disease" in types and "biolink:NamedThing" in types + assert all(re.fullmatch(r"biolink:[A-Z][A-Za-z]*", t) for t in types), types From 77b9f4f08861797b3df2cdc84430ed79eae745d6 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 12 Oct 2026 00:43:16 -0400 Subject: [PATCH 06/25] Match /openapi.json and /favicon.ico literally The dot in each redirect pattern was unescaped, so /openapiXjson and /faviconXico redirected too. The OpenTelemetry exclusions already escape it. A test now checks the /openapi.json redirect and that a near-miss path is a 404. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/__init__.py | 4 ++-- tests/test_openapi.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/nodenorm/handlers/__init__.py b/src/nodenorm/handlers/__init__.py index d2cfa7a..5c34aa2 100644 --- a/src/nodenorm/handlers/__init__.py +++ b/src/nodenorm/handlers/__init__.py @@ -42,11 +42,11 @@ def build_handlers() -> dict[str, tuple[str, Callable]]: handler_collection.append(index_handler) # This redirect ensures we default so the favicon icon can be found in the webapp directory - favicon_handler = (r"/favicon.ico", tornado.web.RedirectHandler, {"url": "/webapp/swaggerui/favicon-32x32.png"}) + favicon_handler = (r"/favicon\.ico", tornado.web.RedirectHandler, {"url": "/webapp/swaggerui/favicon-32x32.png"}) handler_collection.append(favicon_handler) # NodeNorm Redis (FastAPI) serves its OpenAPI document at the root; keep that address working - openapi_handler = (r"/openapi.json", tornado.web.RedirectHandler, {"url": "/webapp/openapi.json"}) + openapi_handler = (r"/openapi\.json", tornado.web.RedirectHandler, {"url": "/webapp/openapi.json"}) handler_collection.append(openapi_handler) handlers = {handler[0]: handler for handler in handler_collection} diff --git a/tests/test_openapi.py b/tests/test_openapi.py index c5a9515..6815d9a 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -63,6 +63,22 @@ def test_setid_response_schema_matches_the_dataclass(): assert documented == {field.name for field in dataclasses.fields(SetIDResponse)} +class TestOpenAPIRedirect(AsyncHTTPTestCase): + """/openapi.json is where NodeNorm Redis, SmartAPI and Babel Validation look for the document.""" + + def get_app(self) -> tornado.web.Application: + return tornado.web.Application(list(build_handlers().values())) + + def test_openapi_json_redirects_to_the_document(self): + response = self.fetch("/openapi.json", follow_redirects=False) + + assert response.code == 301 + assert response.headers["Location"] == "/webapp/openapi.json" + + def test_only_the_exact_path_redirects(self): + assert self.fetch("/openapiXjson", follow_redirects=False).code == 404 + + class TestAllowedConflations(AsyncHTTPTestCase): """Regression: this endpoint returned HTTP 500 because it finished a bare list.""" From e6a16738d506c1d9c4c8ff69bf66038659b83a89 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 12 Oct 2026 00:43:58 -0400 Subject: [PATCH 07/25] Check POST bodies and helper-read parameters against the OpenAPI document The query-parameter check scanned only get() for get_argument calls, so a parameter read in a helper method would drift unnoticed; it now scans the whole handler class. A new check compares the fields each post() reads with .get("...") against the properties of the documented request body (CurieList, and the SetIDQuery items of the /get_setid array). Both were confirmed to fail when a field is removed from the document or a parameter is renamed in a helper. The checks still only see literal names; the README now says so. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++-- tests/test_openapi.py | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fd79bac..d82c03e 100644 --- a/README.md +++ b/README.md @@ -35,5 +35,7 @@ The API is described by `src/nodenorm/webapp/openapi.json`, which is written by verbatim at `/webapp/openapi.json` (with `/openapi.json` redirecting there); the Swagger UI at `/` renders it. `/status` reports its `info.version` as the API version, so bump that when the API changes. `tests/test_openapi.py` checks that the document validates and that its paths, methods, -query parameters and the `SetIDResponse` schema match the handlers, so a handler change that -isn't reflected in the JSON fails the test suite. +query parameters, POST body fields and the `SetIDResponse` schema match the handlers, so a handler +change that isn't reflected in the JSON fails the test suite. The parameter and body checks find +what a handler reads by scanning its source for `get_argument("…")` and `.get("…")` calls with a +literal name, so read new parameters and body fields that way. diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 6815d9a..c307e81 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -52,9 +52,31 @@ def test_documented_methods_match_the_handlers(): def test_documented_query_parameters_match_the_handlers(): + # The whole class, not just get(), so a parameter read in a helper method still counts. for path, handler in API_HANDLERS.items(): documented = {p["name"] for p in SPEC["paths"][path]["get"].get("parameters", []) if p["in"] == "query"} - implemented = set(re.findall(r'get_arguments?\("(\w+)"', inspect.getsource(handler.get))) + implemented = set(re.findall(r'get_arguments?\("(\w+)"', inspect.getsource(handler))) + assert documented == implemented, path + + +def resolve(schema: dict) -> dict: + """Follow a local $ref, and an array to its items, down to the object schema of one body item.""" + if "$ref" in schema: + return resolve(SPEC["components"]["schemas"][schema["$ref"].rsplit("/", 1)[-1]]) + if schema.get("type") == "array": + return resolve(schema["items"]) + return schema + + +def test_documented_request_bodies_match_the_handlers(): + # post() reads each body field with .get("field", default), whether the body is one object + # (get_normalized_nodes) or a list of them (get_setid). + posting = {path: handler for path, handler in API_HANDLERS.items() if "post" in vars(handler)} + assert posting, "no handler defines post()" + for path, handler in posting.items(): + body_schema = SPEC["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"] + documented = set(resolve(body_schema)["properties"]) + implemented = set(re.findall(r'\.get\("(\w+)"', inspect.getsource(handler.post))) assert documented == implemented, path From 9bfd367cd69f4978f23c5a8726d661bdf0465f6f Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:41:42 -0400 Subject: [PATCH 08/25] Return the set-ID error to the client instead of a 500 generate_setid() returned the SetIDResponse dataclass itself on the invalid-conflation and no-normalized-CURIEs paths. Tornado can only write dicts, so GET /get_setid?conflation=Bogus answered 500 and the documented `error` field never reached anyone. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/set_identifiers.py | 6 ++--- tests/test_openapi.py | 30 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/nodenorm/handlers/set_identifiers.py b/src/nodenorm/handlers/set_identifiers.py index 728ff3c..043e440 100644 --- a/src/nodenorm/handlers/set_identifiers.py +++ b/src/nodenorm/handlers/set_identifiers.py @@ -84,7 +84,7 @@ async def generate_setid( :param biothings_metadata: BiothingsNamepspace object containing configuration information :param curies: A list of curies to generate a set ID for. :param conflations: A list of conflations to apply. Must be one or both of 'GeneProtein' and 'DrugChemical'. - :return: A SetIDResponse with the Set ID. + :return: A SetIDResponse with the Set ID, as a dict: Tornado can only write dicts, not dataclasses. """ # Step 0. Prepare the SetIDResponse by filling it with the arguments. @@ -98,7 +98,7 @@ async def generate_setid( "Conflations provided to " + f"generate_setid() are {conflations}, but only 'GeneProtein' and 'DrugChemical' are allowed." ) - return response + return dataclasses.asdict(response) # We use get_normalized_nodes() to normalize all the CURIEs for us. normalization_results = await get_normalized_nodes( @@ -140,7 +140,7 @@ async def generate_setid( # Do we have any normalized CURIEs? If not, return now. if not sorted_normalized_curies: - return response + return dataclasses.asdict(response) normalized_string = "||".join(sorted_normalized_curies) response.normalized_string = normalized_string diff --git a/tests/test_openapi.py b/tests/test_openapi.py index c307e81..b30797e 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -24,7 +24,7 @@ from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.handlers.conflations import ValidConflationsHandler from nodenorm.handlers.semantic_types import SemanticTypeHandler -from nodenorm.handlers.set_identifiers import SetIDResponse +from nodenorm.handlers.set_identifiers import SetIdentifierHandler, SetIDResponse SPEC = json.loads((importlib.resources.files(nodenorm) / "webapp" / "openapi.json").read_text(encoding="utf-8")) @@ -116,6 +116,34 @@ def test_response_matches_the_documented_schema(self): assert {"GeneProtein", "DrugChemical"} <= set(body["conflations"]) +class TestSetIDErrors(AsyncHTTPTestCase): + """Answers that /get_setid gives without reaching Elasticsearch.""" + + def get_app(self) -> tornado.web.Application: + app = tornado.web.Application([(r"/get_setid", SetIdentifierHandler)]) + app.biothings = SimpleNamespace() + return app + + def test_get_reports_an_unknown_conflation(self): + # Regression: generate_setid() returned the dataclass itself here, which Tornado can't write (HTTP 500). + response = self.fetch("/get_setid?curie=MESH:D014867&conflation=Bogus") + + assert response.code == 200 + body = json.loads(response.body) + jsonschema.validate(body, SPEC["components"]["schemas"]["SetIDResponse"]) + assert "Bogus" in body["error"] + assert body["setid"] is None + + def test_post_reports_an_unknown_conflation(self): + body = json.dumps([{"curies": ["MESH:D014867"], "conflations": ["Bogus"]}]) + response = self.fetch("/get_setid", method="POST", body=body) + + assert response.code == 200 + result = json.loads(response.body) + jsonschema.validate(result["0"], SPEC["components"]["schemas"]["SetIDResponse"]) + assert "Bogus" in result["0"]["error"] + + class FakeElasticsearch: """Just enough of AsyncElasticsearch for the semantic-types aggregation.""" From bd59e3d8d564e736f04ffb6edf19e75b133752e1 Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:42:08 -0400 Subject: [PATCH 09/25] Reject a POST /get_setid body that isn't an array of sets The body has to be a JSON array of {curies, conflations} objects. A single object (the shape the old document described) passed the length check and then failed with an AttributeError, and invalid JSON failed in json.loads; both were 500s. They are now 400s that say what shape the body must have. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/set_identifiers.py | 10 +++++++++- src/nodenorm/webapp/openapi.json | 2 +- tests/test_openapi.py | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/nodenorm/handlers/set_identifiers.py b/src/nodenorm/handlers/set_identifiers.py index 043e440..00bbfdd 100644 --- a/src/nodenorm/handlers/set_identifiers.py +++ b/src/nodenorm/handlers/set_identifiers.py @@ -54,7 +54,15 @@ async def get(self): self.finish(set_identifiers) async def post(self): - post_body: list[dict] = json.loads(self.request.body) + try: + post_body: list[dict] = json.loads(self.request.body) + except json.JSONDecodeError as error: + raise HTTPError(detail=f"The body is not valid JSON: {error}", status_code=400) + if not isinstance(post_body, list) or not all(isinstance(group, dict) for group in post_body): + raise HTTPError( + detail='The body must be a JSON array of sets, such as [{"curies": ["MESH:D014867"]}]', + status_code=400, + ) if len(post_body) == 0: raise HTTPError( detail="Missing JSON body, there must be at least one curie to generate a set identifier", diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index e36e50f..3d43d84 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -307,7 +307,7 @@ } }, "400": { - "description": "The body is empty" + "description": "The body is not a non-empty JSON array of objects" } } } diff --git a/tests/test_openapi.py b/tests/test_openapi.py index b30797e..0ab51b4 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -143,6 +143,11 @@ def test_post_reports_an_unknown_conflation(self): jsonschema.validate(result["0"], SPEC["components"]["schemas"]["SetIDResponse"]) assert "Bogus" in result["0"]["error"] + def test_post_rejects_a_body_that_is_not_an_array_of_objects(self): + # A single object was the documented body before; it used to fail with a 500. + for body in ('{"curies": ["MESH:D014867"]}', '["MESH:D014867"]', "[]", "not json"): + assert self.fetch("/get_setid", method="POST", body=body).code == 400, body + class FakeElasticsearch: """Just enough of AsyncElasticsearch for the semantic-types aggregation.""" From 0e5bdebea41cb779ad3af2ab51d7d6cc3fd555e4 Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:42:30 -0400 Subject: [PATCH 10/25] Report status=error from /status when Elasticsearch is down The index mapping (for the Babel version) was read before the try block, so an unreachable cluster raised and /status answered 500 instead of the documented {"status": "error"}. The read now happens inside the try; babel_version and babel_version_url are null when the mapping couldn't be read, and the document says so. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/health.py | 12 ++++++++---- src/nodenorm/webapp/openapi.json | 6 +++--- tests/test_openapi.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/nodenorm/handlers/health.py b/src/nodenorm/handlers/health.py index 2dcd5e5..78e65e4 100644 --- a/src/nodenorm/handlers/health.py +++ b/src/nodenorm/handlers/health.py @@ -30,12 +30,16 @@ async def get(self): async_client: AsyncElasticsearch = self.biothings.elasticsearch.async_client search_indices = self.biothings.elasticsearch.indices - mapping_response = await async_client.indices.get_mapping(index=search_indices) - index_mapping = next(iter(mapping_response.values())) - babel_version = get_babel_version(index_mapping) - babel_version_url = get_babel_release_url(babel_version) version = get_openapi_version() + # Unknown until the index mapping has been read, so they stay None if the cluster is unreachable. + babel_version = None + babel_version_url = None try: + mapping_response = await async_client.indices.get_mapping(index=search_indices) + index_mapping = next(iter(mapping_response.values())) + babel_version = get_babel_version(index_mapping) + babel_version_url = get_babel_release_url(babel_version) + attributes = [ "name", "cpu", diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index 3d43d84..e598b46 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -54,7 +54,7 @@ "get": { "summary": "Status information on this NodeNorm instance", "tags": ["health"], - "description": "Returns information about this NodeNorm instance, the Babel release it serves and the Elasticsearch cluster backing it. `status` is `error` when the Elasticsearch cluster cannot be reached.", + "description": "Returns information about this NodeNorm instance, the Babel release it serves and the Elasticsearch cluster backing it. `status` is `error` when the Elasticsearch cluster cannot be reached; `babel_version` and `babel_version_url` are then `null` if the index could not be read.", "operationId": "status", "responses": { "200": { @@ -66,8 +66,8 @@ "properties": { "status": {"type": "string", "enum": ["running", "error"]}, "version": {"type": "string", "description": "The `info.version` of this OpenAPI document"}, - "babel_version": {"type": "string", "description": "The Babel release loaded into the index"}, - "babel_version_url": {"type": "string", "format": "uri", "description": "Release notes for that Babel release"}, + "babel_version": {"type": ["string", "null"], "description": "The Babel release loaded into the index"}, + "babel_version_url": {"type": ["string", "null"], "format": "uri", "description": "Release notes for that Babel release"}, "backend": {"type": "string", "enum": ["elasticsearch"]}, "biolink_model_toolkit_version": {"type": "string"}, "elasticsearch": {"type": "object", "description": "Per-node statistics from the cluster's `_cat/nodes`"} diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 0ab51b4..b7be859 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -23,6 +23,7 @@ from nodenorm.handlers import build_handlers from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.handlers.conflations import ValidConflationsHandler +from nodenorm.handlers.health import NodeNormHealthHandler from nodenorm.handlers.semantic_types import SemanticTypeHandler from nodenorm.handlers.set_identifiers import SetIdentifierHandler, SetIDResponse @@ -175,3 +176,33 @@ def test_types_are_biolink_class_curies(self): types = body["semantic_types"]["types"] assert "biolink:Disease" in types and "biolink:NamedThing" in types assert all(re.fullmatch(r"biolink:[A-Z][A-Za-z]*", t) for t in types), types + + +class UnreachableElasticsearch: + """An AsyncElasticsearch whose cluster is down.""" + + class indices: + @staticmethod + async def get_mapping(**kwargs): + raise ConnectionError("cluster unreachable") + + +class TestStatusWithoutElasticsearch(AsyncHTTPTestCase): + """Regression: the index mapping was read outside the try, so a down cluster gave a 500, not status=error.""" + + def get_app(self) -> tornado.web.Application: + app = tornado.web.Application([(r"/status", NodeNormHealthHandler)]) + app.biothings = SimpleNamespace( + elasticsearch=SimpleNamespace(indices="nodenorm", async_client=UnreachableElasticsearch()) + ) + return app + + def test_status_is_error(self): + response = self.fetch("/status") + + assert response.code == 200 + body = json.loads(response.body) + jsonschema.validate( + body, SPEC["paths"]["/status"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + ) + assert body["status"] == "error" From 5701a9222a9370bda3c8f8eac56c2b4e80b80985 Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:42:42 -0400 Subject: [PATCH 11/25] Match API routes exactly The patterns were written r"/get_setid?", r"/status?" and so on. In a Tornado route that ? makes the last character optional, so /get_seti and /statu reached the handlers though the OpenAPI document lists only the full paths. test_openapi.py hid this by stripping the ? before comparing routes with the document; it now compares them as written. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/__init__.py | 10 +++++----- tests/test_openapi.py | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/nodenorm/handlers/__init__.py b/src/nodenorm/handlers/__init__.py index 5c34aa2..343547a 100644 --- a/src/nodenorm/handlers/__init__.py +++ b/src/nodenorm/handlers/__init__.py @@ -16,11 +16,11 @@ def build_handlers() -> dict[str, tuple[str, Callable]]: from nodenorm.handlers.version import VersionHandler handler_collection = [ - (r"/get_allowed_conflations?", ValidConflationsHandler), - (r"/get_normalized_nodes?", NormalizedNodesHandler), - (r"/get_semantic_types?", SemanticTypeHandler), - (r"/get_setid?", SetIdentifierHandler), - (r"/status?", NodeNormHealthHandler), + (r"/get_allowed_conflations", ValidConflationsHandler), + (r"/get_normalized_nodes", NormalizedNodesHandler), + (r"/get_semantic_types", SemanticTypeHandler), + (r"/get_setid", SetIdentifierHandler), + (r"/status", NodeNormHealthHandler), (r"/version", VersionHandler), ] # build static file frontend diff --git a/tests/test_openapi.py b/tests/test_openapi.py index b7be859..f2433e5 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -31,7 +31,7 @@ # Only the handlers that answer the API itself: not the static files, index or redirects. API_HANDLERS = { - pattern.rstrip("?"): handler + pattern: handler for pattern, handler, *_ in build_handlers().values() if isinstance(handler, type) and issubclass(handler, NodeNormalizationBaseHandler) } @@ -101,6 +101,11 @@ def test_openapi_json_redirects_to_the_document(self): def test_only_the_exact_path_redirects(self): assert self.fetch("/openapiXjson", follow_redirects=False).code == 404 + def test_api_paths_are_matched_exactly(self): + # Regression: patterns like r"/get_setid?" made the last letter optional, so /get_seti was routed. + for path in API_HANDLERS: + assert self.fetch(path[:-1]).code == 404, path + class TestAllowedConflations(AsyncHTTPTestCase): """Regression: this endpoint returned HTTP 500 because it finished a bare list.""" From b4f22fef26bf60b2739e506c2ec58c0636c0c876 Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:42:50 -0400 Subject: [PATCH 12/25] Scan the whole handler for POST body fields The request-body check read only post()'s source, though the README says fields are found by scanning the handler, and the query-parameter check already scans the whole class. A body field read in a helper method was either missed or reported as undocumented. Co-Authored-By: Claude Opus 5.5 --- tests/test_openapi.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index f2433e5..04ebb20 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -70,14 +70,15 @@ def resolve(schema: dict) -> dict: def test_documented_request_bodies_match_the_handlers(): - # post() reads each body field with .get("field", default), whether the body is one object - # (get_normalized_nodes) or a list of them (get_setid). + # Body fields are read with .get("field", default), whether the body is one object + # (get_normalized_nodes) or a list of them (get_setid). Like the query-parameter check, this + # scans the whole class, so a field read in a helper method still counts. posting = {path: handler for path, handler in API_HANDLERS.items() if "post" in vars(handler)} assert posting, "no handler defines post()" for path, handler in posting.items(): body_schema = SPEC["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"] documented = set(resolve(body_schema)["properties"]) - implemented = set(re.findall(r'\.get\("(\w+)"', inspect.getsource(handler.post))) + implemented = set(re.findall(r'\.get\("(\w+)"', inspect.getsource(handler))) assert documented == implemented, path From 6257de3c48c72be8eaee0ec43a79da6da014c7df Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:43:10 -0400 Subject: [PATCH 13/25] Document HEAD /get_allowed_conflations and check every method The methods check compared only GET and POST, so the HEAD handler on /get_allowed_conflations, which the document didn't list, went unnoticed. It now compares every method Tornado routes except OPTIONS (answered by the base handler for CORS), and the document lists HEAD. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/webapp/openapi.json | 9 +++++++++ tests/test_openapi.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index e598b46..b6113d7 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -359,6 +359,15 @@ } } } + }, + "head": { + "summary": "Check that the list of named conflations is available.", + "tags": ["metadata"], + "description": "The headers of `GET /get_allowed_conflations`, without the body.", + "operationId": "get_allowed_conflations_head", + "responses": { + "200": {"description": "The conflation list is available"} + } } } }, diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 04ebb20..a384ad4 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -45,10 +45,14 @@ def test_every_route_is_documented_and_every_documented_path_is_routed(): assert set(SPEC["paths"]) == set(API_HANDLERS) +# Every method Tornado routes except OPTIONS, which the base handler answers for CORS preflight. +HTTP_METHODS = {method.lower() for method in tornado.web.RequestHandler.SUPPORTED_METHODS} - {"options"} + + def test_documented_methods_match_the_handlers(): for path, handler in API_HANDLERS.items(): - documented = {method for method in SPEC["paths"][path] if method in ("get", "post")} - implemented = {method for method in ("get", "post") if method in vars(handler)} + documented = set(SPEC["paths"][path]) & HTTP_METHODS + implemented = {method for method in HTTP_METHODS if method in vars(handler)} assert documented == implemented, path @@ -122,6 +126,9 @@ def test_response_matches_the_documented_schema(self): jsonschema.validate(body, SPEC["components"]["schemas"]["ConflationList"]) assert {"GeneProtein", "DrugChemical"} <= set(body["conflations"]) + def test_head_is_answered(self): + assert self.fetch("/get_allowed_conflations", method="HEAD").code == 200 + class TestSetIDErrors(AsyncHTTPTestCase): """Answers that /get_setid gives without reaching Elasticsearch.""" From 48a4568929576eaab95ab6c59b89f41bc1ccd87a Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:43:13 -0400 Subject: [PATCH 14/25] Declare jsonschema as a test dependency tests/test_openapi.py imports it directly; it was only installed because openapi-spec-validator happens to depend on it. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e81b7ae..b562d24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ tests = [ "pytest-asyncio >= 0.23.8", "pytest-randomly", "openapi-spec-validator", + "jsonschema", ] telemetry = [ From 8649756167e241051dc713914166c6af4b8ac426 Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:44:12 -0400 Subject: [PATCH 15/25] Reject /get_normalized_nodes flags that aren't booleans parse_boolean() treated every string except "false" as true, so conflate=0 or conflate=no turned conflation on. The flags now accept the strings FastAPI/Pydantic accepted in NodeNorm Redis (true/false, 1/0, yes/no, on/off, t/f, y/n, in any case) and answer 400 for anything else, including an empty value. POST bodies go through the same check, so {"conflate": "false"} is no longer truthy. The document lists the accepted values. This is a behavior change: a caller sending an unrecognized value now gets a 400 where it used to get conflation silently switched on. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/normalized_nodes.py | 44 +++++++++++++++-------- src/nodenorm/webapp/openapi.json | 6 ++-- tests/test_openapi.py | 36 +++++++++++++++++++ 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/nodenorm/handlers/normalized_nodes.py b/src/nodenorm/handlers/normalized_nodes.py index 284e610..8dbd512 100644 --- a/src/nodenorm/handlers/normalized_nodes.py +++ b/src/nodenorm/handlers/normalized_nodes.py @@ -25,6 +25,25 @@ class NormalizedNode: taxa: list[str] +# The strings FastAPI (Pydantic) accepted as booleans in NodeNorm Redis, which this API mirrors. +TRUE_STRINGS = {"true", "1", "yes", "on", "t", "y"} +FALSE_STRINGS = {"false", "0", "no", "off", "f", "n"} + + +def parse_boolean(name: str, argument: Union[str, bool]) -> bool: + """Read a boolean flag, rejecting values that aren't clearly true or false with HTTP 400.""" + if isinstance(argument, bool): + return argument + if isinstance(argument, str): + if argument.lower() in TRUE_STRINGS: + return True + if argument.lower() in FALSE_STRINGS: + return False + raise HTTPError( + detail=f"{name} must be true or false (or 1/0, yes/no, on/off, t/f, y/n), not {argument!r}", status_code=400 + ) + + class NormalizedNodesHandler(NodeNormalizationBaseHandler): """ Mirror implementation to the renci implementation found at @@ -42,17 +61,12 @@ async def get(self): detail="Missing curie argument, there must be at least one curie to normalize", status_code=400 ) - def parse_boolean(argument: Union[str, bool]) -> bool: - if isinstance(argument, bool): - return argument - if isinstance(argument, str): - return not argument.lower() == "false" - return False - - conflate = parse_boolean(self.get_argument("conflate", True)) - drug_chemical_conflate = parse_boolean(self.get_argument("drug_chemical_conflate", False)) - description = parse_boolean(self.get_argument("description", False)) - individual_types = parse_boolean(self.get_argument("individual_types", False)) + conflate = parse_boolean("conflate", self.get_argument("conflate", True)) + drug_chemical_conflate = parse_boolean( + "drug_chemical_conflate", self.get_argument("drug_chemical_conflate", False) + ) + description = parse_boolean("description", self.get_argument("description", False)) + individual_types = parse_boolean("individual_types", self.get_argument("individual_types", False)) normalized_nodes = await get_normalized_nodes( self.biothings, @@ -139,10 +153,10 @@ async def post(self): detail="Missing curie argument, there must be at least one curie to normalize", status_code=400 ) - conflate = post_body.get("conflate", True) - drug_chemical_conflate = post_body.get("drug_chemical_conflate", False) - description = post_body.get("description", False) - individual_types = post_body.get("individual_types", False) + conflate = parse_boolean("conflate", post_body.get("conflate", True)) + drug_chemical_conflate = parse_boolean("drug_chemical_conflate", post_body.get("drug_chemical_conflate", False)) + description = parse_boolean("description", post_body.get("description", False)) + individual_types = parse_boolean("individual_types", post_body.get("individual_types", False)) normalized_nodes = await get_normalized_nodes( self.biothings, diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index b6113d7..8d3724b 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -136,7 +136,7 @@ "get": { "summary": "Get the equivalent identifiers and semantic types for the curie(s) entered.", "tags": ["translator"], - "description": "Returns the equivalent identifiers and semantic types for the curie(s). Each input CURIE maps to a normalized node, or to `null` if it is unknown.", + "description": "Returns the equivalent identifiers and semantic types for the curie(s). Each input CURIE maps to a normalized node, or to `null` if it is unknown. The boolean flags accept `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`, `t`/`f` and `y`/`n`, in any case; any other value is a 400.", "operationId": "get_normalized_nodes_get", "parameters": [ { @@ -191,7 +191,7 @@ } }, "400": { - "description": "No `curie` parameter was supplied" + "description": "No `curie` parameter was supplied, or a flag is not a boolean" } } }, @@ -219,7 +219,7 @@ } }, "400": { - "description": "The body has no `curies`" + "description": "The body has no `curies`, or a flag is not a boolean" } } } diff --git a/tests/test_openapi.py b/tests/test_openapi.py index a384ad4..00e6940 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -15,6 +15,7 @@ from types import SimpleNamespace import jsonschema +import pytest import tornado.web from openapi_spec_validator import validate from tornado.testing import AsyncHTTPTestCase @@ -24,6 +25,7 @@ from nodenorm.handlers.base import NodeNormalizationBaseHandler from nodenorm.handlers.conflations import ValidConflationsHandler from nodenorm.handlers.health import NodeNormHealthHandler +from nodenorm.handlers.normalized_nodes import NormalizedNodesHandler, parse_boolean from nodenorm.handlers.semantic_types import SemanticTypeHandler from nodenorm.handlers.set_identifiers import SetIdentifierHandler, SetIDResponse @@ -219,3 +221,37 @@ def test_status_is_error(self): body, SPEC["paths"]["/status"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] ) assert body["status"] == "error" + + +@pytest.mark.parametrize("value", [True, "true", "TRUE", "1", "yes", "on", "t", "Y"]) +def test_true_flags(value): + assert parse_boolean("conflate", value) is True + + +@pytest.mark.parametrize("value", [False, "false", "False", "0", "no", "off", "f", "N"]) +def test_false_flags(value): + assert parse_boolean("conflate", value) is False + + +@pytest.mark.parametrize("value", ["", "maybe", "2", None, 0]) +def test_other_flags_are_rejected(value): + # Regression: anything but "false" used to mean true, so conflate=0 turned conflation on. + with pytest.raises(tornado.web.HTTPError) as raised: + parse_boolean("conflate", value) + assert raised.value.status_code == 400 + + +class TestNormalizedNodesFlags(AsyncHTTPTestCase): + """A flag that isn't a boolean is rejected before Elasticsearch is queried.""" + + def get_app(self) -> tornado.web.Application: + app = tornado.web.Application([(r"/get_normalized_nodes", NormalizedNodesHandler)]) + app.biothings = SimpleNamespace() + return app + + def test_get_rejects_a_flag_that_is_not_a_boolean(self): + assert self.fetch("/get_normalized_nodes?curie=MESH:D014867&conflate=maybe").code == 400 + + def test_post_rejects_a_flag_that_is_not_a_boolean(self): + body = json.dumps({"curies": ["MESH:D014867"], "conflate": "maybe"}) + assert self.fetch("/get_normalized_nodes", method="POST", body=body).code == 400 From d11a8ad884e92f3946ce9922e74fbf305b7ade2f Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:48:31 -0400 Subject: [PATCH 16/25] Test the empty-set early return in generate_setid() Covers the second path #47 describes: a set whose CURIEs normalize to nothing. get_normalized_nodes() is patched out, so it needs no host. Co-Authored-By: Claude Opus 5.5 --- tests/test_openapi.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 00e6940..b6501f5 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -13,6 +13,7 @@ import json import re from types import SimpleNamespace +from unittest import mock import jsonschema import pytest @@ -159,6 +160,17 @@ def test_post_reports_an_unknown_conflation(self): jsonschema.validate(result["0"], SPEC["components"]["schemas"]["SetIDResponse"]) assert "Bogus" in result["0"]["error"] + def test_post_answers_a_set_with_no_curies(self): + # The other early return in generate_setid(); it too used to return the dataclass (HTTP 500). + empty_lookup = mock.AsyncMock(return_value={}) + with mock.patch("nodenorm.handlers.set_identifiers.get_normalized_nodes", empty_lookup): + response = self.fetch("/get_setid", method="POST", body=json.dumps([{"curies": []}])) + + assert response.code == 200 + result = json.loads(response.body) + jsonschema.validate(result["0"], SPEC["components"]["schemas"]["SetIDResponse"]) + assert result["0"]["normalized_curies"] == [] and result["0"]["setid"] is None + def test_post_rejects_a_body_that_is_not_an_array_of_objects(self): # A single object was the documented body before; it used to fail with a 500. for body in ('{"curies": ["MESH:D014867"]}', '["MESH:D014867"]', "[]", "not json"): From e409ac989913e40abce453ae0bec9c61d246a14c Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:48:37 -0400 Subject: [PATCH 17/25] Explain why the route patterns have no trailing ? Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nodenorm/handlers/__init__.py b/src/nodenorm/handlers/__init__.py index 343547a..bb15c49 100644 --- a/src/nodenorm/handlers/__init__.py +++ b/src/nodenorm/handlers/__init__.py @@ -15,6 +15,8 @@ def build_handlers() -> dict[str, tuple[str, Callable]]: from nodenorm.handlers.set_identifiers import SetIdentifierHandler from nodenorm.handlers.version import VersionHandler + # Patterns are regexes matched against the path alone (query strings never reach them), so + # don't end one with "?": that makes its last character optional. Escape any literal dot. handler_collection = [ (r"/get_allowed_conflations", ValidConflationsHandler), (r"/get_normalized_nodes", NormalizedNodesHandler), From 0ed1dabab0a228cd172d88ff374eddaed684cd6c Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 01:59:35 -0400 Subject: [PATCH 18/25] Use Will Liu's Scripps address as the API contact Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/webapp/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index 8d3724b..85d34e3 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -11,7 +11,7 @@ "infores": "infores:sri-node-normalizer" }, "contact": { - "email": "shuchenliu2014@gmail.com", + "email": "scliu@scripps.edu", "name": "Will Liu", "x-id": "https://github.com/shuchenliu", "x-role": "responsible developer" From c966c44d8a747cbba6c9cb3035ddc3de5000b17a Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 02:38:12 -0400 Subject: [PATCH 19/25] List the production deployment in the OpenAPI servers https://nodenorm-es.transltr.io/ is now live. It goes first, since Swagger UI and generated clients default to the first server. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/webapp/openapi.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index 85d34e3..b9ac64f 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -20,6 +20,12 @@ "description": "Node Normalization takes a CURIE, and returns:
  1. The preferred CURIE for this entity
  2. All other known equivalent identifiers for the entity
  3. Semantic types for the entity as defined by the Biolink Model
This is the Elasticsearch-backed implementation of the Node Normalization API, maintained by the BioThings group at Scripps Research (source code). The data it serves is created by Babel, which finds identifier equivalences and makes sure that CURIE prefixes are Biolink Model compliant. To determine whether Node Normalization is likely to be useful, check /get_semantic_types, which lists the Biolink semantic types for which normalization has been attempted. The semantics of each endpoint are described in the Node Normalization API documentation. To learn more about how Babel creates cliques of equivalent identifiers, read its documentation." }, "servers": [ + { + "description": "Production", + "url": "https://nodenorm-es.transltr.io/", + "x-maturity": "production", + "x-location": "ITRB" + }, { "description": "Continuous integration", "url": "https://nodenorm-es.ci.transltr.io/", From 4b4db66422d60f1ceedb5211923c19c7ed989793 Mon Sep 17 00:00:00 2001 From: gaurav Date: Mon, 12 Oct 2026 02:43:34 -0400 Subject: [PATCH 20/25] Say what the semantic-types check in the API description is for Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/webapp/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index b9ac64f..9e98c34 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -17,7 +17,7 @@ "x-role": "responsible developer" }, "termsOfService": "https://toss.apps.renci.org/tos?service_long=Node+Normalization&provider_long=the+Translator+Consortium", - "description": "Node Normalization takes a CURIE, and returns:
  1. The preferred CURIE for this entity
  2. All other known equivalent identifiers for the entity
  3. Semantic types for the entity as defined by the Biolink Model
This is the Elasticsearch-backed implementation of the Node Normalization API, maintained by the BioThings group at Scripps Research (source code). The data it serves is created by Babel, which finds identifier equivalences and makes sure that CURIE prefixes are Biolink Model compliant. To determine whether Node Normalization is likely to be useful, check /get_semantic_types, which lists the Biolink semantic types for which normalization has been attempted. The semantics of each endpoint are described in the Node Normalization API documentation. To learn more about how Babel creates cliques of equivalent identifiers, read its documentation." + "description": "Node Normalization takes a CURIE, and returns:
  1. The preferred CURIE for this entity
  2. All other known equivalent identifiers for the entity
  3. Semantic types for the entity as defined by the Biolink Model
This is the Elasticsearch-backed implementation of the Node Normalization API, maintained by the BioThings group at Scripps Research (source code). The data it serves is created by Babel, which finds identifier equivalences and makes sure that CURIE prefixes are Biolink Model compliant. To determine whether Node Normalization is likely to be useful for the CURIEs you are interested in, please check /get_semantic_types, which lists the Biolink semantic types for which normalization has been attempted. The semantics of each endpoint are described in the Node Normalization API documentation. To learn more about how Babel creates cliques of equivalent identifiers, read its documentation." }, "servers": [ { From ba34f9350a6eb21a0cd8c253a89dae1fecc49b98 Mon Sep 17 00:00:00 2001 From: gaurav Date: Wed, 14 Oct 2026 12:45:50 -0400 Subject: [PATCH 21/25] Answer 400, not 500, to a POST /get_setid body that isn't UTF-8 json.loads() raises UnicodeDecodeError rather than JSONDecodeError for bytes that aren't valid UTF-8, so such a body escaped the check and became a 500, though the document promises a 400 for a malformed body. The decoding moves to a load_json_body() helper on the base handler so POST /get_normalized_nodes can use it too. Raised by Copilot on #42. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/base.py | 13 +++++++++++++ src/nodenorm/handlers/set_identifiers.py | 6 +----- tests/test_openapi.py | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/nodenorm/handlers/base.py b/src/nodenorm/handlers/base.py index e0a402b..c3e6fc4 100644 --- a/src/nodenorm/handlers/base.py +++ b/src/nodenorm/handlers/base.py @@ -1,6 +1,9 @@ """Shared handler behavior for NodeNormalization API endpoints.""" +import json + from biothings.web.handlers import BaseHandler +from tornado.web import HTTPError class NodeNormalizationBaseHandler(BaseHandler): @@ -26,3 +29,13 @@ def set_default_headers(self): def options(self, *args, **kwargs): self.finish() + + def load_json_body(self): + """Parse the request body as JSON, answering 400 rather than 500 if it can't be. + + json.loads() raises UnicodeDecodeError, not JSONDecodeError, for bytes that aren't valid UTF-8. + """ + try: + return json.loads(self.request.body) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise HTTPError(detail=f"The body is not valid JSON: {error}", status_code=400) diff --git a/src/nodenorm/handlers/set_identifiers.py b/src/nodenorm/handlers/set_identifiers.py index 00bbfdd..85a8523 100644 --- a/src/nodenorm/handlers/set_identifiers.py +++ b/src/nodenorm/handlers/set_identifiers.py @@ -1,7 +1,6 @@ # set_id.py # Code related to generating IDs for sets (as in https://github.com/TranslatorSRI/NodeNormalization/issues/256). import dataclasses -import json import logging import uuid from typing import Optional @@ -54,10 +53,7 @@ async def get(self): self.finish(set_identifiers) async def post(self): - try: - post_body: list[dict] = json.loads(self.request.body) - except json.JSONDecodeError as error: - raise HTTPError(detail=f"The body is not valid JSON: {error}", status_code=400) + post_body: list[dict] = self.load_json_body() if not isinstance(post_body, list) or not all(isinstance(group, dict) for group in post_body): raise HTTPError( detail='The body must be a JSON array of sets, such as [{"curies": ["MESH:D014867"]}]', diff --git a/tests/test_openapi.py b/tests/test_openapi.py index b6501f5..91053c1 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -173,7 +173,7 @@ def test_post_answers_a_set_with_no_curies(self): def test_post_rejects_a_body_that_is_not_an_array_of_objects(self): # A single object was the documented body before; it used to fail with a 500. - for body in ('{"curies": ["MESH:D014867"]}', '["MESH:D014867"]', "[]", "not json"): + for body in ('{"curies": ["MESH:D014867"]}', '["MESH:D014867"]', "[]", "not json", b'[{"curies": ["\xff"]}]'): assert self.fetch("/get_setid", method="POST", body=body).code == 400, body From 037ace39623bee15d3a9fa8d31b715393fd8ac75 Mon Sep 17 00:00:00 2001 From: gaurav Date: Wed, 14 Oct 2026 12:46:31 -0400 Subject: [PATCH 22/25] Answer 400, not 500, to a malformed POST /get_normalized_nodes body The handler called json.loads() with no error handling and then .get() on the result, so a body that wasn't JSON, wasn't UTF-8, or was an array instead of an object all became 500s. It now uses load_json_body() and checks for an object, and the document lists these under the 400 response. Same cause as the UTF-8 case Copilot raised for POST /get_setid on #42. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/normalized_nodes.py | 7 +++++-- src/nodenorm/webapp/openapi.json | 2 +- tests/test_openapi.py | 5 +++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/nodenorm/handlers/normalized_nodes.py b/src/nodenorm/handlers/normalized_nodes.py index 8dbd512..f91745b 100644 --- a/src/nodenorm/handlers/normalized_nodes.py +++ b/src/nodenorm/handlers/normalized_nodes.py @@ -1,5 +1,4 @@ import dataclasses -import json import logging import time from typing import Optional, Union @@ -146,7 +145,11 @@ async def post(self): } } """ - post_body: dict = json.loads(self.request.body) + post_body: dict = self.load_json_body() + if not isinstance(post_body, dict): + raise HTTPError( + detail='The body must be a JSON object, such as {"curies": ["MESH:D014867"]}', status_code=400 + ) normalization_curies = post_body.get("curies", []) if len(normalization_curies) == 0: raise HTTPError( diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index 9e98c34..4b61227 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -225,7 +225,7 @@ } }, "400": { - "description": "The body has no `curies`, or a flag is not a boolean" + "description": "The body is not a JSON object, has no `curies`, or a flag is not a boolean" } } } diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 91053c1..45c24a6 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -267,3 +267,8 @@ def test_get_rejects_a_flag_that_is_not_a_boolean(self): def test_post_rejects_a_flag_that_is_not_a_boolean(self): body = json.dumps({"curies": ["MESH:D014867"], "conflate": "maybe"}) assert self.fetch("/get_normalized_nodes", method="POST", body=body).code == 400 + + def test_post_rejects_a_body_that_is_not_a_json_object(self): + # Each of these used to fail with a 500. + for body in ('["MESH:D014867"]', "not json", b'{"curies": ["\xff"]}'): + assert self.fetch("/get_normalized_nodes", method="POST", body=body).code == 400, body From 0beea062acd61d173cc2b1441aa1c7a7329d5206 Mon Sep 17 00:00:00 2001 From: gaurav Date: Wed, 14 Oct 2026 13:57:58 -0400 Subject: [PATCH 23/25] Validate the OpenAPI document as the server serves it test_document_is_valid_openapi() reads the file from the package, so a broken /webapp/ static route or /openapi.json redirect, or a wrong Content-Type, went unnoticed. The new test fetches /openapi.json through build_handlers(), follows the redirect, and validates the body. Co-Authored-By: Claude Opus 5.5 --- tests/test_openapi.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 45c24a6..8f01a29 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -106,6 +106,14 @@ def test_openapi_json_redirects_to_the_document(self): assert response.code == 301 assert response.headers["Location"] == "/webapp/openapi.json" + def test_served_document_is_valid_openapi(self): + # test_document_is_valid_openapi() reads the file; this checks what the static route serves. + response = self.fetch("/openapi.json") + + assert response.code == 200 + assert response.headers["Content-Type"].startswith("application/json") + validate(json.loads(response.body)) + def test_only_the_exact_path_redirects(self): assert self.fetch("/openapiXjson", follow_redirects=False).code == 404 From 3d000ccd20f59a67b29d9465e917ad9ef1663c15 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Thu, 15 Oct 2026 16:35:35 -0400 Subject: [PATCH 24/25] Identify the service as infores:nodenorm biolink/information-resource-registry#140 added infores:nodenorm for NodeNorm ES and renamed infores:sri-node-normalizer to NodeNorm Redis, so the two services no longer share an infores in SmartAPI. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/webapp/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index 4b61227..ef6a2d3 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -8,7 +8,7 @@ "team": [ "DOGSURF" ], - "infores": "infores:sri-node-normalizer" + "infores": "infores:nodenorm" }, "contact": { "email": "scliu@scripps.edu", From 0c367031cede5453c6b864db97facd5f5e62d3d5 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 2 Nov 2026 13:42:20 -0800 Subject: [PATCH 25/25] Change type field to accept null in openapi.json Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/nodenorm/webapp/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodenorm/webapp/openapi.json b/src/nodenorm/webapp/openapi.json index a96ea6c..37f4c6d 100644 --- a/src/nodenorm/webapp/openapi.json +++ b/src/nodenorm/webapp/openapi.json @@ -602,7 +602,7 @@ "identifier": {"type": "string"}, "label": {"type": "string"}, "description": {"type": "string", "description": "Only when `description` was requested"}, - "type": {"type": "string", "description": "Only when `individual_types` was requested"} + "type": {"type": ["string", "null"], "description": "Only when `individual_types` was requested"} } }, "NormalizedNode": {