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 committed Dec 3, 2024
1 parent 7d602ce commit ef2c5c4
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 @@ -12,11 +12,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 @@ -36,6 +37,13 @@ def __init__(
self._backend.dispatcher,
transport_options,
)
case GraphQLOptions():
from .transport.graphQL.adapter import GraphQLTransport

Check warning on line 41 in src/fastcs/launch.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/launch.py#L41

Added line #L41 was not covered by tests

self._transport = GraphQLTransport(

Check warning on line 43 in src/fastcs/launch.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/launch.py#L43

Added line #L43 was not covered by tests
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

Check warning on line 18 in src/fastcs/transport/graphQL/adapter.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/adapter.py#L18

Added line #L18 was not covered by tests

def create_gui(self) -> None:
raise NotImplementedError

Check warning on line 21 in src/fastcs/transport/graphQL/adapter.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/adapter.py#L21

Added line #L21 was not covered by tests

def run(self) -> None:
self._server.run(self.options.gql)

Check warning on line 24 in src/fastcs/transport/graphQL/adapter.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/adapter.py#L24

Added line #L24 was not covered by tests
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()

Check warning on line 37 in src/fastcs/transport/graphQL/graphQL.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/graphQL.py#L36-L37

Added lines #L36 - L37 were not covered by tests

uvicorn.run(

Check warning on line 39 in src/fastcs/transport/graphQL/graphQL.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/graphQL.py#L39

Added line #L39 was not covered by tests
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

Check warning on line 102 in src/fastcs/transport/graphQL/graphQL.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/graphQL.py#L102

Added line #L102 was not covered by tests
else:
child = FieldTree(name)
self.children[name] = child

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

Check warning on line 109 in src/fastcs/transport/graphQL/graphQL.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/graphQL.py#L109

Added line #L109 was not covered by tests
else:
return child

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

Check warning on line 115 in src/fastcs/transport/graphQL/graphQL.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/graphQL.py#L115

Added line #L115 was not covered by tests
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)

Check warning on line 184 in src/fastcs/transport/graphQL/graphQL.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/graphQL.py#L184

Added line #L184 was not covered by tests
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 ef2c5c4

Please sign in to comment.