diff --git a/example/api/src/main.py b/example/api/src/main.py index 04266833..b1f525cc 100644 --- a/example/api/src/main.py +++ b/example/api/src/main.py @@ -1,9 +1,9 @@ from starlette.responses import JSONResponse +from api.src.queries.cities import CityGraphType +from api.src.queries.hello import UserGraphType from cpl.api.api_module import ApiModule -from cpl.api.application.web_app import WebApp from cpl.application.application_builder import ApplicationBuilder -from cpl.graphql.application.graphql_app import GraphQLApp from cpl.auth.permission.permissions import Permissions from cpl.auth.schema import AuthUser, Role from cpl.core.configuration import Configuration @@ -11,8 +11,8 @@ from cpl.core.console import Console from cpl.core.environment import Environment from cpl.core.utils.cache import Cache from cpl.database.mysql.mysql_module import MySQLModule +from cpl.graphql.application.graphql_app import GraphQLApp from cpl.graphql.graphql_module import GraphQLModule -from cpl.graphql.schema.root_query import RootQuery from queries.hello import HelloQuery from scoped_service import ScopedService from service import PingService @@ -37,6 +37,8 @@ def main(): builder.services.add_cache(AuthUser) builder.services.add_cache(Role) + builder.services.add_transient(CityGraphType) + builder.services.add_transient(UserGraphType) builder.services.add_transient(HelloQuery) app = builder.build() diff --git a/example/api/src/queries/cities.py b/example/api/src/queries/cities.py new file mode 100644 index 00000000..4234f8e2 --- /dev/null +++ b/example/api/src/queries/cities.py @@ -0,0 +1,39 @@ +from cpl.graphql.schema.filter.filter import Filter +from cpl.graphql.schema.object_graph_type import ObjectGraphType + +from cpl.graphql.schema.sort.sort import Sort +from cpl.graphql.schema.sort.sort_order import SortOrder + + +class City: + def __init__(self, id: int, name: str): + self.id = id + self.name = name + + +class CityFilter(Filter[City]): + def __init__(self): + Filter.__init__(self) + self.field("id", int) + self.field("name", str) + + +class CitySort(Sort[City]): + def __init__(self): + Sort.__init__(self) + self.field("id", SortOrder) + self.field("name", SortOrder) + + +class CityGraphType(ObjectGraphType): + def __init__(self): + ObjectGraphType.__init__(self) + + self.string_field( + "id", + resolver=lambda user, *_: user.id, + ) + self.string_field( + "name", + resolver=lambda user, *_: user.name, + ) diff --git a/example/api/src/queries/hello.py b/example/api/src/queries/hello.py index 58c747b1..0f61c27c 100644 --- a/example/api/src/queries/hello.py +++ b/example/api/src/queries/hello.py @@ -1,6 +1,10 @@ +from api.src.queries.cities import CityFilter, CitySort, CityGraphType, City +from api.src.queries.user import User, UserFilter, UserSort, UserGraphType from cpl.api.middleware.request import get_request from cpl.graphql.schema.query import Query +users = [User(i, f"User {i}") for i in range(1, 101)] +cities = [City(i, f"City {i}") for i in range(1, 101)] class HelloQuery(Query): def __init__(self): @@ -9,3 +13,18 @@ class HelloQuery(Query): "message", resolver=lambda *_, name: f"Hello {name} {get_request().state.request_id}", ).with_argument(str, "name", "Name to greet", "world") + + self.collection_field( + UserGraphType, + "users", + UserFilter, + UserSort, + resolver=lambda *_: users, + ) + self.collection_field( + CityGraphType, + "cities", + CityFilter, + CitySort, + resolver=lambda *_: cities, + ) diff --git a/example/api/src/queries/user.py b/example/api/src/queries/user.py new file mode 100644 index 00000000..3c4dd70c --- /dev/null +++ b/example/api/src/queries/user.py @@ -0,0 +1,39 @@ +from cpl.graphql.schema.filter.filter import Filter +from cpl.graphql.schema.object_graph_type import ObjectGraphType + +from cpl.graphql.schema.sort.sort import Sort +from cpl.graphql.schema.sort.sort_order import SortOrder + + +class User: + def __init__(self, id: int, name: str): + self.id = id + self.name = name + + +class UserFilter(Filter[User]): + def __init__(self): + Filter.__init__(self) + self.field("id", int) + self.field("name", str) + + +class UserSort(Sort[User]): + def __init__(self): + Sort.__init__(self) + self.field("id", SortOrder) + self.field("name", SortOrder) + + +class UserGraphType(ObjectGraphType): + def __init__(self): + ObjectGraphType.__init__(self) + + self.string_field( + "id", + resolver=lambda user, *_: user.id, + ) + self.string_field( + "name", + resolver=lambda user, *_: user.name, + ) diff --git a/src/cpl-dependency/cpl/dependency/service_provider.py b/src/cpl-dependency/cpl/dependency/service_provider.py index 23a4216d..38e0ae46 100644 --- a/src/cpl-dependency/cpl/dependency/service_provider.py +++ b/src/cpl-dependency/cpl/dependency/service_provider.py @@ -25,7 +25,7 @@ class ServiceProvider: for descriptor in self._service_descriptors: if typing.get_origin(service_type) is None and ( - descriptor.service_type == service_type + descriptor.service_type.__name__ == service_type.__name__ or typing.get_origin(descriptor.base_type) is None and issubclass(descriptor.base_type, service_type) ): diff --git a/src/cpl-graphql/cpl/graphql/graphql_module.py b/src/cpl-graphql/cpl/graphql/graphql_module.py index cb20a7d3..29d9d79d 100644 --- a/src/cpl-graphql/cpl/graphql/graphql_module.py +++ b/src/cpl-graphql/cpl/graphql/graphql_module.py @@ -1,15 +1,17 @@ from cpl.api.api_module import ApiModule from cpl.dependency.module.module import Module from cpl.dependency.service_provider import ServiceProvider +from cpl.graphql.schema.collection import CollectionGraphType from cpl.graphql.schema.root_query import RootQuery from cpl.graphql.service.schema import Schema from cpl.graphql.service.service import GraphQLService +from cpl.graphql.service.type_converter import TypeConverter class GraphQLModule(Module): dependencies = [ApiModule] - singleton = [Schema] - scoped = [GraphQLService, RootQuery] + singleton = [TypeConverter, Schema] + scoped = [GraphQLService, RootQuery, CollectionGraphType] @staticmethod def configure(services: ServiceProvider) -> None: diff --git a/src/cpl-graphql/cpl/graphql/schema/collection.py b/src/cpl-graphql/cpl/graphql/schema/collection.py new file mode 100644 index 00000000..f14269fc --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/collection.py @@ -0,0 +1,18 @@ +from typing import Generic, Type + +from cpl.core.typing import T +from cpl.graphql.schema.graph_type import GraphType + + +class Collection(Generic[T]): + def __init__(self, nodes: list[T], total_count: int, count: int): + self.nodes = nodes + self.totalCount = total_count + self.count = count + +class CollectionGraphType(GraphType[T]): + def __init__(self, t: Type[GraphType[T]]): + GraphType.__init__(self) + self.string_field("totalCount", resolver=lambda obj, *_: obj.totalCount) + self.string_field("count", resolver=lambda obj, *_: obj.count) + self.list_field("nodes", t, resolver=lambda obj, *_: obj.nodes) diff --git a/src/cpl-graphql/cpl/graphql/schema/field.py b/src/cpl-graphql/cpl/graphql/schema/field.py index 2744f6d2..e6358e83 100644 --- a/src/cpl-graphql/cpl/graphql/schema/field.py +++ b/src/cpl-graphql/cpl/graphql/schema/field.py @@ -1,12 +1,12 @@ from typing import Self from cpl.graphql.schema.argument import Argument -from cpl.graphql.typing import TQuery +from cpl.graphql.typing import TQuery, Resolver class Field: - def __init__(self, name: str, gql_type: type, resolver: callable, subquery: TQuery | None = None): + def __init__(self, name: str, gql_type: type, resolver: Resolver = None, subquery: TQuery = None): self._name = name self._gql_type = gql_type self._resolver = resolver @@ -37,7 +37,7 @@ class Field: def with_argument(self, arg_type: type, name: str, description: str = None, default_value=None) -> Self: if name in self._args: raise ValueError(f"Argument with name '{name}' already exists in field '{self._name}'") - self._args[name] = Argument(name, arg_type, description, default_value) + self._args[name] = Argument(arg_type, name, description, default_value) return self def with_arguments(self, args: list[Argument]) -> Self: @@ -45,5 +45,5 @@ class Field: if not isinstance(arg, Argument): raise ValueError(f"Expected Argument instance, got {type(arg)}") - self.with_argument(arg.name, arg.type, arg.description, arg.default_value) + self.with_argument(arg.type, arg.name, arg.description, arg.default_value) return self diff --git a/src/cpl-graphql/cpl/graphql/schema/filter/__init__.py b/src/cpl-graphql/cpl/graphql/schema/filter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cpl-graphql/cpl/graphql/schema/filter/filter.py b/src/cpl-graphql/cpl/graphql/schema/filter/filter.py new file mode 100644 index 00000000..26339bbc --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/filter/filter.py @@ -0,0 +1,9 @@ +from cpl.core.typing import T +from cpl.graphql.schema.input import Input + + +class Filter(Input[T]): + def __init__( + self, + ): + Input.__init__(self) diff --git a/src/cpl-graphql/cpl/graphql/schema/graph_type.py b/src/cpl-graphql/cpl/graphql/schema/graph_type.py new file mode 100644 index 00000000..8fff69cf --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/graph_type.py @@ -0,0 +1,10 @@ +from typing import Generic + +from cpl.core.typing import T +from cpl.graphql.schema.query import Query + + +class GraphType(Generic[T], Query): + + def __init__(self): + Query.__init__(self) \ No newline at end of file diff --git a/src/cpl-graphql/cpl/graphql/schema/input.py b/src/cpl-graphql/cpl/graphql/schema/input.py new file mode 100644 index 00000000..8f66c69c --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/input.py @@ -0,0 +1,26 @@ +from datetime import datetime +from enum import Enum +from typing import Type, Generic + +import graphene + +from cpl.core.typing import T +from cpl.graphql.schema.field import Field + + +class Input(Generic[T], graphene.InputObjectType): + def __init__( + self, + ): + graphene.InputObjectType.__init__(self) + self._fields: dict[str, Field] = {} + + def get_fields(self) -> dict[str, Field]: + return self._fields + + def field( + self, + field: str, + t: Type["Input"] | Type[int | str | bool | datetime | list | Enum], + ): + self._fields[field] = Field(field, t) diff --git a/src/cpl-graphql/cpl/graphql/schema/object_graph_type.py b/src/cpl-graphql/cpl/graphql/schema/object_graph_type.py new file mode 100644 index 00000000..5cc46a0a --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/object_graph_type.py @@ -0,0 +1,9 @@ +from cpl.core.typing import T +from cpl.graphql.schema.graph_type import GraphType +from cpl.graphql.schema.query import Query + + +class ObjectGraphType(GraphType[T], Query): + + def __init__(self): + Query.__init__(self) \ No newline at end of file diff --git a/src/cpl-graphql/cpl/graphql/schema/query.py b/src/cpl-graphql/cpl/graphql/schema/query.py index ba93c3a1..0e14d6b9 100644 --- a/src/cpl-graphql/cpl/graphql/schema/query.py +++ b/src/cpl-graphql/cpl/graphql/schema/query.py @@ -2,9 +2,12 @@ from typing import Callable, Type from graphene import ObjectType +from cpl.graphql.schema.argument import Argument from cpl.graphql.schema.field import Field +from cpl.graphql.schema.filter.filter import Filter +from cpl.graphql.schema.sort.sort import Sort +from cpl.graphql.schema.sort.sort_order import SortOrder from cpl.graphql.typing import Resolver -from cpl.graphql.utils.type_converter import TypeConverter class Query(ObjectType): @@ -32,7 +35,7 @@ class Query(ObjectType): def with_query(self, name: str, subquery: Type["Query"]): from cpl.graphql.schema.field import Field - f = Field(name=name, gql_type=object, resolver=lambda root, info, **kwargs: {}, subquery=subquery) + f = Field(name=name, gql_type=subquery, resolver=lambda root, info, **kwargs: {}, subquery=subquery) self._fields[name] = f return self._fields[name] @@ -47,3 +50,44 @@ class Query(ObjectType): def bool_field(self, name: str, resolver: Resolver = None) -> "Field": return self.field(name, bool, resolver) + + def list_field(self, name: str, t: type, resolver: Resolver = None) -> "Field": + return self.field(name, list[t], resolver) + + def collection_field( + self, t: type, name: str, filter_type: type, sort_type: type, resolver: Resolver = None + ) -> "Field": + from cpl.graphql.schema.collection import Collection, CollectionGraphType + + def _resolve_collection(*_, filter: Filter, sort: Sort, skip: int, take: int): + items = resolver() + + for field in filter or []: + if filter[field] is None: + continue + + items = [item for item in items if getattr(item, field) == filter[field]] + + for field in sort or []: + if sort[field] is None: + continue + + reverse = sort[field] == SortOrder.DESC + items = sorted(items, key=lambda item: getattr(item, field), reverse=reverse) + + total_count = len(items) + paged = items[skip : skip + take] + return Collection(nodes=paged, total_count=total_count, count=len(paged)) + + # base = getattr(t, "__gqlname__", t.__class__.__name__) + wrapper = CollectionGraphType(t) + # wrapper.set_graphql_name(f"{base}Collection") + f = self.field(name, wrapper, resolver=_resolve_collection) + return f.with_arguments( + [ + Argument(filter_type, "filter"), + Argument(sort_type, "sort"), + Argument(int, "skip", default_value=0), + Argument(int, "take", default_value=10), + ] + ) diff --git a/src/cpl-graphql/cpl/graphql/schema/sort/__init__.py b/src/cpl-graphql/cpl/graphql/schema/sort/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cpl-graphql/cpl/graphql/schema/sort/sort.py b/src/cpl-graphql/cpl/graphql/schema/sort/sort.py new file mode 100644 index 00000000..ccbb6980 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/sort/sort.py @@ -0,0 +1,9 @@ +from cpl.core.typing import T +from cpl.graphql.schema.input import Input + + +class Sort(Input[T]): + def __init__( + self, + ): + Input.__init__(self) diff --git a/src/cpl-graphql/cpl/graphql/schema/sort/sort_order.py b/src/cpl-graphql/cpl/graphql/schema/sort/sort_order.py new file mode 100644 index 00000000..cc3122a4 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/schema/sort/sort_order.py @@ -0,0 +1,6 @@ +from enum import Enum, auto + + +class SortOrder(Enum): + ASC = auto() + DESC = auto() \ No newline at end of file diff --git a/src/cpl-graphql/cpl/graphql/service/schema.py b/src/cpl-graphql/cpl/graphql/service/schema.py index 69e76e48..9912c739 100644 --- a/src/cpl-graphql/cpl/graphql/service/schema.py +++ b/src/cpl-graphql/cpl/graphql/service/schema.py @@ -1,21 +1,22 @@ -from typing import Type - import graphene from cpl.api.logger import APILogger from cpl.dependency.service_provider import ServiceProvider -from cpl.graphql.schema.argument import Argument -from cpl.graphql.schema.query import Query +from cpl.graphql.schema.collection import CollectionGraphType +from cpl.graphql.schema.graph_type import GraphType from cpl.graphql.schema.root_query import RootQuery -from cpl.graphql.typing import Resolver -from cpl.graphql.utils.type_converter import TypeConverter +from cpl.graphql.service.type_converter import TypeConverter class Schema: - def __init__(self, logger: APILogger, query: RootQuery, provider: ServiceProvider): + def __init__(self, logger: APILogger, converter: TypeConverter, query: RootQuery, provider: ServiceProvider): self._logger = logger self._provider = provider + self._converter = converter + + self._types = set(GraphType.__subclasses__()) + self._types.remove(CollectionGraphType) self._query = query self._schema = None @@ -28,37 +29,15 @@ class Schema: def query(self) -> RootQuery: return self._query + def with_type(self, t: type[GraphType]): + self._types.add(t) + return self + def build(self) -> graphene.Schema: self._schema = graphene.Schema( - query=self.to_graphene(self._query), + query=self._converter.to_graphene(self._query), mutation=None, subscription=None, + # types=[self._converter.to_graphene(t) for t in self._types] if len(self._types) > 0 else None, ) return self._schema - - @staticmethod - def _field_to_graphene(t: Type[graphene.Scalar] | type, args: dict[str, Argument] = None, resolver: Resolver = None) -> graphene.Field: - arguments = {} - if args is not None: - arguments = { - arg.name: graphene.Argument(TypeConverter.to_graphene(arg.type), description=arg.description, default_value=arg.default_value) - for arg in args.values() - } - - return graphene.Field(t, args=arguments, resolver=resolver) - - def to_graphene(self, query: Query, name: str | None = None): - assert query is not None, "Query cannot be None" - attrs = {} - - for field in query.get_fields().values(): - if field.type == object and field.subquery is not None: - subquery = self._provider.get_service(field.subquery) - sub = self.to_graphene(subquery, name=field.name.capitalize()) - attrs[field.name] = self._field_to_graphene(sub, field.args, field.resolver) - continue - - attrs[field.name] = self._field_to_graphene(TypeConverter.to_graphene(field.type), field.args, field.resolver) - - class_name = name or query.__class__.__name__ - return type(class_name, (graphene.ObjectType,), attrs) diff --git a/src/cpl-graphql/cpl/graphql/service/type_converter.py b/src/cpl-graphql/cpl/graphql/service/type_converter.py new file mode 100644 index 00000000..bf483b42 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/service/type_converter.py @@ -0,0 +1,89 @@ +import typing +from enum import Enum +from inspect import isclass + +import graphene +from typing import Any, get_origin, get_args + +from cpl.dependency import ServiceProvider +from cpl.graphql.schema.argument import Argument +from cpl.graphql.schema.filter.filter import Filter +from cpl.graphql.schema.graph_type import GraphType +from cpl.graphql.schema.object_graph_type import ObjectGraphType +from cpl.graphql.schema.sort.sort import Sort +from cpl.graphql.typing import Resolver +from cpl.graphql.utils.name_pipe import NamePipe + + +class TypeConverter: + __scalar_map: dict[Any, type[graphene.Scalar]] = { + str: graphene.String, + int: graphene.Int, + float: graphene.Float, + bool: graphene.Boolean, + } + + def __init__(self, provider: ServiceProvider): + self._provider = provider + + def _field_to_graphene(self, t: typing.Type[graphene.Scalar] | type, args: dict[str, Argument] = None, resolver: Resolver = None) -> graphene.Field: + arguments = {} + if args is not None: + arguments = { + arg.name: graphene.Argument(self.to_graphene(arg.type), name=arg.name, description=arg.description, default_value=arg.default_value) + for arg in args.values() + } + + return graphene.Field(t, args=arguments, resolver=resolver) + + def to_graphene(self, t: Any, name: str | None = None) -> Any: + try: + origin = get_origin(t) + args = get_args(t) + + if t in self.__scalar_map: + return self.__scalar_map[t] + + if origin in (list, typing.List): + if not args: + raise ValueError("List must specify element type, e.g. list[str]") + inner = self.to_graphene(args[0]) + return graphene.List(inner) + + if t is list or t is typing.List: + raise ValueError("List must be parametrized: list[str], list[int], list[UserQuery]") + + if isclass(t) and issubclass(t, Enum): + return graphene.Enum.from_enum(t) + + from cpl.graphql.schema.query import Query + if isinstance(t, type) and issubclass(t, (Query)): + query = self._provider.get_service(t) + if query is None: + raise ValueError(f"Could not resolve query of type {t}") + + t = query + + if isinstance(t, type) and issubclass(t, (ObjectGraphType, GraphType, Filter, Sort)): + t = t() + + if isinstance(t, (Query, Filter, Sort)): + attrs = {} + for field in t.get_fields().values(): + if isclass(field.type) and issubclass(field.type, Query) and field.subquery is not None: + subquery = self._provider.get_service(field.subquery) + sub = self.to_graphene(subquery, name=field.name.capitalize()) + attrs[field.name] = self._field_to_graphene(sub, field.args, field.resolver) + continue + + attrs[field.name] = self._field_to_graphene(self.to_graphene(field.type), field.args, field.resolver) + + class_name = NamePipe.to_str(name or t.__class__) + if isinstance(t, (Filter, Sort)): + return type(class_name, (graphene.InputObjectType,), attrs) + + return type(class_name, (graphene.ObjectType,), attrs) + + raise ValueError(f"Unsupported field type: {t}") + except Exception as e: + raise ValueError(f"Failed to convert type {t} to graphene type: {e}") from e \ No newline at end of file diff --git a/src/cpl-graphql/cpl/graphql/utils/name_pipe.py b/src/cpl-graphql/cpl/graphql/utils/name_pipe.py new file mode 100644 index 00000000..7e9b72b1 --- /dev/null +++ b/src/cpl-graphql/cpl/graphql/utils/name_pipe.py @@ -0,0 +1,28 @@ +from cpl.core.pipes import PipeABC +from cpl.core.typing import T +from cpl.graphql.schema.collection import CollectionGraphType +from cpl.graphql.schema.graph_type import GraphType +from cpl.graphql.schema.object_graph_type import ObjectGraphType + + +class NamePipe(PipeABC): + + @staticmethod + def to_str(value: type, *args) -> str: + if isinstance(value, str): + return value + + if not isinstance(value, type): + raise ValueError(f"Expected a type, got {type(value)}") + + if issubclass(value, CollectionGraphType): + return f"{value.__name__.replace(GraphType.__name__, "")}" + + if issubclass(value, (ObjectGraphType, GraphType)): + return value.__name__.replace(GraphType.__name__, "") + + return value.__name__ + + @staticmethod + def from_str(value: str, *args) -> T: + pass diff --git a/src/cpl-graphql/cpl/graphql/utils/type_converter.py b/src/cpl-graphql/cpl/graphql/utils/type_converter.py deleted file mode 100644 index c89b8928..00000000 --- a/src/cpl-graphql/cpl/graphql/utils/type_converter.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Type - -import graphene - -from cpl.graphql.typing import ScalarType - - -class TypeConverter: - - @staticmethod - def from_graphene(t: Type[graphene.Scalar]) -> ScalarType: - graphene_type_map: dict[Type[graphene.Scalar], ScalarType] = { - graphene.String: str, - graphene.Int: int, - graphene.Float: float, - graphene.Boolean: bool, - graphene.ObjectType: object, - } - - if t not in graphene_type_map: - raise ValueError(f"Unsupported field type: {t}") - - return graphene_type_map[t] - - @staticmethod - def to_graphene(t: ScalarType) -> Type[graphene.Scalar]: - type_graphene_map: dict[ScalarType, Type[graphene.Scalar]] = { - str: graphene.String, - int: graphene.Int, - float: graphene.Float, - bool: graphene.Boolean, - object: graphene.ObjectType, - } - - if t not in type_graphene_map: - raise ValueError(f"Unsupported field type: {t}") - - return type_graphene_map[t]