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

Feature request: ariadne server codegen #122

Open
j-riebe opened this issue Apr 4, 2023 · 7 comments
Open

Feature request: ariadne server codegen #122

j-riebe opened this issue Apr 4, 2023 · 7 comments

Comments

@j-riebe
Copy link

j-riebe commented Apr 4, 2023

Feature Request

Are there any plans (or existing plugins) to add support for server-side codegen?

E.g. generating a schema (through make_executable_schema) with:

This would make it possible to implement the ariadne resolvers with static typing based on a python version of the graphql schema (like strawberry but schema first) and reduced amount of boilerplate code.

The graphqlschema-Strategy might go into this direction, but that's not it yet as it produces a pure graphql-core schema.

Example

This request would require something like an ariadneschema-Strategy:

Source

# schema.graphql
type Foo {
  bar(filter: FilterInput!): String!
}

input FilterInput {
  baz: Baz
}

enum Baz {
  one
  two
}

Generated

# generated/types.py
# generate python representation of input types and enum types 
FilterInput = ...
Baz = ...
# generated/ariadne_schema.py

# import input types and enum types
from .types import Baz, FilterInput
from ariadne import ObjectType, InputType, EnumType

# _ - Prefix to prevent name conficts
_Foo = ObjectType("Foo")
_Baz = EnumType("Baz", Baz)
_FilterInput = InputType("FilterInput",  lambda data: FilterInput(**data))

# create resolver skeletons
@_Foo.field("bar")
async def resolve__foo__bar(parent: Any, info: GraphQLResolveInfo, *, filter: FilterInput) -> str:
    """<maybe add description of field definition Foo.bar>"""
    # The actual implementation would need to be defined elsewhere
    return await mypackage.resolvers.resolve__foo__bar(parent, info, filter=filter)

# assemble the ariadne schema
type_defs = ...
bindables = {"Foo": _Foo, "Bar": _Bar, "Baz": _Baz}
schema = make_executable_schema(type_defs, list(bindables.values()))

Custom implementation

While all code above can be inferred from the graphql schema (boilerplate), the only purely server-specific implementation left would be:

# mypackage/resolvers.py
from typing import Any
from ariadne import GraphQLResolveInfo

from generated.types import Baz, FilterInput

async def resolve__foo__bar(parent: Any, info: GraphQLResolveInfo, *, filter: FilterInput) -> str:  # Signature can be copy/pasted
    """Actual implementation can rely on pydantic Models / TypedDict / etc. for the input types."""
    assert isinstance(filter.baz, Baz)
    ...

If the schema for input/enum/return types changes in a way incompatible with the resolver implementation or new resolvers are missing, this can now be recognized by static code analysis tools.
Note: Typing parent types might lead to inconsitent typing, as the parent resolvers may still accept anything deemed valid by ariadne and pass it through to the child resolver - despite static typing.

@rafalp
Copy link
Contributor

rafalp commented Apr 6, 2023

This is something I've been thinking about some recently, but I am always finding those propositions missing some important decisions that would have to be made immediately as work on generator would star.

For example, considering I have this schema:

type Query {
  issue(id: ID!): Issue
}

type Issue {
  id: ID!
  text: String!
  parent: Issue
}

What function signatures would you expect to be generated for Query.issue and Issue.parent fields resolvers?

@j-riebe
Copy link
Author

j-riebe commented Apr 11, 2023

Based on the example given above I'd expect the following skeletons:

# generated/ariadne_schema.py

from ariadne import ObjectType

# import input types and enum types
from .scalars import ID  # Or sth. similar, e.g. ID = str

# _ - Prefix to prevent name conficts
_Query = ObjectType("Query")  # Or ariadne.QueryType
_Issue = ObjectType("Issue")

# create resolver skeletons
@_Query.field("issue")
async def resolve__query__issue(parent: Any, info: GraphQLResolveInfo, *, id: ID) -> Any:
    return await mypackage.resolvers.resolve__query__issue(parent, info, id=id)

@_Issue.field("parent")
async def resolve__issue__parent(parent: Any, info: GraphQLResolveInfo) -> Any:
    return await mypackage.resolvers.resolve__issue__parent(parent, info)

# Resolver for scalar valued fields might be opt-in via config

In this specific example, the codegen ensures that:

  • we cannot produce typos in the field(...) call
  • resolver arguments are statically typed (scalars, inputs, enums)
  • resolver signatures are always syntactically compatible with the schema
  • incompatible changes in the schema can be catched by static type checkers by detecting the mismatch of skeleton-signature and signature of the acutal implementation.

Support for typing of parent and return types should be handled as a separate feature.

@rafalp Am I overlooking something essential?

@rafalp
Copy link
Contributor

rafalp commented Apr 26, 2023

resolver signatures are always syntactically compatible with the schema

But correct me if I am wrong but isn't major draw to typing resolvers that their return types are something else than Any? 🤔

@j-riebe
Copy link
Author

j-riebe commented Apr 27, 2023

With Anyas return type, the "syntactical correctness" I mentioned above is restricted to ensuring that the kwargs match the ones that ariadne expects, which is bascially all that we can read purely from the GraphQL-Schema in terms of resolvers.

I fear that it will be difficult to completely type all resolvers (including parent and return types) due to the fact, that resolvers can be arbitrary functions and the graphql-core default_resolver is very flexible which makes the actual parent and return types tightly coupled to the actual implementation.

The problem with more concrete types is that one can only type the return type of leaf-resolvers (scalars or last custom resolver before scalars) with certainty. Everything else depends on how the leaf-resolvers produce their return value out of their parent value. In addition, those leaf-resolvers don't need to depend on their parent objects at all (which might be odd but possible).

That's why typing the return type of a resolver with a concrete type is only meaningful if all downstream resolvers are simple getters (aka. the default_resolver). In this case the return type can be typed as a tree that is traversable by the basic getters

  • __getitem__ (e.g. TypedDict, see graphql-codegen-ariadne) or
  • __getattribute__ aka. @property (pydantic, dataclass, NamedTuple, ...)
  • and combinations of both.

Annotating the return type for the last custom resolver before the "all scalar values" shouldn't be too complicated, if "the last custom resolver" would to be defined in the config. Unfortunately, those resolvers are likely to be the minority in large and/or highly connected schemas.

For the other resolvers, it is more complex. The only thing that we now here, is that the return type of a resolver must be compatible with all parent types of its immediate child resolvers.
Therefore, a type checker would need to be able to detect, that all parent types of the child resolvers are a subtype of the return type of the parent resolver.

I tried to implement a poc (see below) based on Protocols (similar to graphql-codegen-ariadne) and TypeVars and check it against mypy and pyright. Unfortunately, I couldn't get any of them to detect the falsely typed resolver.

@rafalp Maybe you have an idea on how to do this.

Example

schema.graphql
type Some {
    strange: Strange!
}

type Strange {
    behaviour: String!
    otherBehaviour: [String!]!
}
mypackage.resolvers.py
import typing as t

from graphql import GraphQLResolveInfo


class Strange(t.TypedDict):
    this: str


class StrangeButAsAttribute(t.NamedTuple):
    this: str


def resolve__some__strange(parent: t.Any, info: GraphQLResolveInfo) -> Strange:
    return {"this": "can be anything and still resolve"}


def resolve__strange__behaviour(parent: Strange, info: GraphQLResolveInfo) -> str:
    return parent["this"]


def resolve__strange__behaviour_wrong_parent(
    parent: StrangeButAsAttribute, info: GraphQLResolveInfo
) -> str:
    return parent.this


def resolve__strange__other_behaviour(
    parent: Strange,
    info: GraphQLResolveInfo,
) -> t.List[str]:
    return "Something completely different".split()
example.py
import typing as t

from ariadne import ObjectType
from graphql import GraphQLResolveInfo

import mypackage.resolvers

# === Type base
T = t.TypeVar("T", bound=t.Any, contravariant=True)
K = t.TypeVar("K", bound=t.Any, covariant=True)

Resolvable = t.Union[
    K,
    t.Callable[[], K],
    t.Awaitable[K],
    t.Callable[[], t.Awaitable[K]],
]

AnyIterable = t.Iterable[K] | t.AsyncIterable[K]


# === Resolver protocols
class SomeStrangeResolver(t.Protocol[T, K]):
    def __call__(self, parent: T, info: GraphQLResolveInfo) -> Resolvable[K]: ...


class StrangeBehaviourResolver(t.Protocol[T]):
    def __call__(self, parent: T, info: GraphQLResolveInfo) -> Resolvable[str]: ...


class StrangeOtherBehaviourResolver(t.Protocol[T]):
    def __call__(self, parent: T, info: GraphQLResolveInfo) -> Resolvable[AnyIterable[str]]: ...


# === Ariadne types with additional typing
class SomeObjectType(ObjectType):
    def __init__(self):
        super().__init__("Some")

    def set_field(
        self,
        name: t.Literal["strange"],
        resolver: SomeStrangeResolver,
    ) -> SomeStrangeResolver:
        return super().set_field(name, resolver)


class StrangeObjectType(ObjectType):

    def __init__(self):
        super().__init__("Strange")

    @t.overload
    def set_field(
        self, name: t.Literal["behaviour"], resolver: StrangeBehaviourResolver
    ) -> StrangeBehaviourResolver: ...

    @t.overload
    def set_field(
        self, name: t.Literal["otherBehaviour"], resolver: StrangeOtherBehaviourResolver
    ) -> StrangeOtherBehaviourResolver: ...

    def set_field(self, name, resolver):
        return super().set_field(name, resolver)


Some = SomeObjectType()
Strange = StrangeObjectType()

# === Assignment of resolver functions

# The return type of ...resolve__some__strange must be a super-type of the first arg in the methods below
Some.set_field("strange", mypackage.resolvers.resolve__some__strange)
Strange.set_field("behaviour", mypackage.resolvers.resolve__strange__behaviour)
Strange.set_field("otherBehaviour", mypackage.resolvers.resolve__strange__other_behaviour)

# this should be flagged as wrong but it isn't
Strange.set_field("behaviour", mypackage.resolvers.resolve__strange__behaviour_wrong_parent)

@j-riebe
Copy link
Author

j-riebe commented May 12, 2023

@rafalp I've got a working PoC for the typed resolvers up and running. You can check it out here.

It's based on your initial example with Query and Issue. Right now it's able to determine:

  • all resolvers that resolve to an Issue (namely Query.issue and Issue.parent) must return the same type
  • and that this type must also be compatible with the first argument of all field resolvers (Issue.parent, Issue.id, Issue.text).

@rafalp
Copy link
Contributor

rafalp commented May 12, 2023

Hey, I am sorry. I was going to reply to this after coming back to office after May holidays, but I've got distracted by other work.

The reason why I've asked about generating return types, is because there's separate discussion on same subject happening on Ariadne's discussions page, and people in that discussion were focused on expectation that return types for resolvers will be generated, and considered them highly important.

I agree with your point about generating types for leaf resolvers only. In fact, I have witnessed experiments in past where people implemented 1:1 types for their schema using Python dataclasses, and then used mypy to validate that resolvers return correct values for those types. But unfortunate side effect of this approach was that resolvers on Query and Mutation types had to fetch and structure all the data for GraphQL schema in them, reducing GraphQL to sort of RPC where client may exclude fields from payload if they want to.

But because some types were returned by multiple resolvers, this resulted in people creating utility factory functions "convert my model to GraphQLType dataclass that resolvers return", and I strongly feel that this is an antipattern.

But as I've said before, people kind of still expect it. 🤔

@j-riebe
Copy link
Author

j-riebe commented Jun 18, 2023

Sorry for the even later reply, I was thinking about this topic but didn't came to write a reply.
I want to apologize beforehand, because it will be quite large 😅

I think the typed-resolvers problem boils down to 2 essential core-problems:

  • type compatibility of unknown types and
  • default resolvers for non-scalar return types

1. Type compatibility

A type returned by one resolver must be compatible with all other resolvers that utilize it either as a parent or return type.
This poses a challenge as the actual type can be basically anything as long as all resolvers agree on the same thing.

I got this covered in the PoC mentioned above (tested against mypy, pyright and pyCharm's language server).

At the core, it requires to create one function per ObjectType-node to validate that all incoming and outgoing edges expect the same type.

Given your challenge schema:

type Query {
  issue(id: ID!): Issue
}

type Issue {
  id: ID!
  text: String!
  parent: Issue
}

only a single function is required, to validate return types and parent types match (incl. recursion).

import typing as t
from graphql import GraphQLResolveInfo

from mypackage import resolvers
from .scalars import ID
from .resolver_type_base import Resolvable  # see detail box below

T = t.TypeVar("T")

def validate_issue(
    # Issue as return type
    query__issue__resolver: t.Callable[[t.Any, GraphQLResolveInfo, ID], Resolvable[T | None]],
    # Issue as parent type
    issue__id__resolver: t.Callable[[T, GraphQLResolveInfo], Resolvable[ID]],
    issue__text__resolver: t.Callable[[T, GraphQLResolveInfo], Resolvable[str]],
    # Issue as parent and return type (recursion)
    issue__parent__resolver: t.Callable[[T, GraphQLResolveInfo], Resolvable[T | None]],
) -> None:
    """A body is not required"""

if t.TYPE_CHECKING:
    # By calling the function, the type checker can validate
    # if all of the resolvers can agree on one type without specifying it explicitly
    validate_issue(
        query__issue__resolver=resolvers.resolve__query__issue,
        issue__id__resolver=resolvers.resolve__issue__id,
        issue__text__resolver=resolvers.resolve__issue__text,
        issue__parent__resolver=resolvers.resolve__issue__parent,
    )
Definition of `Resolvable` alias

TL;DR: Anything that can be handled by the default resolver.

import typing as t

_T = t.TypeVar("_T")

SimpleResolvable = t.Union[_T, t.Awaitable[_T]]  # mypy doesn't get this when using pipe (|) syntax
CallableResolvable = t.Callable[[], SimpleResolvable[_T]] | t.Awaitable[t.Callable[[], SimpleResolvable[_T]]]

Resolvable = SimpleResolvable[_T] | CallableResolvable[_T]
Iterable = t.Iterable[_T] | t.AsyncIterable[_T]  # for lists -> Iterable[Resolvable[<type>]]

This works, but the downside of this approach is, that now you need to define all resolvers, even the trivial ones (foo["bar"] and foo.bar).

To further improve this, it is necessary to

  1. detect if the default resolver needs to be used, because there is no custom resolver implementation,
  2. type the default resolver and
  3. and if the default resolver doesn't return a scalar but another object type, all edges leading to either a scalar or to the next custom resolver need to be considered ...

which is where things get ugly.

2. Default resolver

Point 1 is fairly easy to archive by trying to import the resolvers at generation time (try / except ImportError).
The codegen can then decide if the resolver is imported from the users code or if a default resolver needs to be created.

Point 2 for simple scalars has the problem that e.g. Issue.text can be one of 5+ types:

  • a TypedDict --> issue["text"]
  • any other object that handles issue["text"] --> def __getitem__(self, item: Literal["text"]) -> str
  • a simple attribute access issue.text
  • a property (which differs from an attribute in terms of typing) issue.text
  • a callable without possitional args issue.text() --> def text(self, **kwargs) -> str
  • ... + the async versions (but this is only a minor issue)

Just the single Issue.text default resolver would require the following:

Issue.text default resolver

import typing as t


class IssueTextFromTypedDict(t.TypedDict, total=False):
    text: str


class IssueTextFromGetItem(t.Protocol):
    def __getitem__(self, item: t.Literal["text"]) -> str: ...


class IssueTextFromField(t.Protocol):
    text: str


class IssueTextFromProperty(t.Protocol):
    @property
    def text(self) -> str: ...


class IssueTextFromCallable(t.Protocol):
    def text(self) -> str: ...


DefaultResolvableIssueText = t.Union[
    IssueTextFromTypedDict,
    IssueTextFromGetItem,
    IssueTextFromField,
    IssueTextFromProperty,
    IssueTextFromCallable,
]

def resolve__issue__text(obj: DefaultResolvableIssueText) -> str:
    ...

It can then be used like this in the resolver type check

from mypackage import resolvers
from . import default_resolvers

...

validate_issue(
    query__issue__resolver=resolvers.resolve__query__issue,
    issue__id__resolver=resolvers.resolve__issue__id,
    issue__text__resolver=default_resolvers.resolve__issue__text,  # <-- 
    issue__parent__resolver=resolvers.resolve__issue__parent,
)

Now point 3 additionally requires to make default resolver return other default resolvers.
If somewhere down the graph other custom resolvers appear, the link between both custom resolvers must be validated throughout the complete chain of default resolvers.

At this point I'm not sure if current type checkers can handle this - so this will require another PoC (which I will be working on).

Right now, I'm optimistic that it should be possible, or at least with some minor restrictions.
@rafalp Do you think this approach could be an option at all?

@rafalp rafalp self-assigned this Jul 21, 2023
@rafalp rafalp removed their assignment Mar 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants