Skip to content

Commit

Permalink
Attempt graphql backend
Browse files Browse the repository at this point in the history
Apply upstream feedback

Remove fastapi layer from gql

Process review comments

Rebase

create_type -> create_strawberry_type
  • Loading branch information
marcelldls authored and GDYendell committed Dec 10, 2024
1 parent e2dfc01 commit 66f46fa
Show file tree
Hide file tree
Showing 8 changed files with 406 additions and 2 deletions.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"pvi~=0.10.0",
"pytango",
"softioc>=4.5.0",
"strawberry-graphql",
]
dynamic = ["version"]
license.file = "LICENSE"
Expand Down Expand Up @@ -63,7 +64,7 @@ version_file = "src/fastcs/_version.py"

[tool.pyright]
typeCheckingMode = "standard"
reportMissingImports = false # Ignore missing stubs in imported modules
reportMissingImports = false # Ignore missing stubs in imported modules

[tool.pytest.ini_options]
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
Expand Down
10 changes: 9 additions & 1 deletion src/fastcs/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
from .exceptions import LaunchError
from .transport.adapter import TransportAdapter
from .transport.epics.options import EpicsOptions
from .transport.graphQL.options import GraphQLOptions
from .transport.rest.options import RestOptions
from .transport.tango.options import TangoOptions

# Define a type alias for transport options
TransportOptions: TypeAlias = EpicsOptions | TangoOptions | RestOptions
TransportOptions: TypeAlias = EpicsOptions | TangoOptions | RestOptions | GraphQLOptions


class FastCS:
Expand All @@ -38,6 +39,13 @@ def __init__(
self._backend.dispatcher,
transport_options,
)
case GraphQLOptions():
from .transport.graphQL.adapter import GraphQLTransport

self._transport = GraphQLTransport(
controller,
transport_options,
)
case TangoOptions():
from .transport.tango.adapter import TangoTransport

Expand Down
2 changes: 2 additions & 0 deletions src/fastcs/transport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from .epics.options import EpicsGUIOptions as EpicsGUIOptions
from .epics.options import EpicsIOCOptions as EpicsIOCOptions
from .epics.options import EpicsOptions as EpicsOptions
from .graphQL.options import GraphQLOptions as GraphQLOptions
from .graphQL.options import GraphQLServerOptions as GraphQLServerOptions
from .rest.options import RestOptions as RestOptions
from .rest.options import RestServerOptions as RestServerOptions
from .tango.options import TangoDSROptions as TangoDSROptions
Expand Down
Empty file.
24 changes: 24 additions & 0 deletions src/fastcs/transport/graphQL/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from fastcs.controller import Controller
from fastcs.transport.adapter import TransportAdapter

from .graphQL import GraphQLServer
from .options import GraphQLOptions


class GraphQLTransport(TransportAdapter):
def __init__(
self,
controller: Controller,
options: GraphQLOptions | None = None,
):
self.options = options or GraphQLOptions()
self._server = GraphQLServer(controller)

def create_docs(self) -> None:
raise NotImplementedError

def create_gui(self) -> None:
raise NotImplementedError

def run(self) -> None:
self._server.run(self.options.gql)
198 changes: 198 additions & 0 deletions src/fastcs/transport/graphQL/graphQL.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any

import strawberry
import uvicorn
from strawberry.asgi import GraphQL
from strawberry.tools import create_type
from strawberry.types.field import StrawberryField

from fastcs.attributes import AttrR, AttrRW, AttrW, T
from fastcs.controller import BaseController, Controller

from .options import GraphQLServerOptions


class GraphQLServer:
def __init__(self, controller: Controller):
self._controller = controller
self._fields_tree: FieldTree = FieldTree("")
self._app = self._create_app()

def _create_app(self) -> GraphQL:
_add_attribute_operations(self._fields_tree, self._controller)
_add_command_mutations(self._fields_tree, self._controller)

schema_kwargs = {}
for key in ["query", "mutation"]:
if s_type := self._fields_tree.create_strawberry_type(key):
schema_kwargs[key] = s_type
schema = strawberry.Schema(**schema_kwargs) # type: ignore
app = GraphQL(schema)

return app

def run(self, options: GraphQLServerOptions | None = None) -> None:
if options is None:
options = GraphQLServerOptions()

uvicorn.run(
self._app,
host=options.host,
port=options.port,
log_level=options.log_level,
)


def _wrap_attr_set(
attr_name: str,
attribute: AttrW[T],
) -> Callable[[T], Coroutine[Any, Any, None]]:
async def _dynamic_f(value):
await attribute.process(value)
return value

# Add type annotations for validation, schema, conversions
_dynamic_f.__name__ = attr_name
_dynamic_f.__annotations__["value"] = attribute.datatype.dtype
_dynamic_f.__annotations__["return"] = attribute.datatype.dtype

return _dynamic_f


def _wrap_attr_get(
attr_name: str,
attribute: AttrR[T],
) -> Callable[[], Coroutine[Any, Any, Any]]:
async def _dynamic_f() -> Any:
return attribute.get()

_dynamic_f.__name__ = attr_name
_dynamic_f.__annotations__["return"] = attribute.datatype.dtype

return _dynamic_f


def _wrap_as_field(
field_name: str,
strawberry_type: type,
) -> StrawberryField:
def _dynamic_field():
return strawberry_type()

_dynamic_field.__name__ = field_name
_dynamic_field.__annotations__["return"] = strawberry_type

return strawberry.field(_dynamic_field)


class FieldTree:
def __init__(self, name: str):
self.name = name
self.children: dict[str, FieldTree] = {}
self.fields: dict[str, list[StrawberryField]] = {
"query": [],
"mutation": [],
}

def insert(self, path: list[str]) -> "FieldTree":
# Create child if not exist
name = path.pop(0)
if child := self.get_child(name):
pass
else:
child = FieldTree(name)
self.children[name] = child

# Recurse if needed
if path:
return child.insert(path)
else:
return child

def get_child(self, name: str) -> "FieldTree | None":
if name in self.children:
return self.children[name]
else:
return None

def create_strawberry_type(self, strawberry_type: str) -> type | None:
for child in self.children.values():
if new_type := child.create_strawberry_type(strawberry_type):
child_field = _wrap_as_field(
child.name,
new_type,
)
self.fields[strawberry_type].append(child_field)

if self.fields[strawberry_type]:
return create_type(
f"{self.name}{strawberry_type}", self.fields[strawberry_type]
)
else:
return None


def _add_attribute_operations(
fields_tree: FieldTree,
controller: Controller,
) -> None:
for single_mapping in controller.get_controller_mappings():
path = single_mapping.controller.path
if path:
node = fields_tree.insert(path)
else:
node = fields_tree

if node is not None:
for attr_name, attribute in single_mapping.attributes.items():
match attribute:
# mutation for server changes https://graphql.org/learn/queries/
case AttrRW():
node.fields["query"].append(
strawberry.field(_wrap_attr_get(attr_name, attribute))
)
node.fields["mutation"].append(
strawberry.mutation(_wrap_attr_set(attr_name, attribute))
)
case AttrR():
node.fields["query"].append(
strawberry.field(_wrap_attr_get(attr_name, attribute))
)
case AttrW():
node.fields["mutation"].append(
strawberry.mutation(_wrap_attr_set(attr_name, attribute))
)


def _wrap_command(
method_name: str, method: Callable, controller: BaseController
) -> Callable[..., Awaitable[bool]]:
async def _dynamic_f() -> bool:
await getattr(controller, method.__name__)()
return True

_dynamic_f.__name__ = method_name

return _dynamic_f


def _add_command_mutations(fields_tree: FieldTree, controller: Controller) -> None:
for single_mapping in controller.get_controller_mappings():
path = single_mapping.controller.path
if path:
node = fields_tree.insert(path)
else:
node = fields_tree

if node is not None:
for cmd_name, method in single_mapping.command_methods.items():
node.fields["mutation"].append(
strawberry.mutation(
_wrap_command(
cmd_name,
method.fn,
single_mapping.controller,
)
)
)
13 changes: 13 additions & 0 deletions src/fastcs/transport/graphQL/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from dataclasses import dataclass, field


@dataclass
class GraphQLServerOptions:
host: str = "localhost"
port: int = 8080
log_level: str = "info"


@dataclass
class GraphQLOptions:
gql: GraphQLServerOptions = field(default_factory=GraphQLServerOptions)
Loading

0 comments on commit 66f46fa

Please sign in to comment.