Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use simplejson to allow NaN/inf/-inf in JSON response #374

Merged
merged 2 commits into from
Sep 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Release Notes

## 0.3.10 (TBD)
## 0.3.10 (2021-09-23)

### titiler.core

- add custom JSONResponse using [simplejson](https://simplejson.readthedocs.io/en/latest/) to allow NaN/inf/-inf values (ref: https://github.com/developmentseed/titiler/pull/374)
- use `titiler.core.resources.responses.JSONResponse` as default response for `info`, `metadata`, `statistics` and `point` endpoints (ref: https://github.com/developmentseed/titiler/pull/374)

### titiler.application

Expand Down
20 changes: 20 additions & 0 deletions docs/output_format.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,23 @@ data, mask = data[0:-1], data[-1]
```

Notebook: [Working_with_NumpyTile](examples/notebooks/Working_with_NumpyTile.ipynb)

## JSONResponse

Sometimes rio-tiler's responses can contain `NaN`, `Infinity` or `-Infinity` values (e.g for Nodata). Sadly there is no proper ways to encode those values in JSON or at least not all web client supports it.

In order to allow TiTiler to return valid responses we added a custom `JSONResponse` in `v0.3.10` which will automatically translate `float('nan')`, `float('inf')` and `float('-inf')` to `null` and thus avoid in valid JSON response.

```python

from fastapi import FastAPI
from titiler.core.resources.responses import JSONResponse

app = FastAPI(default_response_class=JSONResponse,)

@app.get("/something")
def return_something():
return float('nan')
```

This `JSONResponse` is used by default in `titiler` Tiler Factories where `NaN` are expected (`info`, `metadata`, `statistics` and `point` endpoints).
Binary file not shown.
34 changes: 34 additions & 0 deletions src/titiler/application/tests/routes/test_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,37 @@ def test_validate_cog(app, url):
response = app.get(f"/cog/validate?url={os.path.join(DATA_DIR, 'cog.tif')}")
assert response.status_code == 200
assert response.json()["COG"]


@patch("rio_tiler.io.cogeo.rasterio")
def test_json_response_with_nan(rio, app):
"""test /info endpoint."""
rio.open = mock_rasterio_open

response = app.get("/cog/info?url=https://myurl.com/cog_with_nan.tif")
assert response.status_code == 200
body = response.json()
assert body["dtype"] == "float32"
assert body["nodata_type"] == "Nodata"
assert body["nodata_value"] is None

response = app.get("/cog/info.geojson?url=https://myurl.com/cog_with_nan.tif")
assert response.status_code == 200
assert response.headers["content-type"] == "application/geo+json"
body = response.json()
assert body["geometry"]
assert body["properties"]["nodata_type"] == "Nodata"
assert body["properties"]["nodata_value"] is None

response = app.get("/cog/metadata?url=https://myurl.com/cog_with_nan.tif")
assert response.status_code == 200
body = response.json()
assert body["nodata_type"] == "Nodata"
assert body["nodata_value"] is None

response = app.get(
"/cog/point/79.80860440702253,21.852217086223234?url=https://myurl.com/cog_with_nan.tif"
)
assert response.status_code == 200
body = response.json()
assert body["values"][0] is None
1 change: 1 addition & 0 deletions src/titiler/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"pydantic",
"rasterio",
"rio-tiler>=2.1,<2.2",
"simplejson",
# Additional requirements for python 3.6
"async_exit_stack>=1.0.1,<2.0.0;python_version<'3.7'",
"async_generator>=1.10,<2.0.0;python_version<'3.7'",
Expand Down
10 changes: 9 additions & 1 deletion src/titiler/core/titiler/core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from titiler.core.models.mapbox import TileJSON
from titiler.core.models.OGC import TileMatrixSetList
from titiler.core.resources.enums import ImageType, MediaType, OptionalHeader
from titiler.core.resources.responses import GeoJSONResponse, XMLResponse
from titiler.core.resources.responses import GeoJSONResponse, JSONResponse, XMLResponse
from titiler.core.utils import Timer, bbox_to_feature, data_stats

from fastapi import APIRouter, Body, Depends, Path, Query
Expand Down Expand Up @@ -204,6 +204,7 @@ def info(self):
response_model=Info,
response_model_exclude={"minzoom", "maxzoom", "center"},
response_model_exclude_none=True,
response_class=JSONResponse,
responses={200: {"description": "Return dataset's basic info."}},
)
def info(
Expand Down Expand Up @@ -255,6 +256,7 @@ def metadata(self):
response_model=Metadata,
response_model_exclude={"minzoom", "maxzoom", "center"},
response_model_exclude_none=True,
response_class=JSONResponse,
responses={200: {"description": "Return dataset's metadata."}},
)
def metadata(
Expand Down Expand Up @@ -542,6 +544,7 @@ def point(self):

@self.router.get(
r"/point/{lon},{lat}",
response_class=JSONResponse,
responses={200: {"description": "Return a value for a point"}},
)
def point(
Expand Down Expand Up @@ -780,6 +783,7 @@ def statistics(self):

@self.router.get(
"/statistics",
response_class=JSONResponse,
responses={
200: {
"content": {"application/json": {}},
Expand Down Expand Up @@ -920,6 +924,7 @@ def info(self):
response_model=Dict[str, Info],
response_model_exclude={"minzoom", "maxzoom", "center"},
response_model_exclude_none=True,
response_class=JSONResponse,
responses={
200: {
"description": "Return dataset's basic info or the list of available assets."
Expand Down Expand Up @@ -991,6 +996,7 @@ def metadata(self):
response_model=Dict[str, Metadata],
response_model_exclude={"minzoom", "maxzoom", "center"},
response_model_exclude_none=True,
response_class=JSONResponse,
responses={200: {"description": "Return dataset's metadata."}},
)
def metadata(
Expand Down Expand Up @@ -1041,6 +1047,7 @@ def info(self):
response_model=Info,
response_model_exclude={"minzoom", "maxzoom", "center"},
response_model_exclude_none=True,
response_class=JSONResponse,
responses={
200: {
"description": "Return dataset's basic info or the list of available bands."
Expand Down Expand Up @@ -1107,6 +1114,7 @@ def metadata(self):
response_model=Metadata,
response_model_exclude={"minzoom", "maxzoom", "center"},
response_model_exclude_none=True,
response_class=JSONResponse,
responses={200: {"description": "Return dataset's metadata."}},
)
def metadata(
Expand Down
26 changes: 24 additions & 2 deletions src/titiler/core/titiler/core/resources/responses.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
"""Common response models."""

from starlette.responses import JSONResponse, Response
from typing import Any

import simplejson as json

class XMLResponse(Response):
from starlette import responses


class XMLResponse(responses.Response):
"""XML Response"""

media_type = "application/xml"


class JSONResponse(responses.JSONResponse):
"""Custom JSON Response."""

def render(self, content: Any) -> bytes:
"""Render JSON.

Same defaults as starlette.responses.JSONResponse.render but allow NaN to be replaced by null using simplejson
"""
return json.dumps(
content,
ensure_ascii=False,
allow_nan=False,
indent=None,
ignore_nan=True,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separators=(",", ":"),
).encode("utf-8")


class GeoJSONResponse(JSONResponse):
"""GeoJSON Response"""

Expand Down