wave-api 0.1.0

Typed Rust client for the Wave Accounting GraphQL API
Documentation
#!/usr/bin/env python3
"""Wave OAuth2 authorization flow helper.

Starts a local server, opens the browser for Wave authorization,
captures the auth code, exchanges it for tokens, and updates .env.
"""

import http.server
import json
import os
import sys
import urllib.parse
import urllib.request
import webbrowser
from pathlib import Path

WAVE_AUTH_URL = "https://api.waveapps.com/oauth2/authorize/"
WAVE_TOKEN_URL = "https://api.waveapps.com/oauth2/token/"
REDIRECT_PORT = 3099
REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}/callback"

ENV_PATH = Path(__file__).parent / ".env"


def load_env():
    env = {}
    for line in ENV_PATH.read_text().splitlines():
        line = line.strip()
        if line and "=" in line and not line.startswith("#"):
            key, val = line.split("=", 1)
            env[key.strip()] = val.strip()
    return env


def save_tokens(access_token, refresh_token):
    lines = ENV_PATH.read_text().splitlines()
    new_lines = []
    keys_written = set()
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("TOKEN="):
            new_lines.append(f"TOKEN={access_token}")
            keys_written.add("TOKEN")
        elif stripped.startswith("REFRESH_TOKEN="):
            new_lines.append(f"REFRESH_TOKEN={refresh_token}")
            keys_written.add("REFRESH_TOKEN")
        else:
            new_lines.append(line)
    if "TOKEN" not in keys_written:
        new_lines.append(f"TOKEN={access_token}")
    if "REFRESH_TOKEN" not in keys_written:
        new_lines.append(f"REFRESH_TOKEN={refresh_token}")
    ENV_PATH.write_text("\n".join(new_lines) + "\n")


def exchange_code(client_id, client_secret, code):
    data = urllib.parse.urlencode({
        "client_id": client_id,
        "client_secret": client_secret,
        "code": code,
        "grant_type": "authorization_code",
        "redirect_uri": REDIRECT_URI,
    }).encode()
    req = urllib.request.Request(WAVE_TOKEN_URL, data=data, headers={
        "Content-Type": "application/x-www-form-urlencoded",
    })
    resp = urllib.request.urlopen(req)
    return json.loads(resp.read())


class CallbackHandler(http.server.BaseHTTPRequestHandler):
    auth_code = None

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == "/callback":
            params = urllib.parse.parse_qs(parsed.query)
            if "code" in params:
                CallbackHandler.auth_code = params["code"][0]
                self.send_response(200)
                self.send_header("Content-Type", "text/html")
                self.end_headers()
                self.wfile.write(b"<h1>Authorization successful!</h1><p>You can close this tab.</p>")
            else:
                error = params.get("error", ["unknown"])[0]
                self.send_response(400)
                self.send_header("Content-Type", "text/html")
                self.end_headers()
                self.wfile.write(f"<h1>Error: {error}</h1>".encode())
        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, format, *args):
        pass  # suppress logs


def main():
    env = load_env()
    client_id = env.get("CLIENT_ID")
    client_secret = env.get("CLIENT_SECRET")
    if not client_id or not client_secret:
        print("ERROR: CLIENT_ID and CLIENT_SECRET must be set in .env")
        sys.exit(1)

    auth_params = urllib.parse.urlencode({
        "client_id": client_id,
        "response_type": "code",
        "scope": "account:read business:read customer:read invoice:read product:read sales_tax:read transaction:* user:read vendor:read",
        "redirect_uri": REDIRECT_URI,
    })
    auth_url = f"{WAVE_AUTH_URL}?{auth_params}"

    print(f"Starting callback server on port {REDIRECT_PORT}...")
    server = http.server.HTTPServer(("localhost", REDIRECT_PORT), CallbackHandler)

    print(f"Opening browser for Wave authorization...")
    print(f"\nIf browser doesn't open, visit:\n{auth_url}\n")
    webbrowser.open(auth_url)

    print("Waiting for authorization callback...")
    while CallbackHandler.auth_code is None:
        server.handle_request()

    code = CallbackHandler.auth_code
    server.server_close()
    print(f"Got authorization code. Exchanging for tokens...")

    try:
        token_data = exchange_code(client_id, client_secret, code)
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        print(f"ERROR exchanging code: {e.code} {body}")
        sys.exit(1)

    access_token = token_data["access_token"]
    refresh_token = token_data["refresh_token"]
    expires_in = token_data.get("expires_in", "unknown")
    scope = token_data.get("scope", "")

    save_tokens(access_token, refresh_token)

    print(f"\nSuccess! Tokens saved to .env")
    print(f"  Access token expires in: {expires_in}s")
    print(f"  Scopes: {scope}")
    print(f"  Refresh token saved for automatic renewal")


if __name__ == "__main__":
    main()