Skip to content

Commit

Permalink
510: Add documentation on overriding GraphQL classes (#511)
Browse files Browse the repository at this point in the history
* 510: Add documentation on overriding classes

* Update graphql documentation
  • Loading branch information
tjeerddie authored Feb 29, 2024
1 parent feee519 commit 69104bf
Showing 1 changed file with 260 additions and 0 deletions.
260 changes: 260 additions & 0 deletions docs/architecture/application/graphql.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
# GraphQL documentation

- [Extending the Query and Mutation](#extending-the-query-and-mutation)
- [Add Json schema for metadata](#add-json-schema-for-metadata)
- [Domain Models Auto Registration for GraphQL](#domain-models-auto-registration-for-graphql)
- [Scalars for Auto Registration](#scalars-for-auto-registration)
- [Federating with Autogenerated Types](#federating-with-autogenerated-types)
- [Federating with Specific Subscriptions](#federating-with-specific-subscriptions)
- [Federating with Specific Subscription Product Blocks](#federating-with-specific-subscription-product-blocks)
- [Overriding types](#overriding-types)
- [Overriding CustomerType and Resolvers](#overriding-customertype-and-resolvers)
- [CustomerType Override](#customertype-override)
- [CustomerType Resolver Override](#customertype-resolver-override)
- [CustomerType Related Type Overrides](#customertype-related-type-overrides)

OrchestratorCore comes with a graphql interface that can to be registered after you create your OrchestratorApp.
If you add it after registering your `SUBSCRIPTION_MODEL_REGISTRY` it will automatically create graphql types for them.
Expand Down Expand Up @@ -52,6 +58,7 @@ The metadata in a subscription is completely unrestricted and can have anything.
This functionality is to make metadata descriptive in a `__schema__` for the frontend to be able to render the metadata and know what to do with typing.

example how to update the `__schema__`:

```python
from orchestrator.graphql.schemas.subscription import MetadataDict

Expand Down Expand Up @@ -296,3 +303,256 @@ class YourProductBlock:
```

By following these examples, you can effectively federate autogenerated types (`subscriptions` and `product blocks`) enabling seamless integration across multiple GraphQL endpoints.

## Overriding Types

Overriding strawberry types can be achieved through various methods. One less desirable approach involves extending classes using class inheritance.
However, this method becomes cumbersome when updating a single class, as it necessitates updating all associated types and their corresponding resolvers, essentially impacting the entire structure.

For instance, consider the scenario of overriding the `CustomerType`. you would need to update the related `SubscriptionInterface`, `ProcessType` and their respective resolvers. due to these modifications, all their related types and resolvers would also require updates, resulting in a tedious and error-prone process.

To enhance the override process, we created a helper function `override_class` to override fields. It takes the base class as well as a list of fields that will replace their counterparts within the class or add new fields.

It's worth noting that `SubscriptionInterface` poses a unique challenge due to its auto-generated types. The issue arises from the fact that the models inherited from `SubscriptionInterface` do not automatically update. This can be addressed by utilizing the `override_class` function and incorporating the returned class into the `register_graphql` function. This ensures that the updated class, with overridden fields, becomes the basis for generating the auto-generated models.

```python
# Define a custom subscription interface using the `override_class` function, incorporating specified override fields.
custom_subscription_interface = override_class(SubscriptionInterface, override_fields)

# Register the customized subscription interface when setting up GraphQL in your application.
app.register_graphql(subscription_interface=custom_subscription_interface)
```

quick example (for more indebt check customerType override):

```python
import strawberry
from orchestrator.graphql.utils.override_class import override_class


# Define a strawberry type representing an example entity
@strawberry.type()
class ExampleType:
@strawberry.field(description="Existing field") # type: ignore
def existing(self) -> int:
return 1


# Define a strawberry type for example queries
@strawberry.type(description="Example queries")
class Query:
example: ExampleType = strawberry.field(resolver=lambda: ExampleType())


# Create a resolver for updating the existing field
async def update_existing_resolver() -> str:
return "updated to new type"


# Create a strawberry field with the resolver for the existing field
existing_field = strawberry.field(resolver=update_existing_resolver, description="update existing field") # type: ignore
# Assign a new name to the strawberry field; this name will override the existing field in the class
existing_field.name = "existing"


# Create a new field with a resolver
async def new_resolver() -> int:
return 1


new_field = strawberry.field(resolver=new_resolver, description="a new field") # type: ignore
# Assign a name that is not present in the class yet
new_field.name = "new"

# Use the override_class function to replace fields in the ExampleType
override_class(ExampleType, [new_field, existing_field])
```

### Overriding CustomerType and Resolvers

Within the orchestrator core, there exists a base `CustomerType` designed to provide a default customer, allowing for the customization of data through environment variables.
This approach minimizes the necessity for everyone to implement custom customer logic.

Below, I present an example illustrating how to override the `CustomerType` and its associated resolvers.

#### CustomerType Override

Here's a generic override for the `CustomerType` that introduces a new `subscriptions` relation:

```python
from typing import Annotated

import strawberry

from oauth2_lib.strawberry import authenticated_field
from orchestrator.graphql.pagination import Connection
from orchestrator.graphql.schemas.customer import CustomerType
from orchestrator.graphql.schemas.subscription import (
SubscriptionInterface,
) # noqa: F401
from orchestrator.graphql.types import GraphqlFilter, GraphqlSort, OrchestratorInfo
from orchestrator.graphql.utils.override_class import override_class

# Type annotation for better readability rather than having this directly as a return type
SubscriptionInterfaceType = Connection[
Annotated[
"SubscriptionInterface",
strawberry.lazy("orchestrator.graphql.schemas.subscription"),
]
]


# Resolver for fetching subscriptions of a customer
async def resolve_subscriptions(
root: CustomerType,
info: OrchestratorInfo,
filter_by: list[GraphqlFilter] | None = None,
sort_by: list[GraphqlSort] | None = None,
first: int = 10,
after: int = 0,
) -> SubscriptionInterfaceType:
from orchestrator.graphql.resolvers.subscription import resolve_subscriptions

# Include the filter for the customer ID; since 'customerId' exists in the subscription, filtering updates are not required.
filter_by_customer_id = (filter_by or []) + [GraphqlFilter(field="customerId", value=str(root.customer_id))] # type: ignore
return await resolve_subscriptions(
info, filter_by_customer_id, sort_by, first, after
)


# Create an authenticated field for customer subscriptions
customer_subscriptions_field = authenticated_field(
resolver=resolve_subscriptions, description="Returns subscriptions of a customer"
)
# Assign a new name to the strawberry field; this name will add the 'subscriptions' field in the class
customer_subscriptions_field.name = "subscriptions"

# Override the CustomerType with the new 'subscriptions' field
override_class(CustomerType, [customer_subscriptions_field])
```

#### CustomerType Resolver Override

In this example code, we introduce a resolver override for the `CustomerType`. The scenario involves a supplementary `CustomerTable` in the database, encompassing the default values of `CustomerType`—namely, `customer_id`, `fullname`, and `shortcode`.

```python
import structlog
from sqlalchemy import func, select

from orchestrator.db import db
from orchestrator.db.filters import Filter
from orchestrator.db.range.range import apply_range_to_statement
from orchestrator.db.sorting.sorting import Sort
from orchestrator.graphql.pagination import Connection
from orchestrator.graphql.resolvers.helpers import rows_from_statement
from orchestrator.graphql.schemas.customer import CustomerType
from orchestrator.graphql.types import GraphqlFilter, GraphqlSort, OrchestratorInfo
from orchestrator.graphql.utils.create_resolver_error_handler import (
create_resolver_error_handler,
)
from orchestrator.graphql.utils.to_graphql_result_page import to_graphql_result_page
from orchestrator.utils.search_query import create_sqlalchemy_select
from your_customer_table_location.db.models import CustomerTable

# # Import custom sorting and filtering modules used with `sort_by` and `filter_by`.
# from sort_loc import sort_customers, sort_customers_fields
# from filter_loc import filter_customers, filter_customers_fields

logger = structlog.get_logger(__name__)


# Queries
def resolve_customers(
info: OrchestratorInfo,
filter_by: list[GraphqlFilter] | None = None,
sort_by: list[GraphqlSort] | None = None,
first: int = 10,
after: int = 0,
query: str | None = None,
) -> Connection[CustomerType]:
# ---- DEFAULT RESOLVER LOGIC ----
_error_handler = create_resolver_error_handler(info)

pydantic_filter_by: list[Filter] = [item.to_pydantic() for item in filter_by] if filter_by else [] # type: ignore
pydantic_sort_by: list[Sort] = [item.to_pydantic() for item in sort_by] if sort_by else [] # type: ignore
logger.debug(
"resolve_customers() called",
range=[after, after + first],
sort=pydantic_sort_by,
filter=pydantic_filter_by,
)
# ---- END OF DEFAULT RESOLVER LOGIC ----

select_stmt = select(CustomerTable)

# # Include custom filtering logic if imported
# select_stmt = filter_customers(select_stmt, pydantic_filter_by, _error_handler)

if query is not None:
stmt = create_sqlalchemy_select(
select_stmt,
query,
mappings={},
base_table=CustomerTable,
join_key=CustomerTable.customer_id,
)
else:
stmt = select_stmt

# # Include custom sorting logic if imported
# stmt = sort_customers(stmt, pydantic_sort_by, _error_handler)

# ---- DEFAULT RESOLVER LOGIC ----
total = db.session.scalar(select(func.count()).select_from(stmt.subquery()))
stmt = apply_range_to_statement(stmt, after, after + first + 1)

customers = rows_from_statement(stmt, CustomerTable)
graphql_customers = [
CustomerType(
customer_id=c.customer_id, fullname=c.fullname, shortcode=c.shortcode
)
for c in customers
]
return to_graphql_result_page(
graphql_customers,
first,
after,
total,
sort_customers_fields,
filter_customers_fields,
)
# ---- END OF DEFAULT RESOLVER LOGIC ----
```

#### CustomerType Related Type Overrides

Having overridden the `customer_resolver` and added the `subscriptions` field to the `CustomerType`, the final step involves updating the related strawberry types, namely `SubscriptionInterface` and `ProcessType`.

For both types, the `customer_id` is at the root, allowing us to create a generic override resolver for both.
As we modify `SubscriptionInterface`, it's essential to utilize the returned type (stored in the `custom_subscription_interface` variable) when registering GraphQL in the application using `app.register_graphql(subscription_interface=custom_subscription_interface)`.

```python
async def resolve_customer(root: CustomerType) -> CustomerType:
stmt = select(CustomerTable).where(CustomerTable.customer_id == root.customer_id)

if not (customer := db.session.execute(stmt).scalars().first()):
return CustomerType(
customer_id=root.customer_id, fullname="missing", shortcode="missing"
)

return CustomerType(
customer_id=customer.customer_id,
fullname=customer.fullname,
shortcode=customer.shortcode,
)


# Create a strawberry field with the resolver for the customer field
customer_field = strawberry.field(resolver=resolve_customer, description="Returns customer of a subscription") # type: ignore
# Assign a new name to the strawberry field; this name will add the 'customer' field in the class
customer_field.name = "customer"

# Override the SubscriptionInterface and ProcessType with the new 'customer' field
override_class(ProcessType, [customer_field])
custom_subscription_interface = override_class(SubscriptionInterface, [customer_field])
```

0 comments on commit 69104bf

Please sign in to comment.