diff --git a/docs/plans/2026-03-08-api-integration-design.md b/docs/plans/2026-03-08-api-integration-design.md new file mode 100644 index 0000000..86d468a --- /dev/null +++ b/docs/plans/2026-03-08-api-integration-design.md @@ -0,0 +1,48 @@ +# API Integration Example — Design + +**Date:** 2026-03-08 +**Pattern:** `trigger → api → transform → data` +**Status:** Approved + +## Goal + +Sixth runnable example for workflow-patterns. A REST API data collector that fetches from APIs, transforms JSON responses, and stores structured results. Works offline with sample data fallback. Demonstrates `api → transform → data` — fetch, reshape, persist. + +## Architecture + +``` +select_integration() → fetch_api() → transform_response() → save_results() +trigger api transform data +``` + +## Module Structure + +``` +examples/api-integration/ +├── run.py # CLI entry point +├── .env.example # Optional API tokens +├── .gitignore # .env, __pycache__, output/ +├── pyproject.toml # Zero runtime dependencies (stdlib only) +├── src/api_workflow/ +│ ├── __init__.py +│ ├── models.py # ApiConfig, ApiResponse, Integration +│ ├── integrations.py # 5 integration presets +│ ├── client.py # HTTP client with sample fallback (API layer) +│ ├── transform.py # JSON response transformers +│ ├── storage.py # Save results to file (data layer) +│ └── display.py # Terminal formatting +├── tests/ +│ ├── test_models.py +│ ├── test_integrations.py +│ ├── test_client.py +│ ├── test_transform.py +│ ├── test_storage.py +│ ├── test_display.py +│ └── __init__.py +└── output/ # Saved API results +``` + +## Dependencies + +- Zero runtime dependencies (uses urllib.request from stdlib) +- `pytest` (dev) diff --git a/examples/api-integration/.env.example b/examples/api-integration/.env.example new file mode 100644 index 0000000..bad7a8f --- /dev/null +++ b/examples/api-integration/.env.example @@ -0,0 +1,3 @@ +# API Integration — optional API tokens +# Works without any keys (uses sample data fallback) +# GITHUB_TOKEN=ghp_... diff --git a/examples/api-integration/.gitignore b/examples/api-integration/.gitignore new file mode 100644 index 0000000..f66c6ec --- /dev/null +++ b/examples/api-integration/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +output/ +*.pyc diff --git a/examples/api-integration/pyproject.toml b/examples/api-integration/pyproject.toml new file mode 100644 index 0000000..00c6c20 --- /dev/null +++ b/examples/api-integration/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "api-integration" +version = "0.1.0" +description = "API Integration workflow: trigger -> api -> transform -> data" +requires-python = ">=3.12" +dependencies = [] + +[dependency-groups] +dev = ["pytest"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/api_workflow"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/examples/api-integration/run.py b/examples/api-integration/run.py new file mode 100644 index 0000000..09a47da --- /dev/null +++ b/examples/api-integration/run.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""API Integration workflow runner. + +Pattern: trigger -> api -> transform -> data + +Fetches data from REST APIs, transforms JSON responses, +and saves structured results. Works offline with sample data. + +Usage: + uv run python run.py # interactive selection + uv run python run.py --integration 1 # select by number + uv run python run.py --format json # save as JSON instead of CSV + uv run python run.py --live # force live API call (no fallback) +""" + +import argparse +import os +import sys +from pathlib import Path + +from api_workflow.client import fetch +from api_workflow.display import format_header, format_integration_menu, format_records_table +from api_workflow.integrations import INTEGRATIONS, get_integration +from api_workflow.storage import save_csv, save_json +from api_workflow.transform import transform_response + +OUTPUT_DIR = Path(__file__).parent / "output" + + +def _load_dotenv(): + """Load .env file if it exists.""" + env_path = Path(__file__).parent / ".env" + if not env_path.exists(): + return + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, _, value = line.partition("=") + if key and value: + os.environ.setdefault(key.strip(), value.strip()) + + +def _select_integration() -> int: + """Interactive integration selection. Returns 0-based index.""" + print(format_header("API Integration — Setup")) + print("Choose an integration:") + print(format_integration_menu(INTEGRATIONS)) + print() + try: + choice = input(f"Integration (1-{len(INTEGRATIONS)}): ").strip() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + try: + return int(choice) - 1 + except ValueError: + return 0 + + +def main(): + _load_dotenv() + + parser = argparse.ArgumentParser( + description="API Integration: trigger -> api -> transform -> data" + ) + parser.add_argument( + "--integration", type=int, default=None, help="Integration number (1-5)" + ) + parser.add_argument( + "--format", choices=["csv", "json"], default="csv", help="Output format (default: csv)" + ) + parser.add_argument( + "--live", action="store_true", help="Force live API call (no sample fallback)" + ) + args = parser.parse_args() + + # Step 1: Trigger — select integration + if args.integration is not None: + integration = get_integration(args.integration - 1) + else: + integration = get_integration(_select_integration()) + + print(f"\n── {integration.name} ──\n") + + # Step 2: API — fetch data + sample = None if args.live else integration.sample_response + print(f" Fetching from {integration.api.base_url}...") + response = fetch(integration.api, sample_data=sample) + + if not response.is_success: + print(f" Error: API request failed (status {response.status})") + sys.exit(1) + + print(f" Source: {response.source}") + + # Step 3: Transform — reshape response + records = transform_response(response.data, integration.transform) + print(f" Extracted {len(records)} records\n") + + # Step 4: Data — save results + print(format_header("Results")) + print(format_records_table(records)) + + slug = integration.name.lower().replace(" ", "-") + if args.format == "json": + path = save_json(records, OUTPUT_DIR, slug) + else: + path = save_csv(records, OUTPUT_DIR, slug) + + print(f"\n Results saved to {path}\n") + + +if __name__ == "__main__": + main() diff --git a/examples/api-integration/src/api_workflow/__init__.py b/examples/api-integration/src/api_workflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/api-integration/src/api_workflow/client.py b/examples/api-integration/src/api_workflow/client.py new file mode 100644 index 0000000..a932768 --- /dev/null +++ b/examples/api-integration/src/api_workflow/client.py @@ -0,0 +1,29 @@ +"""HTTP client for the API Integration workflow (API layer). + +Fetches from REST APIs with fallback to sample data. +Uses urllib.request from stdlib — no external dependencies. +""" + +import json +import urllib.request +import urllib.error + +from api_workflow.models import ApiConfig, ApiResponse + + +def fetch_sample(sample_data: dict | list) -> ApiResponse: + """Return sample data as an ApiResponse.""" + return ApiResponse(status=200, data=sample_data, source="sample") + + +def fetch(cfg: ApiConfig, sample_data: dict | list | None = None) -> ApiResponse: + """Fetch from API, falling back to sample data on failure.""" + try: + req = urllib.request.Request(cfg.full_url, headers=cfg.headers) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + return ApiResponse(status=resp.status, data=data, source="live") + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError): + if sample_data is not None: + return fetch_sample(sample_data) + return ApiResponse(status=0, data={"error": "Request failed"}, source="error") diff --git a/examples/api-integration/src/api_workflow/display.py b/examples/api-integration/src/api_workflow/display.py new file mode 100644 index 0000000..b775016 --- /dev/null +++ b/examples/api-integration/src/api_workflow/display.py @@ -0,0 +1,52 @@ +"""Terminal display formatting for the API Integration workflow.""" + +from api_workflow.models import Integration + + +def format_header(title: str) -> str: + """Create a boxed ASCII header.""" + width = max(len(title) + 8, 42) + top = "╔" + "═" * width + "╗" + mid = "║" + title.center(width) + "║" + bot = "╚" + "═" * width + "╝" + return f"\n{top}\n{mid}\n{bot}\n" + + +def format_integration_menu(integrations: list[Integration]) -> str: + """Format integration selection menu.""" + lines = [] + for i, integ in enumerate(integrations, 1): + lines.append(f" {i}. {integ.name:<22s} {integ.description}") + return "\n".join(lines) + + +def format_records_table(records: list[dict], max_rows: int = 15) -> str: + """Format records as an ASCII table.""" + if not records: + return "(no records)" + + headers = list(records[0].keys()) + widths = {h: len(h) for h in headers} + display = records[:max_rows] + for rec in display: + for h in headers: + widths[h] = max(widths[h], len(str(rec.get(h, "")))) + + def row_str(values: dict) -> str: + cells = [str(values.get(h, "")).ljust(widths[h]) for h in headers] + return "│ " + " │ ".join(cells) + " │" + + sep_top = "┌─" + "─┬─".join("─" * widths[h] for h in headers) + "─┐" + sep_mid = "├─" + "─┼─".join("─" * widths[h] for h in headers) + "─┤" + sep_bot = "└─" + "─┴─".join("─" * widths[h] for h in headers) + "─┘" + + header_vals = {h: h for h in headers} + lines = [sep_top, row_str(header_vals), sep_mid] + for rec in display: + lines.append(row_str(rec)) + lines.append(sep_bot) + + if len(records) > max_rows: + lines.append(f"... and {len(records) - max_rows} more rows") + + return "\n".join(lines) diff --git a/examples/api-integration/src/api_workflow/integrations.py b/examples/api-integration/src/api_workflow/integrations.py new file mode 100644 index 0000000..cf97fad --- /dev/null +++ b/examples/api-integration/src/api_workflow/integrations.py @@ -0,0 +1,120 @@ +"""Integration presets for the API Integration workflow.""" + +from api_workflow.models import ApiConfig, Integration, TransformSpec + +INTEGRATIONS: list[Integration] = [ + Integration( + name="GitHub Repos", + description="Fetch popular repos, extract stars and language", + api=ApiConfig( + base_url="https://api.github.com", + endpoint="/search/repositories", + params={"q": "stars:>10000", "sort": "stars", "per_page": "10"}, + headers={"Accept": "application/vnd.github.v3+json"}, + ), + transform=TransformSpec( + extract_path="items", + fields=["full_name", "stargazers_count", "language", "description"], + rename={"stargazers_count": "stars", "full_name": "repo"}, + ), + sample_response={ + "items": [ + {"full_name": "freeCodeCamp/freeCodeCamp", "stargazers_count": 385000, "language": "TypeScript", "description": "Open-source codebase and curriculum"}, + {"full_name": "996icu/996.ICU", "stargazers_count": 269000, "language": "Rust", "description": "Repo for counting�"}, + {"full_name": "EbookFoundation/free-programming-books", "stargazers_count": 315000, "language": None, "description": "Freely available programming books"}, + {"full_name": "jwasham/coding-interview-university", "stargazers_count": 295000, "language": None, "description": "A complete computer science study plan"}, + {"full_name": "sindresorhus/awesome", "stargazers_count": 290000, "language": None, "description": "Awesome lists about all kinds of topics"}, + ] + }, + ), + Integration( + name="Weather Forecast", + description="Fetch 7-day forecast for a city", + api=ApiConfig( + base_url="https://api.open-meteo.com", + endpoint="/v1/forecast", + params={"latitude": "52.52", "longitude": "13.41", "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum", "timezone": "Europe/Berlin"}, + ), + transform=TransformSpec( + extract_path="daily", + fields=["time", "temperature_2m_max", "temperature_2m_min", "precipitation_sum"], + rename={"temperature_2m_max": "max_temp", "temperature_2m_min": "min_temp", "precipitation_sum": "rain_mm"}, + ), + sample_response={ + "daily": { + "time": ["2026-03-08", "2026-03-09", "2026-03-10", "2026-03-11", "2026-03-12", "2026-03-13", "2026-03-14"], + "temperature_2m_max": [8.2, 10.1, 12.5, 9.8, 7.3, 11.0, 13.2], + "temperature_2m_min": [2.1, 3.5, 5.0, 3.2, 1.8, 4.2, 6.1], + "precipitation_sum": [0.0, 2.3, 0.5, 5.1, 0.0, 0.0, 1.2], + } + }, + ), + Integration( + name="Currency Rates", + description="Fetch exchange rates for major currencies", + api=ApiConfig( + base_url="https://open.er-api.com", + endpoint="/v6/latest/USD", + ), + transform=TransformSpec( + extract_path="rates", + fields=["EUR", "GBP", "JPY", "CHF", "CAD", "AUD"], + ), + sample_response={ + "rates": { + "EUR": 0.92, "GBP": 0.79, "JPY": 149.50, "CHF": 0.88, + "CAD": 1.36, "AUD": 1.53, "CNY": 7.24, "INR": 83.12, + } + }, + ), + Integration( + name="HackerNews Top", + description="Fetch top stories from Hacker News", + api=ApiConfig( + base_url="https://hacker-news.firebaseio.com", + endpoint="/v0/topstories.json", + ), + transform=TransformSpec( + extract_path="", + fields=[], + ), + sample_response={ + "stories": [ + {"title": "Show HN: A new approach to distributed systems", "score": 342, "by": "pg", "url": "https://example.com/1"}, + {"title": "Why Rust is the future of systems programming", "score": 287, "by": "dang", "url": "https://example.com/2"}, + {"title": "The hidden costs of microservices", "score": 256, "by": "tptacek", "url": "https://example.com/3"}, + {"title": "Building a database from scratch in Go", "score": 198, "by": "jl", "url": "https://example.com/4"}, + {"title": "A deep dive into WebAssembly performance", "score": 175, "by": "cw", "url": "https://example.com/5"}, + ] + }, + ), + Integration( + name="System Status", + description="Check service health and uptime", + api=ApiConfig( + base_url="https://status.example.com", + endpoint="/api/v1/services", + ), + transform=TransformSpec( + extract_path="services", + fields=["name", "status", "uptime", "response_time_ms"], + rename={"response_time_ms": "latency_ms"}, + ), + sample_response={ + "services": [ + {"name": "API Gateway", "status": "operational", "uptime": "99.98%", "response_time_ms": 45}, + {"name": "Database Primary", "status": "operational", "uptime": "99.99%", "response_time_ms": 12}, + {"name": "Cache Layer", "status": "degraded", "uptime": "99.85%", "response_time_ms": 89}, + {"name": "Auth Service", "status": "operational", "uptime": "99.97%", "response_time_ms": 23}, + {"name": "CDN", "status": "operational", "uptime": "99.99%", "response_time_ms": 8}, + {"name": "Search Index", "status": "maintenance", "uptime": "99.50%", "response_time_ms": 150}, + ] + }, + ), +] + + +def get_integration(index: int) -> Integration: + """Get integration by index, clamping to valid range.""" + clamped = max(0, min(index, len(INTEGRATIONS) - 1)) + return INTEGRATIONS[clamped] diff --git a/examples/api-integration/src/api_workflow/models.py b/examples/api-integration/src/api_workflow/models.py new file mode 100644 index 0000000..699b15c --- /dev/null +++ b/examples/api-integration/src/api_workflow/models.py @@ -0,0 +1,46 @@ +"""Domain models for the API Integration workflow.""" + +from dataclasses import dataclass, field +from urllib.parse import urlencode + + +@dataclass +class ApiConfig: + base_url: str + endpoint: str + headers: dict[str, str] = field(default_factory=dict) + params: dict[str, str] = field(default_factory=dict) + + @property + def full_url(self) -> str: + url = f"{self.base_url}{self.endpoint}" + if self.params: + url += "?" + urlencode(self.params) + return url + + +@dataclass +class ApiResponse: + status: int + data: dict | list + source: str = "live" # "live" or "sample" + + @property + def is_success(self) -> bool: + return 200 <= self.status < 300 + + +@dataclass +class TransformSpec: + extract_path: str + fields: list[str] + rename: dict[str, str] = field(default_factory=dict) + + +@dataclass +class Integration: + name: str + description: str + api: ApiConfig + transform: TransformSpec + sample_response: dict | list | None = None diff --git a/examples/api-integration/src/api_workflow/storage.py b/examples/api-integration/src/api_workflow/storage.py new file mode 100644 index 0000000..e82f311 --- /dev/null +++ b/examples/api-integration/src/api_workflow/storage.py @@ -0,0 +1,37 @@ +"""Data storage for the API Integration workflow (data layer). + +Saves transformed API results to CSV/JSON files. +""" + +import csv +import json +from datetime import datetime +from pathlib import Path + + +def save_json(records: list[dict], output_dir: Path, name: str) -> Path: + """Save records as JSON.""" + output_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + path = output_dir / f"{timestamp}_{name}.json" + path.write_text(json.dumps(records, indent=2, default=str)) + return path + + +def save_csv(records: list[dict], output_dir: Path, name: str) -> Path: + """Save records as CSV.""" + output_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + path = output_dir / f"{timestamp}_{name}.csv" + + if not records: + path.write_text("") + return path + + headers = list(records[0].keys()) + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=headers) + writer.writeheader() + for rec in records: + writer.writerow({h: rec.get(h, "") for h in headers}) + return path diff --git a/examples/api-integration/src/api_workflow/transform.py b/examples/api-integration/src/api_workflow/transform.py new file mode 100644 index 0000000..3884fc1 --- /dev/null +++ b/examples/api-integration/src/api_workflow/transform.py @@ -0,0 +1,48 @@ +"""Response transformers for the API Integration workflow (transform layer). + +Pure functions to extract, filter, and reshape API response data. +""" + +from api_workflow.models import TransformSpec + + +def extract_path(data: dict | list, path: str) -> list: + """Navigate a dot-separated path and return the result as a list.""" + if not path: + return data if isinstance(data, list) else [data] + + current = data + for key in path.split("."): + if isinstance(current, dict) and key in current: + current = current[key] + else: + return [] + + if isinstance(current, list): + return current + return [current] + + +def pick_fields(records: list[dict], fields: list[str]) -> list[dict]: + """Pick specified fields from each record. Empty fields list returns all.""" + if not fields: + return records + return [{f: rec.get(f, "") for f in fields} for rec in records] + + +def transform_response(data: dict | list, spec: TransformSpec) -> list[dict]: + """Full transform: extract path, pick fields, rename.""" + records = extract_path(data, spec.extract_path) + records = pick_fields(records, spec.fields) + + if spec.rename: + renamed = [] + for rec in records: + new_rec = {} + for k, v in rec.items(): + new_key = spec.rename.get(k, k) + new_rec[new_key] = v + renamed.append(new_rec) + records = renamed + + return records diff --git a/examples/api-integration/tests/__init__.py b/examples/api-integration/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/api-integration/tests/test_client.py b/examples/api-integration/tests/test_client.py new file mode 100644 index 0000000..e817401 --- /dev/null +++ b/examples/api-integration/tests/test_client.py @@ -0,0 +1,27 @@ +"""Tests for api_workflow.client.""" + +from api_workflow.client import fetch, fetch_sample +from api_workflow.models import ApiConfig, ApiResponse + + +class TestFetchSample: + def test_returns_sample_data(self): + sample = {"items": [{"id": 1}]} + resp = fetch_sample(sample) + assert resp.status == 200 + assert resp.data == sample + assert resp.source == "sample" + + +class TestFetch: + def test_falls_back_to_sample(self): + cfg = ApiConfig(base_url="https://invalid.example.com", endpoint="/nope") + sample = {"fallback": True} + resp = fetch(cfg, sample_data=sample) + assert resp.source == "sample" + assert resp.data == sample + + def test_no_sample_returns_error(self): + cfg = ApiConfig(base_url="https://invalid.example.com", endpoint="/nope") + resp = fetch(cfg) + assert resp.is_success is False diff --git a/examples/api-integration/tests/test_display.py b/examples/api-integration/tests/test_display.py new file mode 100644 index 0000000..38a346b --- /dev/null +++ b/examples/api-integration/tests/test_display.py @@ -0,0 +1,43 @@ +"""Tests for api_workflow.display.""" + +from api_workflow.display import format_header, format_integration_menu, format_records_table +from api_workflow.models import ApiConfig, Integration, TransformSpec + + +class TestFormatHeader: + def test_contains_title(self): + assert "Test" in format_header("Test") + + def test_has_box_chars(self): + assert "═" in format_header("Test") + + +class TestFormatIntegrationMenu: + def test_lists_all(self): + integrations = [ + Integration( + name="A", description="D1", + api=ApiConfig(base_url="", endpoint=""), + transform=TransformSpec(extract_path="", fields=[]), + ), + Integration( + name="B", description="D2", + api=ApiConfig(base_url="", endpoint=""), + transform=TransformSpec(extract_path="", fields=[]), + ), + ] + result = format_integration_menu(integrations) + assert "1." in result + assert "2." in result + + +class TestFormatRecordsTable: + def test_formats_records(self): + records = [{"name": "Alice", "score": "95"}] + table = format_records_table(records) + assert "Alice" in table + assert "name" in table + + def test_empty_records(self): + table = format_records_table([]) + assert "no" in table.lower() or "empty" in table.lower() diff --git a/examples/api-integration/tests/test_integrations.py b/examples/api-integration/tests/test_integrations.py new file mode 100644 index 0000000..b8fcb82 --- /dev/null +++ b/examples/api-integration/tests/test_integrations.py @@ -0,0 +1,28 @@ +"""Tests for api_workflow.integrations.""" + +from api_workflow.integrations import INTEGRATIONS, get_integration +from api_workflow.models import Integration + + +class TestIntegrations: + def test_five_integrations(self): + assert len(INTEGRATIONS) == 5 + + def test_all_are_integration_instances(self): + for i in INTEGRATIONS: + assert isinstance(i, Integration) + + def test_names_unique(self): + names = [i.name for i in INTEGRATIONS] + assert len(names) == len(set(names)) + + def test_all_have_sample_data(self): + for i in INTEGRATIONS: + assert i.sample_response is not None, f"{i.name} missing sample data" + + def test_get_integration_by_index(self): + assert get_integration(0) == INTEGRATIONS[0] + + def test_get_integration_clamps(self): + assert get_integration(-1) == INTEGRATIONS[0] + assert get_integration(99) == INTEGRATIONS[-1] diff --git a/examples/api-integration/tests/test_models.py b/examples/api-integration/tests/test_models.py new file mode 100644 index 0000000..2408df0 --- /dev/null +++ b/examples/api-integration/tests/test_models.py @@ -0,0 +1,51 @@ +"""Tests for api_workflow.models.""" + +from api_workflow.models import ApiConfig, ApiResponse, Integration, TransformSpec + + +class TestApiConfig: + def test_create(self): + cfg = ApiConfig(base_url="https://api.example.com", endpoint="/data", headers={}) + assert cfg.base_url == "https://api.example.com" + assert cfg.endpoint == "/data" + + def test_full_url(self): + cfg = ApiConfig(base_url="https://api.example.com", endpoint="/v1/items") + assert cfg.full_url == "https://api.example.com/v1/items" + + def test_full_url_with_params(self): + cfg = ApiConfig( + base_url="https://api.example.com", + endpoint="/search", + params={"q": "test", "limit": "10"}, + ) + url = cfg.full_url + assert "q=test" in url + assert "limit=10" in url + + +class TestApiResponse: + def test_create(self): + resp = ApiResponse(status=200, data={"key": "value"}, source="live") + assert resp.status == 200 + assert resp.data["key"] == "value" + assert resp.source == "live" + + def test_is_success(self): + assert ApiResponse(status=200, data={}).is_success is True + assert ApiResponse(status=404, data={}).is_success is False + + +class TestTransformSpec: + def test_create(self): + spec = TransformSpec(extract_path="data.items", fields=["name", "value"]) + assert spec.extract_path == "data.items" + assert spec.fields == ["name", "value"] + + +class TestIntegration: + def test_create(self): + cfg = ApiConfig(base_url="https://example.com", endpoint="/") + spec = TransformSpec(extract_path="data", fields=["id"]) + integ = Integration(name="Test", description="A test", api=cfg, transform=spec) + assert integ.name == "Test" diff --git a/examples/api-integration/tests/test_storage.py b/examples/api-integration/tests/test_storage.py new file mode 100644 index 0000000..da5982c --- /dev/null +++ b/examples/api-integration/tests/test_storage.py @@ -0,0 +1,31 @@ +"""Tests for api_workflow.storage.""" + +import json + +from api_workflow.storage import save_csv, save_json + + +class TestSaveJson: + def test_saves_records(self, tmp_path): + records = [{"name": "A", "value": "1"}] + path = save_json(records, tmp_path, "test") + assert path.exists() + data = json.loads(path.read_text()) + assert len(data) == 1 + + def test_creates_dir(self, tmp_path): + path = save_json([{"x": 1}], tmp_path / "sub", "test") + assert path.exists() + + +class TestSaveCsv: + def test_saves_records(self, tmp_path): + records = [{"name": "A", "value": "1"}, {"name": "B", "value": "2"}] + path = save_csv(records, tmp_path, "test") + assert path.exists() + lines = path.read_text().strip().split("\n") + assert len(lines) == 3 # header + 2 rows + + def test_empty_records(self, tmp_path): + path = save_csv([], tmp_path, "test") + assert path.exists() diff --git a/examples/api-integration/tests/test_transform.py b/examples/api-integration/tests/test_transform.py new file mode 100644 index 0000000..934096f --- /dev/null +++ b/examples/api-integration/tests/test_transform.py @@ -0,0 +1,71 @@ +"""Tests for api_workflow.transform.""" + +from api_workflow.models import TransformSpec +from api_workflow.transform import extract_path, pick_fields, transform_response + + +class TestExtractPath: + def test_simple_path(self): + data = {"items": [1, 2, 3]} + assert extract_path(data, "items") == [1, 2, 3] + + def test_nested_path(self): + data = {"data": {"results": [{"id": 1}]}} + assert extract_path(data, "data.results") == [{"id": 1}] + + def test_empty_path_returns_data(self): + data = [{"id": 1}] + assert extract_path(data, "") == [{"id": 1}] + + def test_missing_path_returns_empty(self): + data = {"other": "value"} + assert extract_path(data, "missing.path") == [] + + def test_single_object_wrapped_in_list(self): + data = {"item": {"id": 1}} + result = extract_path(data, "item") + assert result == [{"id": 1}] + + +class TestPickFields: + def test_pick_specified_fields(self): + records = [{"id": 1, "name": "A", "extra": "x"}] + result = pick_fields(records, ["id", "name"]) + assert result == [{"id": 1, "name": "A"}] + + def test_missing_field_uses_empty(self): + records = [{"id": 1}] + result = pick_fields(records, ["id", "missing"]) + assert result == [{"id": 1, "missing": ""}] + + def test_empty_fields_returns_all(self): + records = [{"a": 1, "b": 2}] + result = pick_fields(records, []) + assert result == [{"a": 1, "b": 2}] + + +class TestTransformResponse: + def test_full_transform(self): + data = {"data": {"repos": [ + {"name": "repo1", "stars": 100, "lang": "Python"}, + {"name": "repo2", "stars": 50, "lang": "Go"}, + ]}} + spec = TransformSpec(extract_path="data.repos", fields=["name", "stars"]) + result = transform_response(data, spec) + assert len(result) == 2 + assert result[0] == {"name": "repo1", "stars": 100} + + def test_transform_empty_data(self): + spec = TransformSpec(extract_path="items", fields=["id"]) + result = transform_response({"items": []}, spec) + assert result == [] + + def test_transform_with_rename(self): + data = {"items": [{"old_name": "val"}]} + spec = TransformSpec( + extract_path="items", + fields=["old_name"], + rename={"old_name": "new_name"}, + ) + result = transform_response(data, spec) + assert result[0]["new_name"] == "val" diff --git a/examples/api-integration/uv.lock b/examples/api-integration/uv.lock new file mode 100644 index 0000000..8859257 --- /dev/null +++ b/examples/api-integration/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "api-integration" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +]