add a bunch of AI stuff. Not yet reviewed

This commit is contained in:
Emil Lerch 2025-12-18 07:23:50 -08:00
parent 17e6e6c218
commit 1b10a916e0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
24 changed files with 4521 additions and 0 deletions

3
zig/.mise.toml Normal file
View file

@ -0,0 +1,3 @@
[tools]
zig = "0.15.2"
zls = "0.15.0"

566
zig/API_ENDPOINTS.md Normal file
View file

@ -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
```

432
zig/ARCHITECTURE.md Normal file
View file

@ -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: `/`, `/<location>`, `/files/<path>`, `/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

641
zig/DATA_FLOW.md Normal file
View file

@ -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)

108
zig/README.md Normal file
View file

@ -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.

714
zig/REWRITE_STRATEGY.md Normal file
View file

@ -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)?

953
zig/TARGET_ARCHITECTURE.md Normal file
View file

@ -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

48
zig/build.zig Normal file
View file

@ -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);
}

17
zig/build.zig.zon Normal file
View file

@ -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",
},
}

43
zig/src/cache/cache.zig vendored Normal file
View file

@ -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);
}
};

106
zig/src/cache/lru.zig vendored Normal file
View file

@ -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);
}

36
zig/src/config.zig Normal file
View file

@ -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);
}
};

77
zig/src/http/handler.zig Normal file
View file

@ -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,
});
}

View file

@ -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;
};
}

View file

@ -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");
}

66
zig/src/http/server.zig Normal file
View file

@ -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();
}
};

60
zig/src/main.zig Normal file
View file

@ -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");
}

29
zig/src/render/ansi.zig Normal file
View file

@ -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);
}

11
zig/src/render/json.zig Normal file
View file

@ -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);
}

137
zig/src/render/line.zig Normal file
View file

@ -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);
}

148
zig/src/weather/metno.zig Normal file
View file

@ -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;
}

85
zig/src/weather/mock.zig Normal file
View file

@ -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);
}

View file

@ -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);
}
};

52
zig/src/weather/types.zig Normal file
View file

@ -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,
};