Skip to content

Commit

Permalink
[SDESK-7381] Support source and parent async resources (#2694)
Browse files Browse the repository at this point in the history
* Move common types to superdesk.core.types

* support default sort resource config

* improve: add `to_list` to ResourceCursorAsync

* improve: allow kwargs to AsyncResourceService.find

* improve: support web endpoint with empty args

* add/fix tests

* update docs

* fix mypy error

* support source attribute

* support parent links in REST endpoints

* improve core test performance

* add tests

* fix pytest-asyncio deprecation

* update docs

* run black

* rename resource_parent_links file

* run black, pass version=None when getting parent item
  • Loading branch information
MarkLark86 authored Sep 16, 2024
1 parent 840e7b3 commit 8d0f4c8
Show file tree
Hide file tree
Showing 16 changed files with 643 additions and 34 deletions.
138 changes: 137 additions & 1 deletion docs/core/web.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,138 @@ For example::
rest_endpoints=RestEndpointConfig(),
)

module = Module(name="tests.users")
module = Module(
name="tests.users",
resources=[user_resource_config],
)


Resource REST Endpoint with Parent
----------------------------------

REST endpoints can also include a parent/child relationship with the resource. This is achieved using the
:class:`RestParentLink <superdesk.core.resources.resource_rest_endpoints.RestParentLink>`
attribute on the RestEndpointConfig.

Example config::

from typing import Annotated
from superdesk.core.module import Module
from superdesk.core.resources import (
ResourceConfig,
ResourceModel,
RestEndpointConfig,
RestParentLink,
)
from superdesk.core.resources.validators import (
validate_data_relation_async,
)

# 1. Define parent resource and config
class Company(ResourceModel):
name: str

company_resource_config = ResourceConfig(
name="companies",
data_class=Company,
rest_endpoints=RestEndpointConfig()
)

# 2. Define child resource and config
class User(ResourceModel):
first_name: str
last_name: str

# 2a. Include a field that references the parent
company: Annotated[
str,
validate_data_relation_async(
company_resource_config.name,
),
]

user_resource_config = ResourceConfig(
name="users",
data_class=User,
rest_endpoints=RestEndpointConfig(

# 2b. Include a link to Company as a parent resource
parent_links=[
RestParentLink(
resource_name=company_resource_config.name,
model_id_field="company",
),
],
),
)

# 3. Register the resources with a module
module = Module(
name="tests.users",
resources=[
company_resource_config,
user_resource_config,
],
)


The above example exposes the following URLs:

* /api/companies
* /api/companies/``<item_id>``
* /api/companies/``<company>``/users
* /api/companies/``<company>``/users/``<item_id>``

As you can see the ``users`` endpoints are prefixed with ``/api/company/<company>/``.

This provides the following functionality:

* Validation that a Company must exist for the user
* Populates the ``company`` field of a User with the ID from the URL
* When searching for users, will only provide users for the specific company provided in the URL of the request

For example::

async def test_users():
# Create the parent Company
response = await client.post(
"/api/company",
json={"name": "Sourcefabric"}
)

# Retrieve the Company ID from the response
company_id = (await response.get_json())[0]

# Attemps to create a user with non-existing company
# responds with a 404 - NotFound error
response = await client.post(
f"/api/company/blah_blah/users",
json={"first_name": "Monkey", "last_name": "Mania"}
)
assert response.status_code == 404

# Create the new User
# Notice the ``company_id`` is used in the URL
response = await client.post(
f"/api/company/{company_id}/users",
json={"first_name": "Monkey", "last_name": "Mania"}
)
user_id = (await response.get_json())[0]

# Retrieve the new user
response = await client.get(
f"/api/company/{company_id}/users/{user_id}"
)
user_dict = await response.get_json()
assert user_dict["company"] == company_id

# Retrieve all company users
response = await client.get(
f"/api/company/{company_id}/users"
)
users_dict = (await response.get_json())["_items"]
assert len(users_dict) == 1
assert users_dict[0]["_id"] == user_id


Validation
Expand Down Expand Up @@ -240,6 +371,11 @@ API References
:members:
:undoc-members:

.. autoclass:: superdesk.core.resources.resource_rest_endpoints.RestParentLink
:member-order: bysource
:members:
:undoc-members:

.. autoclass:: superdesk.core.resources.resource_rest_endpoints.ResourceRestEndpoints
:member-order: bysource
:members:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ exclude = '''
testpaths = ["tests", "superdesk", "apps", "content_api"]
python_files = "*_test.py *_tests.py test_*.py tests_*.py tests.py test.py"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
7 changes: 4 additions & 3 deletions superdesk/core/elastic/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ def register_resource_config(
client_config = ElasticClientConfig.create_from_dict(
self.app.wsgi.config, prefix=resource_config.prefix or "ELASTICSEARCH", freeze=False
)
client_config.index += f"_{resource_name}"
source_name = self.app.resources.get_config(resource_name).datasource_name or resource_name
client_config.index += f"_{source_name}"
client_config.set_frozen(True)

self._resource_clients[resource_name] = ElasticResourceClient(resource_name, client_config, resource_config)
self._resource_clients[resource_name] = ElasticResourceClient(source_name, client_config, resource_config)
self._resource_async_clients[resource_name] = ElasticResourceAsyncClient(
resource_name, client_config, resource_config
source_name, client_config, resource_config
)

def get_client(self, resource_name) -> ElasticResourceClient:
Expand Down
7 changes: 4 additions & 3 deletions superdesk/core/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ def get_all_resource_configs(self) -> Dict[str, MongoResourceConfig]:
return deepcopy(self._resource_configs)

def get_collection_name(self, resource_name: str, versioning: bool = False) -> str:
return resource_name if not versioning else f"{resource_name}_versions"
source_name = self.app.resources.get_config(resource_name).datasource_name or resource_name
return source_name if not versioning else f"{source_name}_versions"

def reset_all_async_connections(self):
for client, _db in self._mongo_clients_async.values():
Expand Down Expand Up @@ -265,7 +266,7 @@ def get_client(self, resource_name: str, versioning: bool = False) -> Tuple[Mong
if not self._mongo_clients.get(mongo_config.prefix):
client_config, dbname = get_mongo_client_config(self.app.wsgi.config, mongo_config.prefix)
client: MongoClient = MongoClient(**client_config)
db = client.get_database(self.get_collection_name(dbname, versioning))
db = client.get_database(dbname if not versioning else f"{dbname}_versions")
self._mongo_clients[mongo_config.prefix] = (client, db)

return self._mongo_clients[mongo_config.prefix]
Expand Down Expand Up @@ -388,7 +389,7 @@ def get_client_async(
if not self._mongo_clients_async.get(mongo_config.prefix):
client_config, dbname = get_mongo_client_config(self.app.wsgi.config, mongo_config.prefix)
client = AsyncIOMotorClient(**client_config)
db = client.get_database(self.get_collection_name(dbname, versioning))
db = client.get_database(dbname if not versioning else f"{dbname}_versions")
self._mongo_clients_async[mongo_config.prefix] = (client, db)

return self._mongo_clients_async[mongo_config.prefix]
Expand Down
4 changes: 3 additions & 1 deletion superdesk/core/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# at https://www.sourcefabric.org/superdesk/license

from .model import Resources, ResourceModel, ResourceModelWithObjectId, ModelWithVersions, ResourceConfig, dataclass
from .resource_rest_endpoints import RestEndpointConfig
from .resource_rest_endpoints import RestEndpointConfig, RestParentLink, get_id_url_type
from .service import AsyncResourceService, AsyncCacheableService
from ..mongo import MongoResourceConfig, MongoIndexOptions
from ..elastic.resources import ElasticResourceConfig
Expand All @@ -23,6 +23,8 @@
"dataclass",
"fields",
"RestEndpointConfig",
"RestParentLink",
"get_id_url_type",
"AsyncResourceService",
"AsyncCacheableService",
"MongoResourceConfig",
Expand Down
5 changes: 5 additions & 0 deletions superdesk/core/resources/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ class ResourceConfig:
#: Optional sorting for this resource
default_sort: SortListParam | None = None

#: Optionally override the name used for the MongoDB/Elastic sources
datasource_name: str | None = None


class Resources:
"""A high level resource class used to manage all resources in the system"""
Expand Down Expand Up @@ -295,6 +298,8 @@ def register(self, config: ResourceConfig):
self._resource_configs[config.name] = config

config.data_class.model_resource_name = config.name
if not config.datasource_name:
config.datasource_name = config.name

mongo_config = config.mongo or MongoResourceConfig()
if config.versioning:
Expand Down
Loading

0 comments on commit 8d0f4c8

Please sign in to comment.