Add regional variant tagging (us/gb) for English words
- SCOWL loader now loads american-words and british-words alongside english-words, tagging each with variant "us", "gb", or NULL (common) - Words appearing in both American and British lists get NULL variant - Add variant column to words table with schema migration - API: /define returns variant field for English, /random and /words accept ?variant=us|gb filter - Add total word count to server status line - Fix rows.Close leak in PopulateCMUTails, add rows.Err() check - Add tests for PopulateCMUTails, cmuTail, TotalWordCount - Expand test seed data and assertions to cover pt-PT definitions, synonyms, and validity checks - Update README with variant docs, /difficulty, /words, /rhyme endpoints, and current database statistics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -103,9 +103,15 @@ func main() {
|
||||
mux.HandleFunc("GET /pronunciation", handlePronunciation(dict))
|
||||
mux.HandleFunc("GET /etymology", handleEtymology(dict))
|
||||
mux.HandleFunc("GET /difficulty", handleDifficulty(dict))
|
||||
mux.HandleFunc("GET /words", handleWords(dict))
|
||||
mux.HandleFunc("GET /rhyme", handleRhyme(dict))
|
||||
mux.HandleFunc("GET /health", handleHealth(dict, *dbPath))
|
||||
|
||||
// Cache total word count for status line
|
||||
if wc, err := dict.TotalWordCount(); err == nil {
|
||||
totalWords.Store(uint64(wc))
|
||||
}
|
||||
|
||||
serverStart = time.Now()
|
||||
addr := fmt.Sprintf("%s:%d", *host, *port)
|
||||
|
||||
@@ -154,6 +160,7 @@ func (r *statusRecorder) WriteHeader(code int) {
|
||||
var (
|
||||
queryCount atomic.Uint64
|
||||
errorCount atomic.Uint64
|
||||
totalWords atomic.Uint64
|
||||
serverStart time.Time
|
||||
lastPath atomic.Value // stores string
|
||||
)
|
||||
@@ -172,8 +179,9 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
rps := float64(n) / time.Since(serverStart).Seconds()
|
||||
errs := errorCount.Load()
|
||||
last, _ := lastPath.Load().(string)
|
||||
fmt.Fprintf(os.Stdout, "\r\033[2KQueries: %d | Errors: %d | Req/s: %.1f | Uptime: %s | Last: %s",
|
||||
n, errs, rps, uptime, last)
|
||||
words := totalWords.Load()
|
||||
fmt.Fprintf(os.Stdout, "\r\033[2KWords: %dk | Queries: %d | Errors: %d | Req/s: %.1f | Uptime: %s | Last: %s",
|
||||
words/1000, n, errs, rps, uptime, last)
|
||||
slog.Debug("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
@@ -201,6 +209,39 @@ func authMiddleware(token string, next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func parseOptions(r *http.Request) dictionary.Options {
|
||||
opts := dictionary.Options{
|
||||
POS: r.URL.Query().Get("pos"),
|
||||
Variant: r.URL.Query().Get("variant"),
|
||||
}
|
||||
if v := r.URL.Query().Get("min"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.MinLength = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("max"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.MaxLength = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("min_freq"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.MinFrequency = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("min_difficulty"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
opts.MinDifficulty = f
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("max_difficulty"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
opts.MaxDifficulty = f
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -246,34 +287,7 @@ func handleRandom(dict *dictionary.Dictionary) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
opts := dictionary.Options{
|
||||
POS: r.URL.Query().Get("pos"),
|
||||
}
|
||||
if v := r.URL.Query().Get("min"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.MinLength = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("max"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.MaxLength = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("min_freq"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.MinFrequency = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("min_difficulty"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
opts.MinDifficulty = f
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("max_difficulty"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
opts.MaxDifficulty = f
|
||||
}
|
||||
}
|
||||
opts := parseOptions(r)
|
||||
|
||||
result, err := dict.RandomWord(lang, opts)
|
||||
if err == dictionary.ErrNoMatch {
|
||||
@@ -293,6 +307,38 @@ func handleRandom(dict *dictionary.Dictionary) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func handleWords(dict *dictionary.Dictionary) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
writeError(w, 400, "bad_request", "lang is required")
|
||||
return
|
||||
}
|
||||
if !dictionary.ValidLang(lang) {
|
||||
writeError(w, 400, "bad_request", "unsupported language: "+lang)
|
||||
return
|
||||
}
|
||||
|
||||
opts := parseOptions(r)
|
||||
|
||||
words, err := dict.Words(lang, opts)
|
||||
if err == dictionary.ErrNoMatch {
|
||||
writeError(w, 404, "no_match", "no words match the given filters")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("words", "error", err)
|
||||
writeError(w, 500, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"lang": lang,
|
||||
"count": len(words),
|
||||
"words": words,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDefine(dict *dictionary.Dictionary) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
word := r.URL.Query().Get("word")
|
||||
@@ -312,11 +358,17 @@ func handleDefine(dict *dictionary.Dictionary) http.HandlerFunc {
|
||||
writeError(w, 500, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
resp := map[string]any{
|
||||
"word": word,
|
||||
"lang": lang,
|
||||
"definitions": defs,
|
||||
})
|
||||
}
|
||||
if lang == "en" {
|
||||
if v, err := dict.WordVariant(word, lang); err == nil && v != "" {
|
||||
resp["variant"] = v
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, resp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ func setupTestServer(t *testing.T) (*dictionary.Dictionary, *http.ServeMux) {
|
||||
mux.HandleFunc("GET /define", handleDefine(dict))
|
||||
mux.HandleFunc("GET /synonyms", handleSynonyms(dict))
|
||||
mux.HandleFunc("GET /translate", handleTranslate(dict))
|
||||
mux.HandleFunc("GET /words", handleWords(dict))
|
||||
mux.HandleFunc("GET /health", handleHealth(dict, ":memory:"))
|
||||
|
||||
t.Cleanup(func() { db.Close() })
|
||||
@@ -201,6 +202,55 @@ func TestTranslateEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordsEndpoint(t *testing.T) {
|
||||
_, mux := setupTestServer(t)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// All English words
|
||||
resp, _ := http.Get(srv.URL + "/words?lang=en")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Lang string `json:"lang"`
|
||||
Count int `json:"count"`
|
||||
Words []string `json:"words"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
if result.Lang != "en" {
|
||||
t.Errorf("lang = %q, want en", result.Lang)
|
||||
}
|
||||
if result.Count != len(result.Words) {
|
||||
t.Errorf("count %d != len(words) %d", result.Count, len(result.Words))
|
||||
}
|
||||
if result.Count != 2 { // happy, cat
|
||||
t.Errorf("count = %d, want 2", result.Count)
|
||||
}
|
||||
|
||||
// With length filter — no match
|
||||
resp, _ = http.Get(srv.URL + "/words?lang=en&min=100")
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("no match: status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Missing lang
|
||||
resp, _ = http.Get(srv.URL + "/words")
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("missing lang: status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Bad lang
|
||||
resp, _ = http.Get(srv.URL + "/words?lang=xx")
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("bad lang: status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
_, mux := setupTestServer(t)
|
||||
srv := httptest.NewServer(mux)
|
||||
|
||||
Reference in New Issue
Block a user