Skip to content

Commit

Permalink
use simplejson to allow NaN/inf/-inf in JSON response (developmentsee…
Browse files Browse the repository at this point in the history
…d#374)

* use simplejson to allow NaN/inf/-inf in JSON response

* use custom JSON responses in factories directly
  • Loading branch information
vincentsarago authored Sep 23, 2021
1 parent 381685e commit a9dbf5b
Show file tree
Hide file tree
Showing 7 changed files with 94 additions and 4 deletions.
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,
separators=(",", ":"),
).encode("utf-8")


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

Expand Down

0 comments on commit a9dbf5b

Please sign in to comment.