# CV Site API Documentation
**Note**: This is my personal CV website API documentation. While the code is open-source (MIT license), this API is designed for my personal site and may change without notice.
## Overview
This API provides endpoints for a bilingual (English/Spanish) CV/resume web application built with Go's standard library. The API supports both traditional HTTP requests and HTMX-powered partial page updates for a seamless single-page application experience.
**Version:** 1.0.0
**Base URL:** `http://localhost:1999` (development)
**Default Port:** 1999 (configurable via `PORT` environment variable)
### Key Features
- π **Bilingual Support**: English and Spanish content switching
- β‘ **HTMX-Aware**: Serves partial HTML for HTMX requests
- π **PDF Export**: Generate PDF resumes using headless Chrome
- π **Security Headers**: Production-grade security headers (CSP, HSTS, etc.)
- π **Health Monitoring**: JSON health check endpoint
- π― **No Authentication**: Public CV/resume site
### Content Types
- **HTML**: `text/html; charset=utf-8` (default)
- **JSON**: `application/json` (health endpoint, errors)
- **PDF**: `application/pdf` (export endpoint)
### Configuration
The server can be configured via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `1999` | Server port |
| `HOST` | `localhost` | Server host |
| `READ_TIMEOUT` | `15` | Read timeout in seconds |
| `WRITE_TIMEOUT` | `15` | Write timeout in seconds |
| `GO_ENV` | `development` | Environment mode (`development`/`production`) |
| `TEMPLATE_HOT_RELOAD` | `true` (dev) | Enable template hot reloading |
---
## Quick Reference
**Quick access to common operations and endpoints.**
### Base URL
```
http://localhost:1999
```
### All Endpoints
| Endpoint | Method | Description | Common Use |
|----------|--------|-------------|------------|
| `/?lang={en\|es}` | GET | Full HTML page with CV content | Initial page load |
| `/cv?lang={en\|es}` | GET | HTML partial for HTMX swaps | Language switching |
| `/text?lang={en\|es}` | GET | Plain text CV for terminal/AI | curl, text browsers |
| `/api/cmd-k?lang={en\|es}` | GET | CMD+K command palette data (JSON) | ninja-keys integration |
| `/export/pdf?lang={en\|es}&length={short\|long}&icons={show\|hide}&version={extended\|clean}` | GET | Download PDF resume with parameters | Export functionality |
| `/health` | GET | Health check (JSON) | Monitoring |
| `/static/{path}` | GET | Static files (CSS, JS, images) | Assets |
### Quick curl Examples
```bash
# Health check
curl http://localhost:1999/health | jq
# English CV (full page)
curl "http://localhost:1999/?lang=en"
# Spanish CV (full page)
curl "http://localhost:1999/?lang=es"
# CV content partial (for HTMX)
curl "http://localhost:1999/cv?lang=en"
# Export PDF (short, clean version)
curl -O -J "http://localhost:1999/export/pdf?lang=en&length=short&version=clean"
# CMD+K command palette data (JSON)
curl -s http://localhost:1999/api/cmd-k | jq '.experiences | length'
# Plain text CV
curl http://localhost:1999/text?lang=en
# Static file with headers
curl -I http://localhost:1999/static/css/main.css
```
### HTMX Integration Pattern
```html
```
### Common Error Codes
| Code | Meaning | Common Cause |
|------|---------|--------------|
| 200 | Success | Request processed correctly |
| 400 | Bad Request | Invalid `lang` parameter (not `en` or `es`) |
| 403 | Forbidden | Origin check failed (PDF endpoint) |
| 404 | Not Found | Invalid route or static file not found |
| 429 | Too Many Requests | Rate limit exceeded (PDF endpoint) |
| 500 | Server Error | Template error, data loading error, PDF generation failed |
### Performance Targets
| Endpoint | Target Response Time |
|----------|---------------------|
| `/health` | <1ms |
| `/` and `/cv` | 7-8ms |
| `/static/*` | <5ms |
| `/export/pdf` | ~3 seconds |
### Environment Configuration
```bash
# Development
PORT=1999
HOST=localhost
GO_ENV=development
# Production
PORT=1999
HOST=0.0.0.0
GO_ENV=production
ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com
```
### Need More Details?
For comprehensive documentation of each endpoint, request/response formats, and advanced usage, see the [Detailed Endpoint Documentation](#detailed-endpoint-documentation) below.
---
## Architecture
### Route Organization
All routes and middleware configuration are centralized in `internal/routes/routes.go` for clean separation of concerns:
```go
// internal/routes/routes.go
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
mux := http.NewServeMux()
// Public routes
mux.HandleFunc("/", cvHandler.Home)
mux.HandleFunc("/cv", cvHandler.CVContent)
mux.HandleFunc("/health", healthHandler.Check)
// Protected PDF endpoint (rate limited + origin checked)
// Static files with cache control
// Middleware chain (Recovery β Logger β SecurityHeaders)
return handler
}
```
This architecture provides:
- β
Single source of truth for routes
- β
Clear middleware chain visibility
- β
Easy route management and testing
- β
Clean separation from main.go
### Data Loading
**Simplified Architecture** (cache removed as of v1.1.0):
- JSON files loaded directly from disk on each request
- No caching layer (over-engineering for static CV data)
- Go's built-in file system caching is sufficient
- Data only changes on deployment/restart
## Endpoints Overview
| Method | Path | Description | HTMX Support | Protection |
|--------|------|-------------|--------------|------------|
| GET | `/` | Full CV page (home) | β No | None |
| GET | `/cv` | CV content partial | β
Yes | None |
| GET | `/text` | Plain text CV for CLI/terminal | β No | None |
| GET | `/api/cmd-k` | CMD+K command palette data (JSON) | β No | Cache Control (1h) |
| POST | `/api/contact` | Contact form submission | β
Yes | BrowserOnly + Rate Limit + CSRF |
| GET | `/switch-language` | Language switching | β
Yes | None |
| GET | `/toggle/length` | CV length toggle | β
Yes | None |
| GET | `/toggle/icons` | Icon visibility toggle | β
Yes | None |
| GET | `/toggle/theme` | Theme toggle | β
Yes | None |
| GET | `/export/pdf` | PDF export | β No | β
Rate Limited + Origin Check |
| GET | `/cv-jamr-{year}-{lang}.pdf` | Shortcut PDF download routes | β No | Redirect to /export/pdf |
| GET | `/health` | Health check | β No | None |
| GET | `/static/*` | Static files (CSS, JS, images) | β No | Cache Control |
---
## Detailed Endpoint Documentation
### 1. GET /
**Description:** Renders the complete CV page with full HTML structure including header, navigation, and footer.
#### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lang` | string | No | `en` | Language code (`en` or `es`) |
#### Request Headers
No special headers required.
#### Response
**Status Code:** `200 OK`
**Content-Type:** `text/html; charset=utf-8`
**Response Body:** Complete HTML document with:
- ``, `
`, `` tags
- Navigation and header
- Full CV content
- Footer
- Embedded styles and scripts
#### Examples
**curl - English CV:**
```bash
curl http://localhost:1999/
```
**curl - Spanish CV:**
```bash
curl http://localhost:1999/?lang=es
```
**Browser:**
```
http://localhost:1999/?lang=en
```
#### Error Responses
**400 Bad Request** - Invalid language parameter:
```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Unsupported language. Use 'en' or 'es'
```
**500 Internal Server Error** - Template or data loading error:
```http
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
Internal Server Error
```
#### Notes
- This endpoint always returns the full page structure
- Ideal for initial page load and direct navigation
- Not optimized for HTMX partial updates (use `/cv` instead)
- Calculates dynamic experience duration and years of experience
- Fetches git repository dates for projects (if available)
---
### 2. GET /cv
**Description:** Renders only the CV content section for HTMX partial page swaps. Returns the same content as `/` but without the HTML wrapper.
#### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lang` | string | No | `en` | Language code (`en` or `es`) |
#### Request Headers
| Header | Value | Description |
|--------|-------|-------------|
| `HX-Request` | `true` | Indicates HTMX request (optional, but recommended) |
#### Response
**Status Code:** `200 OK`
**Content-Type:** `text/html; charset=utf-8`
**Response Body:** CV content HTML fragment (no ``, ``, or `` tags)
#### Examples
**curl - Test HTMX endpoint:**
```bash
curl -H "HX-Request: true" \
"http://localhost:1999/cv?lang=en"
```
**curl - Spanish content:**
```bash
curl "http://localhost:1999/cv?lang=es"
```
**HTMX Integration - Language Switcher:**
```html
```
**JavaScript Fetch:**
```javascript
fetch('/cv?lang=es', {
headers: {
'HX-Request': 'true'
}
})
.then(response => response.text())
.then(html => {
document.getElementById('cv-content').innerHTML = html;
});
```
#### Error Responses
**400 Bad Request** - Invalid language:
```http
HTTP/1.1 400 Bad Request
Content-Type: text/html
Unsupported language. Use 'en' or 'es'
```
**500 Internal Server Error** - Template error:
```http
HTTP/1.1 500 Internal Server Error
Content-Type: text/html
An error occurred. Please try again later.
```
#### Notes
- **HTMX-Aware:** Detects `HX-Request` header for better error formatting
- **Partial Content:** Returns only the CV content div, not full HTML
- **URL Updates:** Recommended to use with `hx-push-url` for browser history
- **Same Logic:** Executes the same data processing as `/` endpoint
- **Performance:** Optimized for fast partial updates without full page reload
---
### 3. GET /text
**Description:** Returns a plain text version of the CV, optimized for CLI tools (curl, wget) and text browsers (lynx, w3m). Auto-detected via User-Agent header.
#### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lang` | string | No | `en` | Language code (`en` or `es`) |
#### Response
**Status Code:** `200 OK`
**Content-Type:** `text/plain; charset=utf-8`
**Response Body:** 80-character wrapped plain text CV with ASCII formatting
#### Examples
```bash
# Get plain text CV (auto-detected via curl User-Agent)
curl http://localhost:1999/text
# Spanish version
curl http://localhost:1999/text?lang=es
# View in text browser
lynx http://localhost:1999/text
```
#### Notes
- Returns CV content formatted for terminal display
- 80-character line width for optimal terminal viewing
- Unicode characters properly handled
- Useful for AI assistants reading CV content
---
### 4. GET /api/cmd-k
**Description:** Returns JSON data for the CMD+K command palette (ninja-keys integration). Provides dynamic entries for experiences, projects, and courses that can be searched.
#### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lang` | string | No | `en` | Language code (`en` or `es`) |
#### Response
**Status Code:** `200 OK`
**Content-Type:** `application/json`
**Cache-Control:** `public, max-age=3600` (1 hour)
**Response Body:**
```json
{
"experiences": [
{"id": "exp-1", "title": "Senior Developer", "section": "experience", "keywords": "..."}
],
"projects": [
{"id": "proj-1", "title": "Project Name", "section": "projects", "keywords": "..."}
],
"courses": [
{"id": "course-1", "title": "Course Name", "section": "courses", "keywords": "..."}
]
}
```
#### Examples
```bash
# Get CMD+K data
curl -s http://localhost:1999/api/cmd-k | jq
# Count experiences
curl -s http://localhost:1999/api/cmd-k | jq '.experiences | length'
```
#### Notes
- Used by ninja-keys web component for command palette
- Cached for 1 hour to reduce server load
- Entries include scroll-to-section functionality
---
### 5. POST /api/contact
**Description:** Contact form submission endpoint with comprehensive security middleware chain.
#### Request Headers
| Header | Required | Description |
|--------|----------|-------------|
| `HX-Request` | Yes | Must be `true` (browser validation) |
| `Referer` or `Origin` | Yes | Must match allowed origins |
| `Content-Type` | Yes | `application/x-www-form-urlencoded` |
#### Request Body
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | 2-100 characters |
| `email` | string | Yes | Valid email format |
| `message` | string | Yes | 10-5000 characters |
| `_csrf` | string | Yes | Valid CSRF token from session |
#### Security Middleware
1. **BrowserOnly** - Blocks curl/Postman/bots (requires HX-Request header)
2. **Rate Limiting** - 5 submissions per hour per IP
3. **CSRF Protection** - Token validation against session
#### Response
**Status Code:** `200 OK` (success) or `400/403/429` (error)
**Content-Type:** `text/html` (HTMX partial)
#### Error Responses
| Code | Reason |
|------|--------|
| 400 | Validation failed (missing fields, invalid email) |
| 403 | Security check failed (no browser headers, invalid CSRF) |
| 429 | Rate limit exceeded (5/hour per IP) |
| 500 | Email sending failed |
#### Notes
- See `docs/CONTACT-FORM-QUICKSTART.md` for implementation details
- SMTP configuration via environment variables
- Returns HTMX partial for seamless form updates
---
### 6. GET /switch-language
**Description:** HTMX endpoint for language switching. Returns updated UI elements.
#### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lang` | string | Yes | - | Target language (`en` or `es`) |
#### Response
Returns HTMX partial with updated language-specific content.
---
### 7. GET /toggle/{preference}
**Description:** HTMX endpoints for CV preference toggles.
#### Endpoints
- `GET /toggle/length` - Toggle CV length (short/long)
- `GET /toggle/icons` - Toggle icon visibility (show/hide)
- `GET /toggle/theme` - Toggle theme (default/clean)
#### Response
Returns HTMX partial with updated toggle state.
---
### 8. GET /cv-jamr-{year}-{lang}.pdf
**Description:** Shortcut routes for default CV PDF downloads. Redirects to `/export/pdf` with appropriate parameters.
#### Examples
```
/cv-jamr-2025-en.pdf β /export/pdf?lang=en&length=short&icons=show&version=clean
/cv-jamr-2025-es.pdf β /export/pdf?lang=es&length=short&icons=show&version=clean
```
---
### 9. GET /export/pdf
**Description:** Generates and downloads a PDF version of the CV using headless Chrome (chromedp). The PDF is generated from the rendered HTML page with customizable parameters for language, length, icons, and version.
#### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lang` | string | No | `en` | Language code (`en` or `es`) |
| `length` | string | No | `short` | CV length (`short` for summary, `long` for detailed) |
| `icons` | string | No | `show` | Icon visibility (`show` or `hide`) |
| `version` | string | No | `extended` | CV version (`extended` for default, `clean` for minimal) |
#### Request Headers
No special headers required.
#### Response
**Status Code:** `200 OK`
**Content-Type:** `application/pdf`
**Headers:**
```http
Content-Type: application/pdf
Content-Disposition: attachment; filename=CV-Juan-AndrΓ©s-Moreno-Rubio-{lang}-{length}-{version}.pdf
Content-Length: [size in bytes]
```
**Response Body:** Binary PDF data
#### Examples
**curl - Download English PDF (short, clean version):**
```bash
curl -O -J "http://localhost:1999/export/pdf?lang=en&length=short&icons=show&version=clean"
# Downloads: CV-Juan-AndrΓ©s-Moreno-Rubio-en-short-clean.pdf
```
**curl - Download Spanish PDF (long, extended version):**
```bash
curl -o cv-es.pdf "http://localhost:1999/export/pdf?lang=es&length=long&icons=hide&version=long"
# Downloads: CV-Juan-AndrΓ©s-Moreno-Rubio-es-long-extended.pdf
```
**curl - Use defaults:**
```bash
curl -O -J "http://localhost:1999/export/pdf"
# Downloads: CV-Juan-AndrΓ©s-Moreno-Rubio-en-short-extended.pdf
```
**wget:**
```bash
wget --content-disposition "http://localhost:1999/export/pdf?lang=en&length=short&version=clean"
```
**HTML Link:**
```html
Download CV (PDF)
```
**HTMX Button (triggers download):**
```html
```
#### Process Flow
1. Server receives PDF export request with parameters
2. Validates all parameters (lang, length, icons, version)
3. Sets cookies for user preferences:
- `cv-language` β `{lang}`
- `cv-length` β `{length}`
- `cv-icons` β `{icons}`
- `cv-theme` β `default` or `clean` based on `{version}`
4. Constructs internal URL: `http://localhost:1999/?lang={lang}`
5. Launches headless Chrome via chromedp
6. Sets cookies in browser context
7. Navigates to the CV page
8. Waits for page load and rendering
9. Generates PDF with print-optimized settings (A4, @media print CSS)
10. Returns PDF with filename: `CV-{Name}-{lang}-{length}-{version}.pdf`
#### Error Responses
**400 Bad Request** - Invalid language:
```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Unsupported language. Use 'en' or 'es'
```
**400 Bad Request** - Invalid length:
```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Unsupported length. Use 'short' or 'long'
```
**400 Bad Request** - Invalid icons option:
```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Unsupported icons option. Use 'show' or 'hide'
```
**400 Bad Request** - Invalid version:
```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Unsupported version. Use 'long' or 'clean'
```
**500 Internal Server Error** - PDF generation failed:
```http
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
Internal Server Error
```
#### Parameter Details
**Language (`lang`):**
- `en` - English content
- `es` - Spanish content
**Length (`length`):**
- `short` - Summary version with concise descriptions
- `long` - Detailed version with full responsibilities and descriptions
**Icons (`icons`):**
- `show` - Display company logos, project icons, and section icons
- `hide` - Hide all icons for a text-only appearance
**Version (`version`):**
- `extended` - Default theme with full styling
- `clean` - Minimal theme optimized for print
#### Notes
- **Timeout:** 30-second timeout for PDF generation
- **Dependencies:** Requires chromedp and a Chrome/Chromium installation
- **Performance:** PDF generation takes 2-5 seconds typically
- **Memory:** Uses headless Chrome, requires adequate system memory
- **Filename Pattern:** `CV-{Name}-{lang}-{length}-{version}.pdf`
- **Cookie Injection:** Parameters are injected as cookies before page rendering
- **Print Styles:** Respects `@media print` CSS rules
- **Size:** Short version ~1.5-2MB, Long version ~2-2.5MB (varies with content)
---
### 4. GET /health
**Description:** Health check endpoint returning server status, version, and timestamp in JSON format. Used for monitoring and load balancer health checks.
#### Query Parameters
None.
#### Request Headers
No special headers required.
#### Response
**Status Code:** `200 OK`
**Content-Type:** `application/json`
**Response Body:**
```json
{
"status": "ok",
"timestamp": "2025-11-12T17:49:00.123Z",
"version": "1.0.0"
}
```
#### Schema
| Field | Type | Description |
|-------|------|-------------|
| `status` | string | Always `"ok"` if server is running |
| `timestamp` | string | ISO 8601 timestamp of the request |
| `version` | string | Application version from main.go |
**Note:** Cache statistics have been removed as of v1.1.0 (caching layer eliminated for simplicity).
#### Examples
**curl:**
```bash
curl http://localhost:1999/health
```
**curl with pretty print:**
```bash
curl -s http://localhost:1999/health | jq
```
**Response:**
```json
{
"status": "ok",
"timestamp": "2025-11-09T14:32:45.123456Z",
"version": "1.0.0"
}
```
**Health Check Script (bash):**
```bash
#!/bin/bash
response=$(curl -s http://localhost:1999/health)
status=$(echo $response | jq -r '.status')
if [ "$status" = "ok" ]; then
echo "β
Server is healthy"
exit 0
else
echo "β Server is down"
exit 1
fi
```
**Monitoring with watch:**
```bash
watch -n 5 'curl -s http://localhost:1999/health | jq'
```
**Load Balancer Configuration (nginx):**
```nginx
upstream cv_backend {
server localhost:1999;
# Health check
check interval=3000 rise=2 fall=3 timeout=1000 type=http;
check_http_send "GET /health HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx;
}
```
#### Error Responses
Health endpoint always returns `200 OK` as long as the server is running. Network errors or server down scenarios will result in connection errors:
```bash
# Server down
curl: (7) Failed to connect to localhost port 1999: Connection refused
```
#### Notes
- **Always Available:** No authentication or rate limiting
- **Simple Check:** Only confirms server is responding
- **No Deep Checks:** Does not validate database, templates, or external dependencies
- **Monitoring:** Ideal for uptime monitoring, load balancers, and Docker health checks
- **Fast Response:** Minimal processing, returns immediately
---
### 5. GET /static/*
**Description:** Serves static files including CSS, JavaScript, images, and fonts with appropriate cache headers.
#### Request Format
```
GET /static/{path/to/file}
```
#### Examples
**CSS:**
```
GET /static/css/main.css
GET /static/css/cv.css
```
**JavaScript:**
```
GET /static/js/htmx.min.js
GET /static/js/app.js
```
**Images:**
```
GET /static/images/logo.png
GET /static/images/courses/codecademy.png
```
**Fonts:**
```
GET /static/fonts/roboto.woff2
```
#### Response
**Status Code:** `200 OK` (file found) or `404 Not Found`
**Content-Type:** Determined by file extension:
- `.css` β `text/css`
- `.js` β `application/javascript`
- `.png` β `image/png`
- `.jpg` β `image/jpeg`
- `.woff2` β `font/woff2`
- etc.
**Cache Headers:**
**Development Mode:**
```http
Cache-Control: public, max-age=3600
```
(1 hour)
**Production Mode:**
```http
Cache-Control: public, max-age=86400
```
(1 day)
#### Examples
**curl - Fetch CSS:**
```bash
curl http://localhost:1999/static/css/main.css
```
**curl - Check cache headers:**
```bash
curl -I http://localhost:1999/static/css/main.css
```
**Response:**
```http
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Content-Type: text/css
Content-Length: 5423
Last-Modified: Sat, 09 Nov 2024 12:00:00 GMT
/* CSS content */
```
**HTML Integration:**
```html
```
#### Error Responses
**404 Not Found:**
```http
HTTP/1.1 404 Not Found
Content-Type: text/plain
404 page not found
```
#### Notes
- **Standard Library:** Uses `http.FileServer` from Go stdlib
- **Path Stripping:** `/static/` prefix is stripped before file lookup
- **Directory Listing:** Disabled (shows 403 if directory accessed)
- **Cache Control:** Environment-aware caching strategy
- **Security:** Served through security middleware (CSP, X-Content-Type-Options)
- **No Compression:** Enable gzip compression in reverse proxy (nginx/Caddy) for production
---
## HTMX Integration
### Overview
The application is designed to work seamlessly with HTMX for dynamic, partial page updates without full page reloads. The primary use case is language switching.
### HTMX Detection
The server detects HTMX requests via the `HX-Request` header:
```go
isHTMX := r.Header.Get("HX-Request") != ""
```
### Response Behavior
| Endpoint | HTMX Header | Response Type |
|----------|-------------|---------------|
| `/` | Any | Full HTML page |
| `/cv` | Present | HTML fragment |
| `/cv` | Absent | HTML fragment |
| `/export/pdf` | Any | PDF binary |
| `/health` | Any | JSON |
### Language Switching Pattern
**HTML Structure:**
```html
```
**How it works:**
1. User clicks language button
2. HTMX intercepts click and sends GET request to `/cv?lang={lang}`
3. HTMX adds `HX-Request: true` header automatically
4. Server returns HTML fragment (CV content only)
5. HTMX swaps content into `#cv-content` div
6. Browser URL updates to `/?lang={lang}` (via `hx-push-url`)
7. No full page reload
### HTMX Attributes Used
| Attribute | Purpose | Example |
|-----------|---------|---------|
| `hx-get` | Specify endpoint | `hx-get="/cv?lang=en"` |
| `hx-target` | Target element | `hx-target="#cv-content"` |
| `hx-swap` | Swap strategy | `hx-swap="innerHTML"` |
| `hx-push-url` | Update URL | `hx-push-url="/?lang=en"` |
| `hx-trigger` | Event trigger | `hx-trigger="click"` |
### Error Handling with HTMX
When errors occur, the server checks for `HX-Request` header and returns appropriate HTML:
**HTMX Error Response:**
```html
Unsupported language. Use 'en' or 'es'
```
**HTMX Error Handling:**
```html
```
### Out-of-Band Swaps
Currently not implemented, but could be used for updating multiple page sections:
```html
CV - Spanish
```
---
## Error Handling
### Error Response Format
The API uses context-aware error responses based on the `Accept` header and `HX-Request` header.
#### 1. JSON Errors (Accept: application/json)
**Request:**
```bash
curl -H "Accept: application/json" \
"http://localhost:1999/?lang=invalid"
```
**Response:**
```json
{
"error": "Bad Request",
"message": "Unsupported language. Use 'en' or 'es'",
"code": 400
}
```
#### 2. HTMX Errors (HX-Request: true)
**Request:**
```bash
curl -H "HX-Request: true" \
"http://localhost:1999/?lang=invalid"
```
**Response:**
```html
Unsupported language. Use 'en' or 'es'
```
#### 3. Standard HTTP Errors (default)
**Request:**
```bash
curl "http://localhost:1999/?lang=invalid"
```
**Response:**
```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Unsupported language. Use 'en' or 'es'
```
### Common Error Codes
| Status Code | Error Type | When It Occurs |
|-------------|------------|----------------|
| 400 | Bad Request | Invalid language parameter |
| 404 | Not Found | Static file not found, invalid route |
| 500 | Internal Server Error | Template rendering error, data loading error, PDF generation error |
### Error Types (Internal)
```go
// Defined in internal/handlers/errors.go
BadRequestError() // 400 - Client error
NotFoundError() // 404 - Resource not found
InternalError() // 500 - Server error (details hidden)
TemplateError() // 500 - Template rendering failed
DataLoadError() // 500 - Failed to load CV data
```
### Internal vs Public Errors
**Internal Errors** (500):
- Details logged server-side only
- Generic message returned to client: `"Internal Server Error"`
- Includes: template errors, data loading errors, PDF generation errors
**Public Errors** (4xx):
- Descriptive message returned to client
- Details safe to expose
- Includes: invalid language, not found
### Error Logging
All errors are logged server-side with context:
```
ERROR [GET /]: Error rendering template: index.html
CLIENT ERROR [GET /]: Unsupported language. Use 'en' or 'es' (status: 400)
```
### Error Handling Best Practices
**Client-Side:**
```javascript
// Fetch with error handling
async function loadCV(lang) {
try {
const response = await fetch(`/cv?lang=${lang}`);
if (!response.ok) {
const error = await response.json();
console.error('API Error:', error.message);
return;
}
const html = await response.text();
document.getElementById('cv-content').innerHTML = html;
} catch (err) {
console.error('Network Error:', err);
alert('Failed to connect to server');
}
}
```
**HTMX Error Handling:**
```html
```
---
## Performance & Caching
### Cache Headers Strategy
#### Static Files (CSS, JS, Images)
**Development:**
```http
Cache-Control: public, max-age=3600
```
- 1 hour cache
- Allows rapid development without stale cache issues
**Production:**
```http
Cache-Control: public, max-age=86400
```
- 1 day cache
- Reduces server load and bandwidth
- Improves page load performance
#### Dynamic Content (HTML)
No cache headers set (browser default behavior):
- CV content changes rarely
- Language switching requires fresh content
- Could add `Cache-Control: private, max-age=300` for 5-minute cache
#### PDF Export
No cache headers:
- Generated on-demand
- Content-Disposition triggers download
- Browser doesn't cache downloads by default
### Template Caching
**Development Mode:**
```go
HotReload: true // Templates reloaded on every request
```
**Production Mode:**
```go
HotReload: false // Templates compiled once on startup
```
### Performance Metrics
| Endpoint | Avg Response Time | Notes |
|----------|-------------------|-------|
| `/` | 5-15ms | Template rendering + data loading |
| `/cv` | 5-15ms | Same as `/` (uses same logic) |
| `/export/pdf` | 2-5 seconds | Headless Chrome rendering |
| `/health` | <1ms | Simple JSON response |
| `/static/*` | <5ms | Direct file serving |
### Optimization Recommendations
#### 1. Add Response Compression
Use reverse proxy (nginx/Caddy) for gzip compression:
**nginx:**
```nginx
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 1000;
```
#### 2. Add ETag Support
```go
// Add to static file handler
w.Header().Set("ETag", `"`+fileHash+`"`)
```
#### 3. Add Conditional Requests
```go
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
```
#### 4. Implement HTTP/2
```go
// Use TLS for HTTP/2 support
server.ListenAndServeTLS("cert.pem", "key.pem")
```
#### 5. PDF Generation Optimization
```go
// Cache generated PDFs for 5 minutes
pdfCache := cache.New(5*time.Minute, 10*time.Minute)
```
### Resource Limits
**Timeouts:**
```go
ReadTimeout: 15 seconds // Request read timeout
WriteTimeout: 15 seconds // Response write timeout
IdleTimeout: 120 seconds // Keep-alive timeout
```
**PDF Generation:**
```go
PDFTimeout: 30 seconds // Chromedp context timeout
```
---
## Security
### Security Headers
All responses include production-grade security headers via middleware:
```http
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()...
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'...
```
**Production Only:**
```http
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
```
### Content Security Policy (CSP)
```
default-src 'self';
script-src 'self' 'unsafe-inline' https://unpkg.com https://code.iconify.design;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.iconify.design;
frame-ancestors 'self';
base-uri 'self';
form-action 'self'
```
**External Resources Allowed:**
- HTMX from unpkg.com
- Icons from iconify.design
- Fonts from Google Fonts
### CORS
Not explicitly configured (same-origin only):
- No `Access-Control-Allow-Origin` headers
- API not designed for cross-origin access
- Could be added if needed for external integrations
### Authentication
**Current State:** None
This is a public CV/resume site with no authentication or authorization.
**Future Considerations:**
- Add admin authentication for CV data updates
- Implement API key for PDF export rate limiting
- Add OAuth for private CV access
### Input Validation
**Language Parameter:**
```go
if lang != "en" && lang != "es" {
return BadRequestError("Unsupported language. Use 'en' or 'es'")
}
```
**Path Traversal Protection:**
- Static file handler uses `http.FileServer` (built-in protection)
- No user-controlled file paths
- No database queries (no SQL injection risk)
### Origin Checking & API Protection
**Status:** β
**Implemented**
The API implements origin checking to prevent external sites from hotlinking to resource-intensive endpoints like PDF generation.
**Protected Endpoints:**
- `/export/pdf` - Full protection (origin checking + rate limiting)
**Configuration via Environment Variable:**
```bash
# Development (default)
ALLOWED_ORIGINS=
# Production
ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com
```
**How It Works:**
1. Checks `Origin` header (CORS requests)
2. Falls back to `Referer` header (navigation requests)
3. Allows localhost in development
4. Blocks external domains in production
5. Requires headers in production for PDF endpoint
**Example Requests:**
```bash
# β
Allowed (localhost in development)
curl http://localhost:1999/export/pdf?lang=en
# β
Allowed (valid referer)
curl -H "Referer: http://localhost:1999/" \
http://localhost:1999/export/pdf?lang=en
# β Blocked (external referer)
curl -H "Referer: https://evil.com/" \
http://localhost:1999/export/pdf?lang=en
# Response: 403 Forbidden
```
For more details on origin checking, see [9-SECURITY.md](9-SECURITY.md#origin-checking).
### Rate Limiting
**Status:** β
**Implemented**
**Current Configuration:**
- **Endpoint:** `/export/pdf`
- **Limit:** 3 requests per minute per IP
- **Window:** 1 minute (rolling)
- **Response:** 429 Too Many Requests when exceeded
**Implementation:**
```go
// Applied in main.go
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute)
protectedPDFHandler := middleware.OriginChecker(
pdfRateLimiter.Middleware(
http.HandlerFunc(cvHandler.ExportPDF),
),
)
```
**Behavior:**
| Requests | Status | Response |
|----------|--------|----------|
| 1st-3rd requests | β
200 OK | PDF generated |
| 4th+ request (within 1 min) | β 429 Too Many Requests | Rate limit exceeded |
| After 1 minute | β
200 OK | Counter reset |
**Rate Limit Response:**
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain; charset=utf-8
Rate limit exceeded. Please try again later.
```
**IP Detection:**
- Checks `X-Forwarded-For` (proxy/CDN)
- Falls back to `X-Real-IP` (alternative proxy header)
- Uses `RemoteAddr` (direct connection)
- Works with Nginx reverse proxy
**Testing Rate Limit:**
```bash
# Generate 4 PDFs quickly to test rate limiting
for i in {1..4}; do
echo "Request $i:"
curl -w "Status: %{http_code}\n" -o /dev/null -s \
http://localhost:1999/export/pdf?lang=en
sleep 1
done
# Expected output:
# Request 1: Status: 200
# Request 2: Status: 200
# Request 3: Status: 200
# Request 4: Status: 429
```
**Customizing Rate Limits:**
Edit `main.go` to adjust limits:
```go
// More restrictive: 5 per hour
pdfRateLimiter := middleware.NewRateLimiter(5, 1*time.Hour)
// Less restrictive: 10 per minute
pdfRateLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
```
For comprehensive protection documentation, see [9-SECURITY.md](9-SECURITY.md#api-protection).
### Security Best Practices
β
**Implemented:**
- Security headers (CSP, X-Frame-Options, etc.)
- HSTS in production
- Input validation
- Error message sanitization (internal errors hidden)
- Timeouts on all operations
- Graceful shutdown
- Origin checking (prevents external hotlinking)
- Rate limiting (PDF endpoint: 3 requests/min per IP)
- IP-based tracking (supports reverse proxies)
β οΈ **Recommended for Production:**
- Use HTTPS (prevents header spoofing)
- Configure `ALLOWED_ORIGINS` for your domain
- Implement request logging with IP addresses
- Add monitoring and alerting for 403/429 responses
- Consider CloudFlare for additional DDoS protection
- Set up log retention for security analysis
---
## Rate Limiting
**Current State:** β
**Fully Implemented** (as of v1.1.0)
### Current Implementation
Rate limiting is configured in `internal/routes/routes.go` and applied via middleware:
#### Per-Endpoint Limits
| Endpoint | Recommended Limit | Burst |
|----------|-------------------|-------|
| `/` | 20 req/min | 10 |
| `/cv` | 30 req/min | 15 |
| `/export/pdf` | 5 req/min | 2 |
| `/health` | Unlimited | - |
| `/static/*` | 100 req/min | 50 |
#### Implementation Options
**1. Nginx (Recommended for Production):**
```nginx
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=pdf:10m rate=5r/m;
server {
location / {
limit_req zone=general burst=10 nodelay;
}
location /cv {
limit_req zone=general burst=15 nodelay;
}
location /export/pdf {
limit_req zone=pdf burst=2 nodelay;
limit_req_status 429;
}
}
```
**2. Go Middleware (for standalone deployment):**
```go
import (
"golang.org/x/time/rate"
"sync"
)
type IPRateLimiter struct {
ips map[string]*rate.Limiter
mu *sync.RWMutex
r rate.Limit
b int
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{
ips: make(map[string]*rate.Limiter),
mu: &sync.RWMutex{},
r: r,
b: b,
}
}
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter, exists := i.ips[ip]
if !exists {
limiter = rate.NewLimiter(i.r, i.b)
i.ips[ip] = limiter
}
return limiter
}
func RateLimitMiddleware(limiter *IPRateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
limiter := limiter.GetLimiter(ip)
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
```
**3. Cloudflare Rate Limiting (easiest):**
- Configure in Cloudflare dashboard
- No code changes required
- Enterprise-grade DDoS protection
---
## Examples & Use Cases
### Use Case 1: Language Switching with HTMX
**Scenario:** User wants to switch between English and Spanish CV without page reload.
**Implementation:**
```html
My CV
```
---
### Use Case 2: PDF Export with Progress Indicator
**Scenario:** User clicks "Download PDF" and sees progress while PDF generates.
**Implementation:**
```html
```
---
### Use Case 3: Health Monitoring Script
**Scenario:** DevOps team wants continuous health monitoring with alerts.
**Implementation:**
```bash
#!/bin/bash
# health-monitor.sh
API_URL="http://localhost:1999/health"
ALERT_EMAIL="admin@example.com"
CHECK_INTERVAL=30 # seconds
check_health() {
response=$(curl -s -w "\n%{http_code}" "$API_URL")
http_code=$(echo "$response" | tail -n 1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
status=$(echo "$body" | jq -r '.status')
version=$(echo "$body" | jq -r '.version')
timestamp=$(echo "$body" | jq -r '.timestamp')
echo "[$(date)] β
Healthy - Status: $status, Version: $version"
return 0
else
echo "[$(date)] β Unhealthy - HTTP $http_code"
send_alert "CV Server is down (HTTP $http_code)"
return 1
fi
}
send_alert() {
message=$1
echo "$message" | mail -s "CV Server Alert" "$ALERT_EMAIL"
# Optional: Slack webhook
curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \
-H 'Content-Type: application/json' \
-d "{\"text\": \"π¨ $message\"}"
}
# Main loop
while true; do
check_health
sleep $CHECK_INTERVAL
done
```
**Run as systemd service:**
```ini
# /etc/systemd/system/cv-health-monitor.service
[Unit]
Description=CV Server Health Monitor
After=network.target
[Service]
Type=simple
User=monitor
ExecStart=/usr/local/bin/health-monitor.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable cv-health-monitor
sudo systemctl start cv-health-monitor
```
---
### Use Case 4: Integration with Analytics
**Scenario:** Track CV views and PDF downloads with analytics.
**Implementation:**
```html
```
---
### Use Case 5: Curl Testing Suite
**Complete API testing with curl:**
```bash
#!/bin/bash
# test-api.sh - Complete API test suite
API_URL="http://localhost:1999"
PASSED=0
FAILED=0
test_endpoint() {
name=$1
url=$2
expected_status=$3
echo "Testing: $name"
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [ "$status" -eq "$expected_status" ]; then
echo " β
PASS (HTTP $status)"
((PASSED++))
else
echo " β FAIL (Expected $expected_status, got $status)"
((FAILED++))
fi
}
echo "=== CV API Test Suite ==="
echo
# Test 1: Health check
test_endpoint "Health Check" "$API_URL/health" 200
# Test 2: Home page (English)
test_endpoint "Home - English" "$API_URL/?lang=en" 200
# Test 3: Home page (Spanish)
test_endpoint "Home - Spanish" "$API_URL/?lang=es" 200
# Test 4: Invalid language
test_endpoint "Invalid Language" "$API_URL/?lang=fr" 400
# Test 5: CV content endpoint
test_endpoint "CV Content" "$API_URL/cv?lang=en" 200
# Test 6: Static CSS
test_endpoint "Static CSS" "$API_URL/static/css/main.css" 200
# Test 7: PDF export (takes time)
echo "Testing: PDF Export (this may take a few seconds)"
pdf_status=$(curl -s -o /tmp/test-cv.pdf -w "%{http_code}" "$API_URL/export/pdf?lang=en")
if [ "$pdf_status" -eq 200 ] && [ -s /tmp/test-cv.pdf ]; then
pdf_size=$(wc -c < /tmp/test-cv.pdf)
echo " β
PASS (HTTP $pdf_status, PDF size: $pdf_size bytes)"
((PASSED++))
rm /tmp/test-cv.pdf
else
echo " β FAIL (HTTP $pdf_status or empty file)"
((FAILED++))
fi
# Test 8: 404 Not Found
test_endpoint "404 Not Found" "$API_URL/nonexistent" 404
# Test 9: Health response format
echo "Testing: Health Response Format"
health=$(curl -s "$API_URL/health")
if echo "$health" | jq -e '.status == "ok"' > /dev/null 2>&1; then
echo " β
PASS (Valid JSON with status:ok)"
((PASSED++))
else
echo " β FAIL (Invalid JSON or missing status)"
((FAILED++))
fi
# Summary
echo
echo "=== Test Summary ==="
echo "Passed: $PASSED"
echo "Failed: $FAILED"
echo "Total: $((PASSED + FAILED))"
if [ $FAILED -eq 0 ]; then
echo "β
All tests passed!"
exit 0
else
echo "β Some tests failed"
exit 1
fi
```
**Run tests:**
```bash
chmod +x test-api.sh
./test-api.sh
```
---
## Troubleshooting
### Common Issues
#### Issue 1: Server Won't Start
**Symptom:**
```
β Failed to initialize templates: open templates/index.html: no such file or directory
```
**Solution:**
```bash
# Ensure you're in the project root
cd /path/to/cv-site
# Check template directory exists
ls -la templates/
# Check file permissions
chmod 644 templates/*.html
```
---
#### Issue 2: PDF Generation Fails
**Symptom:**
```
PDF generation failed: chrome not found
```
**Solution:**
```bash
# macOS
brew install --cask chromium
# Ubuntu/Debian
sudo apt-get install chromium-browser
# Verify installation
which chromium
```
**Alternative:** Use Docker with Chrome pre-installed:
```dockerfile
FROM golang:1.21-alpine
RUN apk add chromium
```
---
#### Issue 3: Language Switch Not Working
**Symptom:** Clicking language buttons doesn't change content
**Diagnosis:**
```bash
# Check HTMX is loaded
curl -I http://localhost:1999/static/js/htmx.min.js
# Test endpoint directly
curl "http://localhost:1999/cv?lang=es"
```
**Solution:**
- Verify HTMX script tag in HTML
- Check browser console for JavaScript errors
- Ensure `hx-target` matches actual element ID
---
#### Issue 4: Static Files Not Loading
**Symptom:** CSS/JS 404 errors
**Diagnosis:**
```bash
# Check static directory
ls -la static/css/
ls -la static/js/
# Test endpoint
curl -I http://localhost:1999/static/css/main.css
```
**Solution:**
```bash
# Create missing directories
mkdir -p static/css static/js static/images
# Check file paths in HTML match actual paths
grep -r 'href="/static' templates/
```
---
#### Issue 5: Slow PDF Generation
**Symptom:** PDF export takes >10 seconds
**Diagnosis:**
```bash
# Time the request
time curl -o test.pdf "http://localhost:1999/export/pdf?lang=en"
```
**Solutions:**
1. **Optimize CSS:** Remove unused styles
2. **Reduce images:** Compress or lazy-load images
3. **Cache PDFs:** Implement 5-minute cache
4. **Increase timeout:** Adjust chromedp timeout
5. **Use worker pool:** Queue PDF generation requests
---
### Debug Mode
Enable verbose logging:
```bash
# Set log flags
export GO_ENV=development
export LOG_LEVEL=debug
# Run server
go run main.go
```
**Logs will show:**
```
[GET] /cv?lang=es 127.0.0.1:54321 - 200 (12.5ms)
[GET] /export/pdf?lang=en 127.0.0.1:54322 - 200 (3.2s)
ERROR [GET /]: Error loading CV data
```
---
### Performance Profiling
**1. Enable pprof:**
```go
import _ "net/http/pprof"
// In main()
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
```
**2. Profile CPU:**
```bash
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
```
**3. Profile Memory:**
```bash
go tool pprof http://localhost:6060/debug/pprof/heap
```
**4. View traces:**
```bash
curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
go tool trace trace.out
```
---
## Appendix
### Environment Variables Reference
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `PORT` | int | `1999` | Server listen port |
| `HOST` | string | `localhost` | Server bind address |
| `GO_ENV` | string | `development` | Environment mode |
| `READ_TIMEOUT` | int | `15` | Request read timeout (seconds) |
| `WRITE_TIMEOUT` | int | `15` | Response write timeout (seconds) |
| `TEMPLATE_DIR` | string | `templates` | Template directory path |
| `PARTIALS_DIR` | string | `templates/partials` | Partial templates path |
| `TEMPLATE_HOT_RELOAD` | bool | `true` (dev) | Enable template hot reload |
| `DATA_DIR` | string | `data` | CV data directory |
### Response Time Targets
| Endpoint | Target | Acceptable | Action Required |
|----------|--------|------------|-----------------|
| `/` | <50ms | <200ms | >200ms investigate |
| `/cv` | <50ms | <200ms | >200ms investigate |
| `/health` | <10ms | <50ms | >50ms investigate |
| `/static/*` | <20ms | <100ms | >100ms add CDN |
| `/export/pdf` | <3s | <5s | >5s optimize |
### HTTP Status Code Reference
| Code | Name | When Used |
|------|------|-----------|
| 200 | OK | Successful request |
| 304 | Not Modified | Cached resource (ETag match) |
| 400 | Bad Request | Invalid language parameter |
| 404 | Not Found | Route or static file not found |
| 429 | Too Many Requests | Rate limit exceeded (if implemented) |
| 500 | Internal Server Error | Template, data, or PDF error |
| 503 | Service Unavailable | Server shutting down |
### Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.1.0 | 2025-11-12 | Route extraction to internal/routes, cache removal, rate limiting implementation |
| 1.0.0 | 2025-11-09 | Initial release with Go rewrite |
### Related Documentation
- [README.md](../README.md) - Project overview and setup
- [CONTRIBUTING.md](../CONTRIBUTING.md) - Contribution guidelines
- [8-DEPLOYMENT.md](8-DEPLOYMENT.md) - Deployment guides
### Support
**Issues:** [GitHub Issues](https://github.com/juanatsap/cv-site/issues)
**Email:** [juan.a.moreno.rubio@gmail.com](mailto:juan.a.moreno.rubio@gmail.com)
---
**Last Updated:** December 1, 2025
**API Version:** 1.2.0
**Documentation Version:** 1.2.0