Skip to content

Commit 70f80b5

Browse files
Merge pull request #28 from developmentseed/moarOutputType
add csv / JSON / GeoJSONSeq output types
2 parents c4ad376 + 667cb06 commit 70f80b5

File tree

17 files changed

+609
-105
lines changed

17 files changed

+609
-105
lines changed

tests/routes/test_endpoints.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,49 @@ def test_landing(app):
1515
assert "text/html" in response.headers["content-type"]
1616
assert "TiFeatures" in response.text
1717

18+
# Check accept headers
19+
response = app.get("/", headers={"accept": "text/html"})
20+
assert response.status_code == 200
21+
assert "text/html" in response.headers["content-type"]
22+
assert "TiFeatures" in response.text
23+
24+
# accept quality
25+
response = app.get(
26+
"/", headers={"accept": "application/json;q=0.9, text/html;q=1.0"}
27+
)
28+
assert response.status_code == 200
29+
assert "text/html" in response.headers["content-type"]
30+
assert "TiFeatures" in response.text
31+
32+
# accept quality but only json is available
33+
response = app.get("/", headers={"accept": "text/csv;q=1.0, application/json"})
34+
assert response.status_code == 200
35+
assert response.headers["content-type"] == "application/json"
36+
body = response.json()
37+
assert body["title"] == "TiFeatures"
38+
39+
# accept quality but only json is available
40+
response = app.get("/", headers={"accept": "text/csv;q=1.0, */*"})
41+
assert response.status_code == 200
42+
assert response.headers["content-type"] == "application/json"
43+
body = response.json()
44+
assert body["title"] == "TiFeatures"
45+
46+
# Invalid accept, return default
47+
response = app.get("/", headers={"accept": "text/htm"})
48+
assert response.status_code == 200
49+
assert response.headers["content-type"] == "application/json"
50+
body = response.json()
51+
assert body["title"] == "TiFeatures"
52+
assert body["links"]
53+
54+
# make sure `?f=` has priority over headers
55+
response = app.get("/?f=json", headers={"accept": "text/html"})
56+
assert response.status_code == 200
57+
assert response.headers["content-type"] == "application/json"
58+
body = response.json()
59+
assert body["title"] == "TiFeatures"
60+
1861

1962
def test_docs(app):
2063
"""Test /api endpoint."""

tests/routes/test_item.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ def test_item(app):
1616
assert "text/html" in response.headers["content-type"]
1717
assert "Collection Item: 1" in response.text
1818

19+
# json output
20+
response = app.get("/collections/public.landsat_wrs/items/1?f=json")
21+
assert response.status_code == 200
22+
assert response.headers["content-type"] == "application/json"
23+
feat = response.json()
24+
assert ["collectionId", "itemId", "id", "pr", "row", "path", "ogc_fid"] == list(
25+
feat.keys()
26+
)
27+
1928
# not found
2029
response = app.get("/collections/public.landsat_wrs/items/50000")
2130
assert response.status_code == 404

tests/routes/test_items.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Test /items endpoints."""
2+
23
import json
34

45

@@ -469,7 +470,6 @@ def test_items_geometry_return_options(app):
469470
assert body["numberReturned"] == 1
470471
assert body["features"][0]["id"] == "1"
471472
assert body["features"][0]["properties"]["ogc_fid"] == 1
472-
print(body["features"][0]["geometry"])
473473
assert body["features"][0]["geometry"] == {
474474
"coordinates": [
475475
[
@@ -483,3 +483,118 @@ def test_items_geometry_return_options(app):
483483
],
484484
"type": "Polygon",
485485
}
486+
487+
488+
def test_output_response_type(app):
489+
"""Make sure /items returns wanted output response type."""
490+
# CSV output
491+
response = app.get("/collections/public.landsat_wrs/items?f=csv")
492+
assert response.status_code == 200
493+
assert "text/csv" in response.headers["content-type"]
494+
body = response.text.splitlines()
495+
assert len(body) == 11
496+
assert body[0] == "collectionId,itemId,id,pr,row,path,ogc_fid,geometry"
497+
498+
# we only accept csv
499+
response = app.get(
500+
"/collections/public.landsat_wrs/items", headers={"accept": "text/csv"}
501+
)
502+
assert response.status_code == 200
503+
assert "text/csv" in response.headers["content-type"]
504+
505+
# we accept csv or json (CSV should be returned)
506+
response = app.get(
507+
"/collections/public.landsat_wrs/items",
508+
headers={"accept": "text/csv;q=1.0, application/json;q=0.4"},
509+
)
510+
assert response.status_code == 200
511+
assert "text/csv" in response.headers["content-type"]
512+
513+
# the first preference is geo+json
514+
response = app.get(
515+
"/collections/public.landsat_wrs/items",
516+
headers={"accept": "application/geo+json, text/csv;q=0.1"},
517+
)
518+
assert response.status_code == 200
519+
assert response.headers["content-type"] == "application/geo+json"
520+
521+
# geojsonseq output
522+
response = app.get("/collections/public.landsat_wrs/items?f=geojsonseq")
523+
assert response.status_code == 200
524+
assert response.headers["content-type"] == "application/geo+json-seq"
525+
body = response.text.splitlines()
526+
assert len(body) == 10
527+
assert json.loads(body[0])["type"] == "Feature"
528+
529+
response = app.get(
530+
"/collections/public.landsat_wrs/items",
531+
headers={"accept": "application/geo+json-seq"},
532+
)
533+
assert response.status_code == 200
534+
assert response.headers["content-type"] == "application/geo+json-seq"
535+
body = response.text.splitlines()
536+
assert len(body) == 10
537+
assert json.loads(body[0])["type"] == "Feature"
538+
539+
# json output
540+
response = app.get("/collections/public.landsat_wrs/items?f=json")
541+
assert response.status_code == 200
542+
assert response.headers["content-type"] == "application/json"
543+
body = response.json()
544+
assert len(body) == 10
545+
feat = body[0]
546+
assert [
547+
"collectionId",
548+
"itemId",
549+
"id",
550+
"pr",
551+
"row",
552+
"path",
553+
"ogc_fid",
554+
"geometry",
555+
] == list(feat.keys())
556+
557+
# json output no geometry
558+
response = app.get("/collections/public.landsat_wrs/items?f=json&geom-column=none")
559+
assert response.status_code == 200
560+
assert response.headers["content-type"] == "application/json"
561+
body = response.json()
562+
assert len(body) == 10
563+
feat = body[0]
564+
assert "geometry" not in feat.keys()
565+
566+
response = app.get(
567+
"/collections/public.landsat_wrs/items",
568+
headers={"accept": "application/json"},
569+
)
570+
assert response.status_code == 200
571+
assert response.headers["content-type"] == "application/json"
572+
body = response.json()
573+
assert len(body) == 10
574+
575+
# ndjson output
576+
response = app.get("/collections/public.landsat_wrs/items?f=ndjson")
577+
assert response.status_code == 200
578+
assert response.headers["content-type"] == "application/ndjson"
579+
body = response.text.splitlines()
580+
assert len(body) == 10
581+
feat = json.loads(body[0])
582+
assert [
583+
"collectionId",
584+
"itemId",
585+
"id",
586+
"pr",
587+
"row",
588+
"path",
589+
"ogc_fid",
590+
"geometry",
591+
] == list(feat.keys())
592+
593+
response = app.get(
594+
"/collections/public.landsat_wrs/items",
595+
headers={"accept": "application/ndjson"},
596+
)
597+
assert response.status_code == 200
598+
assert response.headers["content-type"] == "application/ndjson"
599+
body = response.text.splitlines()
600+
assert len(body) == 10

tifeatures/dbmodel.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ def json_type(self) -> str:
1818
"""Return JSON field type."""
1919
pgtype = self.type
2020

21+
if pgtype.endswith("[]"):
22+
return "array"
23+
2124
if any(
2225
[
2326
pgtype.startswith("int"),
@@ -30,9 +33,6 @@ def json_type(self) -> str:
3033
if pgtype.startswith("bool"):
3134
return "boolean"
3235

33-
if pgtype.endswith("[]"):
34-
return "array"
35-
3636
if any([pgtype.startswith("json"), pgtype.startswith("geo")]):
3737
return "object"
3838

tifeatures/dependencies.py

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from tifeatures.errors import InvalidBBox
1111
from tifeatures.layer import CollectionLayer
1212
from tifeatures.layer import Table as TableLayer
13-
from tifeatures.resources.enums import AcceptType, FilterLang, ResponseType
13+
from tifeatures.resources import enums
1414

1515
from fastapi import HTTPException, Path, Query
1616

@@ -50,19 +50,115 @@ def CollectionParams(
5050
)
5151

5252

53+
def accept_media_type(
54+
accept: str, mediatypes: List[enums.MediaType]
55+
) -> Optional[enums.MediaType]:
56+
"""Return MediaType based on accept header and available mediatype.
57+
58+
Links:
59+
- https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
60+
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept
61+
62+
"""
63+
accept_values = {}
64+
for m in accept.replace(" ", "").split(","):
65+
values = m.split(";")
66+
if len(values) == 1:
67+
name = values[0]
68+
quality = 1.0
69+
else:
70+
name = values[0]
71+
groups = dict([param.split("=") for param in values[1:]]) # type: ignore
72+
try:
73+
q = groups.get("q")
74+
quality = float(q) if q else 1.0
75+
except ValueError:
76+
quality = 0
77+
78+
# if quality is 0 we ignore encoding
79+
if quality:
80+
accept_values[name] = quality
81+
82+
# Create Preference matrix
83+
media_preference = {
84+
v: [n for (n, q) in accept_values.items() if q == v]
85+
for v in sorted({q for q in accept_values.values()}, reverse=True)
86+
}
87+
88+
# Loop through available compression and encoding preference
89+
for _, pref in media_preference.items():
90+
for media in mediatypes:
91+
if media.value in pref:
92+
return media
93+
94+
# If no specified encoding is supported but "*" is accepted,
95+
# take one of the available compressions.
96+
if "*" in accept_values and mediatypes:
97+
return mediatypes[0]
98+
99+
return None
100+
101+
53102
def OutputType(
54103
request: Request,
55-
f: Optional[ResponseType] = Query(None, description="Response MediaType."),
56-
) -> Optional[ResponseType]:
57-
"""Output Response type."""
104+
f: Optional[enums.ResponseType] = Query(
105+
None,
106+
description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header.",
107+
),
108+
) -> Optional[enums.MediaType]:
109+
"""Output MediaType: json or html."""
58110
if f:
59-
return f
111+
return enums.MediaType[f.name]
60112

61-
accept_header = request.headers.get("accept", "")
62-
if accept_header in AcceptType.__members__.values():
63-
return ResponseType[AcceptType(accept_header).name]
113+
accepted_media = [enums.MediaType[v] for v in enums.ResponseType.__members__]
114+
return accept_media_type(request.headers.get("accept", ""), accepted_media)
64115

65-
return None
116+
117+
def QueryablesOutputType(
118+
request: Request,
119+
f: Optional[enums.QueryablesResponseType] = Query(
120+
None,
121+
description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header.",
122+
),
123+
) -> Optional[enums.MediaType]:
124+
"""Output MediaType: json or html."""
125+
if f:
126+
return enums.MediaType[f.name]
127+
128+
accepted_media = [
129+
enums.MediaType[v] for v in enums.QueryablesResponseType.__members__
130+
]
131+
return accept_media_type(request.headers.get("accept", ""), accepted_media)
132+
133+
134+
def ItemsOutputType(
135+
request: Request,
136+
f: Optional[enums.ItemsResponseType] = Query(
137+
None,
138+
description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header.",
139+
),
140+
) -> Optional[enums.MediaType]:
141+
"""Output MediaType: json or html."""
142+
if f:
143+
return enums.MediaType[f.name]
144+
145+
accepted_media = [enums.MediaType[v] for v in enums.ItemsResponseType.__members__]
146+
return accept_media_type(request.headers.get("accept", ""), accepted_media)
147+
148+
149+
def ItemOutputType(
150+
request: Request,
151+
f: Optional[enums.ItemResponseType] = Query(
152+
None,
153+
description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header.",
154+
),
155+
) -> Optional[enums.MediaType]:
156+
"""Output MediaType: json or html."""
157+
if f:
158+
return enums.MediaType[f.name]
159+
160+
accepted_media = [enums.MediaType[v] for v in enums.ItemResponseType.__members__]
161+
return accept_media_type(request.headers.get("accept", ""), accepted_media)
66162

67163

68164
def bbox_query(
@@ -130,17 +226,18 @@ def properties_query(
130226

131227
def filter_query(
132228
query: Optional[str] = Query(None, description="CQL2 Filter", alias="filter"),
133-
filter_lang: Optional[FilterLang] = Query(
134-
FilterLang.cql2_text,
135-
description="CQL2 Language (cql2-text, cql2-json)",
229+
filter_lang: Optional[enums.FilterLang] = Query(
230+
None,
231+
description="CQL2 Language (cql2-text, cql2-json). Defaults to cql2-text.",
136232
alias="filter-lang",
137233
),
138234
) -> Optional[AstType]:
139235
"""Parse Filter Query."""
140236
if query is not None:
141-
if filter_lang == FilterLang.cql2_json:
237+
if filter_lang == enums.FilterLang.cql2_json:
142238
return cql2_json_parser(query)
143-
else:
144-
return cql2_text_parser(query)
239+
240+
# default to cql2-text
241+
return cql2_text_parser(query)
145242

146243
return None

0 commit comments

Comments
 (0)