From 1a81a9f7c5838c0f874a338098652752edaa4af4 Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 13:18:10 +0100 Subject: [PATCH 1/8] Expand and improve rodeo 7.2. Check service-desc. Also, make some errors only warnings when strict isn't set. --- sedr/edreq11.py | 9 ++++++++ sedr/preflight.py | 38 +++++++++++++++++++--------------- sedr/rodeoprofile10.py | 47 +++++++++++++++++++++++++++++++++++++----- sedr/util.py | 15 +++++++++----- 4 files changed, 82 insertions(+), 27 deletions(-) diff --git a/sedr/edreq11.py b/sedr/edreq11.py index fea7ce5..75d6d71 100644 --- a/sedr/edreq11.py +++ b/sedr/edreq11.py @@ -115,6 +115,8 @@ def requirement9_1(jsondata) -> tuple[bool, str]: False, "Landing page does not contain links. See <{spec_ref}> for more info.", ) + + service_desc = "" for link in jsondata["links"]: if not isinstance(link, dict): return ( @@ -131,5 +133,12 @@ def requirement9_1(jsondata) -> tuple[bool, str]: False, f"Link {link} does not have a rel attribute. See <{spec_ref}> for more info.", ) + if link["rel"] == "service-desc": + service_desc = link["href"] + if not service_desc: + return ( + False, + f"Landing page does not contain a service-desc link. See <{spec_ref}> for more info.", + ) util.logger.debug("requirement9_1 Landing page contains required elements.") return True, "" diff --git a/sedr/preflight.py b/sedr/preflight.py index 080801d..3bf93e1 100644 --- a/sedr/preflight.py +++ b/sedr/preflight.py @@ -29,19 +29,19 @@ def parse_landing(url, timeout=10) -> bool: landing_json = response.json() except json.decoder.JSONDecodeError: util.logger.warning("Landing page <%s> is not valid JSON.", url) - return False + return False, landing_json landing, requirement9_1_message = edreq.requirement9_1(landing_json) if not landing: util.logger.error(requirement9_1_message) - return False + return False, landing_json requirementA2_2_A7, requirementA2_2_A7_message = edreq.requirementA2_2_A7( response.raw.version ) if not requirementA2_2_A7: util.logger.error(requirementA2_2_A7_message) - return False + return False, landing_json return True, landing_json @@ -58,24 +58,26 @@ def parse_conformance(url: str, timeout: int, landing_json) -> bool: return False resolves, resolves_message = util.test_conformance_links(jsondata=conformance_json) - util.logger.error(resolves_message) - # TODO: reenable when all conformance links resolves - # if not resolves and util.args.strict: - # return False + if not resolves and util.args.strict: + util.logger.error(resolves_message) + if util.args.strict: + return False requirementA2_2_A5, requirementA2_2_A5_message = edreq.requirementA2_2_A5( jsondata=conformance_json, siteurl=util.args.url ) if not requirementA2_2_A5: util.logger.error(requirementA2_2_A5_message) - return False + if util.args.strict: + return False requirementA11_1, requirementA11_1_message = edreq.requirementA11_1( jsondata=conformance_json ) if not requirementA11_1: util.logger.error(requirementA11_1_message) - return False + if util.args.strict: + return False # Rodeo profile @@ -87,19 +89,21 @@ def parse_conformance(url: str, timeout: int, landing_json) -> bool: "Including tests for Rodeo profile %s", rodeoprofile.conformance_url ) - requirement7_2, requirement7_2_message = rodeoprofile.requirement7_2( - jsondata=landing_json - ) - if not requirement7_2: - util.logger.error(requirement7_2_message) - return False - requirement7_1, requirement7_1_message = rodeoprofile.requirement7_1( jsondata=conformance_json ) if not requirement7_1: util.logger.error(requirement7_1_message) - return False + if util.args.strict: + return False + + requirement7_2, requirement7_2_message = rodeoprofile.requirement7_2( + jsondata=landing_json + ) + if not requirement7_2: + util.logger.error(requirement7_2_message) + if util.args.strict: + return False return True diff --git a/sedr/rodeoprofile10.py b/sedr/rodeoprofile10.py index c195060..4505fb5 100644 --- a/sedr/rodeoprofile10.py +++ b/sedr/rodeoprofile10.py @@ -1,6 +1,7 @@ """rodeo-edr-profile requirements. See .""" import json +import requests import util conformance_url = "http://rodeo-project.eu/spec/rodeo-edr-profile/1/req/core" @@ -22,14 +23,19 @@ def requirement7_1(jsondata: str) -> tuple[bool, str]: def requirement7_2(jsondata: str) -> tuple[bool, str]: - """Check OpenAPI.""" + """ + RODEO EDR Profile + Version: 0.1.0 + + 7.2. OpenAPI + """ spec_url = f"{spec_base_url}#_openapi" openapi_type = "application/vnd.oai.openapi+json;version=" # 3.0" servicedoc_type = "text/html" - # A, B, C for link in jsondata["links"]: if link["rel"] == "service-desc": + # C relation type if openapi_type not in link["type"]: return ( False, @@ -39,16 +45,38 @@ def requirement7_2(jsondata: str) -> tuple[bool, str]: f"<{link['type']}> See <{spec_url}> and <{spec_base_url}" "#_openapi_2> for more info.", ) - break + + # A described using an OpenAPI document + response = requests.get(link["href"], timeout=util.args.timeout) + if not response.status_code < 400: + return ( + False, + f"OpenAPI link service-desc <{link["href"]}> doesn't respond properly. " + f"Status code: {response.status_code}.", + ) + + # B encoded as JSON + try: + jsondata = json.loads(response.json()) + except (json.JSONDecodeError, TypeError) as err: + return ( + False, + f"OpenAPI link service-desc <{link["href"]}> does not contain valid JSON.\n" + f"Error: {err}", + ) + else: return ( False, f"No service-desc link found. See <{spec_url}> for more info.", ) - # D + # D API documentation + service_doc_link = "" for link in jsondata["links"]: if link["rel"] == "service-doc": + service_doc_link = link["href"] + if servicedoc_type not in link["type"]: return ( False, @@ -58,8 +86,17 @@ def requirement7_2(jsondata: str) -> tuple[bool, str]: else: return ( False, - f"Landing page should linkt to service-doc, with type {servicedoc_type}. See <{spec_url}> for more info.", + f"Landing page should link to service-doc. See <{spec_url}> for more info.", + ) + + response = requests.get(service_doc_link, timeout=util.args.timeout) + if not response.status_code < 400: + return ( + False, + f"OpenAPI link service-desc <{link["href"]}> doesn't respond properly. " + f"Status code: {response.status_code}. See <{spec_url}> for more info.", ) + util.logger.debug("Rodeoprofile Requirement 7.2 OK") return True, "" diff --git a/sedr/util.py b/sedr/util.py index fad57ff..2dc77ae 100644 --- a/sedr/util.py +++ b/sedr/util.py @@ -131,13 +131,18 @@ def parse_locations(jsondata) -> None: # ) -def test_conformance_links(jsondata) -> tuple[bool, str]: # pylint: disable=unused-argument - """Test that all conformance links are valid and resolves. - - TODO: http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections doesn't work, so not erroring out. - """ +def test_conformance_links(jsondata) -> tuple[bool, str]: + """Test that all conformance links are valid and resolves.""" msg = "" for link in jsondata["conformsTo"]: + if link in [ + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/conformance", + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", + "http://www.opengis.net/spec/ogcapi-edr-1/1.2/req/oas31", + ]: + # TODO: These links are part of the standard but doesn't work, so skipping for now. + msg += f"test_conformance_links Link {link} doesn't resolv, but that is a known issue. " + continue resp = None try: resp = requests.head(url=link, timeout=10) From fbc1a99ed51bbfa10f883b5c86a10c6e9795c926 Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 13:56:42 +0100 Subject: [PATCH 2/8] Fix types. --- sedr/edreq11.py | 6 +-- sedr/test_rodeoprofile10.py | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 sedr/test_rodeoprofile10.py diff --git a/sedr/edreq11.py b/sedr/edreq11.py index 75d6d71..5afccf6 100644 --- a/sedr/edreq11.py +++ b/sedr/edreq11.py @@ -14,7 +14,7 @@ ] -def requirementA2_2_A5(jsondata: str, siteurl="") -> tuple[bool, str]: +def requirementA2_2_A5(jsondata: dict, siteurl="") -> tuple[bool, str]: """ OGC API - Environmental Data Retrieval Standard Version: 1.1 @@ -58,7 +58,7 @@ def requirementA2_2_A7(version: int) -> tuple[bool, str]: return False, f"HTTP version 1.1 was not used. See <{spec_url}> for more info." -def requirementA11_1(jsondata: str) -> tuple[bool, str]: +def requirementA11_1(jsondata: dict) -> tuple[bool, str]: """ OGC API - Environmental Data Retrieval Standard Version: 1.1 @@ -88,7 +88,7 @@ def requirementA11_1(jsondata: str) -> tuple[bool, str]: ) -def requirement9_1(jsondata) -> tuple[bool, str]: +def requirement9_1(jsondata: dict) -> tuple[bool, str]: """ OGC API - Common - Part 1: Core Version: 1.0.0 diff --git a/sedr/test_rodeoprofile10.py b/sedr/test_rodeoprofile10.py new file mode 100644 index 0000000..057624c --- /dev/null +++ b/sedr/test_rodeoprofile10.py @@ -0,0 +1,86 @@ +"""Unit tests for rodeoprofile10.py.""" + +import unittest +import util +import rodeoprofile10 as profile + + +class TestRodeoprofile(unittest.TestCase): + __version__ = "testversion" + util.args = util.parse_args([], __version__) + util.logger = util.set_up_logging( + args=util.args, logfile=util.args.log_file, version=__version__ + ) + + def test_requirement7_2(self): + landing_json_good = { + "title": "EDR isobaric from Grib", + "description": "An EDR API for isobaric data from Grib files", + "links": [ + { + "href": "https://edrisobaric.k8s.met.no/", + "rel": "self", + "type": "application/json", + "title": "Landing Page", + }, + { + "href": "https://edrisobaric.k8s.met.no/api", + "rel": "service-desc", + "type": "application/vnd.oai.openapi+json;version=3.1", + "title": "OpenAPI document", + }, + { + "href": "https://edrisobaric.k8s.met.no/docs", + "rel": "service-doc", + "type": "text/html", + "title": "OpenAPI document", + }, + { + "href": "https://edrisobaric.k8s.met.no/conformance", + "rel": "conformance", + "type": "application/json", + "title": "Conformance document", + }, + { + "href": "https://edrisobaric.k8s.met.no/collections", + "rel": "data", + "type": "application/json", + "title": "Collections metadata in JSON", + }, + ], + "provider": { + "name": "Meteorologisk institutt / The Norwegian Meteorological Institute", + "url": "https://api.met.no/", + }, + "contact": { + "email": "weatherapi-adm@met.no", + "phone": "+47.22963000", + "address": "Henrik Mohns plass 1", + "postalCode": "0313", + "city": "Oslo", + "country": "Norway", + }, + } + + ok, msg = profile.requirement7_2(landing_json_good, timeout=10) + self.assertTrue(ok) + + landing_json_bad = { + "title": "EDR isobaric from Grib", + "description": "An EDR API for isobaric data from Grib files", + "links": [ + { + "href": "https://edrisobaric.k8s.met.no/", + "rel": "self", + "type": "application/json", + "title": "Landing Page", + }, + ], + } + + ok, _ = profile.requirement7_2(landing_json_bad, timeout=10) + self.assertFalse(ok) + + +if __name__ == "__main__": + unittest.main() From de0e158b641995bda4cbab6e75ba0478f9eb7f85 Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 13:56:59 +0100 Subject: [PATCH 3/8] Fix types --- sedr/preflight.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sedr/preflight.py b/sedr/preflight.py index 3bf93e1..68ef018 100644 --- a/sedr/preflight.py +++ b/sedr/preflight.py @@ -20,9 +20,9 @@ def test_site_response(url: str, timeout=10) -> bool: return True -def parse_landing(url, timeout=10) -> bool: +def parse_landing(url, timeout=10) -> tuple[bool, dict]: """Test that the landing page contains required elements.""" - landing_json = None + landing_json = {} response = requests.get(url, timeout=timeout) try: @@ -48,7 +48,7 @@ def parse_landing(url, timeout=10) -> bool: def parse_conformance(url: str, timeout: int, landing_json) -> bool: """Test that the conformance page contains required elements.""" - conformance_json = None + conformance_json = {} response = requests.get(url, timeout=timeout) try: @@ -98,7 +98,7 @@ def parse_conformance(url: str, timeout: int, landing_json) -> bool: return False requirement7_2, requirement7_2_message = rodeoprofile.requirement7_2( - jsondata=landing_json + jsondata=landing_json, timeout=util.args.timeout ) if not requirement7_2: util.logger.error(requirement7_2_message) From c3bc97c3741e450fb0825b89be76de4851793caa Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 14:02:35 +0100 Subject: [PATCH 4/8] Fix types, improve and tidy 7.2 --- sedr/rodeoprofile10.py | 78 +++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/sedr/rodeoprofile10.py b/sedr/rodeoprofile10.py index 4505fb5..ab38581 100644 --- a/sedr/rodeoprofile10.py +++ b/sedr/rodeoprofile10.py @@ -10,7 +10,7 @@ ) -def requirement7_1(jsondata: str) -> tuple[bool, str]: +def requirement7_1(jsondata: dict) -> tuple[bool, str]: """Check if the conformance page contains the required EDR classes.""" spec_url = f"{spec_base_url}#_requirements_class_core" if conformance_url not in jsondata["conformsTo"]: @@ -22,53 +22,61 @@ def requirement7_1(jsondata: str) -> tuple[bool, str]: return True, "" -def requirement7_2(jsondata: str) -> tuple[bool, str]: +def requirement7_2(jsondata: dict, timeout: int) -> tuple[bool, str]: """ RODEO EDR Profile Version: 0.1.0 7.2. OpenAPI + + jsondata should be a valid landing page json dict. """ spec_url = f"{spec_base_url}#_openapi" openapi_type = "application/vnd.oai.openapi+json;version=" # 3.0" servicedoc_type = "text/html" + service_desc_link = "" + service_desc_type = "" for link in jsondata["links"]: if link["rel"] == "service-desc": - # C relation type - if openapi_type not in link["type"]: - return ( - False, - f"OpenAPI link service-desc should identify the content as " - "openAPI and include version. Example " - ". Found: " - f"<{link['type']}> See <{spec_url}> and <{spec_base_url}" - "#_openapi_2> for more info.", - ) + service_desc_link = link["href"] + service_desc_type = link["type"] + break - # A described using an OpenAPI document - response = requests.get(link["href"], timeout=util.args.timeout) - if not response.status_code < 400: - return ( - False, - f"OpenAPI link service-desc <{link["href"]}> doesn't respond properly. " - f"Status code: {response.status_code}.", - ) + if not service_desc_link: + return ( + False, + f"No service-desc link found. See <{spec_url}> for more info.", + ) - # B encoded as JSON - try: - jsondata = json.loads(response.json()) - except (json.JSONDecodeError, TypeError) as err: - return ( - False, - f"OpenAPI link service-desc <{link["href"]}> does not contain valid JSON.\n" - f"Error: {err}", - ) + # C - relation type + if openapi_type not in service_desc_type: + return ( + False, + f"OpenAPI link service-desc should identify the content as " + "openAPI and include version. Example " + ". Found: " + f"<{service_desc_type}> See <{spec_url}> and <{spec_base_url}" + "#_openapi_2> for more info.", + ) - else: + # A - described using an OpenAPI document + response = requests.get(service_desc_link, timeout=timeout) + if not response.status_code < 400: return ( False, - f"No service-desc link found. See <{spec_url}> for more info.", + f"OpenAPI link service-desc <{service_desc_link}> doesn't respond properly. " + f"Status code: {response.status_code}.", + ) + + # B - encoded as JSON + try: + _ = response.json() + except (json.JSONDecodeError, TypeError) as err: + return ( + False, + f"OpenAPI link service-desc <{service_desc_link}> does not contain valid JSON.\n" + f"Error: {err}", ) # D API documentation @@ -89,7 +97,7 @@ def requirement7_2(jsondata: str) -> tuple[bool, str]: f"Landing page should link to service-doc. See <{spec_url}> for more info.", ) - response = requests.get(service_doc_link, timeout=util.args.timeout) + response = requests.get(service_doc_link, timeout=timeout) if not response.status_code < 400: return ( False, @@ -101,7 +109,7 @@ def requirement7_2(jsondata: str) -> tuple[bool, str]: return True, "" -def requirement7_3(jsondata) -> tuple[bool, str]: +def requirement7_3(jsondata: dict) -> tuple[bool, str]: """Check collection identifier. Can only test B, C. Should only be tested if --strict is set.""" spec_url = f"{spec_base_url}#_collection_identifier" @@ -138,7 +146,7 @@ def requirement7_3(jsondata) -> tuple[bool, str]: ) -def requirement7_4(jsondata: str) -> tuple[bool, str]: +def requirement7_4(jsondata: dict) -> tuple[bool, str]: """Check collection title. Can only test A, B.""" spec_url = f"{spec_base_url}#_collection_title" @@ -162,7 +170,7 @@ def requirement7_4(jsondata: str) -> tuple[bool, str]: ) -def requirement7_5(jsondata: str) -> tuple[bool, str]: +def requirement7_5(jsondata: dict) -> tuple[bool, str]: """Check collection license. Can't test D.""" spec_url = f"{spec_base_url}#_collection_license" # A, B From f69d01705438e9b22a44b38001bc2b160fa513ca Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 14:02:49 +0100 Subject: [PATCH 5/8] Add tests for rodeoprofile. --- sedr/test_rodeoprofile10.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sedr/test_rodeoprofile10.py b/sedr/test_rodeoprofile10.py index 057624c..72fb53f 100644 --- a/sedr/test_rodeoprofile10.py +++ b/sedr/test_rodeoprofile10.py @@ -62,7 +62,7 @@ def test_requirement7_2(self): }, } - ok, msg = profile.requirement7_2(landing_json_good, timeout=10) + ok, _ = profile.requirement7_2(landing_json_good, timeout=10) self.assertTrue(ok) landing_json_bad = { From d9a7ddb321d79a38cc98670e112652c909f74005 Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 14:05:45 +0100 Subject: [PATCH 6/8] Centralize timeout --- sedr/preflight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sedr/preflight.py b/sedr/preflight.py index 68ef018..e827c2f 100644 --- a/sedr/preflight.py +++ b/sedr/preflight.py @@ -57,7 +57,7 @@ def parse_conformance(url: str, timeout: int, landing_json) -> bool: util.logger.warning("Conformance page <%s> is not valid JSON.", url) return False - resolves, resolves_message = util.test_conformance_links(jsondata=conformance_json) + resolves, resolves_message = util.test_conformance_links(jsondata=conformance_json, timeout=util.args.timeout) if not resolves and util.args.strict: util.logger.error(resolves_message) if util.args.strict: From 85a83caf2622c56d9ce93502a0ca2659fd0720a8 Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 14:05:53 +0100 Subject: [PATCH 7/8] Centralize timeout --- sedr/schemat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sedr/schemat.py b/sedr/schemat.py index 96a4305..bd0ac27 100644 --- a/sedr/schemat.py +++ b/sedr/schemat.py @@ -32,7 +32,7 @@ def set_up_schemathesis(args) -> BaseOpenAPISchema: if args.openapi == "": # Attempt to find schema URL automatically - args.openapi = util.locate_openapi_url(args.url) + args.openapi = util.locate_openapi_url(args.url, timeout=util.args.timeout) if len(args.openapi) == 0: raise AssertionError( "Unable to find openapi spec for API. Please supply manually with --openapi " From 92325fc1b0681163ef4c337c09261c29357811ae Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Mon, 25 Nov 2024 14:06:29 +0100 Subject: [PATCH 8/8] Timeout, readability. --- sedr/util.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sedr/util.py b/sedr/util.py index 2dc77ae..74a151a 100644 --- a/sedr/util.py +++ b/sedr/util.py @@ -131,7 +131,7 @@ def parse_locations(jsondata) -> None: # ) -def test_conformance_links(jsondata) -> tuple[bool, str]: +def test_conformance_links(jsondata: dict, timeout: int) -> tuple[bool, str]: """Test that all conformance links are valid and resolves.""" msg = "" for link in jsondata["conformsTo"]: @@ -143,21 +143,21 @@ def test_conformance_links(jsondata) -> tuple[bool, str]: # TODO: These links are part of the standard but doesn't work, so skipping for now. msg += f"test_conformance_links Link {link} doesn't resolv, but that is a known issue. " continue - resp = None + response = requests.Response() try: - resp = requests.head(url=link, timeout=10) + response = requests.head(url=link, timeout=timeout) except requests.exceptions.MissingSchema as error: msg += f"test_conformance_links Link <{link}> from /conformance is malformed: {error}). " - if not resp.status_code < 400: - msg += f"test_conformance_links Link {link} from /conformance is broken (gives status code {resp.status_code}). " + if not response.status_code < 400: + msg += f"test_conformance_links Link {link} from /conformance is broken (gives status code {response.status_code}). " if msg: return False, msg return True, "" -def locate_openapi_url(url: str) -> str: +def locate_openapi_url(url: str, timeout: int) -> str: """Locate the OpenAPI URL based on main URL.""" - request = requests.get(url, timeout=10) + request = requests.get(url, timeout=timeout) # Json # See https://github.com/metno/sedr/issues/6