Skip to content

Commit cde4cfa

Browse files
committed
Implement content_api token resource and service
SDESK-7484
1 parent 4d3f401 commit cde4cfa

File tree

12 files changed

+206
-25
lines changed

12 files changed

+206
-25
lines changed

content_api/tokens/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ def check_auth(self, token, allowed_roles, resource, method):
4545

4646

4747
def init_app(app) -> None:
48-
superdesk.register_resource(TOKEN_RESOURCE, AuthSubscriberTokenResource, SubscriberTokenService, _app=app)
48+
# superdesk.register_resource(TOKEN_RESOURCE, AuthSubscriberTokenResource, SubscriberTokenService, _app=app)
49+
pass

superdesk/core/auth/token_auth.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async def authenticate(self, request: Request):
3232

3333
if token:
3434
token = token.strip()
35-
if token.lower().startswith(("token", "bearer")):
35+
if token.lower().startswith(("token", "bearer", "basic")):
3636
token = token.split(" ")[1] if " " in token else ""
3737
else:
3838
token = request.storage.session.get("session_token")
@@ -79,13 +79,13 @@ async def start_session(self, request: Request, user: dict[str, Any], **kwargs)
7979
async def continue_session(self, request: Request, user: dict[str, Any], **kwargs) -> None:
8080
auth_token = request.storage.session.get("session_token")
8181

82-
if isinstance(auth_token, str):
83-
auth_token = json.loads(auth_token)
84-
8582
if not auth_token:
8683
await self.stop_session(request)
8784
raise SuperdeskApiError.unauthorizedError()
8885

86+
if isinstance(auth_token, str):
87+
auth_token = json.loads(auth_token)
88+
8989
user_service = get_resource_service("users")
9090
request.storage.request.set("user", user)
9191
request.storage.request.set("role", user_service.get_role(user))

superdesk/core/resources/resource_rest_endpoints.py

+76-8
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
# AUTHORS and LICENSE files distributed with this source code, or
99
# at https://www.sourcefabric.org/superdesk/license
1010

11-
from typing import List, Optional, cast, Dict, Any, Type
1211
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
1314

1415
from dataclasses import dataclass
1516
from pydantic import ValidationError, BaseModel, NonNegativeInt
@@ -19,7 +20,7 @@
1920
from bson import ObjectId
2021

2122
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
2324
from superdesk.core.types import (
2425
SearchRequest,
2526
SearchArgs,
@@ -33,11 +34,11 @@
3334
from superdesk.errors import SuperdeskApiError
3435
from superdesk.resource_fields import STATUS, STATUS_OK, ITEMS
3536
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
3738

3839
from .model import ResourceModel
3940
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
4142

4243

4344
@dataclass
@@ -90,6 +91,9 @@ class RestEndpointConfig:
9091

9192
enable_cors: bool = False
9293

94+
# Optionally populate the item nested hateoas
95+
populate_item_hateoas: bool = False
96+
9397

9498
def get_id_url_type(data_class: type[ResourceModel]) -> str:
9599
"""Get the URL param type for the ID field for route registration"""
@@ -297,7 +301,9 @@ async def get_item(
297301
f"{self.resource_config.name} resource with ID '{args.item_id}' not found"
298302
)
299303

304+
self._populate_hateoas_if_needed(request, [item])
300305
response = Response(item, 200, headers)
306+
301307
await signals.web.on_get_response.send(request, response)
302308
return response
303309

@@ -447,8 +453,11 @@ async def search_items(
447453
cursor = await self.service.find(params)
448454
count = await cursor.count()
449455

456+
items = await cursor.to_list_raw()
457+
self._populate_hateoas_if_needed(request, items)
458+
450459
response_data = RestGetResponse(
451-
_items=await cursor.to_list_raw(),
460+
_items=items,
452461
_meta=dict(
453462
page=params.page,
454463
max_results=params.max_results if params.max_results is not None else 25,
@@ -483,7 +492,7 @@ def get_item_cors_headers(self):
483492
if not self.endpoint_config.enable_cors:
484493
return []
485494

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"]
487496
return get_cors_headers(", ".join(methods))
488497

489498
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],
563572

564573
return links
565574

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]:
567590
item.setdefault("_links", {})
568591
item["_links"]["self"] = {
569592
"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),
571594
}
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+
572601
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)

superdesk/core/resources/service.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from superdesk.utc import utcnow
4040
from superdesk.cache import cache
4141
from superdesk.errors import SuperdeskApiError
42-
from superdesk.json_utils import SuperdeskJSONEncoder
42+
from superdesk.json_utils import SuperdeskJSONEncoder, cast_item
4343
from superdesk.resource_fields import ID_FIELD, VERSION_ID_FIELD, CURRENT_VERSION, LATEST_VERSION
4444

4545
from ..app import SuperdeskAsyncApp, get_current_async_app
@@ -691,6 +691,8 @@ async def _mongo_find(
691691
kwargs["sort"] = sort
692692

693693
where = json.loads(req.where or "{}") if isinstance(req.where, str) else req.where or {}
694+
where = cast_item(where)
695+
694696
kwargs["filter"] = where
695697

696698
projection_include, projection_fields = get_projection_from_request(req)

superdesk/core/resources/validators.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,12 @@ def _validate_maxlength(value: MinMaxValueType) -> MinMaxValueType:
106106
class AsyncValidator:
107107
func: Callable[["ResourceModel", Any], Awaitable[None]]
108108

109-
def __init__(self, func: Callable[["ResourceModel", Any], Awaitable[None]]):
109+
# to create links with the related resource
110+
resource_name: str | None = None
111+
112+
def __init__(self, func: Callable[["ResourceModel", Any], Awaitable[None]], resource_name: str | None = None):
110113
self.func = func
114+
self.resource_name = resource_name
111115

112116

113117
DataRelationValueType = str | ObjectId | list[str] | list[ObjectId] | None
@@ -168,7 +172,7 @@ async def validate_resource_exists(item: ResourceModel, item_id: DataRelationVal
168172
),
169173
)
170174

171-
return AsyncValidator(validate_resource_exists)
175+
return AsyncValidator(validate_resource_exists, resource_name)
172176

173177

174178
UniqueValueType = str | list[str] | None

superdesk/core/web/rest_endpoints.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,12 @@ def get_item_url(self, arg_name: str = "item_id") -> str:
126126

127127
return f"{self.get_resource_url()}/<{self.id_param_type}:{arg_name}>"
128128

129-
def gen_url_for_item(self, request: Request, item_id: str) -> str:
130-
"""Ge the URL of an item of this resource, for HATEOAS self link
129+
def gen_url_for_item(self, request: Request, item_id: str, force_append_id: bool = False) -> str:
130+
"""Get the URL of an item of this resource, for HATEOAS self link
131131
132132
:param request: The request instance currently being processed
133133
:param item_id: The ID of the item being returned
134+
:param force_append_id: If True, appends the `item_id` to the path if doesn't end with it
134135
:return: The URL of an item of this resource
135136
"""
136137

@@ -149,7 +150,10 @@ def gen_url_for_item(self, request: Request, item_id: str) -> str:
149150
if url_prefix and path.startswith(url_prefix):
150151
path = path[len(url_prefix) :]
151152

152-
return f"{path}/{item_id}" if request.method == "POST" else path
153+
if (request.method == "POST" or force_append_id) and not path.endswith(item_id):
154+
return f"{path}/{item_id}"
155+
156+
return path
153157

154158
async def get_item(
155159
self,

superdesk/default_settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ def local_to_utc_hour(hour):
418418
#:
419419
#: ..versionadded: 3.0.0
420420
#:
421-
MODULES = ["superdesk.users", "apps.desks_async"]
421+
MODULES = ["superdesk.users", "apps.desks_async", "superdesk.publish.subscriber_token"]
422422

423423
ASYNC_AUTH_CLASS = "superdesk.core.auth.token_auth:TokenAuthorization"
424424

superdesk/factory/app.py

-1
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,6 @@ def setup_sentry(self):
416416

417417
sentry_sdk.init(dsn=self.config["SENTRY_DSN"], integrations=[QuartIntegration(), AsyncioIntegration()])
418418

419-
420419
def extend_eve_home_endpoint(self, links: list[dict]) -> None:
421420
"""Adds async resources to Eve's api root endpoint"""
422421

superdesk/publish/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def transmit():
8484
# must be imported for registration
8585
from superdesk.publish.subscribers import SubscribersResource, SubscribersService # NOQA
8686
from superdesk.publish.publish_queue import PublishQueueResource, PublishQueueService # NOQA
87+
8788
from superdesk.publish.subscriber_token import SubscriberTokenResource, SubscriberTokenService # NOQA
8889

8990

@@ -103,7 +104,7 @@ def init_app(app) -> None:
103104
service = PublishQueueService(endpoint_name, backend=get_backend())
104105
PublishQueueResource(endpoint_name, app=app, service=service)
105106

106-
superdesk.register_resource("subscriber_token", SubscriberTokenResource, SubscriberTokenService)
107+
# superdesk.register_resource("subscriber_token", SubscriberTokenResource, SubscriberTokenService)
107108

108109
app.client_config.update(
109110
{

superdesk/publish/subscriber_token.py

+61-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import superdesk
2+
from pydantic import Field
3+
from typing import Annotated
4+
from datetime import timedelta, datetime
25

3-
from datetime import timedelta
6+
from content_api import MONGO_PREFIX
7+
from superdesk.core.module import Module
48
from superdesk.utc import utcnow
59
from superdesk.utils import get_random_token
6-
from content_api import MONGO_PREFIX
10+
from superdesk.core.auth.privilege_rules import http_method_privilege_based_rules
11+
from superdesk.core.resources import (
12+
ResourceModel,
13+
fields,
14+
AsyncResourceService,
15+
ResourceConfig,
16+
RestEndpointConfig,
17+
)
18+
from superdesk.core.resources.validators import validate_data_relation_async
719

820

921
class SubscriberTokenResource(superdesk.Resource):
@@ -33,3 +45,50 @@ def create(self, docs, **kwargs):
3345
if doc.get("expiry_days"):
3446
doc.setdefault("expiry", utcnow() + timedelta(days=doc["expiry_days"]))
3547
return super().create(docs, **kwargs)
48+
49+
50+
class SubscriberToken(ResourceModel):
51+
id: Annotated[str, Field(alias="_id", default_factory=get_random_token)]
52+
expiry: datetime | None = None
53+
expiry_days: int | None = None
54+
subscriber: Annotated[fields.ObjectId, validate_data_relation_async("subscribers")]
55+
56+
57+
class AsyncSubscriberTokenService(AsyncResourceService[SubscriberToken]):
58+
async def on_create(self, docs: list[SubscriberToken]) -> None:
59+
"""
60+
Calculates and sets expiry dates based on expiry_days param.
61+
62+
Args:
63+
docs: List of subscriber token models to be created
64+
"""
65+
66+
for doc in docs:
67+
if doc.expiry_days:
68+
doc.expiry = utcnow() + timedelta(days=doc.expiry_days)
69+
70+
await super().on_create(docs)
71+
72+
73+
resource_config = ResourceConfig(
74+
name="subscriber_token",
75+
data_class=SubscriberToken,
76+
service=AsyncSubscriberTokenService,
77+
default_sort=[("_created", 1)],
78+
rest_endpoints=RestEndpointConfig(
79+
resource_methods=["GET", "POST"],
80+
item_methods=["GET", "DELETE"],
81+
auth=http_method_privilege_based_rules(
82+
{
83+
"POST": "subscribers",
84+
"DELETE": "subscribers",
85+
}
86+
),
87+
id_param_type="regex('.+')",
88+
populate_item_hateoas=True,
89+
enable_cors=True,
90+
),
91+
)
92+
93+
94+
module = Module("superdesk.publish.subscriber_token", resources=[resource_config])

superdesk/utils.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def get_random_token(n=40):
114114
115115
:param n: how many random bytes to generate
116116
"""
117-
return base64.b64encode(os.urandom(n)).decode()
117+
return base64.urlsafe_b64encode(os.urandom(n)).decode()
118118

119119

120120
def import_by_path(path):
@@ -404,3 +404,13 @@ def flatten(nested_list) -> list:
404404
else:
405405
result.append(i)
406406
return result
407+
408+
409+
def join_url_parts(*parts) -> str:
410+
"""
411+
Join a list of URL parts together, ensuring each part is a valid URL segment
412+
413+
:param parts: The parts of the URL to join
414+
:return: A string of the joined URL
415+
"""
416+
return "/".join(str(part).strip("/") for part in parts if part)

0 commit comments

Comments
 (0)