Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [main, master]
pull_request:
branches: [main, master]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'

- name: Run tests
run: go test ./...

- name: Run tests with race detector
run: go test -race ./...

- name: Run go vet
run: go vet ./...

- name: Build examples
run: go build ./examples/...
2 changes: 1 addition & 1 deletion .golanci.yml → .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ issues:
- source: '^//go:generate '
path: /
linters:
- lll
- lll
225 changes: 162 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,40 @@

`msgcat` is a lightweight i18n message catalog for Go focused on APIs and error handling.

It loads messages from YAML by language, resolves language from `context.Context`, supports runtime message loading for system codes, and can wrap domain errors with localized short/long messages.
It loads messages from YAML by language, resolves language from `context.Context`, supports runtime message loading for system codes (9000–9999), and can wrap domain errors with localized short/long messages.

Maturity: production-ready (`v1.x`) with SemVer and release/migration docs in `docs/`.
**Maturity:** production-ready (`v1.x`) with SemVer and release/migration docs in `docs/`.

**Requirements:** Go 1.26 or later.

---

## Installation

```bash
go get github.com/loopcontext/msgcat
```

---

## Quick Start

### 1. Create message files

Default path:
Default directory (when `ResourcePath` is empty):

```text
./resources/messages
```

One YAML file per language (e.g. `en.yaml`, `es.yaml`). Structure:

| Field | Description |
|----------|-------------|
| `group` | Optional numeric group (e.g. `0`). |
| `default`| Used when a message code is missing: `short` and `long` templates. |
| `set` | Map of message code → `short` / `long` template strings. |

Example `en.yaml`:

```yaml
Expand Down Expand Up @@ -73,71 +87,150 @@ if err != nil {
}
```

### 3. Resolve messages/errors from context
### 3. Resolve messages and errors from context

```go
ctx := context.WithValue(context.Background(), "language", "es-AR")

msg := catalog.GetMessageWithCtx(ctx, 1, "juan")
fmt.Println(msg.ShortText) // "Usuario creado"
fmt.Println(msg.LongText) // "Usuario juan fue creado correctamente"
fmt.Println(msg.Code) // 1

err := catalog.WrapErrorWithCtx(ctx, errors.New("db timeout"), 2, 3, 12345.5, time.Now())
fmt.Println(err.Error()) // localized short message

if catErr, ok := err.(msgcat.Error); ok {
fmt.Println(catErr.ErrorCode()) // 2
fmt.Println(catErr.GetShortMessage())
fmt.Println(catErr.GetLongMessage())
fmt.Println(catErr.Unwrap()) // original "db timeout"
}
```

---

## Configuration

All fields of `msgcat.Config`:

| Field | Type | Description |
|---------------------|----------------|-------------|
| `ResourcePath` | `string` | Directory containing `*.yaml` message files. Default: `./resources/messages`. |
| `CtxLanguageKey` | `ContextKey` | Context key to read language (e.g. `"language"`). Supports typed key and string key lookup. |
| `DefaultLanguage` | `string` | Language used when context has no key or catalog has no match. Recommended: `"en"`. |
| `FallbackLanguages` | `[]string` | Optional fallback list after requested/base (e.g. `[]string{"es"}`). |
| `StrictTemplates` | `bool` | If true, missing template params render as `<missing:N>`. Recommended `true` in production. |
| `Observer` | `Observer` | Optional; receives async events (fallback, missing lang, missing message, template issue). |
| `ObserverBuffer` | `int` | Size of observer event queue. Use ≥ 1 to avoid blocking the request path (e.g. 1024). |
| `StatsMaxKeys` | `int` | Max keys per stats map; overflow goes to `__overflow__`. Use to cap cardinality (e.g. 512). |
| `ReloadRetries` | `int` | Retries on reload parse/read failure (e.g. 2). |
| `ReloadRetryDelay` | `time.Duration`| Delay between retries (e.g. 50ms). |
| `NowFn` | `func() time.Time` | Optional; used for date formatting. Default: `time.Now`. |

---

## Features

- Language resolution from context (typed key and string key compatibility).
- Language fallback chain: requested -> base (`es-ar` -> `es`) -> configured fallbacks -> default -> `en`.
- YAML + runtime-loaded system messages (`9000-9999`).
- Template tokens:
- `{{0}}`, `{{1}}`, ... positional
- `{{plural:i|singular|plural}}`
- `{{num:i}}` localized number format
- `{{date:i}}` localized date format
- Strict template mode (`StrictTemplates`) for missing parameters.
- Error wrapping with localized short/long messages and error code.
- Concurrency-safe reads/writes.
- Runtime reload (`msgcat.Reload`) preserving runtime-loaded messages.
- Observability hooks and counters (`SnapshotStats`).
- **Language from context**
Language is read from `context.Context` using `CtxLanguageKey` (typed or string key).

- **Fallback chain**
Order: requested language → base tag (`es-ar` → `es`) → `FallbackLanguages` → `DefaultLanguage` → `"en"`. First language that exists in the catalog is used.

- **YAML + runtime messages**
Messages from YAML plus runtime-loaded entries via `LoadMessages` for codes **9000–9999** (system range).

- **Template tokens**
- `{{0}}`, `{{1}}`, … — positional parameters.
- `{{plural:i|singular|plural}}` — plural form by parameter at index `i`.
- `{{num:i}}` — localized number for parameter at index `i`.
- `{{date:i}}` — localized date for parameter at index `i` (`time.Time` or `*time.Time`).

- **Strict template mode**
With `StrictTemplates: true`, missing or invalid params produce `<missing:N>` and observer events.

- **Error wrapping**
`WrapErrorWithCtx` and `GetErrorWithCtx` return errors implementing `msgcat.Error`: `ErrorCode()`, `GetShortMessage()`, `GetLongMessage()`, `Unwrap()`.

- **Concurrency**
Safe for concurrent reads; `LoadMessages` and `Reload` are safe to use concurrently with reads.

- **Reload**
`msgcat.Reload(catalog)` reloads YAML from disk with optional retries; runtime-loaded messages (9000–9999) are preserved. On failure, last in-memory state is kept.

- **Observability**
Optional `Observer` plus stats via `SnapshotStats` / `ResetStats`. Observer runs asynchronously and is panic-safe; queue overflow is counted in stats.

---

## API

### Core interface
### Core interface (`MessageCatalog`)

| Method | Description |
|--------|-------------|
| `LoadMessages(lang string, messages []RawMessage) error` | Add or replace messages for a language. Only codes in 9000–9999 are allowed. |
| `GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message` | Resolve message for the context language; never nil. |
| `WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error` | Wrap an error with localized short/long text and message code. |
| `GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error` | Build an error with localized short/long text (no inner error). |

- `LoadMessages(lang string, messages []RawMessage) error`
- `GetMessageWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) *Message`
- `WrapErrorWithCtx(ctx context.Context, err error, msgCode int, msgParams ...interface{}) error`
- `GetErrorWithCtx(ctx context.Context, msgCode int, msgParams ...interface{}) error`
### Types

### Helpers
- **`Message`** — `Code int`, `ShortText string`, `LongText string`.
- **`RawMessage`** — `ShortTpl`, `LongTpl` (YAML: `short`, `long`); used in YAML and `LoadMessages`.
- **`msgcat.Error`** — `Error() string`, `Unwrap() error`, `ErrorCode() int`, `GetShortMessage() string`, `GetLongMessage() string`.

- `msgcat.Reload(catalog MessageCatalog) error`
- `msgcat.SnapshotStats(catalog MessageCatalog) (MessageCatalogStats, error)`
- `msgcat.ResetStats(catalog MessageCatalog) error`
- `msgcat.Close(catalog MessageCatalog) error`
### Package-level helpers

| Function | Description |
|----------|-------------|
| `msgcat.NewMessageCatalog(cfg Config) (MessageCatalog, error)` | Build catalog and load YAML from `ResourcePath`. |
| `msgcat.Reload(catalog MessageCatalog) error` | Reload YAML from disk (with retries if configured). |
| `msgcat.SnapshotStats(catalog MessageCatalog) (MessageCatalogStats, error)` | Copy of current stats. |
| `msgcat.ResetStats(catalog MessageCatalog) error` | Reset all stats counters. |
| `msgcat.Close(catalog MessageCatalog) error` | Stop observer worker and flush; call on shutdown if using an observer. |

### Constants

- `SystemMessageMinCode = 9000`
- `SystemMessageMaxCode = 9999`
- `CodeMissingMessage = 999999998`
- `CodeMissingLanguage = 99999999`
| Constant | Value | Description |
|----------|--------|-------------|
| `SystemMessageMinCode` | 9000 | Min code for runtime-loaded system messages. |
| `SystemMessageMaxCode` | 9999 | Max code for runtime-loaded system messages. |
| `CodeMissingMessage` | 999999002 | Code used when a message is missing in the catalog. |
| `CodeMissingLanguage` | 999999001 | Code used when the language is missing. |

## Observability

Provide an observer in config:
### Observer

Implement `msgcat.Observer` and pass it in `Config.Observer`:

```go
type Observer struct{}

func (Observer) OnLanguageFallback(requested, resolved string) {}
func (Observer) OnLanguageMissing(lang string) {}
func (Observer) OnMessageMissing(lang string, msgCode int) {}
func (Observer) OnLanguageMissing(lang string) {}
func (Observer) OnMessageMissing(lang string, msgCode int) {}
func (Observer) OnTemplateIssue(lang string, msgCode int, issue string) {}
```

Snapshot counters at runtime:
Callbacks are invoked **asynchronously** and are panic-protected. If the observer queue is full, events are dropped and counted in `MessageCatalogStats.DroppedEvents`. Call `msgcat.Close(catalog)` on shutdown when using an observer.

### Stats (`MessageCatalogStats`)

| Field | Description |
|-------|-------------|
| `LanguageFallbacks` | Counts per `"requested->resolved"` language fallback. |
| `MissingLanguages` | Counts per missing language. |
| `MissingMessages` | Counts per `"lang:code"` missing message. |
| `TemplateIssues` | Counts per template issue key (e.g. `"lang:code:issue"`). |
| `DroppedEvents` | Counts per drop reason (e.g. `observer_queue_full`, `observer_closed`). |
| `LastReloadAt` | Time of last successful reload. |

When `StatsMaxKeys` is set, each map is capped; extra keys are aggregated under `"__overflow__"`.

Example:

```go
stats, err := msgcat.SnapshotStats(catalog)
Expand All @@ -151,46 +244,52 @@ if err == nil {
}
```

## Production Notes
---

- Keep `DefaultLanguage` explicit (`en` recommended).
- Define `FallbackLanguages` intentionally (for example for regional traffic).
- Use `StrictTemplates: true` in production to detect bad template usage early.
- Set `ObserverBuffer` to avoid request-path pressure from slow observers.
- Set `StatsMaxKeys` to cap cardinality (`__overflow__` key holds overflow counts).
- Use `go test -race ./...` in CI.
- For periodic YAML refresh, call `msgcat.Reload(catalog)` in a controlled goroutine and prefer atomic file replacement (`write temp + rename`).
- Use `ReloadRetries` and `ReloadRetryDelay` to reduce transient parse/read errors during rollout windows.
- If observer is configured, call `msgcat.Close(catalog)` on service shutdown.
## Production notes

### Runtime Contract
- Set `DefaultLanguage` explicitly (e.g. `"en"`).
- Set `FallbackLanguages` to match your traffic (e.g. regional defaults).
- Use `StrictTemplates: true` to catch bad template usage early.
- Set `ObserverBuffer` (e.g. 1024) so slow observers do not block the request path.
- Set `StatsMaxKeys` (e.g. 512) to avoid unbounded memory; watch `__overflow__` in dashboards.
- Run `go test -race ./...` in CI.
- For periodic YAML updates, call `msgcat.Reload(catalog)` (e.g. from a goroutine) and deploy files atomically (write to temp, then rename).
- Use `ReloadRetries` and `ReloadRetryDelay` to tolerate transient read/parse errors.
- If an observer is configured, call `msgcat.Close(catalog)` on service shutdown.

- `GetMessageWithCtx` / `GetErrorWithCtx` / `WrapErrorWithCtx` are safe for concurrent use.
- `LoadMessages` and `Reload` are safe concurrently with reads.
- `Reload` keeps the last in-memory state if reload fails.
- Observer callbacks are async and panic-protected; overflow is counted in `DroppedEvents`.
### Runtime contract

## Benchmarks
- `GetMessageWithCtx`, `GetErrorWithCtx`, `WrapErrorWithCtx` are safe for concurrent use.
- `LoadMessages` and `Reload` are safe concurrently with these reads.
- `Reload` keeps the previous in-memory state if the reload fails.
- Observer callbacks are async and panic-protected; overflow is reflected in `DroppedEvents`.

---

Run:
## Benchmarks

```bash
go test -run ^$ -bench . -benchmem ./...
```

## Integration Examples
---

- HTTP language middleware sample: `examples/http/main.go`
- Metrics/observer sample (expvar style): `examples/metrics/main.go`
## Examples

## Context7 / LLM Docs
- HTTP language middleware: `examples/http/main.go`
- Metrics/observer (expvar-style): `examples/metrics/main.go`

For full machine-friendly docs, see `docs/CONTEXT7.md`.
For retrieval-optimized chunks, see `docs/CONTEXT7_RETRIEVAL.md`.
---

## Release + Migration
## Docs and release

- Changelog: `docs/CHANGELOG.md`
- Migration guide: `docs/MIGRATION.md`
- Release playbook: `docs/RELEASE.md`
- Support policy: `docs/SUPPORT.md`
| Doc | Description |
|-----|-------------|
| [Changelog](docs/CHANGELOG.md) | Version history. |
| [Migration guide](docs/MIGRATION.md) | Upgrading and config changes. |
| [Release playbook](docs/RELEASE.md) | How to cut a release. |
| [Support policy](docs/SUPPORT.md) | Supported versions and compatibility. |
| [SECURITY.md](SECURITY.md) | How to report vulnerabilities. |
| [Context7](docs/CONTEXT7.md) | Machine-friendly API docs. |
| [Context7 retrieval](docs/CONTEXT7_RETRIEVAL.md) | Retrieval-oriented chunks. |
17 changes: 17 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Security Policy

## Supported Versions

Security updates are applied to the latest minor within the current major version. Critical fixes may be backported to the previous minor when practical (see [Support Policy](docs/SUPPORT.md)).

## Reporting a Vulnerability

Please **do not** open a public issue for security vulnerabilities.

To report a vulnerability:

1. Email the maintainers or open a private security advisory on GitHub if the repo supports it.
2. Include a clear description, steps to reproduce, and impact.
3. Allow reasonable time for a fix before any public disclosure.

We will acknowledge receipt and aim to respond with next steps promptly.
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This project follows Semantic Versioning.

## [Unreleased]

## [1.0.8] - 2026-02-25

### Added
- Async, panic-safe observer pipeline with bounded queue (`ObserverBuffer`).
- Bounded stats cardinality (`StatsMaxKeys`) with `__overflow__` bucket.
Expand All @@ -15,11 +17,16 @@ This project follows Semantic Versioning.
- Production-oriented docs for Context7 + retrieval-friendly docs.
- Additional tests: observer behavior, stats capping/reset, reload retry behavior.
- Benchmarks and fuzz test entrypoints.
- GitHub Actions CI workflow (test, race, vet, examples build).
- `SECURITY.md` for vulnerability reporting.
- `.golangci.yml` for lint configuration (replaces misspelled `.golanci.yml`).

### Changed
- `Reload` now supports transient read/parse retries.
- Observer callbacks are no longer executed inline on request path.
- Stats now enforce key caps to avoid unbounded memory growth.
- Go module requires Go 1.26.
- Replaced deprecated `io/ioutil` with `os` (`ReadDir`, `ReadFile`).

### Fixed
- Updated docs links to `docs/` layout.
Expand Down
Loading