Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
TRANSLATION_BASE_URL=https://example.com
TRANSLATION_APPLICATION_ID=your-application-id
TRANSLATION_API_KEY=your-api-key
# Mock mode: pull the XLSX from a plain URL instead of the API.
# TRANSLATION_SOURCE=url
# TRANSLATION_XLSX_URL=https://example.com/translations.xlsx
PULL_INTERVAL=10m
HTTP_TIMEOUT=30s
INITIAL_PULL_DEADLINE=45s
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ data/
# -----------------------------
tmp/
vendor/

# build output
/cacheppuccino
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ It periodically downloads an XLSX export from a translation service, stores the
## Features

- XLSX import from external translation service
- Mock mode: pull the XLSX from a plain URL instead of the API (`TRANSLATION_SOURCE=url`)
- Periodic background sync (full replace per import; the XLSX is the source of truth)
- SQLite-backed cache
- Fetch translations by page(s) + language
Expand Down Expand Up @@ -101,14 +102,37 @@ other header names (e.g. `Namespace`) are rejected.
`/strings` responses carry an `ETag` derived from the last import hash and
`Cache-Control: public, max-age=60`. Requests with a matching `If-None-Match` get `304 Not Modified`.

## Mock mode (`TRANSLATION_SOURCE=url`)

For QA/alpha instances the service can pull the XLSX from any plain HTTP(S) URL
instead of the IFRC translation API:

```env
TRANSLATION_SOURCE=url
TRANSLATION_XLSX_URL=https://example.com/ifrc-go/translations.xlsx
```

- The file must be in the exact format the translation service exports
(see "XLSX format" above; `Namespace` headers are rejected).
- Public URLs, Azure Blob SAS URLs, and S3 presigned URLs all work; no
auth headers are sent.
- The regular pull loop applies: updates to the hosted file show up within
`PULL_INTERVAL` (alpha uses `1m`); unchanged files are skipped by hash.
- Check `GET /status` to debug a broken file: it reports `source`,
`last_pull_error` (cleared on success), and `last_import_rows`.
- In `url` mode the `TRANSLATION_BASE_URL`, `TRANSLATION_APPLICATION_ID`,
and `TRANSLATION_API_KEY` variables are ignored.


## Environment Variables

| Variable | Required | Description |
|----------|----------|------------|
| `TRANSLATION_BASE_URL` | Yes | Base URL of translation service |
| `TRANSLATION_APPLICATION_ID` | Yes | Translation application ID |
| `TRANSLATION_API_KEY` | Yes | Sent as `X-API-KEY` header |
| `TRANSLATION_SOURCE` | No | `api` (default) or `url` (mock mode) |
| `TRANSLATION_BASE_URL` | api mode | Base URL of translation service |
| `TRANSLATION_APPLICATION_ID` | api mode | Translation application ID |
| `TRANSLATION_API_KEY` | api mode | Sent as `X-API-KEY` header |
| `TRANSLATION_XLSX_URL` | url mode | HTTP(S) URL of the mock XLSX file |
| `SQLITE_PATH` | No | Default: `/data/cacheppuccino.db` |
| `PULL_INTERVAL` | No | Default: `10m` |
| `HTTP_TIMEOUT` | No | Default: `30s` |
Expand Down Expand Up @@ -182,6 +206,8 @@ go run . --schema
- Metadata table stores:
- `last_pull_rfc3339`
- `last_xlsx_sha256`
- `last_pull_error`
- `last_import_rows`

## Development

Expand Down
51 changes: 36 additions & 15 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@ package main
import (
"errors"
"fmt"
"net/url"
"os"
"slices"
"time"
)

// Translation source kinds: the real IFRC API, or a plain XLSX URL used
// to mock the service on QA/alpha instances.
const (
sourceAPI = "api"
sourceURL = "url"
)

type Config struct {
ListenAddr string
SQLitePath string
TranslationSource string
TranslationBaseURL string
TranslationApplicationID string
TranslationAPIKey string
TranslationXLSXURL string
HTTPTimeout time.Duration
PullInterval time.Duration
InitialPullDeadline time.Duration
Expand All @@ -28,14 +38,6 @@ var validLogLevels = []string{"debug", "info", "warn", "error"}
func LoadConfig() (Config, error) {
var errs []error

requireEnv := func(k string) string {
v := os.Getenv(k)
if v == "" {
errs = append(errs, fmt.Errorf("missing required env: %s", k))
}
return v
}

envDuration := func(k string, def time.Duration) time.Duration {
v := os.Getenv(k)
if v == "" {
Expand All @@ -50,15 +52,34 @@ func LoadConfig() (Config, error) {
}

cfg := Config{
ListenAddr: env("LISTEN_ADDR", ":8080"),
SQLitePath: env("SQLITE_PATH", "/data/cacheppuccino.db"),
TranslationBaseURL: requireEnv("TRANSLATION_BASE_URL"),
TranslationApplicationID: requireEnv("TRANSLATION_APPLICATION_ID"),
TranslationAPIKey: requireEnv("TRANSLATION_API_KEY"),
ListenAddr: envOr("LISTEN_ADDR", ":8080"),
SQLitePath: envOr("SQLITE_PATH", "/data/cacheppuccino.db"),
TranslationSource: envOr("TRANSLATION_SOURCE", sourceAPI),
TranslationBaseURL: os.Getenv("TRANSLATION_BASE_URL"),
TranslationApplicationID: os.Getenv("TRANSLATION_APPLICATION_ID"),
TranslationAPIKey: os.Getenv("TRANSLATION_API_KEY"),
TranslationXLSXURL: os.Getenv("TRANSLATION_XLSX_URL"),
HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second),
PullInterval: envDuration("PULL_INTERVAL", 10*time.Minute),
InitialPullDeadline: envDuration("INITIAL_PULL_DEADLINE", 45*time.Second),
LogLevel: env("LOG_LEVEL", "info"),
LogLevel: envOr("LOG_LEVEL", "info"),
}

switch cfg.TranslationSource {
case sourceAPI:
for _, k := range []string{"TRANSLATION_BASE_URL", "TRANSLATION_APPLICATION_ID", "TRANSLATION_API_KEY"} {
if os.Getenv(k) == "" {
errs = append(errs, fmt.Errorf("missing required env: %s (required when TRANSLATION_SOURCE=api)", k))
}
}
case sourceURL:
if cfg.TranslationXLSXURL == "" {
errs = append(errs, errors.New("missing required env: TRANSLATION_XLSX_URL (required when TRANSLATION_SOURCE=url)"))
} else if u, err := url.Parse(cfg.TranslationXLSXURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
errs = append(errs, fmt.Errorf("invalid TRANSLATION_XLSX_URL: %q (must be an http(s) URL)", cfg.TranslationXLSXURL))
}
default:
errs = append(errs, fmt.Errorf("invalid TRANSLATION_SOURCE: %q (valid: api, url)", cfg.TranslationSource))
}

if cfg.HTTPTimeout <= 0 {
Expand All @@ -82,7 +103,7 @@ func LoadConfig() (Config, error) {
return cfg, nil
}

func env(k, def string) string {
func envOr(k, def string) string {
v := os.Getenv(k)
if v == "" {
return def
Expand Down
126 changes: 105 additions & 21 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"time"
)

// ctAllConfigEnvVars is every env var LoadConfig reads. Each test sets all of
// allConfigEnvVars is every env var LoadConfig reads. Each test sets all of
// them explicitly ("" simulates unset) to shield tests from the host env.
var ctAllConfigEnvVars = []string{
var allConfigEnvVars = []string{
"TRANSLATION_SOURCE",
"TRANSLATION_BASE_URL",
"TRANSLATION_APPLICATION_ID",
"TRANSLATION_API_KEY",
"TRANSLATION_XLSX_URL",
"LISTEN_ADDR",
"SQLITE_PATH",
"HTTP_TIMEOUT",
Expand All @@ -20,17 +22,17 @@ var ctAllConfigEnvVars = []string{
"LOG_LEVEL",
}

// ctSetConfigEnv sets every config env var, using values from overrides and
// setConfigEnv sets every config env var, using values from overrides and
// "" for anything not listed there.
func ctSetConfigEnv(t *testing.T, overrides map[string]string) {
func setConfigEnv(t *testing.T, overrides map[string]string) {
t.Helper()
for _, k := range ctAllConfigEnvVars {
for _, k := range allConfigEnvVars {
t.Setenv(k, overrides[k])
}
}

// ctRequiredEnv sets only the three required vars to placeholder values.
func ctRequiredEnv() map[string]string {
// requiredAPIEnv sets only the three required vars to placeholder values.
func requiredAPIEnv() map[string]string {
return map[string]string{
"TRANSLATION_BASE_URL": "https://translate.example.com",
"TRANSLATION_APPLICATION_ID": "app-id",
Expand All @@ -39,7 +41,7 @@ func ctRequiredEnv() map[string]string {
}

func TestLoadConfigDefaults(t *testing.T) {
ctSetConfigEnv(t, ctRequiredEnv())
setConfigEnv(t, requiredAPIEnv())

cfg, err := LoadConfig()
if err != nil {
Expand Down Expand Up @@ -73,6 +75,88 @@ func TestLoadConfigDefaults(t *testing.T) {
if got, want := cfg.LogLevel, "info"; got != want {
t.Errorf("LogLevel = %q, want %q", got, want)
}
if got, want := cfg.TranslationSource, "api"; got != want {
t.Errorf("TranslationSource = %q, want %q", got, want)
}
}

func TestLoadConfigSourceMatrix(t *testing.T) {
tests := []struct {
name string
env map[string]string
wantErr bool
wantInError []string
}{
{
name: "url mode requires only the xlsx url",
env: map[string]string{
"TRANSLATION_SOURCE": "url",
"TRANSLATION_XLSX_URL": "https://files.example.com/translations.xlsx",
},
},
{
name: "url mode without xlsx url fails",
env: map[string]string{"TRANSLATION_SOURCE": "url"},
wantErr: true,
wantInError: []string{"TRANSLATION_XLSX_URL"},
},
{
name: "url mode rejects non-http url",
env: map[string]string{
"TRANSLATION_SOURCE": "url",
"TRANSLATION_XLSX_URL": "ftp://files.example.com/translations.xlsx",
},
wantErr: true,
wantInError: []string{"TRANSLATION_XLSX_URL", "http(s)"},
},
{
name: "invalid source value fails",
env: map[string]string{"TRANSLATION_SOURCE": "s3"},
wantErr: true,
wantInError: []string{"TRANSLATION_SOURCE", `"s3"`},
},
{
name: "api mode still requires the api trio",
env: map[string]string{
"TRANSLATION_SOURCE": "api",
"TRANSLATION_XLSX_URL": "https://files.example.com/translations.xlsx",
},
wantErr: true,
wantInError: []string{
"TRANSLATION_BASE_URL",
"TRANSLATION_APPLICATION_ID",
"TRANSLATION_API_KEY",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setConfigEnv(t, tt.env)

cfg, err := LoadConfig()
if tt.wantErr {
if err == nil {
t.Fatal("LoadConfig() error = nil, want error")
}
for _, want := range tt.wantInError {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not contain %q", err.Error(), want)
}
}
return
}
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
if cfg.TranslationSource != tt.env["TRANSLATION_SOURCE"] {
t.Errorf("TranslationSource = %q, want %q", cfg.TranslationSource, tt.env["TRANSLATION_SOURCE"])
}
if cfg.TranslationXLSXURL != tt.env["TRANSLATION_XLSX_URL"] {
t.Errorf("TranslationXLSXURL = %q, want %q", cfg.TranslationXLSXURL, tt.env["TRANSLATION_XLSX_URL"])
}
})
}
}

func TestLoadConfigMissingRequired(t *testing.T) {
Expand All @@ -84,9 +168,9 @@ func TestLoadConfigMissingRequired(t *testing.T) {

for _, missing := range required {
t.Run(missing, func(t *testing.T) {
env := ctRequiredEnv()
env := requiredAPIEnv()
env[missing] = ""
ctSetConfigEnv(t, env)
setConfigEnv(t, env)

_, err := LoadConfig()
if err == nil {
Expand All @@ -99,7 +183,7 @@ func TestLoadConfigMissingRequired(t *testing.T) {
}

t.Run("all missing", func(t *testing.T) {
ctSetConfigEnv(t, nil)
setConfigEnv(t, nil)

_, err := LoadConfig()
if err == nil {
Expand Down Expand Up @@ -163,11 +247,11 @@ func TestLoadConfigInvalidValues(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := ctRequiredEnv()
env := requiredAPIEnv()
for k, v := range tt.overrides {
env[k] = v
}
ctSetConfigEnv(t, env)
setConfigEnv(t, env)

_, err := LoadConfig()
if err == nil {
Expand All @@ -185,11 +269,11 @@ func TestLoadConfigInvalidValues(t *testing.T) {
func TestLoadConfigMultipleProblems(t *testing.T) {
// Missing one required var plus a bad duration plus a bad log level:
// all must be reported in the single joined error.
env := ctRequiredEnv()
env := requiredAPIEnv()
env["TRANSLATION_API_KEY"] = ""
env["PULL_INTERVAL"] = "bogus"
env["LOG_LEVEL"] = "loud"
ctSetConfigEnv(t, env)
setConfigEnv(t, env)

_, err := LoadConfig()
if err == nil {
Expand All @@ -208,9 +292,9 @@ func TestLoadConfigMultipleProblems(t *testing.T) {

func TestLoadConfigZeroInitialPullDeadline(t *testing.T) {
// Zero means "no deadline" and must be accepted.
env := ctRequiredEnv()
env := requiredAPIEnv()
env["INITIAL_PULL_DEADLINE"] = "0s"
ctSetConfigEnv(t, env)
setConfigEnv(t, env)

cfg, err := LoadConfig()
if err != nil {
Expand All @@ -222,14 +306,14 @@ func TestLoadConfigZeroInitialPullDeadline(t *testing.T) {
}

func TestLoadConfigValidOverrides(t *testing.T) {
env := ctRequiredEnv()
env := requiredAPIEnv()
env["LISTEN_ADDR"] = "127.0.0.1:9999"
env["SQLITE_PATH"] = "/tmp/other.db"
env["HTTP_TIMEOUT"] = "5s"
env["PULL_INTERVAL"] = "1h30m"
env["INITIAL_PULL_DEADLINE"] = "250ms"
env["LOG_LEVEL"] = "debug"
ctSetConfigEnv(t, env)
setConfigEnv(t, env)

cfg, err := LoadConfig()
if err != nil {
Expand Down Expand Up @@ -259,9 +343,9 @@ func TestLoadConfigValidOverrides(t *testing.T) {
func TestLoadConfigValidLogLevels(t *testing.T) {
for _, level := range []string{"debug", "info", "warn", "error"} {
t.Run(level, func(t *testing.T) {
env := ctRequiredEnv()
env := requiredAPIEnv()
env["LOG_LEVEL"] = level
ctSetConfigEnv(t, env)
setConfigEnv(t, env)

cfg, err := LoadConfig()
if err != nil {
Expand Down
Loading
Loading