Skip to content

Commit

Permalink
Merge pull request #257 from dknowles2/rest
Browse files Browse the repository at this point in the history
Introduce a hybrid client that can multiplex across the GraphQL & REST APIs
  • Loading branch information
dknowles2 authored Jan 19, 2025
2 parents 679eec7 + 6012fdd commit 817e559
Show file tree
Hide file tree
Showing 14 changed files with 1,943 additions and 1,246 deletions.
99 changes: 77 additions & 22 deletions pydrawise/auth.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
"""Authentication support for the Hydrawise v2 GraphQL API."""

from asyncio import Lock
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock

import aiohttp

from .base import BaseAuth
from .const import CLIENT_ID, CLIENT_SECRET, REQUEST_TIMEOUT, REST_URL, TOKEN_URL
from .exceptions import NotAuthorizedError

CLIENT_ID = "hydrawise_app"
CLIENT_SECRET = "zn3CrjglwNV1"
TOKEN_URL = "https://app.hydrawise.com/api/v2/oauth/access-token"
DEFAULT_TIMEOUT = 60
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60)
_INVALID_API_KEY = "API key not valid"


class Auth:
@dataclass
class Token:
"""Authentication token."""

token: str
refresh: str
type: str
expires: datetime

def __str__(self) -> str:
return f"{self.type} {self.token}"


class Auth(BaseAuth):
"""Authentication support for the Hydrawise GraphQL API."""

def __init__(self, username: str, password: str) -> None:
Expand All @@ -25,10 +39,7 @@ def __init__(self, username: str, password: str) -> None:
self.__username = username
self.__password = password
self._lock = Lock()
self._token: str | None = None
self._token_type: str | None = None
self._token_expires: datetime | None = None
self._refresh_token: str | None = None
self._token: Token | None = None

async def _fetch_token_locked(self, refresh=False):
data = {
Expand All @@ -38,7 +49,7 @@ async def _fetch_token_locked(self, refresh=False):
if refresh:
assert self._token is not None
data["grant_type"] = "refresh_token"
data["refresh_token"] = self._refresh_token
data["refresh_token"] = self._token.refresh
else:
data["grant_type"] = "password"
data["scope"] = "all"
Expand All @@ -53,23 +64,26 @@ async def _fetch_token_locked(self, refresh=False):
) as resp:
resp_json = await resp.json()
if "error" in resp_json:
self._token_type = None
self._token = None
self._token_expires = None
raise NotAuthorizedError(resp_json["message"])
self._token = resp_json["access_token"]
self._refresh_token = resp_json["refresh_token"]
self._token_type = resp_json["token_type"]
self._token_expires = datetime.now() + timedelta(
seconds=resp_json["expires_in"]
self._token = Token(
token=resp_json["access_token"],
refresh=resp_json["refresh_token"],
type=resp_json["token_type"],
expires=datetime.now() + timedelta(seconds=resp_json["expires_in"]),
)

async def check(self) -> bool:
"""Validates that the credentials are valid."""
await self.check_token()
return True

async def check_token(self):
"""Checks a token and refreshes if necessary."""
with self._lock:
async with self._lock:
if self._token is None:
await self._fetch_token_locked(refresh=False)
elif self._token_expires - datetime.now() < timedelta(minutes=5):
elif self._token.expires - datetime.now() < timedelta(minutes=5):
await self._fetch_token_locked(refresh=True)

async def token(self) -> str:
Expand All @@ -78,5 +92,46 @@ async def token(self) -> str:
:rtype: string
"""
await self.check_token()
with self._lock:
return f"{self._token_type} {self._token}"
async with self._lock:
return str(self._token)


class RestAuth(BaseAuth):
"""Authentication support for the Hydrawise REST API."""

def __init__(self, api_key: str) -> None:
"""Initializer."""
self._api_key = api_key

async def get(self, path: str, **kwargs) -> dict:
"""Perform an authenticated GET request and return the JSON response."""
url = f"{REST_URL}/{path}"
params = {"api_key": self._api_key}
params.update(kwargs)
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=REQUEST_TIMEOUT) as resp:
if resp.status == 404 and await resp.text() == _INVALID_API_KEY:
raise NotAuthorizedError(_INVALID_API_KEY)
resp.raise_for_status()
return await resp.json()

async def check(self) -> bool:
"""Validates that the credentials are valid."""
await self.get("customerdetails.php")
return True


class HybridAuth(Auth, RestAuth):
"""Authentication support for the Hydrawise GraphQL & REST APIs."""

def __init__(self, username: str, password: str, api_key: str) -> None:
"""Initializer."""
Auth.__init__(self, username, password)
RestAuth.__init__(self, api_key)

async def _check_api_token(self):
await self.get("customerdetails.php")

async def check(self) -> bool:
await super().check()
return True
63 changes: 62 additions & 1 deletion pydrawise/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,27 @@
from abc import ABC, abstractmethod
from datetime import datetime

from .schema import Controller, User, Zone, ZoneSuspension
from .schema import (
Controller,
ControllerWaterUseSummary,
Sensor,
SensorFlowSummary,
User,
WateringReportEntry,
Zone,
ZoneSuspension,
)


class BaseAuth(ABC):
"""Base class for Authentication objects."""

@abstractmethod
async def check(self) -> bool:
"""Validates that the credentials are valid.
Returns True on success, otherwise should raise NotAuthorizedError.
"""


class HydrawiseBase(ABC):
Expand Down Expand Up @@ -130,3 +150,44 @@ async def delete_zone_suspension(self, suspension: ZoneSuspension) -> None:
:param suspension: The suspension to delete.
"""

@abstractmethod
async def get_sensors(self, controller: Controller) -> list[Sensor]:
"""Retrieves sensors associated with the given controller.
:param controller: Controller whose sensors to fetch.
:rtype: list[Sensor]
"""

@abstractmethod
async def get_water_flow_summary(
self, controller: Controller, sensor: Sensor, start: datetime, end: datetime
) -> SensorFlowSummary:
"""Retrieves the water flow summary for a given sensor.
:param controller: Controller that controls the sensor.
:param sensor: Sensor for which a water flow summary is fetched.
:param start:
:param end:
:rtype: list[Sensor]
"""

@abstractmethod
async def get_watering_report(
self, controller: Controller, start: datetime, end: datetime
) -> list[WateringReportEntry]:
"""Retrieves a watering report for the given controller and time period.
:param controller: The controller whose watering report to generate.
:param start: Start time.
:param end: End time."""

@abstractmethod
async def get_water_use_summary(
self, controller: Controller, start: datetime, end: datetime
) -> ControllerWaterUseSummary:
"""Calculate the water use for the given controller and time period.
:param controller: The controller whose water use to report.
:param start: Start time
:param end: End time."""
9 changes: 4 additions & 5 deletions pydrawise/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Client library for interacting with Hydrawise's cloud API."""
"""Asynchronous client library for interacting with Hydrawise's GraphQL API."""

import logging
from datetime import datetime, timedelta
Expand All @@ -10,6 +10,7 @@

from .auth import Auth
from .base import HydrawiseBase
from .const import DEFAULT_APP_ID, GRAPHQL_URL
from .exceptions import MutationError
from .schema import (
DSL_SCHEMA,
Expand All @@ -32,8 +33,6 @@
# GQL is quite chatty in logs by default.
gql_log.setLevel(logging.ERROR)

API_URL = "https://app.hydrawise.com/api/v2/graph"


def _prune_watering_report_entries(
entries: list[WateringReportEntry], start: datetime, end: datetime
Expand Down Expand Up @@ -71,7 +70,7 @@ class Hydrawise(HydrawiseBase):
Should be instantiated with an Auth object that handles authentication and low-level transport.
"""

def __init__(self, auth: Auth, app_id: str = "pydrawise") -> None:
def __init__(self, auth: Auth, app_id: str = DEFAULT_APP_ID) -> None:
"""Initializes the client.
:param auth: Handles authentication and transport.
Expand All @@ -82,7 +81,7 @@ def __init__(self, auth: Auth, app_id: str = "pydrawise") -> None:

async def _client(self) -> Client:
headers = {"Authorization": await self._auth.token()}
transport = AIOHTTPTransport(url=API_URL, headers=headers)
transport = AIOHTTPTransport(url=GRAPHQL_URL, headers=headers)
return Client(transport=transport, parse_results=True)

async def _query(self, selector: DSLSelectable) -> dict:
Expand Down
14 changes: 14 additions & 0 deletions pydrawise/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Constants."""

from aiohttp import ClientTimeout

GRAPHQL_URL = "https://app.hydrawise.com/api/v2/graph"
TOKEN_URL = "https://app.hydrawise.com/api/v2/oauth/access-token"
REST_URL = "https://api.hydrawise.com/api/v1"

CLIENT_ID = "hydrawise_app"
CLIENT_SECRET = "zn3CrjglwNV1"

DEFAULT_APP_ID = "pydrawise"

REQUEST_TIMEOUT = ClientTimeout(total=30)
4 changes: 4 additions & 0 deletions pydrawise/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ class MutationError(Error):

class UnknownError(Error):
"""Raised when an unknown problem occurs."""


class ThrottledError(Error):
"""Raised when a request has been throttled."""
Loading

0 comments on commit 817e559

Please sign in to comment.