Skip to content

Commit 2ae5759

Browse files
Merge pull request #21 from developmentseed/Queryables
add queryables endpoint
2 parents 0bc887b + 62a49a3 commit 2ae5759

File tree

9 files changed

+274
-8
lines changed

9 files changed

+274
-8
lines changed

docs/src/endpoints.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ curl http://127.0.0.1:8081 | jq
6161
"type": "application/json",
6262
"title": "Collection metadata"
6363
},
64+
{
65+
"href": "http://127.0.0.1:8081/collections/{collectionId}/queryables",
66+
"rel": "queryables",
67+
"type": "application/schema+json",
68+
"title": "Collection queryables"
69+
},
6470
{
6571
"href": "http://127.0.0.1:8081/collections/{collectionId}/items",
6672
"rel": "data",
@@ -108,7 +114,8 @@ curl http://127.0.0.1:8081/conformance | jq
108114
"http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30",
109115
"http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections",
110116
"http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query",
111-
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter,"
117+
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter,",
118+
"http://www.opengis.net/def/rel/ogc/1.0/queryables"
112119
]
113120
}
114121
```
@@ -146,6 +153,11 @@ curl http://127.0.0.1:8081/collections | jq
146153
"href": "http://127.0.0.1:8081/collections/public.countries/items",
147154
"rel": "items",
148155
"type": "application/geo+json"
156+
},
157+
{
158+
"href": "http://127.0.0.1:8081/collections/public.countries/queryables",
159+
"rel": "queryables",
160+
"type": "application/schema+json"
149161
}
150162
],
151163
"itemType": "feature",
@@ -214,6 +226,11 @@ curl http://127.0.0.1:8081/collections/public.countries | jq
214226
"href": "http://127.0.0.1:8081/collections/public.countries/items",
215227
"rel": "items",
216228
"type": "application/geo+json"
229+
},
230+
{
231+
"href": "http://127.0.0.1:8081/collections/public.countries/queryables",
232+
"rel": "queryables",
233+
"type": "application/schema+json"
217234
}
218235
],
219236
"itemType": "feature",
@@ -226,11 +243,65 @@ curl http://127.0.0.1:8081/collections/public.countries | jq
226243
Ref: https://docs.ogc.org/is/17-069r4/17-069r4.html#_collection_
227244

228245

246+
## Feature Collection's Queryables
247+
248+
Path: `/collections/{collectionId}/queryables`
249+
250+
PathParams:
251+
252+
- **collectionId** (str): Feature Collection Id
253+
254+
QueryParams:
255+
256+
- **f** (str, one of [`geojson`, `json`, `html`]): Select response MediaType.
257+
258+
HeaderParams:
259+
260+
- **accept** (str, one of [`application/geo+json`, `application/json`, `text/html`])): Select response MediaType.
261+
262+
Example:
263+
264+
```json
265+
curl http://127.0.0.1:8081/collections/public.landsat_wrs/queryables | jq
266+
{
267+
"title": "public.landsat_wrs",
268+
"properties": {
269+
"geom": {
270+
"$ref": "https://geojson.org/schema/Geometry.json"
271+
},
272+
"ogc_fid": {
273+
"name": "ogc_fid",
274+
"type": "number"
275+
},
276+
"id": {
277+
"name": "id",
278+
"type": "string"
279+
},
280+
"pr": {
281+
"name": "pr",
282+
"type": "string"
283+
},
284+
"path": {
285+
"name": "path",
286+
"type": "number"
287+
},
288+
"row": {
289+
"name": "row",
290+
"type": "number"
291+
}
292+
},
293+
"type": "object",
294+
"$schema": "https://json-schema.org/draft/2019-09/schema",
295+
"$id": "http://127.0.0.1:8081/collections/public.landsat_wrs/queryables"
296+
}
297+
```
298+
299+
Ref: http://docs.ogc.org/DRAFTS/19-079r1.html#filter-queryables
300+
229301
## Features
230302

231303
Path: `/collections/{collectionId}/items`
232304

233-
234305
PathParams:
235306

236307
- **collectionId** (str): Feature Collection Id
@@ -261,7 +332,6 @@ HeaderParams:
261332

262333
\* **Not in OGC API Features Specification**
263334

264-
265335
!!! Important
266336
Additional query-parameters (form `PROP=VALUE`) will be considered as a **property filter**.
267337

@@ -292,7 +362,6 @@ Ref: https://docs.ogc.org/is/17-069r4/17-069r4.html#_items_ and https://docs.ogc
292362

293363
Path: `/collections/{collectionId}/items/{itemId}`
294364

295-
296365
PathParams:
297366

298367
- **collectionId** (str): Feature Collection Id

tests/routes/test_collections.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_collections(app):
1919

2020

2121
def test_collections_landsat(app):
22-
"""Test /collections endpoint."""
22+
"""Test /collections/{collectionId} endpoint."""
2323
response = app.get("/collections/public.landsat_wrs")
2424
assert response.status_code == 200
2525
assert response.headers["content-type"] == "application/json"
@@ -44,3 +44,19 @@ def test_collections_landsat(app):
4444
assert response.status_code == 422
4545
body = response.json()
4646
assert body["detail"] == "Invalid Table format 'landsat_wrs'."
47+
48+
49+
def test_collections_queryables(app):
50+
"""Test /collections/{collectionId}/queryables endpoint."""
51+
response = app.get("/collections/public.landsat_wrs/queryables")
52+
assert response.status_code == 200
53+
assert response.headers["content-type"] == "application/schema+json"
54+
body = response.json()
55+
assert body["title"] == "public.landsat_wrs"
56+
assert body["type"] == "object"
57+
assert ["title", "properties", "type", "$schema", "$id"] == list(body)
58+
59+
response = app.get("/collections/public.landsat_wrs/queryables?f=html")
60+
assert response.status_code == 200
61+
assert "text/html" in response.headers["content-type"]
62+
assert "Queryables" in response.text

tifeatures/dbmodel.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ class Column(BaseModel):
1313
type: str
1414
description: Optional[str]
1515

16+
@property
17+
def json_type(self) -> str:
18+
"""Return JSON field type."""
19+
pgtype = self.type
20+
21+
if any(
22+
[
23+
pgtype.startswith("int"),
24+
pgtype.startswith("num"),
25+
pgtype.startswith("float"),
26+
]
27+
):
28+
return "number"
29+
30+
if pgtype.startswith("bool"):
31+
return "boolean"
32+
33+
if pgtype.endswith("[]"):
34+
return "array"
35+
36+
if any([pgtype.startswith("json"), pgtype.startswith("geo")]):
37+
return "object"
38+
39+
return "string"
40+
1641

1742
class GeometryColumn(BaseModel):
1843
"""Model for PostGIS geometry/geography column."""

tifeatures/factory.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from tifeatures.layer import CollectionLayer
2222
from tifeatures.layer import Table as TableLayer
2323
from tifeatures.resources.enums import MediaType, ResponseType
24-
from tifeatures.resources.response import GeoJSONResponse
24+
from tifeatures.resources.response import GeoJSONResponse, SchemaJSONResponse
2525
from tifeatures.settings import APISettings
2626

2727
from fastapi import APIRouter, Depends, Path, Query
@@ -179,6 +179,16 @@ def landing(
179179
type=MediaType.json,
180180
rel="data",
181181
),
182+
model.Link(
183+
title="Collection queryables",
184+
href=self.url_for(
185+
request,
186+
"queryables",
187+
collectionId="{collectionId}",
188+
),
189+
type=MediaType.schemajson,
190+
rel="queryables",
191+
),
182192
model.Link(
183193
title="Collection Features",
184194
href=self.url_for(
@@ -245,6 +255,7 @@ def conformance(
245255
"http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections",
246256
"http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query",
247257
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter,",
258+
"http://www.opengis.net/def/rel/ogc/1.0/queryables",
248259
]
249260
)
250261
if output_type and output_type == ResponseType.html:
@@ -328,6 +339,15 @@ def collections(
328339
rel="items",
329340
type=MediaType.geojson,
330341
),
342+
model.Link(
343+
href=self.url_for(
344+
request,
345+
"queryables",
346+
collectionId=collection.id,
347+
),
348+
rel="queryables",
349+
type=MediaType.schemajson,
350+
),
331351
],
332352
}
333353
)
@@ -391,6 +411,15 @@ def collection(
391411
rel="items",
392412
type=MediaType.geojson,
393413
),
414+
model.Link(
415+
href=self.url_for(
416+
request,
417+
"queryables",
418+
collectionId=collection.id,
419+
),
420+
rel="queryables",
421+
type=MediaType.schemajson,
422+
),
394423
],
395424
}
396425
)
@@ -404,6 +433,52 @@ def collection(
404433

405434
return data
406435

436+
@self.router.get(
437+
"/collections/{collectionId}/queryables",
438+
response_model=model.Queryables,
439+
response_model_exclude_none=True,
440+
response_model_by_alias=True,
441+
response_class=SchemaJSONResponse,
442+
responses={
443+
200: {
444+
"content": {
445+
"text/html": {},
446+
"application/schema+json": {},
447+
}
448+
},
449+
},
450+
)
451+
def queryables(
452+
request: Request,
453+
collection=Depends(self.collection_dependency),
454+
output_type: Optional[ResponseType] = Depends(OutputType),
455+
):
456+
"""Queryables for a feature collection.
457+
458+
ref: http://docs.ogc.org/DRAFTS/19-079r1.html#filter-queryables
459+
"""
460+
qs = "?" + str(request.query_params) if request.query_params else ""
461+
462+
data = model.Queryables(
463+
**{
464+
"title": collection.id,
465+
"$id": self.url_for(
466+
request, "queryables", collectionId=collection.id
467+
)
468+
+ qs,
469+
"properties": collection.queryables,
470+
}
471+
)
472+
473+
if output_type and output_type == ResponseType.html:
474+
return create_html_response(
475+
request,
476+
data.json(exclude_none=True),
477+
template_name="queryables",
478+
)
479+
480+
return data
481+
407482
@self.router.get(
408483
"/collections/{collectionId}/items",
409484
response_model=model.Items,

tifeatures/layer.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@
2424
from tifeatures.filter.evaluate import to_filter
2525
from tifeatures.filter.filters import bbox_to_wkt
2626

27+
# Links to geojson schema
28+
geojson_schema = {
29+
"GEOMETRY": "https://geojson.org/schema/Geometry.json",
30+
"POINT": "https://geojson.org/schema/Point.json",
31+
"MULTIPOINT": "https://geojson.org/schema/MultiPoint.json",
32+
"LINESTRING": "https://geojson.org/schema/LineString.json",
33+
"MULTILINESTRING": "https://geojson.org/schema/MultiLineString.json",
34+
"POLYGON": "https://geojson.org/schema/Polygon.json",
35+
"MULTIPOLYGON": "https://geojson.org/schema/MultiPolygon.json",
36+
"GEOMETRYCOLLECTION": "https://geojson.org/schema/GeometryCollection.json",
37+
}
38+
2739

2840
class CollectionLayer(BaseModel, metaclass=abc.ABCMeta):
2941
"""Layer's Abstract BaseClass.
@@ -69,6 +81,11 @@ async def feature(
6981
"""Return a Feature."""
7082
...
7183

84+
@property
85+
def queryables(self) -> Dict:
86+
"""Return the queryables."""
87+
...
88+
7289

7390
class Table(CollectionLayer, DBTable):
7491
"""Table Reader.
@@ -412,6 +429,21 @@ async def feature(
412429

413430
return None
414431

432+
@property
433+
def queryables(self) -> Dict:
434+
"""Return the queryables."""
435+
geometries = self.geometry_columns or []
436+
geoms = {
437+
col.name: {"$ref": geojson_schema.get(col.geometry_type.upper(), "")}
438+
for col in geometries
439+
}
440+
props = {
441+
col.name: {"name": col.name, "type": col.json_type}
442+
for col in self.properties
443+
if col.name not in geoms
444+
}
445+
return {**geoms, **props}
446+
415447

416448
class Function(CollectionLayer):
417449
"""Function Reader.
@@ -475,6 +507,12 @@ async def feature(
475507
# TODO
476508
pass
477509

510+
@property
511+
def queryables(self) -> Dict:
512+
"""Return the queryables."""
513+
# TODO
514+
pass
515+
478516

479517
@dataclass
480518
class FunctionRegistry:

0 commit comments

Comments
 (0)