Skip to content

Commit

Permalink
Merge pull request #39 from mirumee/imgix_resolver
Browse files Browse the repository at this point in the history
Imgix resolver
  • Loading branch information
mat-sop authored Sep 21, 2023
2 parents 83f314f + bea63fa commit eba35c8
Show file tree
Hide file tree
Showing 12 changed files with 712 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Added `CloudflareCacheBackend`.
- Added `DynamoDBCacheBackend`.
- Changed `QueryFilter` and `root_resolver` to split variables between schemas.
- Added `insert_field` utility to `ProxySchema`. Added `get_query_params_resolver` as factory for `imgix` resolvers.


## 0.1.0 (2023-06-13)
Expand Down
94 changes: 94 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,85 @@ app = GraphQL(
```


## imgix query params resolver

`get_query_params_resolver` returns a preconfigured resolver that takes URL string and passed arguments to generate a URL with arguments as query params. It can be used to add [rendering options](https://docs.imgix.com/apis/rendering) to [imgix.com](https://imgix.com) image URL.


### Function arguments:

- `get_url`: a `str` or `Callable` which returns `str`. If `get_url` is a `str` then the resolver will split it by `.` and use substrings as keys to get value from `obj` dict or as attribute names for non dict objects, e.g. with `get_url` set to `"imageData.url"` the resolver will use one of: `obj["imageData"]["url"]`, `obj["imageData"].url`, `obj.imageData["url"]`, `obj.imageData.url` as URL string. If `get_url` is a callable, then resolver will call it with `obj`, `info` and `**kwargs` and use result as URL string.
- `extra_params`: an optional `dict` of query params to be added to the URL string. These can be overridden by kwargs passed to the resolver.
- `get_params`: an optional `Callable` to be called on passed `**kwargs` before they are added to the URL string.
- `serialize_url`: an optional `Callable` to be called on URL string with query params already added. Result is returned directly by the resolver.


### Example with `insert_field`

In this example we assume there is a graphql server which provides following schema:

```gql
type Query {
product: Product!
}

type Product {
imageUrl: String!
}
```

`imageUrl` returns URL string served by [imgix.com](https://imgix.com) and we want to add another field with thumbnail URL.

```python
from ariadne_graphql_proxy import ProxySchema, get_context_value, set_resolver
from ariadne_graphql_proxy.contrib.imgix import get_query_params_resolver


proxy_schema = ProxySchema()
proxy_schema.add_remote_schema("https://remote-schema.local")
proxy_schema.insert_field(
type_name="Product",
field_str="thumbnailUrl(w: Int, h: Int): String!",
)

final_schema = proxy_schema.get_final_schema()

set_resolver(
final_schema,
"Product",
"thumbnailUrl",
get_query_params_resolver(
"imageUrl",
extra_params={"h": 128, "w": 128, "fit": "min"},
),
)
```

With an added resolver, `thumbnailUrl` will return `imageUrl` with additional query parameters. `fit` is always set to `min`. `w` and `h` are set to `128` by default, but can be changed by query argument, e.g.

```gql
query getProduct {
product {
imageUrl
thumbnailUrl
smallThumbnailUrl: thumbnailUrl(w: 32, h: 32)
}
}
```

```json
{
"data": {
"product": {
"imageUrl": "https://test-imageix.com/product-image.jpg",
"thumbnailUrl": "https://test-imageix.com/product-image.jpg?h=128&w=128&fit=min",
"smallThumbnailUrl": "https://test-imageix.com/product-image.jpg?h=32&w=32&fit=min"
}
}
}
```


## Proxying headers

Ariadne GraphQL Proxy requires that `GraphQLResolveInfo.context` attribute is a dictionary containing `headers` key, which in itself is a `Dict[str, str]` dictionary.
Expand Down Expand Up @@ -762,6 +841,21 @@ def get_sub_schema(self, schema_id: int) -> GraphQLSchema:
Returns sub schema with given id. If schema doesn't exist, raises `IndexError`.


### `insert_field`

```python
def insert_field(self, type_name: str, field_str: str):
```

Inserts field into all schemas with given `type_name`. The field is automatically delayed - excluded from queries run by `root_resolver` against the remote GraphQL APIs.


#### Required arguments

- `type_name`: a `str` with the name of the type into which the field will be inserted.
- `field_str`: a `str` with SDL field representation, e.g. `"fieldA(argA: String!) Int"`.


### `get_final_schema`

```python
Expand Down
3 changes: 3 additions & 0 deletions ariadne_graphql_proxy/contrib/imgix/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .query_params_resolver import get_query_params_resolver

__all__ = ["get_query_params_resolver"]
62 changes: 62 additions & 0 deletions ariadne_graphql_proxy/contrib/imgix/query_params_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from functools import partial
from typing import Any, Callable, Optional, Union, cast
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse

from graphql import GraphQLResolveInfo


def get_attribute_value(
obj: Any, info: GraphQLResolveInfo, attribute_str: str, **kwargs
) -> Any:
value = obj
for attr in attribute_str.split("."):
try:
value = value.get(attr)
except AttributeError:
value = getattr(value, attr, None)
return value


def get_query_params_resolver(
get_url: Union[str, Callable[..., str]],
extra_params: Optional[dict[str, Any]] = None,
get_params: Optional[Callable[..., dict[str, Any]]] = None,
serialize_url: Optional[Callable[[str], Any]] = None,
):
get_source_url = cast(
Callable[..., str],
(
get_url
if callable(get_url)
else partial(get_attribute_value, attribute_str=get_url)
),
)
params = cast(dict[str, Any], extra_params if extra_params is not None else {})
get_params_from_kwargs = cast(
Callable[..., dict[str, Any]],
get_params if get_params is not None else lambda **kwargs: kwargs,
)
serialize = cast(
Callable[[str], Any],
serialize_url if serialize_url is not None else lambda url: url,
)

def resolver(obj: Any, info: GraphQLResolveInfo, **kwargs):
source_url = get_source_url(obj, info, **kwargs)
parse_result = urlparse(source_url)
query_params = parse_qs(parse_result.query)
query_params.update(params)
query_params.update(get_params_from_kwargs(**kwargs))
result_url = urlunparse(
(
parse_result.scheme,
parse_result.netloc,
parse_result.path,
parse_result.params,
urlencode(query_params),
parse_result.fragment,
)
)
return serialize(result_url)

return resolver
20 changes: 19 additions & 1 deletion ariadne_graphql_proxy/proxy_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
from .merge import merge_schemas
from .query_filter import QueryFilter
from .remote_schema import get_remote_schema
from .standard_types import STANDARD_TYPES
from .standard_types import STANDARD_TYPES, add_missing_scalar_types
from .str_to_field import (
get_field_definition_from_str,
get_graphql_field_from_field_definition,
)


class ProxySchema:
Expand Down Expand Up @@ -83,6 +87,8 @@ def add_schema(
exclude_directives_args=exclude_directives_args,
)

schema.type_map = add_missing_scalar_types(schema.type_map)

self.schemas.append(schema)
self.urls.append(url)

Expand Down Expand Up @@ -135,6 +141,18 @@ def add_delayed_fields(self, delayed_fields: Dict[str, List[str]]):
for field_name in type_fields:
self.fields_map[type_name].pop(field_name, None)

def insert_field(self, type_name: str, field_str: str):
field_definition = get_field_definition_from_str(field_str=field_str)
field_name = field_definition.name.value
for schema in self.schemas:
type_ = schema.type_map.get(type_name)
if not type_ or not hasattr(type_, "fields"):
continue

type_.fields[field_name] = get_graphql_field_from_field_definition(
schema=schema, field_definition=field_definition
)

def get_sub_schema(self, schema_id: int) -> GraphQLSchema:
try:
return self.schemas[schema_id]
Expand Down
14 changes: 14 additions & 0 deletions ariadne_graphql_proxy/standard_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from graphql import GraphQLBoolean, GraphQLFloat, GraphQLID, GraphQLInt, GraphQLString

STANDARD_TYPES = (
"ID",
"Boolean",
Expand All @@ -13,3 +15,15 @@
"__Directive",
"__DirectiveLocation",
)


def add_missing_scalar_types(schema_types: dict) -> dict:
scalar_types = {
"ID": GraphQLID,
"Boolean": GraphQLBoolean,
"Float": GraphQLFloat,
"Int": GraphQLInt,
"String": GraphQLString,
}
scalar_types.update(schema_types)
return scalar_types
58 changes: 58 additions & 0 deletions ariadne_graphql_proxy/str_to_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from typing import cast

from graphql import (
FieldDefinitionNode,
GraphQLArgument,
GraphQLField,
GraphQLInputType,
GraphQLOutputType,
GraphQLSchema,
assert_input_type,
assert_output_type,
parse,
type_from_ast,
value_from_ast,
)


def get_field_definition_from_str(field_str: str) -> FieldDefinitionNode:
document = parse(f"type Placeholder{{ {field_str} }}")

if len(document.definitions) != 1:
raise ValueError("Field str has to define 1 type.")

definition = document.definitions[0]

fields = getattr(definition, "fields", [])
if len(fields) != 1:
raise ValueError("Field str has to provide only 1 field.")

return fields[0]


def get_graphql_field_from_field_definition(
schema: GraphQLSchema, field_definition: FieldDefinitionNode
) -> GraphQLField:
field_type = cast(GraphQLOutputType, type_from_ast(schema, field_definition.type))
assert_output_type(field_type)

field_args = {}
for arg in field_definition.arguments:
arg_type = cast(GraphQLInputType, type_from_ast(schema, arg.type))
assert_input_type(arg_type)
arg_default_value = value_from_ast(value_node=arg.default_value, type_=arg_type)

field_args[arg.name.value] = GraphQLArgument(
type_=arg_type,
default_value=arg_default_value,
)

description = (
None if not field_definition.description else field_definition.description.value
)

return GraphQLField(
type_=field_type,
args=field_args,
description=description,
)
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def schema():
name(arg: Generic, other: Generic): String!
rank(arg: Generic, other: Generic): Int!
}
input InputType {
arg1: Float!
arg2: Boolean!
arg3: String!
arg4: ID!
arg5: Int!
}
"""
)

Expand Down
Empty file added tests/contrib/imgix/__init__.py
Empty file.
Loading

0 comments on commit eba35c8

Please sign in to comment.