diff --git a/zig/.mise.toml b/zig/.mise.toml new file mode 100644 index 0000000..99f262b --- /dev/null +++ b/zig/.mise.toml @@ -0,0 +1,3 @@ +[tools] +zig = "0.15.2" +zls = "0.15.0" diff --git a/zig/API_ENDPOINTS.md b/zig/API_ENDPOINTS.md new file mode 100644 index 0000000..25bd0ec --- /dev/null +++ b/zig/API_ENDPOINTS.md @@ -0,0 +1,566 @@ +# wttr.in API Endpoints Reference + +## Base URL + +- Production: `https://wttr.in` +- Local: `http://localhost:8082` (Go proxy) → `http://localhost:8002` (Python backend) + +## Weather Query Endpoints + +### GET / + +Get weather for IP-based location. + +**Response:** ANSI weather report (or HTML if browser User-Agent) + +**Example:** +```bash +curl wttr.in +``` + +### GET /{location} + +Get weather for specific location. + +**Path Parameters:** +- `location` - City name, coordinates, airport code, or special location + +**Location Formats:** +- City name: `London`, `New York`, `Salt+Lake+City` +- Coordinates: `55.7558,37.6173` +- Airport (IATA): `muc`, `ham` +- Search term: `~Eiffel+Tower`, `~Kilimanjaro` +- Domain: `@github.com`, `@msu.ru` +- IP address: `8.8.8.8` +- Auto-detect: `MyLocation` or empty +- Moon: `Moon`, `Moon@2016-12-25` +- Cyclic: `London:Paris:Berlin` (rotates based on time) + +**Special Prefixes:** +- `~` - Search for location (uses geolocator) +- `@` - Resolve domain to IP, then get location +- No prefix - Exact location name + +**Query Parameters:** See "Query Parameters" section below + +**Examples:** +```bash +curl wttr.in/London +curl wttr.in/~Eiffel+Tower +curl wttr.in/@github.com +curl wttr.in/Moon +curl wttr.in/London:Paris:Berlin?period=60 +``` + +### GET /{location}.png + +Get weather as PNG image. + +**Path Parameters:** +- `location` - Same as above, but with `.png` extension + +**PNG Filename Format:** +``` +{location}_{options}.png +``` + +**Options in filename:** +- `{width}x` - Width in pixels (e.g., `200x`) +- `x{height}` - Height in pixels (e.g., `x300`) +- `{width}x{height}` - Both dimensions (e.g., `200x300`) +- `lang={code}` - Language code (e.g., `lang=ru`) +- Single-letter options: `t`, `n`, `q`, etc. (see below) +- Separate multiple options with `_` + +**Examples:** +```bash +curl wttr.in/Paris.png > paris.png +curl wttr.in/London_200x_t_lang=fr.png > london.png +curl wttr.in/Berlin_0tqp_lang=de.png > berlin.png +``` + +**Note:** Use `_` instead of `?` and `&` for PNG URLs + +## Special Pages + +### GET /:help + +Display help page with usage instructions. + +**Query Parameters:** +- `lang={code}` - Language for help page + +**Example:** +```bash +curl wttr.in/:help +curl wttr.in/:help?lang=de +``` + +### GET /:bash.function + +Get bash function for shell integration. + +**Example:** +```bash +curl wttr.in/:bash.function >> ~/.bashrc +``` + +### GET /:translation + +Display translation information and supported languages. + +**Example:** +```bash +curl wttr.in/:translation +``` + +### GET /:iterm2 + +Get iTerm2 integration instructions. + +**Example:** +```bash +curl wttr.in/:iterm2 +``` + +## Static Files + +### GET /files/{path} + +Serve static files. + +**Path Parameters:** +- `path` - Relative path to file in share/static/ + +**Example:** +```bash +curl wttr.in/files/example-wttr-v2.png +``` + +### GET /favicon.ico + +Get favicon. + +## Query Parameters + +### Single-Letter Options + +Combine multiple single-letter options in the query string without values: + +```bash +curl wttr.in/London?Tnq +``` + +**Available Options:** + +| Option | Description | +|--------|-------------| +| `A` | Force ANSI output (even for browsers) | +| `n` | Narrow output (narrower terminal width) | +| `m` | Use metric units | +| `M` | Use m/s for wind speed | +| `u` | Use imperial units (Fahrenheit, mph) | +| `I` | Inverted colors | +| `t` | Transparency for PNG (transparency=150) | +| `T` | No terminal sequences (plain text) | +| `p` | Add padding | +| `0` | Show current weather only (no forecast) | +| `1` | Show today + 1 day | +| `2` | Show today + 2 days | +| `3` | Show today + 3 days (default) | +| `q` | Quiet mode (no caption) | +| `Q` | Super quiet (no city name) | +| `F` | No follow line (no "Follow @igor_chubin") | + +**Examples:** +```bash +curl wttr.in/London?T # Plain text, no ANSI +curl wttr.in/London?u # Imperial units +curl wttr.in/London?m # Metric units +curl wttr.in/London?0 # Current weather only +curl wttr.in/London?Tnq # Plain text, narrow, quiet +``` + +### Named Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `lang={code}` | Language code (2-letter ISO 639-1) | `lang=de` | +| `format={fmt}` | Output format (see below) | `format=3` | +| `view={view}` | View type (alias for format) | `view=v2` | +| `period={sec}` | Update interval for cyclic locations | `period=60` | +| `transparency={0-255}` | PNG transparency level | `transparency=100` | + +**Examples:** +```bash +curl wttr.in/London?lang=fr +curl wttr.in/London?format=3 +curl wttr.in/London:Paris?period=60 +``` + +### Combining Parameters + +```bash +curl wttr.in/London?Tn&lang=de&format=3 +curl wttr.in/Paris?u&format=v2 +``` + +## Output Formats + +### format=1 (One-line, compact) + +```bash +curl wttr.in/London?format=1 +# Output: London: ⛅️ +7°C +``` + +### format=2 (One-line, with wind) + +```bash +curl wttr.in/London?format=2 +# Output: London: ⛅️ +7°C 🌬️↗11km/h +``` + +### format=3 (One-line, detailed) + +```bash +curl wttr.in/London?format=3 +# Output: London: ⛅️ +7°C 🌬️↗11km/h 💧65% +``` + +### format=4 (One-line, full) + +```bash +curl wttr.in/London?format=4 +# Output: London: ⛅️ +7°C 🌬️↗11km/h 💧65% ☀️06:45-16:32 +``` + +### format={custom} (Custom format) + +Use `%` notation to create custom formats: + +| Code | Description | +|------|-------------| +| `%c` | Weather condition emoji | +| `%C` | Weather condition text | +| `%h` | Humidity | +| `%t` | Temperature (actual) | +| `%f` | Temperature (feels like) | +| `%w` | Wind | +| `%l` | Location | +| `%m` | Moon phase 🌑🌒🌓🌔🌕🌖🌗🌘 | +| `%M` | Moon day | +| `%p` | Precipitation (mm) | +| `%o` | Probability of precipitation | +| `%P` | Pressure (hPa) | +| `%D` | Dawn time | +| `%S` | Sunrise time | +| `%z` | Zenith time | +| `%s` | Sunset time | +| `%d` | Dusk time | + +**Examples:** +```bash +curl wttr.in/London?format="%l:+%c+%t" +# Output: London: ⛅️ +7°C + +curl wttr.in/London?format="%l:+%C+%t+%w" +# Output: London: Partly cloudy +7°C ↗11km/h + +curl wttr.in/London?format="%D+%S+%z+%s+%d" +# Output: 06:30 06:45 12:15 16:32 17:00 +``` + +**Note:** In tmux.conf, escape `%` as `%%` + +### format=v2 (Data-rich experimental) + +Experimental format with additional information: +- Temperature and precipitation graphs +- Moon phase for 4 days +- Current conditions with all metrics +- Timezone information +- Astronomical times (dawn, sunrise, zenith, sunset, dusk) +- Precise GPS coordinates + +```bash +curl wttr.in/London?format=v2 +# or +curl v2.wttr.in/London +# or (Nerd Fonts) +curl v2d.wttr.in/London # day variant +curl v2n.wttr.in/London # night variant +``` + +**Limitations:** +- Terminal only (no HTML) +- English only + +### format=j1 (JSON) + +Machine-readable JSON format with all weather data. + +```bash +curl wttr.in/London?format=j1 +``` + +**Response Structure:** +```json +{ + "current_condition": [{ + "FeelsLikeC": "25", + "FeelsLikeF": "76", + "cloudcover": "100", + "humidity": "76", + "observation_time": "04:08 PM", + "precipMM": "0.2", + "pressure": "1019", + "temp_C": "22", + "temp_F": "72", + "uvIndex": 5, + "visibility": "16", + "weatherCode": "122", + "weatherDesc": [{"value": "Overcast"}], + "winddir16Point": "NNE", + "winddirDegree": "20", + "windspeedKmph": "7", + "windspeedMiles": "4" + }], + "nearest_area": [...], + "request": [...], + "weather": [...] +} +``` + +**Weather Codes:** See https://www.worldweatheronline.com/developer/api/docs/weather-icons.aspx + +### format=p1 (Prometheus Metrics) + +Prometheus-compatible metrics format. + +```bash +curl wttr.in/London?format=p1 +``` + +**Response:** +``` +# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius +temperature_feels_like_celsius{forecast="current"} 7 +# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit +temperature_feels_like_fahrenheit{forecast="current"} 45 +... +``` + +**Prometheus Config Example:** +```yaml +- job_name: 'wttr_in_london' + static_configs: + - targets: ['wttr.in'] + metrics_path: '/London' + params: + format: ['p1'] +``` + +## Language Support + +54 languages supported via `lang` parameter or subdomain: + +```bash +curl wttr.in/London?lang=de +curl de.wttr.in/London +``` + +**Supported Languages:** +af, ar, az, be, bg, bs, ca, cy, da, de, el, en, eo, es, et, eu, fa, fr, fy, ga, he, hr, hu, hy, ia, id, is, it, ja, kk, lv, mk, nb, nl, nn, oc, pl, pt, pt-br, ro, ru, sl, te, th, tr, uk, uz, vi, zh-cn, zh-tw + +**Language Detection Priority:** +1. Subdomain (e.g., `de.wttr.in`) +2. `lang` parameter +3. `Accept-Language` header +4. Default (English) + +## Multiple Locations + +Query multiple locations in one request using `:` separator: + +```bash +curl wttr.in/London:Paris:Berlin?format=3 +# Output: +# London: ⛅️ +7°C +# Paris: ☀️ +12°C +# Berlin: 🌧 +5°C +``` + +**Cyclic Locations:** + +Rotate through locations based on time: + +```bash +curl wttr.in/London:Paris:Berlin?period=60 +``` + +This will show: +- London for 60 seconds +- Paris for next 60 seconds +- Berlin for next 60 seconds +- Repeat + +Useful for tmux status bars or monitoring dashboards. + +## HTTP Headers + +### Request Headers + +| Header | Purpose | +|--------|---------| +| `User-Agent` | Determines output format (ANSI vs HTML) | +| `Accept-Language` | Language preference | +| `X-Forwarded-For` | Client IP (for location detection) | +| `X-Real-IP` | Client IP (alternative) | +| `X-PNG-Query-For` | Client IP (for PNG requests) | + +### Response Headers + +| Header | Value | +|--------|-------| +| `Content-Type` | `text/plain` or `text/html` or `image/png` | +| `Cache-Control` | `no-cache, no-store, must-revalidate` (PNG only) | +| `Access-Control-Allow-Origin` | `*` | + +## HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 403 | Forbidden (location blacklisted) | +| 429 | Too Many Requests (rate limit exceeded) | +| 500 | Internal Server Error | +| 503 | Service Unavailable (API error) | + +## Rate Limits + +Per-IP limits: +- 300 requests per minute +- 3600 requests per hour +- 86400 requests per day + +**Response on limit exceeded:** +- HTTP 429 +- Body: "Too many queries" message + +## Caching + +Responses are cached for 1000-2000 seconds (randomized). + +**Cache key includes:** +- User-Agent +- Query string +- Client IP +- Language + +**Non-cacheable requests:** +- Cyclic locations (containing `:`) + +## Examples + +### Basic Usage + +```bash +# Current location +curl wttr.in + +# Specific city +curl wttr.in/London + +# Airport +curl wttr.in/muc + +# Coordinates +curl wttr.in/55.7558,37.6173 + +# Moon phase +curl wttr.in/Moon +``` + +### Output Formats + +```bash +# Plain text +curl wttr.in/London?T + +# One-line +curl wttr.in/London?format=3 + +# JSON +curl wttr.in/London?format=j1 + +# PNG +curl wttr.in/London.png > weather.png +``` + +### Units + +```bash +# Metric +curl wttr.in/London?m + +# Imperial +curl wttr.in/London?u +``` + +### Languages + +```bash +# German +curl wttr.in/London?lang=de +curl de.wttr.in/London + +# French +curl fr.wttr.in/Paris +``` + +### Customization + +```bash +# Narrow, quiet, plain text +curl wttr.in/London?Tnq + +# Current weather only +curl wttr.in/London?0 + +# Custom format +curl wttr.in/London?format="%l:+%c+%t+%w" + +# Transparent PNG +curl wttr.in/London_t.png > weather.png +``` + +### Shell Integration + +```bash +# Add to .bashrc +weather() { + curl wttr.in/$1 +} + +# Use it +weather London +weather "New York" +``` + +### Tmux Status Bar + +```bash +# In tmux.conf +set -g status-right '#(curl -s wttr.in/London?format="%%l:+%%c%%20%%t%%60%%w")' +``` + +### Multiple Locations + +```bash +# All at once +curl 'wttr.in/{London,Paris,Berlin}?format=3' + +# Cyclic (for monitoring) +curl wttr.in/London:Paris:Berlin?period=60&format=3 +``` diff --git a/zig/ARCHITECTURE.md b/zig/ARCHITECTURE.md new file mode 100644 index 0000000..faf19fa --- /dev/null +++ b/zig/ARCHITECTURE.md @@ -0,0 +1,432 @@ +# wttr.in Architecture Documentation + +## System Overview + +wttr.in is a console-oriented weather forecast service with a hybrid Python/Go architecture: +- **Go proxy layer** (cmd/): LRU caching proxy with prefetching +- **Python backend** (lib/, bin/): Weather data fetching, formatting, rendering +- **Static assets** (share/): Translations, templates, emoji, help files + +## Request Flow + +``` +Client Request + ↓ +Go Proxy (port 8082) - LRU cache + prefetch + ↓ (cache miss) +Python Backend (port 8002) - Flask/gevent + ↓ +Location Resolution (GeoIP/IP2Location/IPInfo) + ↓ +Weather API (met.no or WorldWeatherOnline) + ↓ +Format & Render (ANSI/HTML/PNG/JSON/Prometheus) + ↓ +Response (cached with TTL 1000-2000s) +``` + +## Component Breakdown + +### 1. Go Proxy Layer (cmd/) + +**Files:** +- `cmd/srv.go` - Main HTTP server (port 8082) +- `cmd/processRequest.go` - Request processing & caching logic +- `cmd/peakHandling.go` - Peak time prefetching (cron-based) + +**Responsibilities:** +- LRU cache (12,800 entries, 1000-1500s TTL) +- Cache key: `UserAgent:Host+URI:ClientIP:AcceptLanguage` +- Prefetch popular requests at :24 and :54 past the hour +- Forward cache misses to Python backend (127.0.0.1:9002) +- Handle concurrent requests (InProgress flag prevents thundering herd) + +**Key Logic:** +- `dontCache()`: Skip caching for cyclic requests (location contains `:`) +- `getCacheDigest()`: Generate cache key from request metadata +- `processRequest()`: Main request handler with cache-aside pattern +- `savePeakRequest()`: Record requests at :30 and :00 for prefetching + +### 2. Python Backend (bin/, lib/) + +#### Entry Points (bin/) + +**bin/srv.py** - Main Flask application +- Listens on port 8002 (configurable via WTTRIN_SRV_PORT) +- Routes: `/`, `/`, `/files/`, `/favicon.ico` +- Uses gevent WSGI server for async I/O +- Delegates to `wttr_srv.wttr()` for all weather requests + +**bin/proxy.py** - Weather API proxy (separate service) +- Caches weather API responses +- Transforms met.no/WWO data to standard JSON +- Test mode support (WTTRIN_TEST env var) +- Handles translations for weather conditions + +**bin/geo-proxy.py** - Geolocation service proxy +- Not examined in detail (separate microservice) + +#### Core Logic (lib/) + +**lib/wttr_srv.py** - Main request handler +- `wttr(location, request)` - Entry point for all weather queries +- `parse_request()` - Parse location, language, format from request +- `_response()` - Generate response (checks cache, calls renderers) +- Rate limiting (300/min, 3600/hour, 24*3600/day per IP) +- ThreadPool (25 workers) for PNG rendering +- Two-phase processing: fast path (cache/static) then full path + +**lib/parse_query.py** - Query string parsing +- Parse single-letter options: `n`=narrow, `m`=metric, `u`=imperial, `T`=no-terminal, etc. +- Parse PNG filenames: `City_200x_lang=ru.png` → structured dict +- Serialize/deserialize query state (base64+zlib for short URLs) +- Metric vs imperial logic (US IPs default to imperial) + +**lib/location.py** - Location resolution +- `location_processing()` - Main entry point +- IP → Location: GeoIP2 (MaxMind), IP2Location API, IPInfo API +- Location normalization (lowercase, strip special chars) +- Geolocator service (localhost:8004) for GPS coordinates +- IATA airport code support +- Alias resolution (share/aliases file) +- Blacklist checking (share/blacklist file) +- Hemisphere detection for moon phases +- Special prefixes: + - `~` = search term (use geolocator) + - `@` = domain name (resolve to IP first) + - No prefix = exact location name + +**lib/globals.py** - Configuration +- Environment variables: WTTR_MYDIR, WTTR_GEOLITE, WTTR_WEGO, etc. +- File paths: cache dirs, static files, translations +- API keys: IP2Location, IPInfo, WorldWeatherOnline +- Constants: NOT_FOUND_LOCATION, PLAIN_TEXT_AGENTS, QUERY_LIMITS +- IP location order: geoip → ip2location → ipinfo + +**lib/cache.py** - LRU cache (Python side) +- In-memory LRU (10,000 entries, pylru) +- File cache for large responses (>80 bytes) +- TTL: 1000-2000s (randomized) +- Cache key: `UserAgent:QueryString:ClientIP:Lang` +- Dynamic timestamp replacement: `%{{NOW(timezone)}}` + +**lib/limits.py** - Rate limiting +- Per-IP query limits (minute/hour/day buckets) +- Whitelist support +- Returns 429 on limit exceeded + +#### View Renderers (lib/view/) + +**lib/view/wttr.py** - Main weather view +- Calls `wego` (Go binary) for weather rendering +- Passes flags: -inverse, -wind_in_ms, -narrow, -lang, -imperial +- Post-processes output (location name, formatting) +- Converts to HTML if needed + +**lib/view/line.py** - One-line format +- Formats: 1, 2, 3, 4, or custom with % notation +- Custom format codes: %c=condition, %t=temp, %h=humidity, %w=wind, etc. +- Supports multiple locations (`:` separated) + +**lib/view/v2.py** - Data-rich v2 format +- Experimental format with more detail +- Moon phase, astronomical times, temperature graphs +- Terminal-only, English-only + +**lib/view/moon.py** - Moon phase view +- Uses `pyphoon-lolcat` for rendering +- Supports date selection: `Moon@2016-12-25` + +**lib/view/prometheus.py** - Prometheus metrics +- Exports weather data as Prometheus metrics +- Format: `p1` + +#### Formatters (lib/fmt/) + +**lib/fmt/png.py** - PNG rendering +- Converts ANSI terminal output to PNG images +- Uses pyte (terminal emulator) + PIL +- Transparency support +- Font rendering + +**lib/fmt/unicodedata2.py** - Unicode handling +- Character width calculations for terminal rendering + +#### Other Modules (lib/) + +**lib/translations.py** - i18n support +- 54 languages supported +- Weather condition translations +- Help file translations (share/translations/) +- Language detection from Accept-Language header + +**lib/constants.py** - Weather constants +- Weather codes (WWO API) +- Condition mappings +- Emoji mappings + +**lib/buttons.py** - HTML UI elements +- Add interactive buttons to HTML output + +**lib/fields.py** - Data field extraction +- Parse weather API responses + +**lib/weather_data.py** - Weather data structures + +**lib/airports.py** - IATA code handling + +**lib/metno.py** - met.no API client +- Norwegian Meteorological Institute API +- Transforms to standard JSON format + +### 3. Static Assets (share/) + +**share/translations/** - 54 language files +- Format: `{lang}.txt` (weather conditions) +- Format: `{lang}-help.txt` (help pages) + +**share/emoji/** - Weather emoji PNGs +- Used for PNG rendering + +**share/static/** - Web assets +- favicon.ico +- style.css +- example images + +**share/templates/** - Jinja2 templates +- index.html (HTML output wrapper) + +**share/** - Data files +- `aliases` - Location aliases (from:to format) +- `blacklist` - Blocked locations +- `list-of-iata-codes.txt` - Airport codes +- `help.txt` - English help +- `bash-function.txt` - Shell integration +- `translation.txt` - Translation info page + +## API Endpoints + +### Weather Queries + +- `GET /` - Weather for IP-based location +- `GET /{location}` - Weather for specific location +- `GET /{location}.png` - PNG image output +- `GET /{location}?{options}` - Weather with options + +### Special Pages + +- `GET /:help` - Help page +- `GET /:bash.function` - Shell function +- `GET /:translation` - Translation info +- `GET /:iterm2` - iTerm2 integration + +### Static Files + +- `GET /files/{path}` - Static assets +- `GET /favicon.ico` - Favicon + +## Query Parameters + +### Single-letter Options (combined in query string) + +- `A` - Force ANSI output +- `n` - Narrow output +- `m` - Metric units +- `M` - m/s for wind speed +- `u` - Imperial units +- `I` - Inverted colors +- `t` - Transparency (PNG) +- `T` - No terminal sequences +- `p` - Padding +- `0-3` - Number of days +- `q` - No caption +- `Q` - No city name +- `F` - No follow line + +### Named Parameters + +- `lang={code}` - Language override +- `format={fmt}` - Output format (1-4, v2, j1, p1, custom) +- `view={view}` - View type (alias for format) +- `period={sec}` - Update interval for cyclic locations + +### PNG Filename Format + +`{location}_{width}x{height}_{options}_lang={lang}.png` + +Example: `London_200x_t_lang=ru.png` + +## Output Formats + +1. **ANSI** - Terminal with colors/formatting +2. **Plain text** - No ANSI codes (T option) +3. **HTML** - Web browser output +4. **PNG** - Image file +5. **JSON** (j1) - Machine-readable data +6. **Prometheus** (p1) - Metrics format +7. **One-line** (1-4) - Compact formats +8. **v2** - Data-rich experimental format + +## External Dependencies + +### Weather APIs + +- **met.no** (Norwegian Meteorological Institute) - Primary, free +- **WorldWeatherOnline** - Fallback, requires API key + +### Geolocation + +- **GeoLite2** (MaxMind) - Free GeoIP database (required) +- **IP2Location** - Commercial API (optional, needs key) +- **IPInfo** - Commercial API (optional, needs key) +- **Geolocator service** - localhost:8004 (GPS coordinates) + +### External Binaries + +- **wego** (we-lang) - Go weather rendering binary +- **pyphoon-lolcat** - Moon phase rendering + +### Python Libraries + +- Flask - Web framework +- gevent - Async I/O +- geoip2 - GeoIP lookups +- geopy - Geocoding +- requests - HTTP client +- PIL - Image processing +- pyte - Terminal emulator +- pytz - Timezone handling +- pylru - LRU cache + +### Go Libraries + +- github.com/hashicorp/golang-lru - LRU cache +- github.com/robfig/cron - Cron scheduler + +## Configuration + +### Environment Variables + +- `WTTR_MYDIR` - Installation directory +- `WTTR_GEOLITE` - Path to GeoLite2-City.mmdb +- `WTTR_WEGO` - Path to wego binary +- `WTTR_LISTEN_HOST` - Bind address (default: "") +- `WTTR_LISTEN_PORT` - Port (default: 8002) +- `WTTR_USER_AGENT` - Custom user agent +- `WTTR_IPLOCATION_ORDER` - IP location method order +- `WTTRIN_SRV_PORT` - Override listen port +- `WTTRIN_TEST` - Enable test mode + +### API Key Files + +- `~/.wwo.key` - WorldWeatherOnline API key +- `~/.ip2location.key` - IP2Location API key +- `~/.ipinfo.key` - IPInfo token +- `~/.wegorc` - Wego configuration (JSON) + +### Data Directories + +- `/wttr.in/cache/ip2l/` - IP location cache +- `/wttr.in/cache/png/` - PNG cache +- `/wttr.in/cache/lru/` - LRU file cache +- `/wttr.in/cache/proxy-wwo/` - Weather API cache +- `/wttr.in/log/` - Log files + +## Caching Strategy + +### Three-tier Cache + +1. **Go LRU** (12,800 entries, 1000-1500s TTL) + - In-memory, fastest + - Full HTTP responses + - Shared across all requests + +2. **Python LRU** (10,000 entries, 1000-2000s TTL) + - In-memory for small responses (<80 bytes) + - File-backed for large responses + - Per-process cache + +3. **File Cache** + - IP location cache (persistent) + - Weather API cache (persistent) + - PNG cache (persistent) + +### Cache Keys + +- Go: `UserAgent:Host+URI:ClientIP:AcceptLanguage` +- Python: `UserAgent:QueryString:ClientIP:Lang` + +### Cache Invalidation + +- TTL-based expiration (no manual invalidation) +- Randomized TTL prevents thundering herd +- Non-cacheable: cyclic requests (location contains `:`) + +## Prefetching + +- Cron jobs at :24 and :54 past the hour +- Records popular requests at :30 and :00 +- Spreads prefetch over 5 minutes (300s) +- Prevents cache expiry during peak times + +## Rate Limiting + +- Per-IP limits: 300/min, 3600/hour, 24*3600/day +- Whitelist support (MY_EXTERNAL_IP) +- Returns HTTP 429 on limit exceeded +- Implemented in Python layer only + +## Error Handling + +- Location not found → "not found" location (fallback weather) +- API errors → 503 Service Unavailable +- Malformed requests → 500 Internal Server Error (HTML) or error message (text) +- Blocked locations → 403 Forbidden +- Rate limit → 429 Too Many Requests + +## Logging + +- Main log: `/wttr.in/log/main.log` +- Debug log: `/tmp/wttr.in-debug.log` +- Go proxy logs to stdout + +## Testing + +- No unit tests +- Integration test: `test/query.sh` + - Makes HTTP requests to running server + - Compares SHA1 hashes of responses + - Test data in `test/test-data/signatures` +- CI: flake8 linting only (no actual tests run) + +## Known Issues & Limitations + +1. No unit test coverage +2. v2 format is experimental (terminal-only, English-only) +3. Moon phase Unicode ambiguity (hemisphere-dependent) +4. Hardcoded IP whitelist (MY_EXTERNAL_IP) +5. Multiple cache layers with different keys +6. Mixed Python/Go codebase +7. External binary dependencies (wego, pyphoon) +8. Requires external geolocator service (port 8004) +9. File cache grows unbounded +10. No cache warming on startup + +## Performance Characteristics + +- Go proxy handles ~12,800 cached requests in memory +- Python backend spawns 25 threads for PNG rendering +- Gevent provides async I/O for Python +- Prefetching reduces latency during peak times +- File cache avoids memory pressure for large responses +- Rate limiting prevents abuse + +## Security Considerations + +- IP-based rate limiting +- Location blacklist +- No authentication required +- User-provided location names passed to external APIs +- File cache uses MD5 hashes (not cryptographic) +- No input sanitization for location names +- Trusts X-Forwarded-For header diff --git a/zig/DATA_FLOW.md b/zig/DATA_FLOW.md new file mode 100644 index 0000000..461f2c3 --- /dev/null +++ b/zig/DATA_FLOW.md @@ -0,0 +1,641 @@ +# wttr.in Data Flow Documentation + +## Request Processing Flow + +### 1. Initial Request + +``` +Client → Go Proxy (port 8082) +``` + +**Input:** +- HTTP request with location in URL +- Headers: User-Agent, Accept-Language, X-Forwarded-For +- Query parameters + +**Go Proxy Actions:** +1. Extract cache key: `UserAgent:Host+URI:ClientIP:AcceptLanguage` +2. Check if request is cacheable (no `:` in location) +3. Look up in LRU cache (12,800 entries) + +**Cache Hit Path:** +``` +Go Proxy → Check expiry → Return cached response +``` + +**Cache Miss Path:** +``` +Go Proxy → Set InProgress flag → Forward to Python Backend +``` + +### 2. Python Backend Processing + +``` +Go Proxy → Python Backend (port 8002) → Flask Router +``` + +**Flask Routes:** +- `/` → `wttr_srv.wttr(None, request)` +- `/{location}` → `wttr_srv.wttr(location, request)` +- `/:help`, `/:bash.function`, etc. → Static file handlers + +### 3. Request Parsing (wttr_srv.py) + +**Phase 1: Fast Path (Cache + Static)** + +```python +parse_request(location, request, query, fast_mode=True) + ↓ +_response(parsed_query, query, fast_mode=True) + ↓ +Check Python LRU cache + ↓ +Check if static page (:help, :bash.function, etc.) + ↓ +Return if found, else continue to slow path +``` + +**Phase 2: Full Processing** + +```python +parse_request(location, request, query, fast_mode=False) + ↓ +Location Processing + ↓ +_response(parsed_query, query, fast_mode=False) + ↓ +Render weather + ↓ +Cache and return +``` + +### 4. Location Processing (location.py) + +**Input:** Location string, Client IP + +**Processing Steps:** + +``` +1. Detect location type + ├─ Empty/MyLocation → Use client IP + ├─ IP address → Resolve to location + ├─ @domain → Resolve domain to IP, then location + ├─ ~search → Use geolocator service + ├─ Moon → Special moon handler + └─ Name → Use as-is + +2. Normalize location + ├─ Lowercase + ├─ Replace _ and + with space + └─ Remove special chars (!@#$*;:\) + +3. Check aliases (share/aliases) + └─ from:to mapping + +4. Check blacklist (share/blacklist) + └─ Return 403 if blocked + +5. Resolve location + ├─ IP → Location (GeoIP/IP2Location/IPInfo) + ├─ Name → GPS coords (Geolocator service) + └─ IATA code → Airport location + +6. Get hemisphere (for moon queries) + └─ GPS latitude > 0 = North +``` + +**Output:** +- `location` - Normalized location or GPS coords +- `override_location_name` - Display name +- `full_address` - Full address from geolocator +- `country` - Country name +- `query_source_location` - Client's location (city, country) +- `hemisphere` - True=North, False=South + +### 5. IP to Location Resolution + +**Method Priority (configurable via WTTR_IPLOCATION_ORDER):** + +``` +1. GeoIP (MaxMind GeoLite2) + ├─ Read from GeoLite2-City.mmdb + ├─ Extract city and country + └─ Fast, local, free + +2. IP2Location API (optional) + ├─ HTTP GET to api.ip2location.com + ├─ Requires API key (~/.ip2location.key) + ├─ Cache result in /wttr.in/cache/ip2l/{ip} + └─ Format: city;country + +3. IPInfo API (optional) + ├─ HTTP GET to ipinfo.io + ├─ Requires token (~/.ipinfo.key) + ├─ Cache result in /wttr.in/cache/ip2l/{ip} + └─ JSON response + +Fallback: NOT_FOUND_LOCATION ("not found") +``` + +**Caching:** +- File cache: `/wttr.in/cache/ip2l/{ip_address}` +- Format: `city;country` or `location;country;extra;city` +- Persistent across restarts + +### 6. Geolocator Service + +**For search terms (~location) and non-ASCII names:** + +``` +Python Backend → HTTP GET localhost:8004/{location} + ↓ +Geolocator Service (separate microservice) + ↓ +Returns JSON: +{ + "latitude": 48.8582602, + "longitude": 2.29449905432, + "address": "Tour Eiffel, 5, Avenue Anatole France..." +} +``` + +**Used for:** +- `~Eiffel Tower` → GPS coordinates +- `~Kilimanjaro` → GPS coordinates +- Non-ASCII location names +- IATA airport codes + +### 7. Weather Data Fetching + +**Two data sources (configured via WWO_KEY):** + +#### Option A: met.no (Norwegian Meteorological Institute) + +``` +Python Backend → metno.py + ↓ +HTTP GET to api.met.no + ↓ +Parse XML/JSON response + ↓ +Transform to standard JSON format + ↓ +Return weather data +``` + +**Advantages:** +- Free, no API key required +- High quality data +- No rate limits + +#### Option B: WorldWeatherOnline (WWO) + +``` +Python Backend → bin/proxy.py (separate service) + ↓ +Check proxy cache (/wttr.in/cache/proxy-wwo/) + ↓ +If miss: HTTP GET to api.worldweatheronline.com + ↓ +Cache response + ↓ +Return weather data +``` + +**Advantages:** +- More locations supported +- Historical data available + +**Disadvantages:** +- Requires API key (~/.wwo.key) +- Rate limited (500 queries/day free tier) + +**Weather Data Structure:** +```json +{ + "current_condition": [{ + "temp_C": "22", + "temp_F": "72", + "weatherCode": "122", + "weatherDesc": [{"value": "Overcast"}], + "windspeedKmph": "7", + "humidity": "76", + ... + }], + "weather": [ + { + "date": "2025-12-17", + "maxtempC": "25", + "mintempC": "18", + "hourly": [...] + } + ] +} +``` + +### 8. Weather Rendering + +**Route to appropriate renderer based on query:** + +``` +parsed_query → Determine view type + ↓ + ├─ format=1,2,3,4 → view/line.py (one-line format) + ├─ format=j1 → Return raw JSON + ├─ format=p1 → view/prometheus.py + ├─ format=v2 → view/v2.py (data-rich) + ├─ location=Moon → view/moon.py + └─ default → view/wttr.py (main view) +``` + +#### Main View (view/wttr.py) + +``` +get_wetter(parsed_query) + ↓ +Call wego binary (Go program) + ├─ Pass flags: -city, -lang, -imperial, -narrow, etc. + ├─ wego fetches weather data + ├─ wego renders ANSI output + └─ Return ANSI text + ↓ +Post-process output + ├─ Add location name override + ├─ Add "not found" message if needed + └─ Format for display + ↓ +If HTML output: + └─ Convert ANSI to HTML (ansi2html.sh) +``` + +**wego Command Example:** +```bash +/path/to/we-lang \ + --city=London,GB \ + -lang=en \ + -imperial \ + -narrow \ + -location_name="London" +``` + +#### One-Line View (view/line.py) + +``` +wttr_line(query, parsed_query) + ↓ +Get weather data (JSON) + ↓ +Parse format string + ├─ Predefined: 1, 2, 3, 4 + └─ Custom: %c, %t, %h, %w, etc. + ↓ +Replace format codes with data + ├─ %c → Weather emoji + ├─ %t → Temperature + ├─ %h → Humidity + └─ etc. + ↓ +Return formatted string +``` + +**Format Examples:** +- `format=3` → `London: ⛅️ +7°C` +- `format=%l:+%c+%t` → `London: ⛅️ +7°C` + +#### Moon View (view/moon.py) + +``` +get_moon(parsed_query) + ↓ +Parse date from location (Moon@2016-12-25) + ↓ +Call pyphoon-lolcat binary + ├─ Pass date parameter + └─ Return ASCII moon phase art + ↓ +Return moon phase output +``` + +#### v2 View (view/v2.py) + +``` +Experimental data-rich format + ↓ +Get weather data + ↓ +Render: + ├─ Temperature graph (ASCII) + ├─ Precipitation graph (ASCII) + ├─ Moon phases (4 days) + ├─ Current conditions (detailed) + ├─ Astronomical times (dawn, sunrise, etc.) + └─ GPS coordinates + ↓ +Return formatted output +``` + +#### Prometheus View (view/prometheus.py) + +``` +Get weather data (JSON) + ↓ +Convert to Prometheus metrics format + ├─ temperature_feels_like_celsius{forecast="current"} 7 + ├─ humidity_percent{forecast="current"} 65 + └─ etc. + ↓ +Return metrics text +``` + +### 9. PNG Rendering (fmt/png.py) + +**For .png requests:** + +``` +ANSI text output + ↓ +Spawn thread (ThreadPool, 25 workers) + ↓ +render_ansi(output, options) + ↓ +Create virtual terminal (pyte) + ├─ Feed ANSI sequences + └─ Capture terminal state + ↓ +Render to image (PIL) + ├─ Draw characters with font + ├─ Apply colors from ANSI codes + └─ Apply transparency if requested + ↓ +Return PNG bytes + ↓ +Cache in /wttr.in/cache/png/ +``` + +**Options:** +- `t` - Transparency (150) +- `transparency={0-255}` - Custom transparency +- `{width}x{height}` - Image dimensions + +### 10. Translation (translations.py) + +**Language Detection:** + +``` +1. Check subdomain (de.wttr.in → lang=de) +2. Check lang parameter (?lang=de) +3. Check Accept-Language header +4. Default to English +``` + +**Translation Files:** +- `share/translations/{lang}.txt` - Weather conditions +- `share/translations/{lang}-help.txt` - Help pages + +**Translation Process:** + +``` +Weather condition text (English) + ↓ +Look up in translations.py:TRANSLATIONS dict + ↓ +Find translation for target language + ↓ +Return translated text +``` + +**Example:** +```python +TRANSLATIONS = { + "en": {"Partly cloudy": "Partly cloudy"}, + "de": {"Partly cloudy": "Teilweise bewölkt"}, + "fr": {"Partly cloudy": "Partiellement nuageux"} +} +``` + +### 11. Caching (cache.py) + +**Python LRU Cache:** + +``` +Request → Generate cache signature + ↓ +signature = f"{user_agent}:{query_string}:{client_ip}:{lang}" + ↓ +Check in-memory LRU (10,000 entries) + ↓ +If found and not expired: + ├─ If value starts with "file:" or "bfile:" + │ └─ Read from /wttr.in/cache/lru/{md5_hash} + └─ Return value + ↓ +If not found: + ├─ Generate response + ├─ If response > 80 bytes: + │ ├─ Write to /wttr.in/cache/lru/{md5_hash} + │ └─ Store "file:{md5_hash}" in LRU + └─ Else: Store value directly in LRU + ↓ +Set expiry: current_time + random(1000, 2000) seconds + ↓ +Return response +``` + +**Dynamic Timestamps:** + +``` +Cached response with %{{NOW(timezone)}} + ↓ +On retrieval: Replace with current time in timezone + ↓ +Example: %{{NOW(Europe/London)}} → 14:32:15+0000 +``` + +### 12. Response Wrapping + +**Final response preparation:** + +``` +Response text/bytes + ↓ +Determine content type + ├─ PNG → image/png + ├─ HTML → text/html + └─ ANSI/text → text/plain + ↓ +Add buttons (if HTML and not format query) + ├─ Add interactive UI elements + └─ Wrap in HTML template + ↓ +Set HTTP headers + ├─ Content-Type + ├─ Cache-Control (PNG only) + └─ Access-Control-Allow-Origin: * + ↓ +Return Flask response +``` + +### 13. Go Proxy Caching + +**After Python backend returns:** + +``` +Python Backend → Response + ↓ +Go Proxy receives response + ↓ +If status code 200 or 304: + ├─ Store in LRU cache + ├─ Set expiry: current_time + random(1000, 1500) seconds + └─ Remove InProgress flag + ↓ +Else (error): + └─ Remove from cache + ↓ +Return response to client +``` + +### 14. Peak Request Prefetching + +**Cron-based prefetching:** + +``` +Every hour at :30 and :00 + ↓ +Record incoming requests in sync.Map + ↓ +At :24 and :54 (5 minutes before peak) + ↓ +Iterate through recorded requests + ↓ +For each request: + ├─ Spawn goroutine + ├─ Call processRequest() (refreshes cache) + ├─ Sleep (spread over 300 seconds) + └─ Delete from sync.Map + ↓ +Cache is warm for peak time +``` + +**Peak Times:** +- :30 past the hour (recorded at :30, prefetched at :24) +- :00 on the hour (recorded at :00, prefetched at :54) + +## Data Structures + +### Parsed Query + +```python +{ + "location": "London,GB", # Normalized location + "orig_location": "London", # Original input + "override_location_name": None, # Display name override + "full_address": "London, UK", # Full address + "country": "GB", # Country code + "query_source_location": ("Paris", "France"), # Client location + "hemisphere": True, # North=True, South=False + "lang": "en", # Language code + "view": None, # View type (v2, etc.) + "html_output": False, # HTML vs ANSI + "png_filename": None, # PNG filename if .png request + "ip_addr": "1.2.3.4", # Client IP + "user_agent": "curl/7.68.0", # User agent + "request_url": "http://...", # Full request URL + + # Query options + "use_metric": True, # Metric units + "use_imperial": False, # Imperial units + "use_ms_for_wind": False, # m/s for wind + "narrow": False, # Narrow output + "inverted_colors": False, # Inverted colors + "no-terminal": False, # Plain text + "no-caption": False, # No caption + "no-city": False, # No city name + "no-follow-line": False, # No follow line + "days": "3", # Number of days + "transparency": None, # PNG transparency + "padding": False, # Add padding + "force-ansi": False, # Force ANSI +} +``` + +### Cache Entry (Go) + +```go +type responseWithHeader struct { + InProgress bool // Request being processed + Expires time.Time // Expiration time + Body []byte // Response body + Header http.Header // HTTP headers + StatusCode int // HTTP status code +} +``` + +### Cache Entry (Python) + +```python +{ + "val": "response text" or "file:md5hash", + "expiry": 1702834567.123 # Unix timestamp +} +``` + +## Error Handling Flow + +### Location Not Found + +``` +Location resolution fails + ↓ +Set location = NOT_FOUND_LOCATION ("not found") + ↓ +Fetch weather for default location (Oymyakon) + ↓ +Append "not found" message in user's language + ↓ +Return response +``` + +### API Error + +``` +Weather API returns error + ↓ +Log error + ↓ +If HTML output: + └─ Return malformed-response.html (500) +Else: + └─ Return "capacity limit reached" message (503) +``` + +### Rate Limit Exceeded + +``` +Check IP against limits (300/min, 3600/hour, 86400/day) + ↓ +If exceeded: + └─ Return 429 with error message +``` + +### Blocked Location + +``` +Check location against blacklist + ↓ +If blocked: + └─ Return 403 Forbidden +``` + +## Performance Optimizations + +1. **Two-tier caching** (Go + Python) +2. **Fast path** (cache + static files checked first) +3. **File cache** for large responses (>80 bytes) +4. **Prefetching** at peak times +5. **ThreadPool** for PNG rendering (25 workers) +6. **Gevent** for async I/O in Python +7. **LRU eviction** prevents memory bloat +8. **Randomized TTL** prevents thundering herd +9. **InProgress flag** prevents duplicate work +10. **IP location caching** (persistent file cache) diff --git a/zig/README.md b/zig/README.md new file mode 100644 index 0000000..00771e7 --- /dev/null +++ b/zig/README.md @@ -0,0 +1,108 @@ +# wttr.in Zig Rewrite Documentation + +This directory contains comprehensive documentation for rewriting wttr.in in Zig. + +## Documentation Files + +### [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) ⭐ NEW +Target architecture for Zig rewrite: +- Single binary design +- Simplified caching (one layer) +- Pluggable weather provider interface +- Rate limiting middleware +- Module structure +- Testing strategy +- Performance targets + +### [ARCHITECTURE.md](ARCHITECTURE.md) +Current system architecture documentation: +- Component breakdown (Go proxy, Python backend, static assets) +- Request flow diagrams +- API endpoints +- External dependencies +- Caching strategy +- Configuration +- Known issues + +### [API_ENDPOINTS.md](API_ENDPOINTS.md) +Complete API reference: +- All endpoints with examples +- Query parameters +- Output formats +- Language support +- HTTP headers +- Rate limits +- Usage examples + +### [DATA_FLOW.md](DATA_FLOW.md) +Detailed request processing flow: +- Step-by-step request handling +- Location resolution process +- Weather data fetching +- Rendering pipeline +- Caching mechanisms +- Error handling +- Performance optimizations + +### [REWRITE_STRATEGY.md](REWRITE_STRATEGY.md) +Comprehensive rewrite plan: +- Goals and non-goals +- Phase-by-phase breakdown (10-11 weeks) +- Module organization +- Testing strategy +- Risk mitigation +- Success criteria +- Alternative approaches + +## Quick Start + +1. **Read ARCHITECTURE.md** - Understand the current system +2. **Read DATA_FLOW.md** - Understand how requests are processed +3. **Read REWRITE_STRATEGY.md** - Understand the rewrite plan +4. **Refer to API_ENDPOINTS.md** - When implementing specific endpoints + +## Current System Summary + +**Architecture:** Hybrid Python/Go +- Go proxy (port 8082): LRU caching, prefetching +- Python backend (port 8002): Weather logic, rendering +- External binaries: wego (Go), pyphoon (Python) +- External services: Geolocator (port 8004) + +**Key Stats:** +- ~5000 lines of Python +- ~400 lines of Go +- 54 languages supported +- 12,800 entry LRU cache +- 1000-2000s cache TTL +- Zero unit tests (only integration tests) + +**Main Challenges:** +1. ANSI weather rendering (currently done by wego) +2. PNG image generation (PIL + pyte) +3. GeoIP database parsing (MaxMind format) +4. 54 language translations +5. Multiple output formats (ANSI, HTML, PNG, JSON, Prometheus) + +## Recommended Approach + +**Option A: Incremental (Recommended)** +1. Replace Go proxy with Zig (2 weeks) +2. Test and deploy +3. Replace Python backend with Zig (8-9 weeks) +4. Full cutover + +**Option B: Full Rewrite** +- All at once (10-11 weeks) +- Higher risk, but cleaner result + +## Next Steps + +1. Review documentation +2. Choose rewrite strategy +3. Set up Zig project structure +4. Begin implementation (start with HTTP server or Go proxy) + +## Questions? + +See REWRITE_STRATEGY.md "Questions to Answer" section for key decisions needed. diff --git a/zig/REWRITE_STRATEGY.md b/zig/REWRITE_STRATEGY.md new file mode 100644 index 0000000..8f22041 --- /dev/null +++ b/zig/REWRITE_STRATEGY.md @@ -0,0 +1,714 @@ +# wttr.in Zig Rewrite Strategy + +## Goals + +1. **Single binary** - Replace Python + Go with one Zig executable +2. **No external binaries** - Eliminate wego, pyphoon dependencies +3. **Maintain compatibility** - All existing API endpoints work identically +4. **Improve performance** - Faster response times, lower memory usage +5. **Add tests** - Comprehensive test coverage from day one +6. **Simplify deployment** - Single binary + data files + +## Non-Goals + +- Rewriting weather APIs (still use met.no/WWO) +- Changing API surface (maintain backward compatibility) +- Rewriting geolocator service (keep as separate service for now) + +## Phase 1: Analysis & Design ✓ + +**Status:** Complete (this document) + +**Deliverables:** +- [x] ARCHITECTURE.md - System overview +- [x] API_ENDPOINTS.md - API reference +- [x] DATA_FLOW.md - Request processing flow +- [x] REWRITE_STRATEGY.md - This document + +## Phase 2: Foundation (Week 1-2) + +### 2.1 Project Setup + +**Tasks:** +- [ ] Create `build.zig` with proper structure +- [ ] Set up module organization +- [ ] Configure dependencies (if any) +- [ ] Set up test framework +- [ ] Create CI/CD pipeline (GitHub Actions) + +**Modules:** +``` +src/ +├── main.zig # Entry point +├── server.zig # HTTP server +├── router.zig # Request routing +├── config.zig # Configuration +├── cache/ +│ ├── lru.zig # LRU cache implementation +│ └── file.zig # File-backed cache +├── location/ +│ ├── resolver.zig # Location resolution +│ ├── geoip.zig # GeoIP lookups +│ └── normalize.zig # Location normalization +├── weather/ +│ ├── client.zig # Weather API client +│ ├── metno.zig # met.no API +│ └── wwo.zig # WorldWeatherOnline API +├── render/ +│ ├── ansi.zig # ANSI rendering +│ ├── html.zig # HTML rendering +│ ├── json.zig # JSON rendering +│ ├── png.zig # PNG rendering +│ ├── line.zig # One-line format +│ └── v2.zig # v2 format +├── i18n/ +│ ├── translations.zig # Translation system +│ └── loader.zig # Load translation files +└── utils/ + ├── http.zig # HTTP utilities + ├── ip.zig # IP address handling + └── time.zig # Time utilities +``` + +### 2.2 Core HTTP Server + +**Tasks:** +- [ ] Implement HTTP server using std.http.Server +- [ ] Request parsing (headers, query params, path) +- [ ] Response building (status, headers, body) +- [ ] Basic routing (exact match, wildcard) +- [ ] Static file serving + +**Tests:** +- [ ] Parse GET requests +- [ ] Parse query parameters +- [ ] Route to handlers +- [ ] Serve static files +- [ ] Handle 404s + +**Acceptance Criteria:** +- Server listens on configurable port +- Handles concurrent requests +- Routes to placeholder handlers +- Serves files from share/ directory + +### 2.3 Configuration System + +**Tasks:** +- [ ] Load environment variables +- [ ] Load config files (if needed) +- [ ] Validate configuration +- [ ] Provide defaults + +**Configuration:** +```zig +const Config = struct { + listen_host: []const u8, + listen_port: u16, + geolite_path: []const u8, + cache_dir: []const u8, + log_level: LogLevel, + // API keys + ip2location_key: ?[]const u8, + ipinfo_token: ?[]const u8, + wwo_key: ?[]const u8, +}; +``` + +**Tests:** +- [ ] Load from environment +- [ ] Apply defaults +- [ ] Validate paths exist + +## Phase 3: Caching Layer (Week 2-3) + +### 3.1 LRU Cache + +**Tasks:** +- [ ] Implement generic LRU cache +- [ ] Thread-safe operations (Mutex) +- [ ] TTL support +- [ ] Eviction policy +- [ ] Cache statistics + +**Tests:** +- [ ] Insert and retrieve +- [ ] LRU eviction +- [ ] TTL expiration +- [ ] Concurrent access +- [ ] Memory limits + +**Acceptance Criteria:** +- Configurable size (default 12,800) +- O(1) get/put operations +- Thread-safe +- TTL-based expiration + +### 3.2 File Cache + +**Tasks:** +- [ ] Store large responses to disk +- [ ] MD5 hash for filenames +- [ ] Read/write with proper locking +- [ ] Cleanup old files + +**Tests:** +- [ ] Write and read files +- [ ] Handle binary data +- [ ] Concurrent access +- [ ] Disk space limits + +### 3.3 Cache Integration + +**Tasks:** +- [ ] Generate cache keys +- [ ] Check cache before processing +- [ ] Store responses after processing +- [ ] Handle InProgress flag (prevent thundering herd) + +**Tests:** +- [ ] Cache hit returns cached response +- [ ] Cache miss processes request +- [ ] Concurrent requests for same key +- [ ] Cache key generation + +## Phase 4: Location Resolution (Week 3-4) + +### 4.1 Location Parsing + +**Tasks:** +- [ ] Parse location from URL +- [ ] Normalize location names +- [ ] Handle special prefixes (~, @) +- [ ] Parse cyclic locations (:) +- [ ] Load aliases file +- [ ] Load blacklist file + +**Tests:** +- [ ] Parse city names +- [ ] Parse coordinates +- [ ] Parse IATA codes +- [ ] Parse special locations (Moon, etc.) +- [ ] Normalize names +- [ ] Apply aliases +- [ ] Check blacklist + +### 4.2 GeoIP Lookup + +**Tasks:** +- [ ] Read MaxMind GeoLite2 database +- [ ] IP to location lookup +- [ ] Cache results + +**Options:** +- Use C library (libmaxminddb) via @cImport +- Or: Parse MMDB format in pure Zig + +**Tests:** +- [ ] Lookup IPv4 address +- [ ] Lookup IPv6 address +- [ ] Handle not found +- [ ] Cache lookups + +### 4.3 External Geolocation + +**Tasks:** +- [ ] HTTP client for geolocator service +- [ ] Parse JSON responses +- [ ] Error handling + +**Tests:** +- [ ] Query geolocator +- [ ] Parse response +- [ ] Handle errors +- [ ] Timeout handling + +### 4.4 IP Location APIs + +**Tasks:** +- [ ] IP2Location API client +- [ ] IPInfo API client +- [ ] File-based caching +- [ ] Fallback chain + +**Tests:** +- [ ] Query each API +- [ ] Parse responses +- [ ] Cache results +- [ ] Fallback on error + +## Phase 5: Weather Data (Week 4-5) + +### 5.1 HTTP Client + +**Tasks:** +- [ ] Generic HTTP client +- [ ] Connection pooling +- [ ] Timeout handling +- [ ] Retry logic +- [ ] User-Agent setting + +**Tests:** +- [ ] GET requests +- [ ] POST requests +- [ ] Headers +- [ ] Timeouts +- [ ] Retries + +### 5.2 met.no Client + +**Tasks:** +- [ ] API endpoint construction +- [ ] Parse XML/JSON responses +- [ ] Transform to internal format +- [ ] Error handling + +**Tests:** +- [ ] Fetch weather data +- [ ] Parse response +- [ ] Handle API errors +- [ ] Handle rate limits + +### 5.3 WorldWeatherOnline Client + +**Tasks:** +- [ ] API endpoint construction +- [ ] Parse JSON responses +- [ ] Transform to internal format +- [ ] Caching (proxy cache) + +**Tests:** +- [ ] Fetch weather data +- [ ] Parse response +- [ ] Handle API errors +- [ ] Cache responses + +### 5.4 Weather Data Model + +**Tasks:** +- [ ] Define internal weather data structure +- [ ] Conversion from met.no format +- [ ] Conversion from WWO format +- [ ] JSON serialization + +**Tests:** +- [ ] Convert met.no data +- [ ] Convert WWO data +- [ ] Serialize to JSON +- [ ] Validate data + +## Phase 6: Rendering (Week 5-7) + +### 6.1 ANSI Renderer + +**Tasks:** +- [ ] Generate ANSI weather report +- [ ] ASCII art for weather conditions +- [ ] Color codes +- [ ] Box drawing characters +- [ ] Temperature graphs +- [ ] Wind direction arrows + +**Tests:** +- [ ] Render current weather +- [ ] Render forecast +- [ ] Apply colors +- [ ] Handle narrow mode +- [ ] Handle inverted colors + +**Acceptance Criteria:** +- Output matches wego format +- All weather conditions supported +- Configurable width + +### 6.2 One-Line Renderer + +**Tasks:** +- [ ] Parse format string +- [ ] Replace format codes (%c, %t, etc.) +- [ ] Handle predefined formats (1-4) +- [ ] Emoji support + +**Tests:** +- [ ] Format 1-4 +- [ ] Custom format strings +- [ ] All format codes +- [ ] Multiple locations + +### 6.3 JSON Renderer + +**Tasks:** +- [ ] Serialize weather data to JSON +- [ ] Match WWO API format +- [ ] Pretty printing + +**Tests:** +- [ ] Serialize current conditions +- [ ] Serialize forecast +- [ ] Match expected format + +### 6.4 HTML Renderer + +**Tasks:** +- [ ] Convert ANSI to HTML +- [ ] Apply CSS styling +- [ ] Add interactive buttons +- [ ] Template system + +**Tests:** +- [ ] Convert ANSI codes +- [ ] Apply colors +- [ ] Render buttons +- [ ] Template rendering + +### 6.5 PNG Renderer + +**Tasks:** +- [ ] Render ANSI to image +- [ ] Font rendering +- [ ] Color support +- [ ] Transparency + +**Options:** +- Use C library (libpng, freetype) via @cImport +- Or: Pure Zig implementation (more work) + +**Tests:** +- [ ] Render text +- [ ] Apply colors +- [ ] Apply transparency +- [ ] Handle fonts + +### 6.6 v2 Renderer + +**Tasks:** +- [ ] Data-rich format +- [ ] Temperature graphs +- [ ] Moon phases +- [ ] Astronomical times + +**Tests:** +- [ ] Render all sections +- [ ] Handle different locations +- [ ] Match expected format + +### 6.7 Prometheus Renderer + +**Tasks:** +- [ ] Convert weather data to metrics +- [ ] Prometheus text format +- [ ] Metric naming + +**Tests:** +- [ ] Render metrics +- [ ] Match Prometheus format +- [ ] All weather fields + +## Phase 7: Translation System (Week 7-8) + +### 7.1 Translation Loader + +**Tasks:** +- [ ] Load translation files +- [ ] Parse translation format +- [ ] Build translation tables +- [ ] Language detection + +**Tests:** +- [ ] Load all language files +- [ ] Parse translations +- [ ] Detect language from header +- [ ] Detect language from subdomain + +### 7.2 Message Translation + +**Tasks:** +- [ ] Translate weather conditions +- [ ] Translate UI messages +- [ ] Fallback to English + +**Tests:** +- [ ] Translate conditions +- [ ] Translate messages +- [ ] Handle missing translations +- [ ] Fallback logic + +## Phase 8: Request Processing (Week 8-9) + +### 8.1 Query Parser + +**Tasks:** +- [ ] Parse query parameters +- [ ] Parse single-letter options +- [ ] Parse PNG filenames +- [ ] Serialize/deserialize state + +**Tests:** +- [ ] Parse all options +- [ ] Parse PNG filenames +- [ ] Serialize state +- [ ] Deserialize state + +### 8.2 Request Handler + +**Tasks:** +- [ ] Main request handler +- [ ] Fast path (cache + static) +- [ ] Full path (location + weather + render) +- [ ] Error handling +- [ ] Rate limiting + +**Tests:** +- [ ] Handle cached requests +- [ ] Handle static pages +- [ ] Handle weather queries +- [ ] Handle errors +- [ ] Enforce rate limits + +### 8.3 Rate Limiting + +**Tasks:** +- [ ] Per-IP counters +- [ ] Time buckets (minute, hour, day) +- [ ] Whitelist support +- [ ] Return 429 on limit + +**Tests:** +- [ ] Count requests +- [ ] Enforce limits +- [ ] Reset counters +- [ ] Whitelist bypass + +## Phase 9: Integration & Testing (Week 9-10) + +### 9.1 End-to-End Tests + +**Tasks:** +- [ ] Test all API endpoints +- [ ] Test all output formats +- [ ] Test all query options +- [ ] Test error cases +- [ ] Compare with Python output + +**Test Cases:** +- [ ] Basic weather query +- [ ] Location resolution +- [ ] All output formats +- [ ] All languages +- [ ] PNG rendering +- [ ] Rate limiting +- [ ] Error handling + +### 9.2 Performance Testing + +**Tasks:** +- [ ] Benchmark request latency +- [ ] Benchmark throughput +- [ ] Memory profiling +- [ ] Cache hit rates +- [ ] Compare with Python/Go + +**Metrics:** +- Requests per second +- Average latency +- P95/P99 latency +- Memory usage +- Cache hit rate + +### 9.3 Compatibility Testing + +**Tasks:** +- [ ] Run integration test (test/query.sh) +- [ ] Compare SHA1 hashes +- [ ] Fix any differences +- [ ] Document intentional changes + +## Phase 10: Deployment (Week 10-11) + +### 10.1 Packaging + +**Tasks:** +- [ ] Build release binary +- [ ] Package data files +- [ ] Create Docker image +- [ ] Write deployment docs + +### 10.2 Migration Plan + +**Tasks:** +- [ ] Deploy Zig version alongside Python/Go +- [ ] Route small percentage of traffic +- [ ] Monitor errors and performance +- [ ] Gradually increase traffic +- [ ] Full cutover + +**Rollback Plan:** +- Keep Python/Go running +- Route traffic back if issues +- Fix issues in Zig version +- Retry migration + +### 10.3 Documentation + +**Tasks:** +- [ ] Update README +- [ ] Installation instructions +- [ ] Configuration guide +- [ ] API documentation +- [ ] Development guide + +## Risks & Mitigations + +### Risk: PNG Rendering Complexity + +**Mitigation:** +- Start with external library (libpng, freetype) +- Consider pure Zig later if needed +- Or: Keep PNG rendering in separate service + +### Risk: GeoIP Database Parsing + +**Mitigation:** +- Use C library (libmaxminddb) initially +- Consider pure Zig parser later +- Well-documented format + +### Risk: ANSI Rendering Differences + +**Mitigation:** +- Extensive testing against wego output +- Pixel-perfect comparison for PNG +- Accept minor differences if functionally equivalent + +### Risk: Performance Regression + +**Mitigation:** +- Benchmark early and often +- Profile hot paths +- Optimize critical sections +- Compare with Python/Go baseline + +### Risk: Translation File Parsing + +**Mitigation:** +- Simple format (key: value) +- Robust parser with error handling +- Validate all files on startup + +### Risk: Weather API Changes + +**Mitigation:** +- Abstract API clients +- Version API responses +- Monitor for changes +- Fallback to other API + +## Success Criteria + +### Functional + +- [ ] All API endpoints work +- [ ] All output formats match +- [ ] All languages supported +- [ ] All query options work +- [ ] Integration tests pass + +### Performance + +- [ ] Latency < Python/Go +- [ ] Throughput > Python/Go +- [ ] Memory < Python/Go +- [ ] Binary size < 10MB + +### Quality + +- [ ] >80% test coverage +- [ ] No memory leaks +- [ ] No crashes under load +- [ ] Clean error handling + +### Operational + +- [ ] Single binary deployment +- [ ] No external dependencies (except data files) +- [ ] Easy configuration +- [ ] Good logging +- [ ] Metrics/monitoring + +## Timeline + +**Total: 10-11 weeks** + +- Week 1-2: Foundation +- Week 2-3: Caching +- Week 3-4: Location +- Week 4-5: Weather +- Week 5-7: Rendering +- Week 7-8: Translation +- Week 8-9: Integration +- Week 9-10: Testing +- Week 10-11: Deployment + +**Milestones:** + +1. **Week 2:** HTTP server running, basic routing +2. **Week 4:** Location resolution working +3. **Week 6:** Weather data fetching working +4. **Week 8:** ANSI rendering working +5. **Week 10:** All features complete, testing done +6. **Week 11:** Deployed to production + +## Alternative: Incremental Approach + +Instead of full rewrite, replace components incrementally: + +### Option A: Replace Go Proxy First + +1. Rewrite Go proxy in Zig (Week 1-2) +2. Keep Python backend +3. Test and deploy +4. Then rewrite Python backend (Week 3-11) + +**Advantages:** +- Smaller initial scope +- Faster time to value +- De-risks Zig HTTP handling +- Can abandon if issues + +### Option B: Replace Python Backend First + +1. Keep Go proxy +2. Rewrite Python backend in Zig (Week 1-10) +3. Test and deploy +4. Then replace Go proxy (Week 11) + +**Advantages:** +- Most complexity in backend +- Proves out rendering logic +- Can keep Go proxy if it works well + +### Recommendation: Option A + +Start with Go proxy replacement: +- Smaller scope (400 lines vs 5000+) +- Clear interface boundary +- Tests caching/HTTP in Zig +- Quick win (2 weeks) +- De-risks larger rewrite + +## Next Steps + +1. **Review this document** - Get feedback on approach +2. **Choose strategy** - Full rewrite vs incremental +3. **Set up project** - Create build.zig, directory structure +4. **Start coding** - Begin with HTTP server or Go proxy replacement +5. **Iterate** - Build, test, refine + +## Questions to Answer + +- [ ] Use C libraries (libpng, freetype, libmaxminddb) or pure Zig? +- [ ] Keep geolocator as separate service or integrate? +- [ ] Keep wego/pyphoon or rewrite rendering? +- [ ] Full rewrite or incremental replacement? +- [ ] Target Zig version (0.11, 0.12, 0.13)? +- [ ] Async I/O strategy (std.event, manual, blocking)? diff --git a/zig/TARGET_ARCHITECTURE.md b/zig/TARGET_ARCHITECTURE.md new file mode 100644 index 0000000..5ff3abe --- /dev/null +++ b/zig/TARGET_ARCHITECTURE.md @@ -0,0 +1,953 @@ +# wttr.in Target Architecture (Zig) + +## Overview + +Single Zig binary (`wttr`) with simplified architecture: +- One HTTP server (karlseguin/http.zig) +- One caching layer (LRU + file-backed) +- Pluggable weather provider interface +- Rate limiting as injectable middleware +- English-only (i18n can be added later) +- No cron-based prefetching (operational concern) + +## System Diagram + +``` +Client Request + ↓ +HTTP Server (http.zig) + ↓ +Rate Limiter (middleware) + ↓ +Router + ↓ +Request Handler + ↓ +Cache Check + ↓ (miss) +Location Resolver + ↓ +Weather Provider (interface) + ├─ MetNo (default) + └─ Mock (tests) + ↓ +Renderer + ├─ ANSI + ├─ JSON + ├─ Line + └─ (PNG/HTML later) + ↓ +Cache Store + ↓ +Response +``` + +## Module Structure + +``` +wttr/ +├── build.zig +├── build.zig.zon +└── src/ + ├── main.zig # Entry point, server setup + ├── config.zig # Configuration + ├── http/ + │ ├── server.zig # HTTP server wrapper + │ ├── router.zig # Route matching + │ ├── handler.zig # Request handlers + │ └── middleware.zig # Rate limiter (Token Bucket) + ├── cache/ + │ ├── cache.zig # Cache interface + │ ├── lru.zig # In-memory LRU + │ └── file.zig # File-backed storage + ├── location/ + │ ├── resolver.zig # Main location resolution + │ ├── geoip.zig # GeoIP wrapper (libmaxminddb) + │ ├── normalize.zig # Location normalization + │ └── geolocator.zig # HTTP client for geolocator service + ├── weather/ + │ ├── provider.zig # Weather provider interface + │ ├── metno.zig # met.no implementation + │ ├── mock.zig # Mock for tests + │ └── types.zig # Weather data structures + ├── render/ + │ ├── ansi.zig # ANSI terminal output + │ ├── json.zig # JSON output + │ └── line.zig # One-line format + └── util/ + ├── ip.zig # IP address utilities + └── hash.zig # Hashing utilities +``` + +## Core Components + +### 1. HTTP Server (main.zig, http/) + +**Responsibilities:** +- Listen on configured port +- Parse HTTP requests +- Route to handlers +- Apply middleware (rate limiting) +- Return responses + +**Dependencies:** +- karlseguin/http.zig + +**Configuration:** +```zig +const Config = struct { + listen_host: []const u8 = "0.0.0.0", + listen_port: u16 = 8002, + cache_size: usize = 10_000, + cache_dir: []const u8 = "/tmp/wttr-cache", + geolite_path: []const u8 = "./GeoLite2-City.mmdb", + geolocator_url: []const u8 = "http://localhost:8004", +}; +``` + +**Routes:** +```zig +GET / → weather for IP location +GET /{location} → weather for location +GET /:help → help page +GET /files/{path} → static files +``` + +### 2. Rate Limiter (http/middleware.zig) + +**Algorithm:** Token Bucket + +**Responsibilities:** +- Track token buckets per IP +- Refill tokens at configured rate +- Consume tokens on request +- Return 429 when bucket empty +- Injectable as middleware + +**Interface:** +```zig +pub const RateLimiter = struct { + buckets: HashMap([]const u8, TokenBucket), + config: RateLimitConfig, + allocator: Allocator, + + pub fn init(allocator: Allocator, config: RateLimitConfig) !RateLimiter; + pub fn check(self: *RateLimiter, ip: []const u8) !void; + pub fn middleware(self: *RateLimiter) Middleware; + pub fn deinit(self: *RateLimiter) void; +}; + +pub const RateLimitConfig = struct { + capacity: u32 = 300, // Max tokens in bucket + refill_rate: u32 = 5, // Tokens per second + refill_interval_ms: u64 = 200, // Refill every 200ms (5 tokens/sec) +}; + +const TokenBucket = struct { + tokens: f64, + capacity: u32, + last_refill: i64, // Unix timestamp (ms) + + fn refill(self: *TokenBucket, now: i64, rate: u32, interval_ms: u64) void { + const elapsed = now - self.last_refill; + const intervals = @as(f64, @floatFromInt(elapsed)) / @as(f64, @floatFromInt(interval_ms)); + const new_tokens = intervals * @as(f64, @floatFromInt(rate)); + + self.tokens = @min( + self.tokens + new_tokens, + @as(f64, @floatFromInt(self.capacity)) + ); + self.last_refill = now; + } + + fn consume(self: *TokenBucket, count: f64) bool { + if (self.tokens >= count) { + self.tokens -= count; + return true; + } + return false; + } +}; +``` + +**Implementation:** +- HashMap of IP → TokenBucket +- Each request consumes 1 token +- Tokens refill at configured rate (default: 5/second) +- Bucket capacity: 300 tokens (allows bursts) +- Periodic cleanup of old buckets (not accessed in 1 hour) + +**Example Configuration:** +```zig +// Allow 300 requests burst, then 5 req/sec sustained +const config = RateLimitConfig{ + .capacity = 300, + .refill_rate = 5, + .refill_interval_ms = 200, +}; +``` + +### 3. Cache (cache/) + +**Single-layer cache with two storage backends:** + +**Interface:** +```zig +pub const Cache = struct { + lru: LRU, + file_store: FileStore, + + pub fn init(allocator: Allocator, config: CacheConfig) !Cache; + pub fn get(self: *Cache, key: []const u8) ?[]const u8; + pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl: u64) !void; +}; + +pub const CacheConfig = struct { + max_entries: usize = 10_000, + file_threshold: usize = 1024, // Store to file if > 1KB + cache_dir: []const u8, +}; +``` + +**Cache Key:** +``` +{user_agent}:{path}:{query}:{client_ip} +``` + +**Storage Strategy:** +- Small responses (<1KB): In-memory LRU only +- Large responses (≥1KB): LRU stores file path, data on disk +- TTL: 1000-2000s (randomized to prevent thundering herd) + +**LRU Implementation:** +```zig +pub const LRU = struct { + map: HashMap([]const u8, Entry), + list: DoublyLinkedList([]const u8), + max_entries: usize, + + const Entry = struct { + value: []const u8, + expires: i64, + node: *Node, + }; + + pub fn get(self: *LRU, key: []const u8) ?[]const u8; + pub fn put(self: *LRU, key: []const u8, value: []const u8, expires: i64) !void; +}; +``` + +### 4. Location Resolver (location/) + +**Responsibilities:** +- Parse location from URL +- Normalize location names +- Resolve IP to location (GeoIP) +- Resolve names to coordinates (geolocator) +- Handle special prefixes (~, @) + +**Interface:** +```zig +pub const LocationResolver = struct { + geoip: GeoIP, + geolocator_url: []const u8, + allocator: Allocator, + + pub fn init(allocator: Allocator, config: LocationConfig) !LocationResolver; + pub fn resolve(self: *LocationResolver, location: ?[]const u8, client_ip: []const u8) !Location; +}; + +pub const Location = struct { + name: []const u8, // "London" or "51.5074,-0.1278" + display_name: ?[]const u8, // Override for display + country: ?[]const u8, // "GB" + source_ip: []const u8, // Client IP +}; +``` + +**Resolution Logic:** +```zig +fn resolve(location: ?[]const u8, client_ip: []const u8) !Location { + // 1. Empty/null → Use client IP + if (location == null) return resolveIP(client_ip); + + // 2. IP address → Resolve to location + if (isIP(location)) return resolveIP(location); + + // 3. @domain → Resolve domain to IP, then location + if (location[0] == '@') return resolveDomain(location[1..]); + + // 4. ~search → Use geolocator + if (location[0] == '~') return geolocate(location[1..]); + + // 5. Name → Normalize and use + return Location{ + .name = normalize(location), + .display_name = null, + .country = null, + .source_ip = client_ip, + }; +} +``` + +**GeoIP:** +- Wraps libmaxminddb C library via @cImport +- Built from source using Zig build system (not system library) +- Exposes clean Zig interface +- Hides C implementation details + +**Build Integration:** +The libmaxminddb library will be built as part of the Zig build process: +- Source code vendored in `vendor/libmaxminddb/` +- Compiled using `b.addStaticLibrary()` or `b.addObject()` +- Linked into the main binary +- No system library dependency required + +```zig +// location/geoip.zig +const c = @cImport({ + @cInclude("maxminddb.h"); +}); + +pub const GeoIP = struct { + mmdb: c.MMDB_s, + allocator: Allocator, + + pub fn init(allocator: Allocator, db_path: []const u8) !GeoIP { + var self = GeoIP{ + .mmdb = undefined, + .allocator = allocator, + }; + + const path_z = try allocator.dupeZ(u8, db_path); + defer allocator.free(path_z); + + const status = c.MMDB_open(path_z.ptr, c.MMDB_MODE_MMAP, &self.mmdb); + if (status != c.MMDB_SUCCESS) { + return error.GeoIPOpenFailed; + } + + return self; + } + + pub fn lookup(self: *GeoIP, ip: []const u8) !?GeoIPResult { + const ip_z = try self.allocator.dupeZ(u8, ip); + defer self.allocator.free(ip_z); + + var gai_error: c_int = 0; + var mmdb_error: c_int = 0; + + const result = c.MMDB_lookup_string( + &self.mmdb, + ip_z.ptr, + &gai_error, + &mmdb_error + ); + + if (gai_error != 0 or mmdb_error != c.MMDB_SUCCESS) { + return null; + } + + if (!result.found_entry) { + return null; + } + + // Extract city and country + var entry_data: c.MMDB_entry_data_s = undefined; + + const city = blk: { + const status = c.MMDB_get_value( + &result.entry, + &entry_data, + "city", "names", "en", null + ); + if (status == c.MMDB_SUCCESS and entry_data.has_data) { + const str = entry_data.utf8_string[0..entry_data.data_size]; + break :blk try self.allocator.dupe(u8, str); + } + break :blk null; + }; + + const country = blk: { + const status = c.MMDB_get_value( + &result.entry, + &entry_data, + "country", "names", "en", null + ); + if (status == c.MMDB_SUCCESS and entry_data.has_data) { + const str = entry_data.utf8_string[0..entry_data.data_size]; + break :blk try self.allocator.dupe(u8, str); + } + break :blk null; + }; + + return GeoIPResult{ + .city = city, + .country = country, + }; + } + + pub fn deinit(self: *GeoIP) void { + c.MMDB_close(&self.mmdb); + } +}; + +pub const GeoIPResult = struct { + city: ?[]const u8, + country: ?[]const u8, + + pub fn deinit(self: GeoIPResult, allocator: Allocator) void { + if (self.city) |city| allocator.free(city); + if (self.country) |country| allocator.free(country); + } +}; +``` + +### 5. Weather Provider (weather/) + +**Interface (pluggable):** +```zig +pub const WeatherProvider = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + fetch: *const fn (ptr: *anyopaque, location: []const u8) anyerror!WeatherData, + deinit: *const fn (ptr: *anyopaque) void, + }; + + pub fn fetch(self: WeatherProvider, location: []const u8) !WeatherData { + return self.vtable.fetch(self.ptr, location); + } +}; + +pub const WeatherData = struct { + location: []const u8, + current: CurrentCondition, + forecast: []ForecastDay, + + pub const CurrentCondition = struct { + temp_c: f32, + temp_f: f32, + condition: []const u8, + weather_code: u16, + humidity: u8, + wind_kph: f32, + wind_dir: []const u8, + pressure_mb: f32, + precip_mm: f32, + }; + + pub const ForecastDay = struct { + date: []const u8, + max_temp_c: f32, + min_temp_c: f32, + condition: []const u8, + weather_code: u16, + hourly: []HourlyForecast, + }; + + pub const HourlyForecast = struct { + time: []const u8, + temp_c: f32, + condition: []const u8, + weather_code: u16, + wind_kph: f32, + precip_mm: f32, + }; +}; +``` + +**MetNo Implementation:** +```zig +pub const MetNo = struct { + allocator: Allocator, + http_client: HttpClient, + + pub fn init(allocator: Allocator) !MetNo; + pub fn provider(self: *MetNo) WeatherProvider; + pub fn fetch(self: *MetNo, location: []const u8) !WeatherData; +}; +``` + +**Mock Implementation (for tests):** +```zig +pub const MockWeather = struct { + allocator: Allocator, + responses: HashMap([]const u8, WeatherData), + + pub fn init(allocator: Allocator) !MockWeather; + pub fn provider(self: *MockWeather) WeatherProvider; + pub fn addResponse(self: *MockWeather, location: []const u8, data: WeatherData) !void; + pub fn fetch(self: *MockWeather, location: []const u8) !WeatherData; +}; +``` + +### 6. Renderers (render/) + +**ANSI Renderer:** +```zig +pub const AnsiRenderer = struct { + pub fn render(allocator: Allocator, data: WeatherData, options: RenderOptions) ![]const u8; +}; + +pub const RenderOptions = struct { + narrow: bool = false, + days: u8 = 3, + use_imperial: bool = false, + no_caption: bool = false, +}; +``` + +**Output Format:** +``` +Weather for: London, GB + + \ / Clear + .-. 10 – 11 °C + ― ( ) ― ↑ 11 km/h + `-' 10 km + / \ 0.0 mm +``` + +**JSON Renderer:** +```zig +pub const JsonRenderer = struct { + pub fn render(allocator: Allocator, data: WeatherData) ![]const u8; +}; +``` + +**One-Line Renderer:** +```zig +pub const LineRenderer = struct { + pub fn render(allocator: Allocator, data: WeatherData, format: []const u8) ![]const u8; +}; + +// Formats: +// 1: "London: ⛅️ +7°C" +// 2: "London: ⛅️ +7°C 🌬️↗11km/h" +// 3: "London: ⛅️ +7°C 🌬️↗11km/h 💧65%" +// Custom: "%l: %c %t" → "London: ⛅️ +7°C" +``` + +### 7. Request Handler (http/handler.zig) + +**Main handler flow:** +```zig +pub const HandleWeatherOptions = struct { + cache: *Cache, + resolver: *LocationResolver, + provider: WeatherProvider, +}; + +pub fn handleWeather( + req: *http.Request, + res: *http.Response, + options: HandleWeatherOptions, +) !void { + // 1. Parse request + const location = parseLocation(req.url.path); + const render_opts = parseOptions(req.url.query); + const client_ip = getClientIP(req); + + // 2. Generate cache key + const cache_key = try generateCacheKey(req, client_ip); + + // 3. Check cache + if (options.cache.get(cache_key)) |cached| { + return res.write(cached); + } + + // 4. Resolve location + const resolved = try options.resolver.resolve(location, client_ip); + + // 5. Fetch weather + const weather = try options.provider.fetch(resolved.name); + + // 6. Render + const output = try renderWeather(weather, render_opts); + + // 7. Cache + const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000); + try options.cache.put(cache_key, output, ttl); + + // 8. Return + return res.write(output); +} +``` + +## Configuration + +**Environment Variables:** +```bash +WTTR_LISTEN_HOST=0.0.0.0 +WTTR_LISTEN_PORT=8002 +WTTR_CACHE_SIZE=10000 +WTTR_CACHE_DIR=/tmp/wttr-cache +WTTR_GEOLITE_PATH=./GeoLite2-City.mmdb +WTTR_GEOLOCATOR_URL=http://localhost:8004 +``` + +**Config File (optional):** +```zig +// config.zig +pub const Config = struct { + listen_host: []const u8, + listen_port: u16, + cache_size: usize, + cache_dir: []const u8, + geolite_path: []const u8, + geolocator_url: []const u8, + + pub fn load(allocator: Allocator) !Config { + return Config{ + .listen_host = std.os.getenv("WTTR_LISTEN_HOST") orelse "0.0.0.0", + .listen_port = parsePort(std.os.getenv("WTTR_LISTEN_PORT")) orelse 8002, + .cache_size = parseSize(std.os.getenv("WTTR_CACHE_SIZE")) orelse 10_000, + .cache_dir = std.os.getenv("WTTR_CACHE_DIR") orelse "/tmp/wttr-cache", + .geolite_path = std.os.getenv("WTTR_GEOLITE_PATH") orelse "./GeoLite2-City.mmdb", + .geolocator_url = std.os.getenv("WTTR_GEOLOCATOR_URL") orelse "http://localhost:8004", + }; + } +}; +``` + +## Dependencies + +**External (Zig packages):** +- karlseguin/http.zig - HTTP server +- rockorager/zeit - Time utilities + +**Vendored (built from source):** +- libmaxminddb - GeoIP lookups (C library, built with Zig build system) + +**Standard Library:** +- std.http.Client - HTTP client for weather APIs +- std.json - JSON parsing/serialization +- std.HashMap - Cache and rate limiter storage +- std.crypto.random - Random TTL generation + +## Build Configuration + +**build.zig:** +```zig +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Build libmaxminddb from source + const maxminddb = b.addStaticLibrary(.{ + .name = "maxminddb", + .target = target, + .optimize = optimize, + }); + maxminddb.linkLibC(); + maxminddb.addIncludePath(.{ .path = "vendor/libmaxminddb/include" }); + maxminddb.addCSourceFiles(.{ + .files = &.{ + "vendor/libmaxminddb/src/maxminddb.c", + "vendor/libmaxminddb/src/data-pool.c", + }, + .flags = &.{"-std=c99"}, + }); + + const exe = b.addExecutable(.{ + .name = "wttr", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + // Add http.zig dependency + const http = b.dependency("http", .{ + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("http", http.module("http")); + + // Add zeit dependency + const zeit = b.dependency("zeit", .{ + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("zeit", zeit.module("zeit")); + + // Link libmaxminddb (built from source) + exe.linkLibrary(maxminddb); + exe.addIncludePath(.{ .path = "vendor/libmaxminddb/include" }); + exe.linkLibC(); + + b.installArtifact(exe); + + // Tests + const tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("http", http.module("http")); + tests.root_module.addImport("zeit", zeit.module("zeit")); + tests.linkLibrary(maxminddb); + tests.addIncludePath(.{ .path = "vendor/libmaxminddb/include" }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_tests.step); +} +``` + +**Project Structure:** +``` +wttr/ +├── build.zig +├── build.zig.zon +├── vendor/ +│ └── libmaxminddb/ # Vendored libmaxminddb source +│ ├── include/ +│ │ └── maxminddb.h +│ └── src/ +│ ├── maxminddb.c +│ └── data-pool.c +└── src/ + └── ... +``` + +**build.zig.zon:** +```zig +.{ + .name = "wttr", + .version = "0.1.0", + .dependencies = .{ + .http = .{ + .url = "https://github.com/karlseguin/http.zig/archive/refs/tags/v0.1.0.tar.gz", + .hash = "...", + }, + .zeit = .{ + .url = "https://github.com/rockorager/zeit/archive/refs/tags/v0.1.0.tar.gz", + .hash = "...", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} +``` + +## Testing Strategy + +**Unit Tests:** +```zig +// cache/lru.zig +test "LRU basic operations" { + var lru = try LRU.init(testing.allocator, 3); + defer lru.deinit(); + + try lru.put("key1", "value1", 9999999999); + try testing.expectEqualStrings("value1", lru.get("key1").?); +} + +test "LRU eviction" { + var lru = try LRU.init(testing.allocator, 2); + defer lru.deinit(); + + try lru.put("key1", "value1", 9999999999); + try lru.put("key2", "value2", 9999999999); + try lru.put("key3", "value3", 9999999999); + + try testing.expect(lru.get("key1") == null); // Evicted + try testing.expectEqualStrings("value2", lru.get("key2").?); +} + +// weather/mock.zig +test "mock weather provider" { + var mock = try MockWeather.init(testing.allocator); + defer mock.deinit(); + + const data = WeatherData{ /* ... */ }; + try mock.addResponse("London", data); + + const provider = mock.provider(); + const result = try provider.fetch("London"); + try testing.expectEqual(data.current.temp_c, result.current.temp_c); +} + +// http/middleware.zig +test "rate limiter enforces limits" { + const zeit = @import("zeit"); + + var limiter = try RateLimiter.init(testing.allocator, .{ + .capacity = 10, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + // Consume all tokens + var i: usize = 0; + while (i < 10) : (i += 1) { + try limiter.check("1.2.3.4"); + } + + // Next request should fail + try testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); + + // Wait for refill (1 second = 1 token) + std.time.sleep(1_100_000_000); // 1.1 seconds + + // Should succeed now + try limiter.check("1.2.3.4"); +} + +test "rate limiter allows bursts" { + var limiter = try RateLimiter.init(testing.allocator, .{ + .capacity = 100, + .refill_rate = 5, + .refill_interval_ms = 200, + }); + defer limiter.deinit(); + + // Should allow 100 requests immediately (burst) + var i: usize = 0; + while (i < 100) : (i += 1) { + try limiter.check("1.2.3.4"); + } + + // 101st should fail + try testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); +} +``` + +**Integration Tests:** +```zig +test "end-to-end weather request" { + var mock = try MockWeather.init(testing.allocator); + defer mock.deinit(); + + // Setup mock data + try mock.addResponse("London", test_weather_data); + + // Create server components + var cache = try Cache.init(testing.allocator, .{}); + defer cache.deinit(); + + var resolver = try LocationResolver.init(testing.allocator, .{}); + defer resolver.deinit(); + + // Simulate request + const output = try handleWeatherRequest( + "London", + &cache, + &resolver, + mock.provider(), + ); + defer testing.allocator.free(output); + + try testing.expect(std.mem.indexOf(u8, output, "London") != null); + try testing.expect(std.mem.indexOf(u8, output, "°C") != null); +} +``` + +## Deployment + +**Single Binary:** +```bash +# Build +zig build -Doptimize=ReleaseFast + +# Run +./zig-out/bin/wttr + +# With config +WTTR_LISTEN_PORT=8080 ./zig-out/bin/wttr +``` + +**Docker:** +```dockerfile +FROM alpine:latest +COPY zig-out/bin/wttr /usr/local/bin/wttr +COPY GeoLite2-City.mmdb /data/GeoLite2-City.mmdb +ENV WTTR_GEOLITE_PATH=/data/GeoLite2-City.mmdb +EXPOSE 8002 +CMD ["/usr/local/bin/wttr"] +``` + +## Performance Targets + +**Latency:** +- Cache hit: <1ms +- Cache miss: <100ms (depends on weather API) +- P95: <150ms +- P99: <300ms + +**Throughput:** +- >10,000 req/s (cached) +- >1,000 req/s (uncached) + +**Memory:** +- Base: <50MB +- With 10,000 cache entries: <200MB +- Binary size: <5MB + +## Future Enhancements (Not in Initial Version) + +1. **i18n Support** - Add translation system +2. **PNG Rendering** - Add image output +3. **HTML Output** - Add web browser support +4. **v2 Format** - Data-rich experimental format +5. **Prometheus Format** - Metrics output +6. **Moon Phases** - Special moon queries +7. **Multiple Weather Providers** - WWO, OpenWeather, etc. +8. **Metrics/Observability** - Prometheus metrics, structured logging +9. **Admin API** - Cache stats, health checks + +## Migration from Current System + +**Phase 1: Parallel Deployment** +- Deploy Zig version on different port (8003) +- Route 1% of traffic to Zig version +- Monitor errors and performance +- Compare outputs + +**Phase 2: Gradual Rollout** +- Increase traffic to 10%, 25%, 50%, 100% +- Monitor at each step +- Rollback if issues + +**Phase 3: Cutover** +- Switch all traffic to Zig version +- Keep Python/Go as backup for 1 week +- Decommission old system + +## Success Criteria + +**Functional:** +- [ ] All core endpoints work (/, /{location}, /:help) +- [ ] ANSI output matches current system +- [ ] JSON output matches current system +- [ ] One-line formats work +- [ ] Location resolution works (IP, name, coordinates) +- [ ] Rate limiting works +- [ ] Caching works + +**Performance:** +- [ ] Latency ≤ current system +- [ ] Throughput ≥ current system +- [ ] Memory ≤ current system +- [ ] Binary size <10MB + +**Quality:** +- [ ] >80% test coverage +- [ ] No memory leaks (valgrind clean) +- [ ] No crashes under load +- [ ] Clean error handling + +**Operational:** +- [ ] Single binary deployment +- [ ] Simple configuration +- [ ] Good logging +- [ ] Easy to debug diff --git a/zig/build.zig b/zig/build.zig new file mode 100644 index 0000000..fa911e3 --- /dev/null +++ b/zig/build.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const httpz = b.dependency("httpz", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "wttr", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + exe.root_module.addImport("httpz", httpz.module("httpz")); + exe.linkLibC(); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + tests.root_module.addImport("httpz", httpz.module("httpz")); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/zig/build.zig.zon b/zig/build.zig.zon new file mode 100644 index 0000000..c36613d --- /dev/null +++ b/zig/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .wttr, + .version = "0.1.0", + .dependencies = .{ + .httpz = .{ + .url = "https://github.com/karlseguin/http.zig/archive/refs/heads/master.tar.gz", + .hash = "httpz-0.0.0-PNVzrEktBwCzPoiua-S8LAYo2tILqczm3tSpneEzLQ9L", + }, + }, + .fingerprint = 0x710c2b57e81aa678, + .minimum_zig_version = "0.15.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/zig/src/cache/cache.zig b/zig/src/cache/cache.zig new file mode 100644 index 0000000..ed2bae4 --- /dev/null +++ b/zig/src/cache/cache.zig @@ -0,0 +1,43 @@ +const std = @import("std"); +const LRU = @import("lru.zig").LRU; + +pub const Cache = struct { + allocator: std.mem.Allocator, + lru: LRU, + cache_dir: []const u8, + file_threshold: usize, + + pub const Config = struct { + max_entries: usize = 10_000, + file_threshold: usize = 1024, + cache_dir: []const u8, + }; + + pub fn init(allocator: std.mem.Allocator, config: Config) !Cache { + std.fs.makeDirAbsolute(config.cache_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + return Cache{ + .allocator = allocator, + .lru = try LRU.init(allocator, config.max_entries), + .cache_dir = try allocator.dupe(u8, config.cache_dir), + .file_threshold = config.file_threshold, + }; + } + + pub fn get(self: *Cache, key: []const u8) ?[]const u8 { + return self.lru.get(key); + } + + pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void { + const now = std.time.milliTimestamp(); + const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); + try self.lru.put(key, value, expires); + } + + pub fn deinit(self: *Cache) void { + self.lru.deinit(); + self.allocator.free(self.cache_dir); + } +}; diff --git a/zig/src/cache/lru.zig b/zig/src/cache/lru.zig new file mode 100644 index 0000000..244966c --- /dev/null +++ b/zig/src/cache/lru.zig @@ -0,0 +1,106 @@ +const std = @import("std"); + +pub const LRU = struct { + allocator: std.mem.Allocator, + map: std.StringHashMap(Entry), + max_entries: usize, + + const Entry = struct { + value: []const u8, + expires: i64, + access_count: u64, + }; + + pub fn init(allocator: std.mem.Allocator, max_entries: usize) !LRU { + return LRU{ + .allocator = allocator, + .map = std.StringHashMap(Entry).init(allocator), + .max_entries = max_entries, + }; + } + + pub fn get(self: *LRU, key: []const u8) ?[]const u8 { + var entry = self.map.getPtr(key) orelse return null; + + const now = std.time.milliTimestamp(); + if (now > entry.expires) { + self.remove(key); + return null; + } + + entry.access_count += 1; + return entry.value; + } + + pub fn put(self: *LRU, key: []const u8, value: []const u8, expires: i64) !void { + if (self.map.get(key)) |old_entry| { + self.allocator.free(old_entry.value); + _ = self.map.remove(key); + } + + if (self.map.count() >= self.max_entries) { + self.evictOldest(); + } + + const key_copy = try self.allocator.dupe(u8, key); + const value_copy = try self.allocator.dupe(u8, value); + + try self.map.put(key_copy, .{ + .value = value_copy, + .expires = expires, + .access_count = 0, + }); + } + + fn evictOldest(self: *LRU) void { + var oldest_key: ?[]const u8 = null; + var oldest_access: u64 = std.math.maxInt(u64); + + var it = self.map.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.access_count < oldest_access) { + oldest_access = entry.value_ptr.access_count; + oldest_key = entry.key_ptr.*; + } + } + + if (oldest_key) |key| { + self.remove(key); + } + } + + fn remove(self: *LRU, key: []const u8) void { + if (self.map.fetchRemove(key)) |kv| { + self.allocator.free(kv.value.value); + self.allocator.free(kv.key); + } + } + + pub fn deinit(self: *LRU) void { + var it = self.map.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.value_ptr.value); + self.allocator.free(entry.key_ptr.*); + } + self.map.deinit(); + } +}; + +test "LRU basic operations" { + var lru = try LRU.init(std.testing.allocator, 3); + defer lru.deinit(); + + try lru.put("key1", "value1", 9999999999999); + try std.testing.expectEqualStrings("value1", lru.get("key1").?); +} + +test "LRU eviction" { + var lru = try LRU.init(std.testing.allocator, 2); + defer lru.deinit(); + + try lru.put("key1", "value1", 9999999999999); + try lru.put("key2", "value2", 9999999999999); + try lru.put("key3", "value3", 9999999999999); + + try std.testing.expect(lru.get("key1") == null); +} diff --git a/zig/src/config.zig b/zig/src/config.zig new file mode 100644 index 0000000..dd7cb49 --- /dev/null +++ b/zig/src/config.zig @@ -0,0 +1,36 @@ +const std = @import("std"); + +pub const Config = struct { + listen_host: []const u8, + listen_port: u16, + cache_size: usize, + cache_dir: []const u8, + geolite_path: []const u8, + geolocator_url: []const u8, + + pub fn load(allocator: std.mem.Allocator) !Config { + return Config{ + .listen_host = std.process.getEnvVarOwned(allocator, "WTTR_LISTEN_HOST") catch try allocator.dupe(u8, "0.0.0.0"), + .listen_port = blk: { + const port_str = std.process.getEnvVarOwned(allocator, "WTTR_LISTEN_PORT") catch break :blk 8002; + defer allocator.free(port_str); + break :blk std.fmt.parseInt(u16, port_str, 10) catch 8002; + }, + .cache_size = blk: { + const size_str = std.process.getEnvVarOwned(allocator, "WTTR_CACHE_SIZE") catch break :blk 10_000; + defer allocator.free(size_str); + break :blk std.fmt.parseInt(usize, size_str, 10) catch 10_000; + }, + .cache_dir = std.process.getEnvVarOwned(allocator, "WTTR_CACHE_DIR") catch try allocator.dupe(u8, "/tmp/wttr-cache"), + .geolite_path = std.process.getEnvVarOwned(allocator, "WTTR_GEOLITE_PATH") catch try allocator.dupe(u8, "./GeoLite2-City.mmdb"), + .geolocator_url = std.process.getEnvVarOwned(allocator, "WTTR_GEOLOCATOR_URL") catch try allocator.dupe(u8, "http://localhost:8004"), + }; + } + + pub fn deinit(self: Config, allocator: std.mem.Allocator) void { + allocator.free(self.listen_host); + allocator.free(self.cache_dir); + allocator.free(self.geolite_path); + allocator.free(self.geolocator_url); + } +}; diff --git a/zig/src/http/handler.zig b/zig/src/http/handler.zig new file mode 100644 index 0000000..8c79037 --- /dev/null +++ b/zig/src/http/handler.zig @@ -0,0 +1,77 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const Cache = @import("../cache/cache.zig").Cache; +const WeatherProvider = @import("../weather/provider.zig").WeatherProvider; +const ansi = @import("../render/ansi.zig"); +const line = @import("../render/line.zig"); + +pub const HandleWeatherOptions = struct { + cache: *Cache, + provider: WeatherProvider, +}; + +pub fn handleWeather( + opts: *HandleWeatherOptions, + req: *httpz.Request, + res: *httpz.Response, +) !void { + try handleWeatherInternal(opts, req, res, null); +} + +pub fn handleWeatherLocation( + opts: *HandleWeatherOptions, + req: *httpz.Request, + res: *httpz.Response, +) !void { + const location = req.param("location") orelse "London"; + try handleWeatherInternal(opts, req, res, location); +} + +fn handleWeatherInternal( + opts: *HandleWeatherOptions, + req: *httpz.Request, + res: *httpz.Response, + location: ?[]const u8, +) !void { + const allocator = req.arena; + + const cache_key = try generateCacheKey(allocator, req, location); + + if (opts.cache.get(cache_key)) |cached| { + res.content_type = .TEXT; + res.body = cached; + return; + } + + const loc = location orelse "London"; + const weather = try opts.provider.fetch(allocator, loc); + defer weather.deinit(); + + const query = try req.query(); + const format = query.get("format"); + const output = if (format) |fmt| + try line.render(allocator, weather, fmt) + else + try ansi.render(allocator, weather, .{}); + + const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000); + try opts.cache.put(cache_key, output, ttl); + + res.content_type = .TEXT; + res.body = output; +} + +fn generateCacheKey( + allocator: std.mem.Allocator, + req: *httpz.Request, + location: ?[]const u8, +) ![]const u8 { + const loc = location orelse ""; + const query = try req.query(); + const format = query.get("format") orelse ""; + return std.fmt.allocPrint(allocator, "{s}:{s}:{s}", .{ + req.url.path, + loc, + format, + }); +} diff --git a/zig/src/http/middleware.zig b/zig/src/http/middleware.zig new file mode 100644 index 0000000..e740f5f --- /dev/null +++ b/zig/src/http/middleware.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const RateLimiter = @import("rate_limiter.zig").RateLimiter; + +pub fn rateLimitMiddleware(limiter: *RateLimiter, req: *httpz.Request, res: *httpz.Response) !void { + const ip = req.address.in.sa.addr; + var ip_buf: [16]u8 = undefined; + const ip_str = try std.fmt.bufPrint(&ip_buf, "{d}.{d}.{d}.{d}", .{ + ip & 0xFF, + (ip >> 8) & 0xFF, + (ip >> 16) & 0xFF, + (ip >> 24) & 0xFF, + }); + + limiter.check(ip_str) catch { + res.status = 429; + res.body = "Too Many Requests"; + return; + }; +} diff --git a/zig/src/http/rate_limiter.zig b/zig/src/http/rate_limiter.zig new file mode 100644 index 0000000..84294db --- /dev/null +++ b/zig/src/http/rate_limiter.zig @@ -0,0 +1,149 @@ +const std = @import("std"); + +pub const RateLimiter = struct { + allocator: std.mem.Allocator, + buckets: std.StringHashMap(TokenBucket), + config: Config, + mutex: std.Thread.Mutex, + + pub const Config = struct { + capacity: u32 = 300, + refill_rate: u32 = 5, + refill_interval_ms: u64 = 200, + }; + + const TokenBucket = struct { + tokens: f64, + capacity: u32, + last_refill: i64, + + fn refill(self: *TokenBucket, now: i64, rate: u32, interval_ms: u64) void { + const elapsed = now - self.last_refill; + const intervals = @as(f64, @floatFromInt(elapsed)) / @as(f64, @floatFromInt(interval_ms)); + const new_tokens = intervals * @as(f64, @floatFromInt(rate)); + + self.tokens = @min( + self.tokens + new_tokens, + @as(f64, @floatFromInt(self.capacity)), + ); + self.last_refill = now; + } + + fn consume(self: *TokenBucket, count: f64) bool { + if (self.tokens >= count) { + self.tokens -= count; + return true; + } + return false; + } + }; + + pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter { + return RateLimiter{ + .allocator = allocator, + .buckets = std.StringHashMap(TokenBucket).init(allocator), + .config = config, + .mutex = .{}, + }; + } + + pub fn check(self: *RateLimiter, ip: []const u8) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + + const now = std.time.milliTimestamp(); + + const result = try self.buckets.getOrPut(ip); + if (!result.found_existing) { + const ip_copy = try self.allocator.dupe(u8, ip); + result.key_ptr.* = ip_copy; + result.value_ptr.* = TokenBucket{ + .tokens = @floatFromInt(self.config.capacity), + .capacity = self.config.capacity, + .last_refill = now, + }; + } + + var bucket = result.value_ptr; + bucket.refill(now, self.config.refill_rate, self.config.refill_interval_ms); + + if (!bucket.consume(1.0)) { + return error.RateLimitExceeded; + } + } + + pub fn deinit(self: *RateLimiter) void { + var it = self.buckets.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + } + self.buckets.deinit(); + } +}; + +test "rate limiter allows requests within capacity" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 10, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + var i: usize = 0; + while (i < 10) : (i += 1) { + try limiter.check("1.2.3.4"); + } +} + +test "rate limiter blocks after capacity exhausted" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 5, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + var i: usize = 0; + while (i < 5) : (i += 1) { + try limiter.check("1.2.3.4"); + } + + try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); +} + +test "rate limiter refills tokens over time" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 10, + .refill_rate = 5, + .refill_interval_ms = 100, + }); + defer limiter.deinit(); + + var i: usize = 0; + while (i < 10) : (i += 1) { + try limiter.check("1.2.3.4"); + } + + try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); + + std.Thread.sleep(250 * std.time.ns_per_ms); + + try limiter.check("1.2.3.4"); +} + +test "rate limiter tracks different IPs separately" { + var limiter = try RateLimiter.init(std.testing.allocator, .{ + .capacity = 2, + .refill_rate = 1, + .refill_interval_ms = 1000, + }); + defer limiter.deinit(); + + try limiter.check("1.2.3.4"); + try limiter.check("1.2.3.4"); + + try std.testing.expectError(error.RateLimitExceeded, limiter.check("1.2.3.4")); + + try limiter.check("5.6.7.8"); + try limiter.check("5.6.7.8"); +} diff --git a/zig/src/http/server.zig b/zig/src/http/server.zig new file mode 100644 index 0000000..76f30c6 --- /dev/null +++ b/zig/src/http/server.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const handler = @import("handler.zig"); +const RateLimiter = @import("rate_limiter.zig").RateLimiter; +const middleware = @import("middleware.zig"); + +pub const Server = struct { + allocator: std.mem.Allocator, + httpz_server: httpz.Server(*Context), + context: Context, + + const Context = struct { + options: handler.HandleWeatherOptions, + rate_limiter: *RateLimiter, + }; + + pub fn init( + allocator: std.mem.Allocator, + host: []const u8, + port: u16, + options: handler.HandleWeatherOptions, + rate_limiter: *RateLimiter, + ) !Server { + const ctx = try allocator.create(Context); + ctx.* = .{ + .options = options, + .rate_limiter = rate_limiter, + }; + + var httpz_server = try httpz.Server(*Context).init(allocator, .{ + .address = host, + .port = port, + }, ctx); + + var router = try httpz_server.router(.{}); + router.get("/", handleWeatherRoot, .{}); + router.get("/:location", handleWeatherLocation, .{}); + + return Server{ + .allocator = allocator, + .httpz_server = httpz_server, + .context = ctx.*, + }; + } + + fn handleWeatherRoot(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void { + try middleware.rateLimitMiddleware(ctx.rate_limiter, req, res); + if (res.status == 429) return; + try handler.handleWeather(&ctx.options, req, res); + } + + fn handleWeatherLocation(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void { + try middleware.rateLimitMiddleware(ctx.rate_limiter, req, res); + if (res.status == 429) return; + try handler.handleWeatherLocation(&ctx.options, req, res); + } + + pub fn listen(self: *Server) !void { + std.log.info("wttr listening on port {d}", .{self.httpz_server.config.port.?}); + try self.httpz_server.listen(); + } + + pub fn deinit(self: *Server) void { + self.httpz_server.deinit(); + } +}; diff --git a/zig/src/main.zig b/zig/src/main.zig new file mode 100644 index 0000000..cdba81e --- /dev/null +++ b/zig/src/main.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const config = @import("config.zig"); +const Cache = @import("cache/cache.zig").Cache; +const MetNo = @import("weather/metno.zig").MetNo; +const types = @import("weather/types.zig"); +const Server = @import("http/server.zig").Server; +const RateLimiter = @import("http/rate_limiter.zig").RateLimiter; + +pub const std_options: std.Options = .{ + .log_level = .info, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var stdout_buffer: [4096]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + + const cfg = try config.Config.load(allocator); + defer cfg.deinit(allocator); + + try stdout.print("wttr starting on {s}:{d}\n", .{ cfg.listen_host, cfg.listen_port }); + try stdout.print("Cache size: {d}\n", .{cfg.cache_size}); + try stdout.print("Cache dir: {s}\n", .{cfg.cache_dir}); + try stdout.flush(); + + var cache = try Cache.init(allocator, .{ + .max_entries = cfg.cache_size, + .cache_dir = cfg.cache_dir, + }); + defer cache.deinit(); + + var rate_limiter = try RateLimiter.init(allocator, .{ + .capacity = 300, + .refill_rate = 5, + .refill_interval_ms = 200, + }); + defer rate_limiter.deinit(); + + var metno = try MetNo.init(allocator); + defer metno.deinit(); + + var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{ + .cache = &cache, + .provider = metno.provider(), + }, &rate_limiter); + + try server.listen(); +} + +test { + std.testing.refAllDecls(@This()); + _ = @import("cache/lru.zig"); + _ = @import("weather/mock.zig"); + _ = @import("http/rate_limiter.zig"); + _ = @import("render/line.zig"); +} diff --git a/zig/src/render/ansi.zig b/zig/src/render/ansi.zig new file mode 100644 index 0000000..88b941b --- /dev/null +++ b/zig/src/render/ansi.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const types = @import("../weather/types.zig"); + +pub const RenderOptions = struct { + narrow: bool = false, + days: u8 = 3, + use_imperial: bool = false, + no_caption: bool = false, +}; + +pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: RenderOptions) ![]const u8 { + var output: std.ArrayList(u8) = .empty; + errdefer output.deinit(allocator); + + if (!options.no_caption) { + try output.writer(allocator).print("Weather for: {s}\n\n", .{data.location}); + } + + const temp = if (options.use_imperial) data.current.temp_f else data.current.temp_c; + const temp_unit = if (options.use_imperial) "°F" else "°C"; + + try output.writer(allocator).print(" \\ / {s}\n", .{data.current.condition}); + try output.writer(allocator).print(" .-. {d:.1}{s}\n", .{ temp, temp_unit }); + try output.writer(allocator).print(" ― ( ) ― {s} {d:.1} km/h\n", .{ data.current.wind_dir, data.current.wind_kph }); + try output.writer(allocator).print(" `-' Humidity: {d}%\n", .{data.current.humidity}); + try output.writer(allocator).print(" / \\ {d:.1} mm\n\n", .{data.current.precip_mm}); + + return output.toOwnedSlice(allocator); +} diff --git a/zig/src/render/json.zig b/zig/src/render/json.zig new file mode 100644 index 0000000..e0a9287 --- /dev/null +++ b/zig/src/render/json.zig @@ -0,0 +1,11 @@ +const std = @import("std"); +const types = @import("../weather/types.zig"); + +pub fn render(allocator: std.mem.Allocator, data: types.WeatherData) ![]const u8 { + var output: std.ArrayList(u8) = .empty; + errdefer output.deinit(allocator); + + try std.json.stringify(data, .{}, output.writer(allocator)); + + return output.toOwnedSlice(allocator); +} diff --git a/zig/src/render/line.zig b/zig/src/render/line.zig new file mode 100644 index 0000000..c12a170 --- /dev/null +++ b/zig/src/render/line.zig @@ -0,0 +1,137 @@ +const std = @import("std"); +const types = @import("../weather/types.zig"); + +pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8) ![]const u8 { + if (std.mem.eql(u8, format, "1")) { + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C", .{ + data.location, + getConditionEmoji(data.current.weather_code), + data.current.temp_c, + }); + } else if (std.mem.eql(u8, format, "2")) { + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C 🌬️{s}{d:.0}km/h", .{ + data.location, + getConditionEmoji(data.current.weather_code), + data.current.temp_c, + data.current.wind_dir, + data.current.wind_kph, + }); + } else if (std.mem.eql(u8, format, "3")) { + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C 🌬️{s}{d:.0}km/h 💧{d}%%", .{ + data.location, + getConditionEmoji(data.current.weather_code), + data.current.temp_c, + data.current.wind_dir, + data.current.wind_kph, + data.current.humidity, + }); + } else if (std.mem.eql(u8, format, "4")) { + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C 🌬️{s}{d:.0}km/h 💧{d}%% ☀️", .{ + data.location, + getConditionEmoji(data.current.weather_code), + data.current.temp_c, + data.current.wind_dir, + data.current.wind_kph, + data.current.humidity, + }); + } else { + return renderCustom(allocator, data, format); + } +} + +fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8) ![]const u8 { + var output: std.ArrayList(u8) = .empty; + errdefer output.deinit(allocator); + + var i: usize = 0; + while (i < format.len) { + if (format[i] == '%' and i + 1 < format.len) { + const code = format[i + 1]; + switch (code) { + 'c' => try output.appendSlice(allocator, getConditionEmoji(data.current.weather_code)), + 'C' => try output.appendSlice(allocator, data.current.condition), + 'h' => try output.writer(allocator).print("{d}", .{data.current.humidity}), + 't' => try output.writer(allocator).print("{d:.0}", .{data.current.temp_c}), + 'f' => try output.writer(allocator).print("{d:.0}", .{data.current.temp_c}), + 'w' => try output.writer(allocator).print("{s}{d:.0}km/h", .{ data.current.wind_dir, data.current.wind_kph }), + 'l' => try output.appendSlice(allocator, data.location), + 'p' => try output.writer(allocator).print("{d:.1}", .{data.current.precip_mm}), + 'P' => try output.writer(allocator).print("{d:.0}", .{data.current.pressure_mb}), + '%' => try output.append(allocator, '%'), + else => { + try output.append(allocator, '%'); + try output.append(allocator, code); + }, + } + i += 2; + } else { + try output.append(allocator, format[i]); + i += 1; + } + } + + return output.toOwnedSlice(allocator); +} + +fn getConditionEmoji(code: u16) []const u8 { + return switch (code) { + 113 => "☀️", + 116 => "⛅️", + 119, 122 => "☁️", + 143, 248, 260 => "🌫", + 176, 263, 266, 293, 296 => "🌦", + 185, 281, 284, 311, 314, 317, 350, 362, 365, 374, 377 => "🌧", + 200, 386, 389, 392, 395 => "⛈", + 227, 230, 320, 323, 326, 329, 332, 335, 338, 368, 371 => "🌨", + 179, 182 => "❄️", + else => "🌡️", + }; +} + +test "format 1" { + const data = types.WeatherData{ + .location = "London", + .current = .{ + .temp_c = 15.0, + .temp_f = 59.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 65, + .wind_kph = 10.0, + .wind_dir = "N", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &.{}, + .allocator = std.testing.allocator, + }; + + const output = try render(std.testing.allocator, data, "1"); + defer std.testing.allocator.free(output); + + try std.testing.expectEqualStrings("London: ☀️ 15°C", output); +} + +test "custom format" { + const data = types.WeatherData{ + .location = "London", + .current = .{ + .temp_c = 15.0, + .temp_f = 59.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 65, + .wind_kph = 10.0, + .wind_dir = "N", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &.{}, + .allocator = std.testing.allocator, + }; + + const output = try render(std.testing.allocator, data, "%l: %c %t°C"); + defer std.testing.allocator.free(output); + + try std.testing.expectEqualStrings("London: ☀️ 15°C", output); +} diff --git a/zig/src/weather/metno.zig b/zig/src/weather/metno.zig new file mode 100644 index 0000000..b145f9a --- /dev/null +++ b/zig/src/weather/metno.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const weather_provider = @import("provider.zig"); +const types = @import("types.zig"); + +pub const MetNo = struct { + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) !MetNo { + return MetNo{ + .allocator = allocator, + }; + } + + pub fn provider(self: *MetNo) weather_provider.WeatherProvider { + return .{ + .ptr = self, + .vtable = &.{ + .fetch = fetch, + .deinit = deinitProvider, + }, + }; + } + + fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { + const self: *MetNo = @ptrCast(@alignCast(ptr)); + + const coords = try parseLocation(location); + + const url = try std.fmt.allocPrint( + self.allocator, + "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}", + .{ coords.lat, coords.lon }, + ); + defer self.allocator.free(url); + + var client = std.http.Client{ .allocator = self.allocator }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + + var response_buf: [1024 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + const result = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .GET, + .response_writer = &writer, + .extra_headers = &.{ + .{ .name = "User-Agent", .value = "wttr.in/1.0" }, + }, + }); + + if (result.status != .ok) { + return error.WeatherApiFailed; + } + + const response_body = response_buf[0..writer.end]; + + const json_data = try std.json.parseFromSlice( + std.json.Value, + self.allocator, + response_body, + .{}, + ); + defer json_data.deinit(); + + return try parseMetNoResponse(allocator, location, json_data.value); + } + + fn deinitProvider(ptr: *anyopaque) void { + const self: *MetNo = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + pub fn deinit(self: *MetNo) void { + _ = self; + } +}; + +const Coords = struct { + lat: f64, + lon: f64, +}; + +fn parseLocation(location: []const u8) !Coords { + if (std.mem.indexOf(u8, location, ",")) |comma_idx| { + const lat_str = location[0..comma_idx]; + const lon_str = location[comma_idx + 1 ..]; + return Coords{ + .lat = try std.fmt.parseFloat(f64, lat_str), + .lon = try std.fmt.parseFloat(f64, lon_str), + }; + } + + return Coords{ .lat = 51.5074, .lon = -0.1278 }; +} + +fn parseMetNoResponse(allocator: std.mem.Allocator, location: []const u8, json: std.json.Value) !types.WeatherData { + const properties = json.object.get("properties") orelse return error.InvalidResponse; + const timeseries = properties.object.get("timeseries") orelse return error.InvalidResponse; + + if (timeseries.array.items.len == 0) return error.InvalidResponse; + + const current = timeseries.array.items[0]; + const data = current.object.get("data") orelse return error.InvalidResponse; + const instant = data.object.get("instant") orelse return error.InvalidResponse; + const details = instant.object.get("details") orelse return error.InvalidResponse; + + const temp_c = @as(f32, @floatCast(details.object.get("air_temperature").?.float)); + const humidity = @as(u8, @intFromFloat(details.object.get("relative_humidity").?.float)); + const wind_kph = @as(f32, @floatCast(details.object.get("wind_speed").?.float * 3.6)); + const pressure_mb = @as(f32, @floatCast(details.object.get("air_pressure_at_sea_level").?.float)); + + const next_1h = data.object.get("next_1_hours"); + const symbol_code = if (next_1h) |n1h| + n1h.object.get("summary").?.object.get("symbol_code").?.string + else + "clearsky_day"; + + return types.WeatherData{ + .location = try allocator.dupe(u8, location), + .current = .{ + .temp_c = temp_c, + .temp_f = temp_c * 9.0 / 5.0 + 32.0, + .condition = try allocator.dupe(u8, symbol_code), + .weather_code = symbolCodeToWeatherCode(symbol_code), + .humidity = humidity, + .wind_kph = wind_kph, + .wind_dir = try allocator.dupe(u8, "N"), + .pressure_mb = pressure_mb, + .precip_mm = 0.0, + }, + .forecast = &.{}, + .allocator = allocator, + }; +} + +fn symbolCodeToWeatherCode(symbol: []const u8) u16 { + if (std.mem.indexOf(u8, symbol, "clearsky")) |_| return 113; + if (std.mem.indexOf(u8, symbol, "fair")) |_| return 116; + if (std.mem.indexOf(u8, symbol, "partlycloudy")) |_| return 116; + if (std.mem.indexOf(u8, symbol, "cloudy")) |_| return 119; + if (std.mem.indexOf(u8, symbol, "fog")) |_| return 143; + if (std.mem.indexOf(u8, symbol, "rain")) |_| return 296; + if (std.mem.indexOf(u8, symbol, "sleet")) |_| return 362; + if (std.mem.indexOf(u8, symbol, "snow")) |_| return 338; + if (std.mem.indexOf(u8, symbol, "thunder")) |_| return 200; + return 113; +} diff --git a/zig/src/weather/mock.zig b/zig/src/weather/mock.zig new file mode 100644 index 0000000..ed5e7a2 --- /dev/null +++ b/zig/src/weather/mock.zig @@ -0,0 +1,85 @@ +const std = @import("std"); +const weather_provider = @import("provider.zig"); +const types = @import("types.zig"); + +pub const MockWeather = struct { + allocator: std.mem.Allocator, + responses: std.StringHashMap(types.WeatherData), + + pub fn init(allocator: std.mem.Allocator) !MockWeather { + return MockWeather{ + .allocator = allocator, + .responses = std.StringHashMap(types.WeatherData).init(allocator), + }; + } + + pub fn provider(self: *MockWeather) weather_provider.WeatherProvider { + return .{ + .ptr = self, + .vtable = &.{ + .fetch = fetch, + .deinit = deinitProvider, + }, + }; + } + + pub fn addResponse(self: *MockWeather, location: []const u8, data: types.WeatherData) !void { + const key = try self.allocator.dupe(u8, location); + try self.responses.put(key, data); + } + + fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { + const self: *MockWeather = @ptrCast(@alignCast(ptr)); + const data = self.responses.get(location) orelse return error.LocationNotFound; + + return types.WeatherData{ + .location = try allocator.dupe(u8, data.location), + .current = data.current, + .forecast = try allocator.dupe(types.ForecastDay, data.forecast), + .allocator = allocator, + }; + } + + fn deinitProvider(ptr: *anyopaque) void { + const self: *MockWeather = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + pub fn deinit(self: *MockWeather) void { + var it = self.responses.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + } + self.responses.deinit(); + } +}; + +test "mock weather provider" { + var mock = try MockWeather.init(std.testing.allocator); + defer mock.deinit(); + + const data = types.WeatherData{ + .location = "London", + .current = .{ + .temp_c = 15.0, + .temp_f = 59.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 65, + .wind_kph = 10.0, + .wind_dir = "N", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &.{}, + .allocator = std.testing.allocator, + }; + + try mock.addResponse("London", data); + + const p = mock.provider(); + const result = try p.fetch(std.testing.allocator, "London"); + defer result.deinit(); + + try std.testing.expectEqual(@as(f32, 15.0), result.current.temp_c); +} diff --git a/zig/src/weather/provider.zig b/zig/src/weather/provider.zig new file mode 100644 index 0000000..fc7a041 --- /dev/null +++ b/zig/src/weather/provider.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const types = @import("types.zig"); + +pub const WeatherProvider = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + fetch: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator, location: []const u8) anyerror!types.WeatherData, + deinit: *const fn (ptr: *anyopaque) void, + }; + + pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, location: []const u8) !types.WeatherData { + return self.vtable.fetch(self.ptr, allocator, location); + } + + pub fn deinit(self: WeatherProvider) void { + self.vtable.deinit(self.ptr); + } +}; diff --git a/zig/src/weather/types.zig b/zig/src/weather/types.zig new file mode 100644 index 0000000..2ecf1cb --- /dev/null +++ b/zig/src/weather/types.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +pub const WeatherData = struct { + location: []const u8, + current: CurrentCondition, + forecast: []ForecastDay, + allocator: std.mem.Allocator, + + pub fn deinit(self: WeatherData) void { + self.allocator.free(self.location); + for (self.forecast) |day| { + self.allocator.free(day.date); + self.allocator.free(day.condition); + for (day.hourly) |hour| { + self.allocator.free(hour.time); + self.allocator.free(hour.condition); + } + self.allocator.free(day.hourly); + } + self.allocator.free(self.forecast); + } +}; + +pub const CurrentCondition = struct { + temp_c: f32, + temp_f: f32, + condition: []const u8, + weather_code: u16, + humidity: u8, + wind_kph: f32, + wind_dir: []const u8, + pressure_mb: f32, + precip_mm: f32, +}; + +pub const ForecastDay = struct { + date: []const u8, + max_temp_c: f32, + min_temp_c: f32, + condition: []const u8, + weather_code: u16, + hourly: []HourlyForecast, +}; + +pub const HourlyForecast = struct { + time: []const u8, + temp_c: f32, + condition: []const u8, + weather_code: u16, + wind_kph: f32, + precip_mm: f32, +};