Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDESK-7381] Support source and parent async resources #2694

Merged
merged 19 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting discussion about this setting pytest-dev/pytest-asyncio#924

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I read that, seems silly to me!

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
Loading