diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..33c2a1e --- /dev/null +++ b/api/main.py @@ -0,0 +1,33 @@ +import os +from fastapi import FastAPI, Depends, HTTPException, Security +from fastapi.security.api_key import APIKeyHeader +from strawberry.fastapi import GraphQLRouter +from dotenv import load_dotenv + +from api.schema import schema + +load_dotenv() + +API_KEY_NAME = "X-API-Key" +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + +def get_api_key(api_key_header: str = Security(api_key_header)): + expected_api_key = os.getenv("API_KEY") + if not expected_api_key: + # If no key is configured, deny all requests for safety + raise HTTPException(status_code=500, detail="API_KEY not configured on server") + + if api_key_header == expected_api_key: + return api_key_header + raise HTTPException(status_code=401, detail="Invalid or missing API Key") + +graphql_app = GraphQLRouter(schema) + +app = FastAPI(title="Wecker API") + +# Add auth dependency to the graphql route +app.include_router(graphql_app, prefix="/graphql", dependencies=[Depends(get_api_key)]) + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/api/schema.py b/api/schema.py new file mode 100644 index 0000000..16eba66 --- /dev/null +++ b/api/schema.py @@ -0,0 +1,68 @@ +import strawberry +from typing import List, Optional +import os +from api.crontab_manager import CrontabManager + +def get_manager(): + tabfile = os.getenv("TABFILE") + return CrontabManager(tabfile=tabfile) + +@strawberry.type +class Alarm: + id: str + cron_expression: str + command: str + is_enabled: bool + +@strawberry.type +class Query: + @strawberry.field + def get_alarms(self) -> List[Alarm]: + manager = get_manager() + return [Alarm(**a) for a in manager.get_alarms()] + + @strawberry.field + def get_alarm(self, id: str) -> Optional[Alarm]: + manager = get_manager() + alarms = manager.get_alarms() + for a in alarms: + if a["id"] == id: + return Alarm(**a) + return None + +@strawberry.type +class Mutation: + @strawberry.field + def set_alarm( + self, + cron_expression: str, + command: str, + is_enabled: bool = True, + id: Optional[str] = None + ) -> Alarm: + manager = get_manager() + new_id = manager.set_alarm( + alarm_id=id, + cron_expression=cron_expression, + command=command, + is_enabled=is_enabled + ) + return Alarm( + id=new_id, + cron_expression=cron_expression, + command=command, + is_enabled=is_enabled + ) + + @strawberry.field + def delete_alarm(self, id: str) -> bool: + manager = get_manager() + # Verify it exists + alarms = manager.get_alarms() + exists = any(a["id"] == id for a in alarms) + if exists: + manager.delete_alarm(id) + return True + return False + +schema = strawberry.Schema(query=Query, mutation=Mutation) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..1d7db80 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,117 @@ +from fastapi.testclient import TestClient +import tempfile +import os + +# We need to set the environment variable before importing the app +os.environ["API_KEY"] = "test-secret" +dummy_tab = tempfile.mktemp() +with open(dummy_tab, "w") as f: + f.write("") +os.environ["TABFILE"] = dummy_tab + +from api.main import app # noqa: E402 + +client = TestClient(app) + +def test_auth_missing(): + query = """ + query { + getAlarms { + id + } + } + """ + response = client.post("/graphql", json={"query": query}) + assert response.status_code == 401 + +def test_auth_invalid(): + query = """ + query { + getAlarms { + id + } + } + """ + response = client.post("/graphql", json={"query": query}, headers={"X-API-Key": "wrong"}) + assert response.status_code == 401 + +def test_graphql_workflow(): + headers = {"X-API-Key": "test-secret"} + + # 1. Get empty alarms + query_get = """ + query { + getAlarms { + id + } + } + """ + res = client.post("/graphql", json={"query": query_get}, headers=headers) + assert res.status_code == 200 + assert res.json()["data"]["getAlarms"] == [] + + # 2. Set alarm + mutation_set = """ + mutation { + setAlarm(cronExpression: "30 7 * * *", command: "python wecker.py", isEnabled: true) { + id + cronExpression + command + isEnabled + } + } + """ + res = client.post("/graphql", json={"query": mutation_set}, headers=headers) + assert res.status_code == 200 + alarm = res.json()["data"]["setAlarm"] + assert alarm["cronExpression"] == "30 7 * * *" + assert alarm["command"] == "python wecker.py" + assert alarm["isEnabled"] is True + alarm_id = alarm["id"] + + # 3. Get alarms lists it + res = client.post("/graphql", json={"query": query_get}, headers=headers) + assert len(res.json()["data"]["getAlarms"]) == 1 + assert res.json()["data"]["getAlarms"][0]["id"] == alarm_id + + # 4. Get specific alarm + query_one = f""" + query {{ + getAlarm(id: "{alarm_id}") {{ + id + cronExpression + }} + }} + """ + res = client.post("/graphql", json={"query": query_one}, headers=headers) + assert res.json()["data"]["getAlarm"]["id"] == alarm_id + assert res.json()["data"]["getAlarm"]["cronExpression"] == "30 7 * * *" + + # 5. Update alarm + mutation_update = f""" + mutation {{ + setAlarm(id: "{alarm_id}", cronExpression: "0 8 * * *", command: "python wecker.py", isEnabled: false) {{ + id + cronExpression + isEnabled + }} + }} + """ + res = client.post("/graphql", json={"query": mutation_update}, headers=headers) + alarm_updated = res.json()["data"]["setAlarm"] + assert alarm_updated["id"] == alarm_id + assert alarm_updated["cronExpression"] == "0 8 * * *" + assert alarm_updated["isEnabled"] is False + + # 6. Delete alarm + mutation_delete = f""" + mutation {{ + deleteAlarm(id: "{alarm_id}") + }} + """ + res = client.post("/graphql", json={"query": mutation_delete}, headers=headers) + assert res.json()["data"]["deleteAlarm"] is True + + # 7. List again is empty + res = client.post("/graphql", json={"query": query_get}, headers=headers) + assert res.json()["data"]["getAlarms"] == []