|
8 | 8 | # AUTHORS and LICENSE files distributed with this source code, or
|
9 | 9 | # at https://www.sourcefabric.org/superdesk/license
|
10 | 10 |
|
11 |
| -from typing import List, Optional, cast, Dict, Any, Type |
12 | 11 | import math
|
| 12 | +from inspect import get_annotations |
| 13 | +from typing import Annotated, List, Optional, cast, Dict, Any, Type, get_args, get_origin, get_type_hints |
13 | 14 |
|
14 | 15 | from dataclasses import dataclass
|
15 | 16 | from pydantic import ValidationError, BaseModel, NonNegativeInt
|
|
19 | 20 | from bson import ObjectId
|
20 | 21 |
|
21 | 22 | from superdesk.core import json
|
22 |
| -from superdesk.core.app import get_current_async_app |
| 23 | +from superdesk.core.app import get_app_config, get_current_async_app |
23 | 24 | from superdesk.core.types import (
|
24 | 25 | SearchRequest,
|
25 | 26 | SearchArgs,
|
|
33 | 34 | from superdesk.errors import SuperdeskApiError
|
34 | 35 | from superdesk.resource_fields import STATUS, STATUS_OK, ITEMS
|
35 | 36 | from superdesk.core.web import RestEndpoints, ItemRequestViewArgs, Endpoint
|
36 |
| -from superdesk.utils import get_cors_headers |
| 37 | +from superdesk.utils import get_cors_headers, join_url_parts |
37 | 38 |
|
38 | 39 | from .model import ResourceModel
|
39 | 40 | from .resource_config import ResourceConfig
|
40 |
| -from .validators import convert_pydantic_validation_error_for_response |
| 41 | +from .validators import AsyncValidator, convert_pydantic_validation_error_for_response |
41 | 42 |
|
42 | 43 |
|
43 | 44 | @dataclass
|
@@ -90,6 +91,9 @@ class RestEndpointConfig:
|
90 | 91 |
|
91 | 92 | enable_cors: bool = False
|
92 | 93 |
|
| 94 | + # Optionally populate the item nested hateoas |
| 95 | + populate_item_hateoas: bool = False |
| 96 | + |
93 | 97 |
|
94 | 98 | def get_id_url_type(data_class: type[ResourceModel]) -> str:
|
95 | 99 | """Get the URL param type for the ID field for route registration"""
|
@@ -297,7 +301,9 @@ async def get_item(
|
297 | 301 | f"{self.resource_config.name} resource with ID '{args.item_id}' not found"
|
298 | 302 | )
|
299 | 303 |
|
| 304 | + self._populate_hateoas_if_needed(request, [item]) |
300 | 305 | response = Response(item, 200, headers)
|
| 306 | + |
301 | 307 | await signals.web.on_get_response.send(request, response)
|
302 | 308 | return response
|
303 | 309 |
|
@@ -447,8 +453,11 @@ async def search_items(
|
447 | 453 | cursor = await self.service.find(params)
|
448 | 454 | count = await cursor.count()
|
449 | 455 |
|
| 456 | + items = await cursor.to_list_raw() |
| 457 | + self._populate_hateoas_if_needed(request, items) |
| 458 | + |
450 | 459 | response_data = RestGetResponse(
|
451 |
| - _items=await cursor.to_list_raw(), |
| 460 | + _items=items, |
452 | 461 | _meta=dict(
|
453 | 462 | page=params.page,
|
454 | 463 | max_results=params.max_results if params.max_results is not None else 25,
|
@@ -483,7 +492,7 @@ def get_item_cors_headers(self):
|
483 | 492 | if not self.endpoint_config.enable_cors:
|
484 | 493 | return []
|
485 | 494 |
|
486 |
| - methods = (self.endpoint_config.resource_methods or ["GET", "PATCH", "DELETE"]) + ["OPTIONS", "HEAD"] |
| 495 | + methods = (self.endpoint_config.item_methods or ["GET", "PATCH", "DELETE"]) + ["OPTIONS", "HEAD"] |
487 | 496 | return get_cors_headers(", ".join(methods))
|
488 | 497 |
|
489 | 498 | def _build_resource_hateoas(self, req: SearchRequest, doc_count: Optional[int], request: Request) -> Dict[str, Any]:
|
@@ -563,10 +572,69 @@ def _build_resource_hateoas(self, req: SearchRequest, doc_count: Optional[int],
|
563 | 572 |
|
564 | 573 | return links
|
565 | 574 |
|
566 |
| - def _populate_item_hateoas(self, request: Request, item: dict[str, Any]) -> dict[str, Any]: |
| 575 | + def _populate_hateoas_if_needed(self, request: Request, items: list[dict[str, Any]]): |
| 576 | + """ |
| 577 | + Populate the hateoas information for a list of items only if the |
| 578 | + endpoint config `populate_item_hateoas` flag is set to True |
| 579 | + """ |
| 580 | + |
| 581 | + if not self.endpoint_config.populate_item_hateoas: |
| 582 | + return |
| 583 | + |
| 584 | + for item in items: |
| 585 | + self._populate_item_hateoas(request, item, force_append_id=True) |
| 586 | + |
| 587 | + def _populate_item_hateoas( |
| 588 | + self, request: Request, item: dict[str, Any], force_append_id: bool = False |
| 589 | + ) -> dict[str, Any]: |
567 | 590 | item.setdefault("_links", {})
|
568 | 591 | item["_links"]["self"] = {
|
569 | 592 | "title": self.resource_config.title or self.resource_config.data_class.__name__,
|
570 |
| - "href": self.gen_url_for_item(request, item["_id"]), |
| 593 | + "href": self.gen_url_for_item(request, item["_id"], force_append_id), |
571 | 594 | }
|
| 595 | + |
| 596 | + # extract related links from the `resource_config.data_class` relations (annotations) |
| 597 | + related_links = self._get_related_links_from_annotations(item) |
| 598 | + if related_links: |
| 599 | + item["_links"]["related"] = related_links |
| 600 | + |
572 | 601 | return item
|
| 602 | + |
| 603 | + def _get_related_links_from_annotations(self, item: dict[str, Any]) -> dict[str, Any]: |
| 604 | + """Extract related links from the `resource_config.data_class` annotations. |
| 605 | +
|
| 606 | + Examines the data class annotations for Annotated fields that contain AsyncValidator metadata. |
| 607 | + For each field with a resource name specified in its validator, generates a related link |
| 608 | + if the field has a value in the item. |
| 609 | + """ |
| 610 | + related_links = {} |
| 611 | + for field_name, annotation in get_annotations(self.resource_config.data_class).items(): |
| 612 | + if get_origin(annotation) is Annotated: |
| 613 | + relations = [meta for meta in get_args(annotation) if isinstance(meta, AsyncValidator)] |
| 614 | + for rel in relations: |
| 615 | + field_value = item.get(field_name) |
| 616 | + if field_value and rel.resource_name: |
| 617 | + related_links[field_name] = { |
| 618 | + "title": field_name.title(), |
| 619 | + "href": self._gen_url_for_related_resource(rel.resource_name, field_value), |
| 620 | + } |
| 621 | + return related_links |
| 622 | + |
| 623 | + def _gen_url_for_related_resource(self, resource_name: str, item_id: str) -> str: |
| 624 | + """Generate a URL for a related resource.""" |
| 625 | + |
| 626 | + # TODO-ASYNC: default to resource_name as not all resources are async ready yet |
| 627 | + resource_url = resource_name |
| 628 | + |
| 629 | + try: |
| 630 | + app = get_current_async_app() |
| 631 | + resource_config = app.resources.get_config(resource_name) |
| 632 | + if resource_config.rest_endpoints is not None: |
| 633 | + resource_url = resource_config.rest_endpoints.url or resource_name |
| 634 | + except KeyError: |
| 635 | + pass |
| 636 | + |
| 637 | + url_prefix = get_app_config("URL_PREFIX") or "" |
| 638 | + api_version = get_app_config("API_VERSION") or "" |
| 639 | + |
| 640 | + return join_url_parts(url_prefix, api_version, resource_url, item_id) |
0 commit comments