|
1 | 1 | """Catalogs extension.""" |
2 | 2 |
|
3 | 3 | import logging |
4 | | -from typing import List, Optional, Type |
5 | | -from urllib.parse import urlencode |
| 4 | +from typing import Any, Dict, List, Optional, Type |
| 5 | +from urllib.parse import parse_qs, urlencode, urlparse |
6 | 6 |
|
7 | 7 | import attr |
8 | 8 | from fastapi import APIRouter, FastAPI, HTTPException, Query, Request |
@@ -42,7 +42,9 @@ class CatalogsExtension(ApiExtension): |
42 | 42 |
|
43 | 43 | client: BaseCoreClient = attr.ib(default=None) |
44 | 44 | settings: dict = attr.ib(default=attr.Factory(dict)) |
45 | | - conformance_classes: List[str] = attr.ib(default=attr.Factory(list)) |
| 45 | + conformance_classes: List[str] = attr.ib( |
| 46 | + default=attr.Factory(lambda: ["https://api.stacspec.org/v1.0.0-rc.2/children"]) |
| 47 | + ) |
46 | 48 | router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) |
47 | 49 | response_class: Type[Response] = attr.ib(default=JSONResponse) |
48 | 50 |
|
@@ -176,6 +178,17 @@ def register(self, app: FastAPI, settings=None) -> None: |
176 | 178 | tags=["Catalogs"], |
177 | 179 | ) |
178 | 180 |
|
| 181 | + # Add endpoint for Children Extension |
| 182 | + self.router.add_api_route( |
| 183 | + path="/catalogs/{catalog_id}/children", |
| 184 | + endpoint=self.get_catalog_children, |
| 185 | + methods=["GET"], |
| 186 | + response_class=self.response_class, |
| 187 | + summary="Get Catalog Children", |
| 188 | + description="Retrieve all children (Catalogs and Collections) of this catalog.", |
| 189 | + tags=["Catalogs"], |
| 190 | + ) |
| 191 | + |
179 | 192 | app.include_router(self.router, tags=["Catalogs"]) |
180 | 193 |
|
181 | 194 | async def catalogs( |
@@ -852,6 +865,142 @@ async def get_catalog_collection_item( |
852 | 865 | item_id=item_id, collection_id=collection_id, request=request |
853 | 866 | ) |
854 | 867 |
|
| 868 | + async def get_catalog_children( |
| 869 | + self, |
| 870 | + catalog_id: str, |
| 871 | + request: Request, |
| 872 | + limit: int = 10, |
| 873 | + token: str = None, |
| 874 | + type: Optional[str] = Query( |
| 875 | + None, description="Filter by resource type (Catalog or Collection)" |
| 876 | + ), |
| 877 | + ) -> Dict[str, Any]: |
| 878 | + """ |
| 879 | + Get all children (Catalogs and Collections) of a specific catalog. |
| 880 | +
|
| 881 | + This is a 'Union' endpoint that returns mixed content types. |
| 882 | + """ |
| 883 | + # 1. Verify the parent catalog exists |
| 884 | + await self.client.database.find_catalog(catalog_id) |
| 885 | + |
| 886 | + # 2. Build the Search Query |
| 887 | + # We search the COLLECTIONS_INDEX because it holds both Catalogs and Collections |
| 888 | + |
| 889 | + # Base filter: Parent match |
| 890 | + # This finds anything where 'parent_ids' contains this catalog_id |
| 891 | + filter_queries = [{"term": {"parent_ids": catalog_id}}] |
| 892 | + |
| 893 | + # Optional filter: Type |
| 894 | + if type: |
| 895 | + # If user asks for ?type=Catalog, we only return Catalogs |
| 896 | + filter_queries.append({"term": {"type": type}}) |
| 897 | + |
| 898 | + # 3. Calculate Pagination (Search After) |
| 899 | + body = { |
| 900 | + "query": {"bool": {"filter": filter_queries}}, |
| 901 | + "sort": [{"id": {"order": "asc"}}], # Stable sort for pagination |
| 902 | + "size": limit, |
| 903 | + } |
| 904 | + |
| 905 | + # Handle search_after token - split by '|' to get all sort values |
| 906 | + search_after: Optional[List[str]] = None |
| 907 | + if token: |
| 908 | + try: |
| 909 | + # The token should be a pipe-separated string of sort values |
| 910 | + # e.g., "collection-1" |
| 911 | + from typing import cast |
| 912 | + |
| 913 | + search_after_parts = cast(List[str], token.split("|")) |
| 914 | + # If the number of sort fields doesn't match token parts, ignore the token |
| 915 | + if len(search_after_parts) != len(body["sort"]): # type: ignore |
| 916 | + search_after = None |
| 917 | + else: |
| 918 | + search_after = search_after_parts |
| 919 | + except Exception: |
| 920 | + search_after = None |
| 921 | + |
| 922 | + if search_after is not None: |
| 923 | + body["search_after"] = search_after |
| 924 | + |
| 925 | + # 4. Execute Search |
| 926 | + search_result = await self.client.database.client.search( |
| 927 | + index=COLLECTIONS_INDEX, body=body |
| 928 | + ) |
| 929 | + |
| 930 | + # 5. Process Results |
| 931 | + hits = search_result.get("hits", {}).get("hits", []) |
| 932 | + total = search_result.get("hits", {}).get("total", {}).get("value", 0) |
| 933 | + |
| 934 | + children = [] |
| 935 | + for hit in hits: |
| 936 | + doc = hit["_source"] |
| 937 | + resource_type = doc.get( |
| 938 | + "type", "Collection" |
| 939 | + ) # Default to Collection if missing |
| 940 | + |
| 941 | + # Serialize based on type |
| 942 | + # This ensures we hide internal fields like 'parent_ids' correctly |
| 943 | + if resource_type == "Catalog": |
| 944 | + child = self.client.catalog_serializer.db_to_stac(doc, request) |
| 945 | + else: |
| 946 | + child = self.client.collection_serializer.db_to_stac(doc, request) |
| 947 | + |
| 948 | + children.append(child) |
| 949 | + |
| 950 | + # 6. Format Response |
| 951 | + # The Children extension uses a specific response format |
| 952 | + response = { |
| 953 | + "children": children, |
| 954 | + "links": [ |
| 955 | + {"rel": "self", "type": "application/json", "href": str(request.url)}, |
| 956 | + { |
| 957 | + "rel": "root", |
| 958 | + "type": "application/json", |
| 959 | + "href": str(request.base_url), |
| 960 | + }, |
| 961 | + { |
| 962 | + "rel": "parent", |
| 963 | + "type": "application/json", |
| 964 | + "href": f"{str(request.base_url)}catalogs/{catalog_id}", |
| 965 | + }, |
| 966 | + ], |
| 967 | + "numberReturned": len(children), |
| 968 | + "numberMatched": total, |
| 969 | + } |
| 970 | + |
| 971 | + # 7. Generate Next Link |
| 972 | + next_token = None |
| 973 | + if len(hits) == limit: |
| 974 | + next_token_values = hits[-1].get("sort") |
| 975 | + if next_token_values: |
| 976 | + # Join all sort values with '|' to create the token |
| 977 | + next_token = "|".join(str(val) for val in next_token_values) |
| 978 | + |
| 979 | + if next_token: |
| 980 | + # Get existing query params |
| 981 | + parsed_url = urlparse(str(request.url)) |
| 982 | + params = parse_qs(parsed_url.query) |
| 983 | + |
| 984 | + # Update params |
| 985 | + params["token"] = [next_token] |
| 986 | + params["limit"] = [str(limit)] |
| 987 | + if type: |
| 988 | + params["type"] = [type] |
| 989 | + |
| 990 | + # Flatten params for urlencode (parse_qs returns lists) |
| 991 | + flat_params = { |
| 992 | + k: v[0] if isinstance(v, list) else v for k, v in params.items() |
| 993 | + } |
| 994 | + |
| 995 | + next_link = { |
| 996 | + "rel": "next", |
| 997 | + "type": "application/json", |
| 998 | + "href": f"{request.base_url}catalogs/{catalog_id}/children?{urlencode(flat_params)}", |
| 999 | + } |
| 1000 | + response["links"].append(next_link) |
| 1001 | + |
| 1002 | + return response |
| 1003 | + |
855 | 1004 | async def delete_catalog_collection( |
856 | 1005 | self, catalog_id: str, collection_id: str, request: Request |
857 | 1006 | ) -> None: |
|
0 commit comments