diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index bcdf36b..8c27dfb 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -16,6 +16,15 @@ class EdrAPI: """Used to modify the default responses before returning data to the user.""" + CONFORMANCE_CLASSES = sorted( + { + "https://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "https://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", + "https://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core", + } + ) + SCHEMA_CLASS = "https://schemas.opengis.net/ogcapi/edr/1.1/openapi" + @jsonldify @staticmethod def landing_page(api: API, request: APIRequest) -> Tuple[dict, int, str]: @@ -51,7 +60,24 @@ def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: Tuple[dict, int, str] Headers, HTTP Status, and Content returned as a tuple. """ - return pygeoapi.api.openapi_(api, request) + html_path = "openapi/redoc.html" if request._args.get("ui") == "redoc" else "openapi/swagger.html" + headers = request.get_response_headers(**api.api_headers) + + if request.format == pygeoapi.api.F_HTML: + data = {"openapi-document-path": f"{api.base_url}/openapi"} + content = pygeoapi.api.render_j2_template( + api.tpl_config, api.config["server"]["templates"], html_path, data, request.locale + ) + + return headers, HTTPStatus.OK, content + + headers["Content-Type"] = "application/vnd.oai.openapi+json;version=3.0" + + if isinstance(api.openapi, dict): + openapi = EdrAPI._openapi_update(api.openapi) + return headers, HTTPStatus.OK, to_json(openapi, api.pretty_print) + else: + return headers, HTTPStatus.OK, api.openapi @staticmethod def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: @@ -69,7 +95,18 @@ def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: Tuple[dict, int, str] Headers, HTTP Status, and Content returned as a tuple. """ - return pygeoapi.api.conformance(api, request) + html_path = "conformance.html" + conformance = {"conformsTo": list(EdrAPI.CONFORMANCE_CLASSES)} + + headers = request.get_response_headers(**api.api_headers) + if request.format == pygeoapi.api.F_HTML: + content = pygeoapi.api.render_j2_template( + api.tpl_config, api.config["server"]["templates"], html_path, conformance, request.locale + ) + + return headers, HTTPStatus.OK, content + + return headers, HTTPStatus.OK, to_json(conformance, api.pretty_print) @jsonldify @staticmethod @@ -336,3 +373,293 @@ def _instance_parameters( }, } return instance_parameters + + @staticmethod + def _openapi_update(api: Dict[str, Any]) -> Dict[str, Any]: + """Update the default OpenAPI definition to a custom format. + + Parameters + ---------- + api : Dict[str, Any] + The OpenAPI definition to be updated. + + Returns + ------- + Dict[str, Any] + The customized OpenAPI definition. + """ + server_tag = "Server" + collection_tag = "Collection Information" + instance_tag = "Instance Information" + query_tag = "Query" + + resource_not_found_error = {"description": "Resource not found."} + internal_application_error = { + "description": "Internal application error", + "content": { + "application/xml": { + "schema": { + "type": "object", + "format": "xml", + "xml": {"name": "ExceptionReport"}, + }, + "example": ( + '' + "" + '' + "Internal application error" + "" + "" + ), + } + }, + } + + query_base_parameters = [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/crs.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/datetime.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/parameter-name.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/z.yaml"}, + ] + query_responses = { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/200.yaml", + }, + "400": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/400.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + } + + openapi = {} + openapi["openapi"] = api.get("openapi") + openapi["info"] = api.get("info") + openapi["servers"] = api.get("servers") + openapi["tags"] = [server_tag, collection_tag, instance_tag, query_tag] + openapi["paths"] = { + "/": { + "get": { + "summary": "Landing Page", + "description": "Landing page of the API.", + "tags": [server_tag], + "operationId": "getLandingPage", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/core/landingPage.yaml", + }, + "default": internal_application_error, + }, + }, + }, + "/api": { + "get": { + "summary": "Capabilities of the API.", + "description": "API", + "tags": [server_tag], + "operationId": "getApi", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "description": "API capabilities", + }, + "default": internal_application_error, + }, + }, + }, + "/conformance": { + "get": { + "summary": "Conformance classes defining standard compliance of the API.", + "description": "Conformance Classes", + "tags": [server_tag], + "operationId": "getConformance", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/core/conformance.yaml", + }, + "default": internal_application_error, + }, + }, + }, + "/collections": { + "get": { + "summary": "Collection information for all available collections.", + "description": "Collections", + "tags": [collection_tag], + "operationId": "getCollections", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/collections/collections.yaml", + }, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}": { + "get": { + "summary": "Collection information for a single collection.", + "description": "Collection", + "tags": [collection_tag], + "operationId": "getCollection", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/collections/collection.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}/area": { + "get": { + "summary": "Query a collection for an area.", + "description": "Collection Area Query", + "tags": [query_tag], + "operationId": "getCollectionArea", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/areaCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/cube": { + "get": { + "summary": "Query a collection for a cube.", + "description": "Collection Cube Query", + "tags": [query_tag], + "operationId": "getCollectionCube", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/bbox.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/position": { + "get": { + "summary": "Query a collection for a position.", + "description": "Collection Position Query", + "tags": [query_tag], + "operationId": "getCollectionPosition", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/positionCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/instances/": { + "get": { + "summary": "Instance information for all available instances in a collection.", + "description": "Collection Instances", + "tags": [instance_tag], + "operationId": "getCollectionInstances", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/instances.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}/instances/{instanceId}": { + "get": { + "summary": "Instance information for a single instance in a collection.", + "description": "Collection Instance", + "tags": [instance_tag], + "operationId": "getCollectionInstance", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/instances.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}/instances/{instanceId}/area": { + "get": { + "summary": "Query a collection instance for an area.", + "description": "Instance Area Query", + "tags": [query_tag], + "operationId": "getCollectionInstanceArea", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/areaCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/instances/{instanceId}/cube": { + "get": { + "summary": "Query a collection instance for a cube.", + "description": "Instance Cube Query", + "tags": [query_tag], + "operationId": "getCollectionInstanceCube", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/bbox.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/instances/{instanceId}/position": { + "get": { + "summary": "Query a collection instance for a position.", + "description": "Instance Position Query", + "tags": [query_tag], + "operationId": "getCollectionInstancePosition", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/positionCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + } + + return openapi diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index ada92fe..1c14d6b 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -18,6 +18,18 @@ class EdrProvider(BaseEDRProvider): """Custom provider to be used with layer data sources.""" _layers_dict = defaultdict(list) + _extra_args = defaultdict() + + @classmethod + def set_extra_query_args(cls, args: Dict[str, Any]): + """Set the extra arguments which will be available to the provider on a request. + + Parameters + ---------- + args : Dict[str, Any] + The extra arguments which are not included in the default requests. + """ + cls._extra_args = args @classmethod def set_layers(cls, base_url: str, layers: List[pogc.Layer]): @@ -147,11 +159,15 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): output_format = kwargs.get("format_") datetime_arg = kwargs.get("datetime_") z_arg = kwargs.get("z") + resolution_x = self._extra_args.get("resolution-x") + resolution_y = self._extra_args.get("resolution-y") instance = self.validate_instance(instance) requested_parameters = self.validate_parameters(requested_parameters) output_format = self.validate_output_format(output_format) + resolution_x, resolution_y = self.validate_resolution(resolution_x, resolution_y) + crs = self.interpret_crs(requested_coordinates.crs) available_times = self.get_datetimes(list(self.parameters.values()), instance) available_altitudes = self.get_altitudes(list(self.parameters.values())) time_coords = self.interpret_time_coordinates( @@ -175,7 +191,10 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): # Handle defining native coordinates for the query, these should match between each layer coordinates_list = next(iter(requested_parameters.values())).get_coordinates_list() self.check_query_condition(len(coordinates_list) == 0, "Native coordinates not found.") - requested_native_coordinates = self.get_native_coordinates(requested_coordinates, coordinates_list[0]) + resolution_lon, resolution_lat = self.crs_converter(resolution_x, resolution_y, crs) + requested_native_coordinates = self.get_native_coordinates( + requested_coordinates, coordinates_list[0], resolution_lat, resolution_lon + ) self.check_query_condition( bool(requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE), @@ -195,7 +214,6 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): or output_format == settings.JSON.lower() or output_format == settings.HTML.lower() ): - crs = self.interpret_crs(requested_native_coordinates.crs if requested_native_coordinates else None) layers = self.get_layers(self.base_url, self.collection_id) return self.to_coverage_json(layers, dataset, crs) @@ -402,6 +420,44 @@ def validate_output_format(self, output_format: str | None) -> str: return output_format.lower() + def validate_resolution(self, resolution_x: str | None, resolution_y: str | None) -> Tuple[int, int]: + """Validate the resolutions and return the values as integers. + + If no resolution is provided in a specific direction a zero value should be used to indicate native resolution. + + Parameters + ---------- + resolution_x : str | None + The resolution in the x-direction or None. + resolution_y : str | None + The resolution in the y-direction or None. + + Returns + ------- + Tuple[int, int] + Resolution x and y as integers. + + Raises + ------ + ProviderInvalidQueryError + Raised if either of the provided resolutions is invalid. + """ + valid_resolutions = True + validated_resolution_x = 0 + validated_resolution_y = 0 + try: + validated_resolution_x = int(0 if resolution_x is None else resolution_x) + validated_resolution_y = int(0 if resolution_y is None else resolution_y) + valid_resolutions = validated_resolution_x >= 0 and validated_resolution_y >= 0 + except ValueError: + valid_resolutions = False + + if not valid_resolutions: + msg = "Invalid resolution provided, expected positive integer." + raise ProviderInvalidQueryError(msg, user_msg=msg) + + return validated_resolution_x, validated_resolution_y + def validate_instance(self, instance: str | None) -> str | None: """Validate the instance for a query. @@ -503,6 +559,13 @@ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) units_data_array = units_data_array.drop_vars({"time", "time_forecastOffsetHr", "forecastOffsetHr"}) units_data_array = units_data_array.rename(time_forecastOffsetHr="time") units_data_array = units_data_array.assign_coords(time=time_data + forecast_offsets) + if units_data_array.attrs.get("bounds", None): + filtered_bounds = { + coord: bnd + for coord, bnd in units_data_array.attrs["bounds"].items() + if coord in units_data_array.coords.dims + } + units_data_array.attrs["bounds"] = filtered_bounds return units_data_array @@ -934,6 +997,8 @@ def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id def get_native_coordinates( source_coordinates: podpac.Coordinates, target_coordinates: podpac.Coordinates, + resolution_lat: int, + resolution_lon: int, ) -> podpac.Coordinates: """Find the intersecting latitude and longitude coordinates between the source and target. @@ -943,15 +1008,38 @@ def get_native_coordinates( The source coordinates to be converted. target_coordinates : podpac.Coordinates The target coordinates to find intersections on. + resolution_lat: int + The desired resolution in the latitudinal direction, with a zero value using native resolution. + resolution_lon: int + The desired resolution in the longitudinal direction, with a zero value using native resolution. Returns ------- podpac.Coordinates The converted coordinates source coordinates intersecting with the target coordinates. """ + latitudes = ( + target_coordinates["lat"].coordinates + if resolution_lat == 0 + else np.linspace( + np.min(source_coordinates["lat"].coordinates), + np.max(source_coordinates["lat"].coordinates), + resolution_lat, + ) + ) + longitudes = ( + target_coordinates["lon"].coordinates + if resolution_lon == 0 + else np.linspace( + np.min(source_coordinates["lon"].coordinates), + np.max(source_coordinates["lon"].coordinates), + resolution_lon, + ) + ) + # Find intersections with target keeping source crs target_spatial_coordinates = podpac.Coordinates( - [target_coordinates["lat"], target_coordinates["lon"]], dims=["lat", "lon"], crs=target_coordinates.crs + [latitudes, longitudes], dims=["lat", "lon"], crs=target_coordinates.crs ) source_intersection_coordinates = target_spatial_coordinates.intersect(source_coordinates, dims=["lat", "lon"]) source_intersection_coordinates = source_intersection_coordinates.transform(source_coordinates.crs) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index e883418..827a0cd 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -245,6 +245,7 @@ def collection_query( """ self.clean_configuration_cache() self.update_configuration_base_url(request) + EdrProvider.set_extra_query_args(request._args) headers, http_status, content = EdrAPI.get_collection_edr_query( self.api, request, collection_id, instance_id, query_type=query_type, location_id=None ) diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 0ee0dfa..fe68e97 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -271,6 +271,41 @@ def test_edr_provider_cube_request_valid_bbox( ) +def test_edr_provider_cube_request_valid_bbox_with_resolution( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the cube method of the EDR Provider class with a valid bounding box and a specific resolution. + + The tested node is adjusted to ensure interpolation is used. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + base_url = "/" + args = single_layer_cube_args_internal + parameter_name = single_layer_cube_args_internal["select_properties"][0] + resolution_x = 15 + resolution_y = 20 + + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + layers[0].node = layers[0].node.interpolate() + provider.set_layers(base_url, layers) + provider.set_extra_query_args({"resolution-x": resolution_x, "resolution-y": resolution_y}) + + response = provider.cube(**args) + + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == resolution_x * resolution_y + assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( + response["domain"]["ranges"][parameter_name]["values"] + ) + + def test_edr_provider_cube_request_invalid_bbox( layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] ): diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 249eac4..ca0aaaf 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -89,7 +89,7 @@ def test_edr_routes_conformance(layers: List[pogc.Layer]): assert status == HTTPStatus.OK assert len(response["conformsTo"]) > 0 - assert "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core" in response["conformsTo"] + assert "https://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core" in response["conformsTo"] def test_edr_routes_api(): @@ -102,7 +102,7 @@ def test_edr_routes_api(): assert status == HTTPStatus.OK assert response["paths"]["/"] - assert response["paths"]["/openapi"] + assert response["paths"]["/api"] assert response["paths"]["/conformance"] assert response["paths"]["/collections"]