Skip to content

Commit 664b7e3

Browse files
committed
use csv.DictWriter for csv, add ndjson output, return WKT for json/csv if geometry is present
1 parent e1df1dd commit 664b7e3

File tree

4 files changed

+80
-37
lines changed

4 files changed

+80
-37
lines changed

tests/routes/test_items.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ def test_output_response_type(app):
493493
assert "text/csv" in response.headers["content-type"]
494494
body = response.text.splitlines()
495495
assert len(body) == 11
496-
assert body[0] == "collectionId;itemId;id;pr;row;path;ogc_fid;geometry"
496+
assert body[0] == "collectionId,itemId,id,pr,row,path,ogc_fid,geometry"
497497

498498
# we only accept csv
499499
response = app.get(

tifeatures/factory.py

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""tifeatures.factory: router factories."""
22

3+
import csv
34
import json
45
from dataclasses import dataclass, field
5-
from typing import Any, Callable, List, Optional
6+
from typing import Any, Callable, Dict, Iterable, List, Optional
67

78
import jinja2
89
from pygeofilter.ast import AstType
@@ -38,6 +39,30 @@
3839
from starlette.responses import StreamingResponse
3940
from starlette.templating import Jinja2Templates, _TemplateResponse
4041

42+
43+
class DummyWriter:
44+
"""Dummy writer that implements write for use with csv.writer."""
45+
46+
def write(self, line: str):
47+
"""Return line."""
48+
return line
49+
50+
51+
def iter_csv(data: Iterable[Dict]):
52+
"""Creates an iterator that returns lines of csv from an iterable of dicts."""
53+
54+
initial = True
55+
writer = None
56+
for row in data:
57+
if initial:
58+
fieldnames = row.keys()
59+
writer = csv.DictWriter(DummyWriter(), fieldnames=fieldnames)
60+
yield writer.writeheader()
61+
initial = False
62+
if writer:
63+
yield writer.writerow(row)
64+
65+
4166
settings = APISettings()
4267

4368
# custom template directory
@@ -591,41 +616,54 @@ async def items(
591616
simplify=simplify,
592617
)
593618

594-
# CSV Response
595-
if output_type == MediaType.csv:
596-
props = list(items[0].properties.keys()) + ["geometry"] if items else []
597-
rows = [";".join(["collectionId", "itemId", *props]) + "\n"]
598-
for f in items:
599-
rows.append(
600-
";".join(
601-
list(
602-
map(
603-
str,
604-
[
605-
collection.id,
606-
f.id,
607-
*f.properties.values(),
608-
f.geometry.wkt,
609-
],
610-
)
611-
)
612-
)
613-
+ "\n"
619+
if output_type in (
620+
MediaType.csv,
621+
MediaType.json,
622+
MediaType.ndjson,
623+
):
624+
if items[0].geometry is not None:
625+
rows = (
626+
{
627+
"collectionId": collection.id,
628+
"itemId": f.id,
629+
**f.properties,
630+
"geometry": f.geometry.wkt,
631+
}
632+
for f in items
614633
)
615-
return StreamingResponse(
616-
iter(rows),
617-
media_type=MediaType.csv,
618-
headers={"Content-Disposition": "attachment;filename=items.csv"},
619-
)
620-
621-
# JSON Response
622-
if output_type == MediaType.json:
623-
return JSONResponse(
624-
[
625-
{"colectionId": collection.id, "itemId": f.id, **f.properties}
634+
else:
635+
rows = (
636+
{
637+
"collectionId": collection.id,
638+
"itemId": f.id,
639+
**f.properties,
640+
}
626641
for f in items
627-
]
628-
)
642+
)
643+
644+
# CSV Response
645+
if output_type == MediaType.csv:
646+
return StreamingResponse(
647+
iter_csv(rows),
648+
media_type=MediaType.csv,
649+
headers={
650+
"Content-Disposition": "attachment;filename=items.csv"
651+
},
652+
)
653+
654+
# JSON Response
655+
if output_type == MediaType.json:
656+
return JSONResponse([row for row in rows])
657+
658+
# NDJSON Response
659+
if output_type == MediaType.ndjson:
660+
return StreamingResponse(
661+
(row + "\n" for row in rows),
662+
media_type=MediaType.ndjson,
663+
headers={
664+
"Content-Disposition": "attachment;filename=items.ndjson"
665+
},
666+
)
629667

630668
qs = "?" + str(request.query_params) if request.query_params else ""
631669
links = [
@@ -659,7 +697,10 @@ async def items(
659697
)
660698
links.append(
661699
model.Link(
662-
href=url, rel="next", type=MediaType.geojson, title="Next page"
700+
href=url,
701+
rel="next",
702+
type=MediaType.geojson,
703+
title="Next page",
663704
),
664705
)
665706

tifeatures/resources/enums.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class ItemsResponseType(str, Enum):
2525
json = "json"
2626
csv = "csv"
2727
geojsonseq = "geojsonseq"
28+
ndjson = "ndjson"
2829

2930

3031
class ItemResponseType(str, Enum):
@@ -40,6 +41,7 @@ class MediaType(str, Enum):
4041

4142
xml = "application/xml"
4243
json = "application/json"
44+
ndjson = "application/ndjson"
4345
geojson = "application/geo+json"
4446
geojsonseq = "application/geo+json-seq"
4547
schemajson = "application/schema+json"

tifeatures/resources/response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import ORJSONResponse as JSONResponse
77
except ImportError: # pragma: nocover
88
orjson = None # type: ignore
9-
from starlette.reponses import JSONResponse
9+
from starlette.responses import JSONResponse
1010

1111

1212
class GeoJSONResponse(JSONResponse):

0 commit comments

Comments
 (0)