From 8f91fa82e6c730f3456efa94c193297bdb03c34a Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Thu, 20 Mar 2025 11:17:25 +0000 Subject: [PATCH 1/3] add graphql endpoints for parameters only --- pyproject.toml | 2 +- src/daq_config_server/app.py | 68 +++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 929a2f9..4a386bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ readme = "README.md" requires-python = ">=3.11" [project.optional-dependencies] -server = ["fastapi", "uvicorn", "redis", "hiredis"] +server = ["fastapi", "uvicorn", "redis", "hiredis", "strawberry-graphql"] dev = [ "copier", "httpx", diff --git a/src/daq_config_server/app.py b/src/daq_config_server/app.py index ee45aea..c589c48 100644 --- a/src/daq_config_server/app.py +++ b/src/daq_config_server/app.py @@ -1,10 +1,12 @@ from os import environ +import strawberry import uvicorn from fastapi import FastAPI, Request, Response, status from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from redis import Redis +from strawberry.fastapi import GraphQLRouter from .beamline_parameters import ( BEAMLINE_PARAMETER_PATHS, @@ -12,8 +14,68 @@ ) from .constants import DATABASE_KEYS, ENDPOINTS +BEAMLINE_PARAM_PATH = "" +BEAMLINE_PARAMS: GDABeamlineParameters | None = None DEV_MODE = bool(int(environ.get("DEV_MODE") or 0)) + +@strawberry.type +class BeamlineParameter: + key: str + value: str | None # Some parameters may be None + + +@strawberry.type +class FeatureFlag: + name: str + value: bool + + +@strawberry.type +class Query: + @strawberry.field + def beamline_parameter(self, key: str) -> BeamlineParameter | None: + """Fetch a single beamline parameter""" + if BEAMLINE_PARAMS is None: + return None + + value = BEAMLINE_PARAMS.params.get(key) + if value is None: + return None + return BeamlineParameter(key=key, value=value) + + @strawberry.field + def all_beamline_parameters( + self, keys: list[str] | None = None + ) -> list[BeamlineParameter]: + """Fetch multiple beamline parameters (all or filtered)""" + if BEAMLINE_PARAMS is None: + return [] + if keys is None: + return [ + BeamlineParameter(key=k, value=v) + for k, v in BEAMLINE_PARAMS.params.items() + ] + return [ + BeamlineParameter(key=k, value=BEAMLINE_PARAMS.params.get(k)) + for k in keys + if k in BEAMLINE_PARAMS.params + ] + + @strawberry.field + def feature_flags(self, get_values: bool = False) -> list[FeatureFlag]: + """Get all feature flags (as names or with values)""" + flags = valkey.smembers("feature_flags") # Set name in Redis + if not get_values: + return [ + FeatureFlag(name=flag, value=False) for flag in flags + ] # Only names, values ignored + return [ + FeatureFlag(name=flag, value=bool(int(valkey.get(flag)))) for flag in flags + ] + + +graphql_schema = strawberry.Schema(query=Query) ROOT_PATH = "/api" print(f"{DEV_MODE=}") print(f"{ROOT_PATH=}") @@ -35,14 +97,12 @@ allow_methods=["*"], allow_headers=["*"], ) +graphql_app = GraphQLRouter(graphql_schema) +app.include_router(graphql_app, prefix="/graphql") valkey = Redis(host="localhost", port=6379, decode_responses=True) - __all__ = ["main"] -BEAMLINE_PARAM_PATH = "" -BEAMLINE_PARAMS: GDABeamlineParameters | None = None - @app.get(ENDPOINTS.BL_PARAM + "/{param}") def get_beamline_parameter(param: str): From cce987d38acd078b73105795330daf4487c30b55 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Thu, 20 Mar 2025 11:47:26 +0000 Subject: [PATCH 2/3] inital approach to implementation --- src/daq_config_server/app.py | 54 +++++++++++++-- tests/test_graphql.py | 129 +++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/test_graphql.py diff --git a/src/daq_config_server/app.py b/src/daq_config_server/app.py index c589c48..5caf745 100644 --- a/src/daq_config_server/app.py +++ b/src/daq_config_server/app.py @@ -20,17 +20,30 @@ @strawberry.type -class BeamlineParameter: +class BeamlineParameterModel(BaseModel): key: str - value: str | None # Some parameters may be None + value: str | None -@strawberry.type -class FeatureFlag: +@strawberry.experimental.pydantic.pydantic_model( + model=BeamlineParameterModel, all_fields=True +) +class BeamlineParameter: + pass + + +class FeatureFlagModel(BaseModel): name: str value: bool +@strawberry.experimental.pydantic.pydantic_model( + model=FeatureFlagModel, all_fields=True +) +class FeatureFlag: + pass + + @strawberry.type class Query: @strawberry.field @@ -75,7 +88,35 @@ def feature_flags(self, get_values: bool = False) -> list[FeatureFlag]: ] -graphql_schema = strawberry.Schema(query=Query) +@strawberry.type +class Mutation: + @strawberry.mutation + def create_feature_flag(self, name: str, value: bool = False) -> FeatureFlag: + """Create a new feature flag. If it already exists, return an error.""" + if valkey.sismember("feature_flags", name): + raise ValueError(f"Feature flag '{name}' already exists!") + valkey.sadd("feature_flags", name) + valkey.set(name, int(value)) + return FeatureFlag(name=name, value=value) + + @strawberry.mutation + def update_feature_flag(self, name: str, value: bool) -> FeatureFlag: + """Update the value of an existing feature flag.""" + if not valkey.sismember("feature_flags", name): + raise ValueError(f"Feature flag '{name}' does not exist!") + valkey.set(name, int(value)) + return FeatureFlag(name=name, value=value) + + @strawberry.mutation + def delete_feature_flag(self, name: str) -> bool: + """Delete a feature flag. Returns True if deleted, False if not found.""" + if not valkey.sismember("feature_flags", name): + return False + valkey.srem("feature_flags", name) + valkey.delete(name) + return True + + ROOT_PATH = "/api" print(f"{DEV_MODE=}") print(f"{ROOT_PATH=}") @@ -97,6 +138,9 @@ def feature_flags(self, get_values: bool = False) -> list[FeatureFlag]: allow_methods=["*"], allow_headers=["*"], ) + +graphql_schema = strawberry.Schema(query=Query, mutation=Mutation) + graphql_app = GraphQLRouter(graphql_schema) app.include_router(graphql_app, prefix="/graphql") diff --git a/tests/test_graphql.py b/tests/test_graphql.py new file mode 100644 index 0000000..26fd6ed --- /dev/null +++ b/tests/test_graphql.py @@ -0,0 +1,129 @@ +from fastapi.testclient import TestClient + +from daq_config_server.app import app + +client = TestClient(app) + +GRAPHQL_ENDPOINT = "/graphql" + + +def graphql_query(query: str, variables: dict | None = None): + """Helper function to send GraphQL queries.""" + if variables is None: + variables = {} + response = client.post( + GRAPHQL_ENDPOINT, json={"query": query, "variables": variables} + ) + return response.json() + + +def test_fetch_single_beamline_parameter(): + query = """ + query ($key: String!) { + beamlineParameter(key: $key) { + key + value + } + } + """ + variables = {"key": "energy"} + response = graphql_query(query, variables) + + assert "errors" not in response + assert response["data"]["beamlineParameter"]["key"] == "energy" + assert response["data"]["beamlineParameter"]["value"] is not None + + +def test_fetch_all_beamline_parameters(): + query = """ + query { + allBeamlineParameters { + key + value + } + } + """ + response = graphql_query(query) + + assert "errors" not in response + assert isinstance(response["data"]["allBeamlineParameters"], list) + assert len(response["data"]["allBeamlineParameters"]) > 0 + + +def test_fetch_feature_flags(): + query = """ + query { + featureFlags { + name + } + } + """ + response = graphql_query(query) + + assert "errors" not in response + assert isinstance(response["data"]["featureFlags"], list) + + +def test_fetch_feature_flags_with_values(): + query = """ + query { + featureFlags(getValues: true) { + name + value + } + } + """ + response = graphql_query(query) + + assert "errors" not in response + assert isinstance(response["data"]["featureFlags"], list) + for flag in response["data"]["featureFlags"]: + assert isinstance(flag["name"], str) + assert isinstance(flag["value"], bool) + + +def test_create_feature_flag(): + mutation = """ + mutation ($name: String!, $value: Boolean!) { + createFeatureFlag(name: $name, value: $value) { + name + value + } + } + """ + variables = {"name": "dark_mode", "value": True} + response = graphql_query(mutation, variables) + + assert "errors" not in response + assert response["data"]["createFeatureFlag"]["name"] == "dark_mode" + assert response["data"]["createFeatureFlag"]["value"] is True + + +def test_update_feature_flag(): + mutation = """ + mutation ($name: String!, $value: Boolean!) { + updateFeatureFlag(name: $name, value: $value) { + name + value + } + } + """ + variables = {"name": "dark_mode", "value": False} + response = graphql_query(mutation, variables) + + assert "errors" not in response + assert response["data"]["updateFeatureFlag"]["name"] == "dark_mode" + assert response["data"]["updateFeatureFlag"]["value"] is False + + +def test_delete_feature_flag(): + mutation = """ + mutation ($name: String!) { + deleteFeatureFlag(name: $name) + } + """ + variables = {"name": "dark_mode"} + response = graphql_query(mutation, variables) + + assert "errors" not in response + assert response["data"]["deleteFeatureFlag"] is True From b4973d415e98605525d7c1886bfc787b3d423806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Malinowski?= <56644812+stan-dot@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:25:23 +0000 Subject: [PATCH 3/3] Update src/daq_config_server/app.py Co-authored-by: Callum Forrester --- src/daq_config_server/app.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/daq_config_server/app.py b/src/daq_config_server/app.py index 5caf745..a018fa3 100644 --- a/src/daq_config_server/app.py +++ b/src/daq_config_server/app.py @@ -46,17 +46,6 @@ class FeatureFlag: @strawberry.type class Query: - @strawberry.field - def beamline_parameter(self, key: str) -> BeamlineParameter | None: - """Fetch a single beamline parameter""" - if BEAMLINE_PARAMS is None: - return None - - value = BEAMLINE_PARAMS.params.get(key) - if value is None: - return None - return BeamlineParameter(key=key, value=value) - @strawberry.field def all_beamline_parameters( self, keys: list[str] | None = None