From 0b1b1d2521e69dc15ced8641ccfe947118707091 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Fri, 8 Nov 2024 21:00:45 +0300 Subject: [PATCH 1/9] update dataset name; allow test datasets --- app/routes/titiler/umd_glad_dist_alerts.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/routes/titiler/umd_glad_dist_alerts.py b/app/routes/titiler/umd_glad_dist_alerts.py index 04d346e1..a84ac5ae 100644 --- a/app/routes/titiler/umd_glad_dist_alerts.py +++ b/app/routes/titiler/umd_glad_dist_alerts.py @@ -18,8 +18,7 @@ router = APIRouter() -# TODO: update to the actual dataset when ready -DATASET = "dan_test" +DATASET = "umd_glad_dist_alerts" today = date.today() @@ -30,8 +29,16 @@ tags=["Raster Tiles"], response_description="PNG Raster Tile", ) +@router.get( + "/{dataset}/{version}/titiler/{z}/{x}/{y}.png", # for testing datasets - hidden from docs. + response_class=Response, + tags=["Raster Tiles"], + response_description="PNG Raster Tile", + include_in_schema=False, +) async def glad_dist_alerts_raster_tile( *, + dataset: str = DATASET, version, xyz: Tuple[int, int, int] = Depends(raster_xyz), start_date: Optional[str] = Query( @@ -71,7 +78,7 @@ async def glad_dist_alerts_raster_tile( tile_x, tile_y, zoom = xyz bands = ["default", "intensity"] - folder: str = f"s3://{DATA_LAKE_BUCKET}/{DATASET}/{version}/raster/epsg-4326/cog" + folder: str = f"s3://{DATA_LAKE_BUCKET}/{dataset}/{version}/raster/epsg-4326/cog" with AlertsReader(input=folder) as reader: # NOTE: the bands in the output `image_data` array will be in the order of # the input `bands` list From 691bd241ab2ca54862ae6032b2983f8c9ed85b81 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Sun, 10 Nov 2024 15:45:07 +0300 Subject: [PATCH 2/9] add titiler endpoint to cloudfront --- .../modules/content_delivery_network/main.tf | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/terraform/modules/content_delivery_network/main.tf b/terraform/modules/content_delivery_network/main.tf index 9b45eca4..e86f8be0 100644 --- a/terraform/modules/content_delivery_network/main.tf +++ b/terraform/modules/content_delivery_network/main.tf @@ -455,6 +455,31 @@ resource "aws_cloudfront_distribution" "tiles" { } } + ordered_cache_behavior { + allowed_methods = local.methods + cached_methods = local.methods + target_origin_id = "dynamic" + compress = true + path_pattern = "*/titiler/*" + default_ttl = 86400 + max_ttl = 86400 + min_ttl = 0 + smooth_streaming = false + trusted_signers = [] + viewer_protocol_policy = "redirect-to-https" + + forwarded_values { + headers = local.headers + query_string = true + query_string_cache_keys = [] + + cookies { + forward = "none" + whitelisted_names = [] + } + } + } + # Default static vector tiles are stored on S3 # They won't change and can stay in cache for a year # We will set response headers for selected tile caches in S3 if required From 4a5326459ba5758d525931fb5999147b45514896 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Mon, 11 Nov 2024 13:34:23 +0300 Subject: [PATCH 3/9] add clarifying comment for the two titiler paths --- terraform/modules/content_delivery_network/main.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/terraform/modules/content_delivery_network/main.tf b/terraform/modules/content_delivery_network/main.tf index e86f8be0..6d40e01d 100644 --- a/terraform/modules/content_delivery_network/main.tf +++ b/terraform/modules/content_delivery_network/main.tf @@ -423,7 +423,7 @@ resource "aws_cloudfront_distribution" "tiles" { } } - # send all Titiler requests to tile cache app + # send all generic Titiler requests to tile cache app ordered_cache_behavior { allowed_methods = local.methods cached_methods = local.methods @@ -455,6 +455,7 @@ resource "aws_cloudfront_distribution" "tiles" { } } +# pass requests for DIST alerts test datasets to tile cache app ordered_cache_behavior { allowed_methods = local.methods cached_methods = local.methods From 41b59435b4bcb10e871e541a955acb7e3d1aedc6 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Mon, 11 Nov 2024 21:41:19 +0300 Subject: [PATCH 4/9] fix tree cover loss year field type; update var name to make mypy happy --- app/routes/titiler/umd_glad_dist_alerts.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/routes/titiler/umd_glad_dist_alerts.py b/app/routes/titiler/umd_glad_dist_alerts.py index a84ac5ae..039e5670 100644 --- a/app/routes/titiler/umd_glad_dist_alerts.py +++ b/app/routes/titiler/umd_glad_dist_alerts.py @@ -68,8 +68,8 @@ async def glad_dist_alerts_raster_tile( None, description="Alerts in pixels with tree cover height (in meters) below this threshold won't be displayed. `umd_tree_cover_height_2020` dataset in the API is used for this masking.", ), - tree_cover_loss_cutoff: bool = Query( - False, + tree_cover_loss_cutoff: Optional[int] = Query( + None, ge=2021, description="""This filter is to be used in conjunction with `tree_cover_density` and `tree_cover_height` filters to detect only alerts in forests, by masking out pixels that have had tree cover loss prior to the alert.""", ), @@ -96,23 +96,23 @@ async def glad_dist_alerts_raster_tile( filter_datasets = GLOBALS.dist_alerts_forest_filters if tree_cover_density: - dataset = filter_datasets["tree_cover_density"] + filter_dataset = filter_datasets["tree_cover_density"] with COGReader( - f"s3://{DATA_LAKE_BUCKET}/{dataset['dataset']}/{dataset['version']}/raster/epsg-4326/cog/default.tif" + f"s3://{DATA_LAKE_BUCKET}/{filter_dataset['dataset']}/{filter_dataset['version']}/raster/epsg-4326/cog/default.tif" ) as reader: dist_alert.tree_cover_density_data = reader.tile(tile_x, tile_y, zoom) if tree_cover_height: - dataset = filter_datasets["tree_cover_height"] + filter_dataset = filter_datasets["tree_cover_height"] with COGReader( - f"s3://{DATA_LAKE_BUCKET}/{dataset['dataset']}/{dataset['version']}/raster/epsg-4326/cog/default.tif" + f"s3://{DATA_LAKE_BUCKET}/{filter_dataset['dataset']}/{filter_dataset['version']}/raster/epsg-4326/cog/default.tif" ) as reader: dist_alert.tree_cover_height_data = reader.tile(tile_x, tile_y, zoom) if tree_cover_loss_cutoff: - dataset = filter_datasets["tree_cover_loss"] + filter_dataset = filter_datasets["tree_cover_loss"] with COGReader( - f"s3://{DATA_LAKE_BUCKET}/{dataset['dataset']}/{dataset['version']}/raster/epsg-4326/cog/default.tif" + f"s3://{DATA_LAKE_BUCKET}/{filter_dataset['dataset']}/{filter_dataset['version']}/raster/epsg-4326/cog/default.tif" ) as reader: dist_alert.tree_cover_loss_data = reader.tile(tile_x, tile_y, zoom) From 2692cd69b253a8deb04bf2db1607fcbcd429027b Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Wed, 13 Nov 2024 17:05:36 +0300 Subject: [PATCH 5/9] consistent and descriptive forest filter query params; detailed decoding doc --- app/routes/titiler/umd_glad_dist_alerts.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/routes/titiler/umd_glad_dist_alerts.py b/app/routes/titiler/umd_glad_dist_alerts.py index 039e5670..a674f9fa 100644 --- a/app/routes/titiler/umd_glad_dist_alerts.py +++ b/app/routes/titiler/umd_glad_dist_alerts.py @@ -52,23 +52,24 @@ async def glad_dist_alerts_raster_tile( description="Only show alerts until given date.", ), render_type: RenderType = Query( - RenderType.encoded, description="Render true color or encoded tiles" + RenderType.encoded, + description="Render true color or encoded tiles. Encoded tiles have the alert date and confidence packed in the image RGB channels for front-end interactive use, such as date filtering with supported technologies. Decoding instructions: Alert Date is calculated as `red * 255 + green`, representing days since 2020-12-31. Confidence is calculated as `floor(blue / 100)`, with values of `2` (high) or `1` (low). Intensity is calculated as `mod(blue, 100)`, with a maximum value of `55`.", ), alert_confidence: Optional[AlertConfidence] = Query( AlertConfidence.low, description="Show alerts that are at least of this confidence level", ), - tree_cover_density: Optional[int] = Query( + tree_cover_density_threshold: Optional[int] = Query( None, ge=0, le=100, description="Alerts in pixels with tree cover density (in percent) below this threshold won't be displayed. `umd_tree_cover_density_2010` is used for this masking.", ), - tree_cover_height: Optional[int] = Query( + tree_cover_height_threshold: Optional[int] = Query( None, description="Alerts in pixels with tree cover height (in meters) below this threshold won't be displayed. `umd_tree_cover_height_2020` dataset in the API is used for this masking.", ), - tree_cover_loss_cutoff: Optional[int] = Query( + tree_cover_loss_threshold: Optional[int] = Query( None, ge=2021, description="""This filter is to be used in conjunction with `tree_cover_density` and `tree_cover_height` filters to detect only alerts in forests, by masking out pixels that have had tree cover loss prior to the alert.""", @@ -89,27 +90,27 @@ async def glad_dist_alerts_raster_tile( end_date=end_date, render_type=render_type, alert_confidence=alert_confidence, - tree_cover_density_mask=tree_cover_density, - tree_cover_height_mask=tree_cover_height, - tree_cover_loss_mask=tree_cover_loss_cutoff, + tree_cover_density_mask=tree_cover_density_threshold, + tree_cover_height_mask=tree_cover_height_threshold, + tree_cover_loss_mask=tree_cover_loss_threshold, ) filter_datasets = GLOBALS.dist_alerts_forest_filters - if tree_cover_density: + if tree_cover_density_threshold: filter_dataset = filter_datasets["tree_cover_density"] with COGReader( f"s3://{DATA_LAKE_BUCKET}/{filter_dataset['dataset']}/{filter_dataset['version']}/raster/epsg-4326/cog/default.tif" ) as reader: dist_alert.tree_cover_density_data = reader.tile(tile_x, tile_y, zoom) - if tree_cover_height: + if tree_cover_height_threshold: filter_dataset = filter_datasets["tree_cover_height"] with COGReader( f"s3://{DATA_LAKE_BUCKET}/{filter_dataset['dataset']}/{filter_dataset['version']}/raster/epsg-4326/cog/default.tif" ) as reader: dist_alert.tree_cover_height_data = reader.tile(tile_x, tile_y, zoom) - if tree_cover_loss_cutoff: + if tree_cover_loss_threshold: filter_dataset = filter_datasets["tree_cover_loss"] with COGReader( f"s3://{DATA_LAKE_BUCKET}/{filter_dataset['dataset']}/{filter_dataset['version']}/raster/epsg-4326/cog/default.tif" From c35d97249d1057b265367f65293e7cbb5656ba90 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Wed, 13 Nov 2024 18:29:58 +0300 Subject: [PATCH 6/9] doc styling; add alert decoding example --- app/routes/titiler/umd_glad_dist_alerts.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/routes/titiler/umd_glad_dist_alerts.py b/app/routes/titiler/umd_glad_dist_alerts.py index a674f9fa..ff98dd31 100644 --- a/app/routes/titiler/umd_glad_dist_alerts.py +++ b/app/routes/titiler/umd_glad_dist_alerts.py @@ -53,7 +53,19 @@ async def glad_dist_alerts_raster_tile( ), render_type: RenderType = Query( RenderType.encoded, - description="Render true color or encoded tiles. Encoded tiles have the alert date and confidence packed in the image RGB channels for front-end interactive use, such as date filtering with supported technologies. Decoding instructions: Alert Date is calculated as `red * 255 + green`, representing days since 2020-12-31. Confidence is calculated as `floor(blue / 100)`, with values of `2` (high) or `1` (low). Intensity is calculated as `mod(blue, 100)`, with a maximum value of `55`.", + description=( + "Render true color or encoded tiles. Encoded tiles have the alert " + "date and confidence packed in the image RGB channels for front-end interactive use, " + "such as date filtering with supported technologies. " + "Decoding instructions: Alert Date is calculated as `red * 255 + green`, " + "representing days since 2020-12-31. Confidence is calculated as `floor(blue / 100)`, " + "with values of `2` (high) or `1` (low). Intensity is calculated as `mod(blue, 100)`, " + "with a maximum value of `55`. " + "For example, a pixel RGB value of `(3, 26, 255)` would decode to: " + "**alert date**: `3 * 255 + 26 = 791` (or 2023-03-02), " + "**confidence**: `floor(255, 100) = 2` (high), and " + "**intensity**: `mod(255, 100) = 55`" + ), ), alert_confidence: Optional[AlertConfidence] = Query( AlertConfidence.low, From 24dbdaef275030e41a5acf7d17adb4c4290d7667 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Wed, 13 Nov 2024 18:49:20 +0300 Subject: [PATCH 7/9] doc updates --- app/routes/titiler/umd_glad_dist_alerts.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/routes/titiler/umd_glad_dist_alerts.py b/app/routes/titiler/umd_glad_dist_alerts.py index ff98dd31..cd419818 100644 --- a/app/routes/titiler/umd_glad_dist_alerts.py +++ b/app/routes/titiler/umd_glad_dist_alerts.py @@ -55,7 +55,8 @@ async def glad_dist_alerts_raster_tile( RenderType.encoded, description=( "Render true color or encoded tiles. Encoded tiles have the alert " - "date and confidence packed in the image RGB channels for front-end interactive use, " + "date, confidence and intensity (value for use in alpha/transparency channel to fade out isolated alert pixels at low zoom levels) " + "packed in the image RGB channels for front-end interactive use, " "such as date filtering with supported technologies. " "Decoding instructions: Alert Date is calculated as `red * 255 + green`, " "representing days since 2020-12-31. Confidence is calculated as `floor(blue / 100)`, " @@ -75,16 +76,16 @@ async def glad_dist_alerts_raster_tile( None, ge=0, le=100, - description="Alerts in pixels with tree cover density (in percent) below this threshold won't be displayed. `umd_tree_cover_density_2010` is used for this masking.", + description="Show alerts in pixels with tree cover density (in percent) greater than or equal to this threshold. `umd_tree_cover_density_2010` is used for this masking.", ), tree_cover_height_threshold: Optional[int] = Query( None, - description="Alerts in pixels with tree cover height (in meters) below this threshold won't be displayed. `umd_tree_cover_height_2020` dataset in the API is used for this masking.", + description="Show alerts in pixels with tree cover height (in meters) greater than or equal to this threshold. `umd_tree_cover_height_2020` dataset in the API is used for this masking.", ), tree_cover_loss_threshold: Optional[int] = Query( None, ge=2021, - description="""This filter is to be used in conjunction with `tree_cover_density` and `tree_cover_height` filters to detect only alerts in forests, by masking out pixels that have had tree cover loss prior to the alert.""", + description="""This filter is to be used in conjunction with `tree_cover_density_threshold` and `tree_cover_height_threshold` filters to detect only alerts in forests, by masking out pixels that have had tree cover loss prior to the alert.""", ), ) -> Response: """UMD GLAD DIST alerts raster tiles.""" From e2c71b704fc9e9f2ced11b8e503126983f7e5bf5 Mon Sep 17 00:00:00 2001 From: Solomon Negusse Date: Wed, 13 Nov 2024 21:24:01 +0300 Subject: [PATCH 8/9] minor doc fix --- app/routes/titiler/umd_glad_dist_alerts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/titiler/umd_glad_dist_alerts.py b/app/routes/titiler/umd_glad_dist_alerts.py index cd419818..bc173313 100644 --- a/app/routes/titiler/umd_glad_dist_alerts.py +++ b/app/routes/titiler/umd_glad_dist_alerts.py @@ -64,7 +64,7 @@ async def glad_dist_alerts_raster_tile( "with a maximum value of `55`. " "For example, a pixel RGB value of `(3, 26, 255)` would decode to: " "**alert date**: `3 * 255 + 26 = 791` (or 2023-03-02), " - "**confidence**: `floor(255, 100) = 2` (high), and " + "**confidence**: `floor(255 / 100) = 2` (high), and " "**intensity**: `mod(255, 100) = 55`" ), ), From bc6a6c77c306d5c63212f61bee44db9bc2ef5968 Mon Sep 17 00:00:00 2001 From: Gary Tempus Date: Thu, 21 Nov 2024 14:19:25 -0500 Subject: [PATCH 9/9] :bug: fix: implementation replacement on redirect (#176) (#178) * :bug: fix: Only replace implementation with `dynamic` Supports FLAG-1126 * :white_check_mark: test: add initial pytest suite * :art: refactor: create more declarative methods and tests * :memo: docs: add docstring to handler * :memo: docs: add implementation note regarding relative URLs (cherry picked from commit 8d79ee75271bd3b57879144722e4fa0f0cfcecef) --- .../redirect_s3_404/src/lambda_function.py | 88 +++++++--- tests/lambda/test_redirect_s3_404.py | 165 ++++++++++++++++++ 2 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 tests/lambda/test_redirect_s3_404.py diff --git a/terraform/modules/content_delivery_network/lambda_functions/redirect_s3_404/src/lambda_function.py b/terraform/modules/content_delivery_network/lambda_functions/redirect_s3_404/src/lambda_function.py index 7af0e28e..8c0cb8b1 100644 --- a/terraform/modules/content_delivery_network/lambda_functions/redirect_s3_404/src/lambda_function.py +++ b/terraform/modules/content_delivery_network/lambda_functions/redirect_s3_404/src/lambda_function.py @@ -1,12 +1,28 @@ # mypy: ignore-errors +from urllib.parse import urlparse, urlunparse def handler(event, context): - """ - This function updates the HTTP status code in the response to 307, to redirect to another - path (cache behavior) that has a different origin configured. Note the following: + """This function updates the HTTP status code in the response to 307, to + redirect to another path (cache behavior) that has a different origin + configured. + + Note the following: 1. The function is triggered in an origin response 2. The response status from the origin server is an error status code (404) + + The pattern for the incoming request uri: + + /{dataset}/{version}/{implementation}/{z}/{x}/{y}.(png|pbf) + + results in a redirect response like: + + /{dataset}/{version}/dynamic/{z}/{x}/{y}.(png|pbf)?implementation={implementation} + + *Implementation Note: The request URI and redirect URL are relative (with a leading '/'). When python `splits` the string + of a relative URL, the first element of the list is the empty string (''). Therefore, + + /{dataset}/{version}/{implementation}/{z}/{x}/{y}.(png|pbf) has seven (7) elements after splitting. """ response = event["Records"][0]["cf"]["response"] @@ -17,38 +33,56 @@ def handler(event, context): # custom origin is tile cache app. URL is passed via custom header set in cloud front # (env variables are not support for Lambda@Edge) - if int(response["status"]) == 404 and is_tile(request["uri"]): + parsed_url = urlparse(request["uri"]) + path_parts = parsed_url.path.split("/") - implementation = get_implementation(request["uri"]) + if int(response["status"]) == 404 and is_tile(path_parts): + implementation = replace_implementation_in_path(path_parts) + querystring = add_implementation_to_query_params( + implementation, request["querystring"] + ) + updated_url = urlunparse( + parsed_url._replace(path="/".join(path_parts), query=querystring) + ) + update_headers_for_redirect(headers, updated_url) + return build_redirect_response(headers) - redirect_path = request["uri"].replace(implementation, "dynamic") + return response - if request["querystring"]: - querystring = f"{request['querystring']}&implementation={implementation}" - else: - querystring = f"implementation={implementation}" - redirect_path += f"?{querystring}" +def is_tile(uri): + """The resource is a tile if its last path element ends in .png or .pbf.""" + print("REQUEST URI", "/".join(uri)) + return len(uri) == 7 and uri[6][-4:] in [".png", ".pbf"] - headers["location"] = [{"key": "Location", "value": redirect_path}] - headers["content-type"] = [{"key": "Content-Type", "value": "application/json"}] - headers["content-encoding"] = [{"key": "Content-Encoding", "value": "UTF-8"}] - response = { - "status": "307", - "statusDescription": "Temporary Redirect", - "headers": headers, - } +def replace_implementation_in_path(path_parts): + """Replace the implementation path segment with "dynamic" and return the + original implementation.""" + implementation = path_parts[3] + path_parts[3] = "dynamic" + return implementation - return response +def add_implementation_to_query_params(implementation, query_string): + implementation_param = f"implementation={implementation}" + if query_string: + querystring = f"{query_string}&{implementation_param}" + else: + querystring = implementation_param + return querystring -def is_tile(uri): - print("REQUEST URI", uri) - parts = uri.split("/") - return len(parts) == 7 and parts[6][-4:] in [".png", ".pbf"] + +def update_headers_for_redirect(headers, updated_url): + headers["location"] = [{"key": "Location", "value": updated_url}] + headers["content-type"] = [{"key": "Content-Type", "value": "application/json"}] + headers["content-encoding"] = [{"key": "Content-Encoding", "value": "UTF-8"}] -def get_implementation(uri): - parts = uri.split("/") - return parts[3] +def build_redirect_response(headers): + response = { + "status": "307", + "statusDescription": "Temporary Redirect", + "headers": headers, + } + return response diff --git a/tests/lambda/test_redirect_s3_404.py b/tests/lambda/test_redirect_s3_404.py new file mode 100644 index 00000000..79c04179 --- /dev/null +++ b/tests/lambda/test_redirect_s3_404.py @@ -0,0 +1,165 @@ +from terraform.modules.content_delivery_network.lambda_functions.redirect_s3_404.src.lambda_function import ( + handler, +) + + +def create_event(status="404", uri="not important for this test", querystring=""): + """Helper method to create the base event dictionary with customizable + status, URI, and query string.""" + return { + "Records": [ + { + "cf": { + "response": { + "status": status, + "headers": { + "content-type": [ + {"key": "Content-Type", "value": "application/json"} + ] + }, + }, + "request": { + "uri": uri, + "querystring": querystring, + }, + } + } + ] + } + + +class TestRedirectOnlyTileRequestsThatAreNotFound: + def test_handler_does_not_modify_response_if_status_is_something_other_than_404( + self, + ): + event = create_event(status="200") + + response = handler(event, {}) + + assert response == { + "status": "200", + "headers": { + "content-type": [{"key": "Content-Type", "value": "application/json"}] + }, + } + + def test_handler_creates_a_redirect_response_if_status_is_404_and_is_a_png_tile( + self, + ): + event = create_event( + uri="/sbtn_natural_forests_map/v202310/natural_forest/10/20/30.png" + ) + + response = handler(event, {}) + + assert ( + response.items() + >= { + "status": "307", + "statusDescription": "Temporary Redirect", + }.items() + ) + + def test_handler_creates_a_redirect_response_if_status_is_404_and_is_a_pbf_tile( + self, + ): + event = create_event( + uri="/sbtn_natural_forests_map/v202310/natural_forest/10/20/30.pbf" + ) + + response = handler(event, {}) + + assert ( + response.items() + >= { + "status": "307", + "statusDescription": "Temporary Redirect", + }.items() + ) + + def test_handler_does_not_modify_response_if_request_is_a_resource_other_than_a_tile( + self, + ): + event = create_event( + uri="/sbtn_natural_forests_map/v202310/natural_forest/10/20/30.txt" + ) + + response = handler(event, {}) + + assert response == { + "status": "404", + "headers": { + "content-type": [{"key": "Content-Type", "value": "application/json"}] + }, + } + + +class TestRedirectsToADynamicTileResource: + def test_original_implementation_is_replaced_with_dynamic(self): + implementation = "natural_forest" + event = create_event( + uri=f"/sbtn_natural_forests_map/v202310/{implementation}/10/20/30.png" + ) + + response = handler(event, {}) + + assert response["headers"]["location"][0]["key"] == "Location" + assert ( + "/sbtn_natural_forests_map/v202310/dynamic/10/20/30.png" + in response["headers"]["location"][0]["value"] + ) + + +class TestAddsOriginalImplementationToTheExistingQueryParams: + def test_original_implementation_is_added_to_the_list_of_query_parameters(self): + implementation = "natural_forest" + event = create_event( + uri=f"/sbtn_natural_forests_map/v202310/{implementation}/10/20/30.png", + querystring="some_param=30", + ) + + response = handler(event, {}) + + assert response["headers"]["location"][0]["key"] == "Location" + assert ( + "?some_param=30&implementation=natural_forest" + in response["headers"]["location"][0]["value"] + ) + + def test_original_implementation_is_added_as_a_query_parameter(self): + implementation = "natural_forest" + event = create_event( + uri=f"/sbtn_natural_forests_map/v202310/{implementation}/10/20/30.png" + ) + + response = handler(event, {}) + + assert response["headers"]["location"][0]["key"] == "Location" + assert ( + "?implementation=natural_forest" + in response["headers"]["location"][0]["value"] + ) + + +class TestStandardHeaderInfoIsAddedToRedirect: + def test_content_type_is_set(self): + event = create_event( + uri="/sbtn_natural_forests_map/v202310/default/10/20/30.png" + ) + + response = handler(event, {}) + + assert response["headers"]["content-type"] == [ + {"key": "Content-Type", "value": "application/json"} + ] + + def test_content_encoding_is_set(self): + event = create_event( + uri="/sbtn_natural_forests_map/v202310/default/10/20/30.png" + ) + + response = handler(event, {}) + + assert response["headers"]["content-encoding"] == [ + {"key": "Content-Encoding", "value": "UTF-8"} + ]