Added websocket hub for handling websocket clients, with a wsserve command

which runs a plain TCP server for receiving websocket hub clients, with
server config, CSRF and rate limit middleware.

- internal/websocket: Hub pushing messages to WebSocket clients (ServeHTTP,
  example.com/ws upgrade with optional CSRF check) and plain TCP clients (ServeTCP,
  newline-delimited JSON envelopes); ping/pong keepalive, per-client message
  rate limit, slow consumer handling, graceful Shutdown, debug logging of
  messages sent and received
- internal/websocket/tcpserver: standalone TCP server for a hub, with accept
  back-off, a per-IP connection limit (server.websocket.connection_limit) and
  graceful shutdown
- internal/cli/wsserve: `wsserve` command running the TCP server, with
  --listen/--host/--port overrides
- internal/middleware: keyed token bucket rate limiter, real client IP from
  trusted proxies
- internal/httpx: RFC 9457 problem responses, request IDs in context
- internal/config: server section (host, port, shutdown timeout, rate limiter
  idle TTL, websocket); util.ByteSize settings (e.g. "32KiB"); lists of
  sections as single keys; empty lists stored as nil; isExplicitlySet for
  printLoadedConfig; JobSearchConfigSource renamed to JSCSourcesSource;
  TestSections checks every section's methods
- internal/util: ByteSize, parsed and formatted with go-humanize
This commit is contained in:
bwilliams committed 2026-10-14 15:55:25 +01:00
1 parent 803a8929e6
commit c9f42b62ad
42 files changed
+3619 -57

No files matched your search

+186 -16
View File
@@ -1,12 +1,16 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"time"
"git.avsdev.uk/Go/job-search/internal/util"
)
// Configuration sections are plain structs described with struct tags:
@@ -22,6 +26,19 @@ import (
//
// time.Duration fields report reflect.String from TypeOf and accept either a
// time.Duration or a duration string such as "90s" in SetValue.
// util.ByteSize fields work the same way, with size strings such as "32KiB".
//
// []string fields report reflect.Slice and accept either a []string or a
// comma-separated string such as "a, b" in SetValue (and so in default tags).
// An empty list is stored as nil.
//
// A list of sections, []S where S is a section struct (e.g. a list of extra
// headers), is a single key reporting reflect.Slice. It is given out as a
// []map[string]any, one map per item as Map gives it, and SetValue accepts
// a []S, a list of maps (as Viper reads a YAML or JSON list of objects) or a
// JSON array string. Each item starts from its defaults; its keys are set as
// SetValue sets them, so values may be strings to parse. If the item type has
// a validator, every field of each item is validated, including ones not set.
//
// A section can check values before they are stored by implementing
// validator on its pointer type.
@@ -31,16 +48,18 @@ const (
)
var (
errUnknownKey = errors.New("unknown configuration key")
durationType = reflect.TypeOf(time.Duration(0))
timeType = reflect.TypeOf(time.Time{})
errUnknownKey = errors.New("unknown configuration key")
durationType = reflect.TypeOf(time.Duration(0))
byteSizeType = reflect.TypeOf(util.ByteSize(0))
timeType = reflect.TypeOf(time.Time{})
stringSliceType = reflect.TypeOf([]string{})
)
// Section is the method set of every configuration section, including
// JobSearchConfig itself, implemented on the section's pointer type. Keys are
// dotted paths relative to the section, e.g. "sleeper.interval" in
// JobSearchConfigSources. Durations are given out as strings such as "10m0s",
// matching TypeOf's reflect.String for them. TestSections checks every
// JSCSources. Durations and byte sizes are given out as strings such as
// "10m0s" and "32KiB", matching TypeOf's reflect.String for them. TestSections checks every
// section, found by walking JobSearchConfig, implements it.
type Section interface {
// Map returns the section as a map of keys to values, with nested
@@ -48,8 +67,9 @@ type Section interface {
Map() map[string]any
// Flatten returns the section as a flat map of dotted keys, each
// prefixed with the section's own key (its JSCK* constant), so a
// section's keys match how its parent flattens it. JobSearchConfig and
// the embedded JobSearchConfigSource have no prefix.
// section's keys match how its parent flattens it. JobSearchConfig, the
// embedded JSCSourcesSource and types used under several keys, such as
// JSCRateLimitRule, have no prefix.
Flatten() map[string]any
// TypeOf returns the kind of the value at key, which is how it is read
// from Viper, or an error for an unknown key.
@@ -80,6 +100,13 @@ type field struct {
hasDefault bool
}
// isSectionList reports whether t is a list of sections, []S for a section
// struct S.
func isSectionList(t reflect.Type) bool {
return t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Struct &&
t.Elem() != timeType
}
// fieldsOf lists the configuration fields of a section type in declaration
// order, with the fields of embedded structs promoted into it.
func fieldsOf(t reflect.Type) []field {
@@ -201,19 +228,48 @@ func flattenInto(m map[string]any, prefix string, section reflect.Value) {
}
// exportValue returns a field's value as Map, Flatten and ValueOf give it
// out: durations become strings such as "1m30s", so values agree with kindOf
// and can be passed back to SetValue; everything else is unchanged.
// out: durations and byte sizes become strings such as "1m30s" and "32KiB",
// so values agree with kindOf and can be passed back to SetValue; []string is
// copied; a list of sections becomes a []map[string]any (nil if empty);
// everything else is unchanged.
func exportValue(fv reflect.Value) any {
if fv.Type() == durationType {
switch fv.Type() {
case durationType:
return time.Duration(fv.Int()).String()
case byteSizeType:
return util.ByteSize(fv.Int()).String()
}
return fv.Interface()
if isSectionList(fv.Type()) {
var items []map[string]any
for i := range fv.Len() {
items = append(items, mapOfValue(fv.Index(i)))
}
return items
}
v := fv.Interface()
if s, ok := v.([]string); ok {
// A copy, so changing it doesn't change the configuration.
return slices.Clone(s)
}
return v
}
// splitList splits a comma-separated list, trimming spaces around each item
// and dropping empty ones, so "" is an empty list.
func splitList(s string) []string {
var items []string
for item := range strings.SplitSeq(s, ",") {
if item = strings.TrimSpace(item); item != "" {
items = append(items, item)
}
}
return items
}
// kindOf is the kind reported by TypeOf, i.e. how the value is read from
// Viper. Durations are read as strings and parsed by SetValue.
// Viper. Durations and byte sizes are read as strings and parsed by SetValue.
func kindOf(t reflect.Type) reflect.Kind {
if t == durationType {
if t == durationType || t == byteSizeType {
return reflect.String
}
return t.Kind()
@@ -265,11 +321,19 @@ func setField(section reflect.Value, f field, value any) error {
}
// convert turns value into a value of type t. Values already of the right
// type are used as-is; strings are parsed.
// type are used as-is, except that an empty []string becomes nil, as an empty
// comma-separated list does (see splitList); strings are parsed. Lists of
// sections are converted by convertSectionList.
func convert(value any, t reflect.Type) (reflect.Value, error) {
if isSectionList(t) {
return convertSectionList(value, t)
}
if value != nil {
rv := reflect.ValueOf(value)
if rv.Type().AssignableTo(t) {
if t == stringSliceType && rv.Len() == 0 {
return reflect.Zero(t), nil
}
return rv, nil
}
if s, ok := value.(string); ok {
@@ -279,17 +343,123 @@ func convert(value any, t reflect.Type) (reflect.Value, error) {
return reflect.Value{}, fmt.Errorf("unsupported type %T", value)
}
// convertSectionList turns value into a list of sections of type t: a value
// of type t, a list of maps of keys to values, or a JSON array of objects.
// nil and empty lists become a nil list.
func convertSectionList(value any, t reflect.Type) (reflect.Value, error) {
if s, ok := value.(string); ok {
var items []map[string]any
if strings.TrimSpace(s) != "" {
if err := json.Unmarshal([]byte(s), &items); err != nil {
return reflect.Value{}, fmt.Errorf("unable to convert to a list of %s: %w", t.Elem(), err)
}
}
value = items
}
if value == nil {
return reflect.Zero(t), nil
}
rv := reflect.ValueOf(value)
if rv.Kind() != reflect.Slice {
return reflect.Value{}, fmt.Errorf("unsupported type %T", value)
}
if rv.Len() == 0 {
return reflect.Zero(t), nil
}
if rv.Type().AssignableTo(t) {
for i := range rv.Len() {
if err := validateSection(rv.Index(i)); err != nil {
return reflect.Value{}, fmt.Errorf("item %d: %w", i, err)
}
}
return rv, nil
}
list := reflect.MakeSlice(t, 0, rv.Len())
for i := range rv.Len() {
item, err := sectionFromMap(rv.Index(i).Interface(), t.Elem())
if err != nil {
return reflect.Value{}, fmt.Errorf("item %d: %w", i, err)
}
list = reflect.Append(list, item)
}
return list, nil
}
// sectionFromMap builds a section of type t from a map of its keys to values,
// starting from its defaults. Values that are neither strings nor of the
// field's type (e.g. numbers read from YAML) are formatted and parsed. The
// finished section is validated as a whole (see validateSection), so a key
// left out of the map can't skip its validation.
func sectionFromMap(value any, t reflect.Type) (reflect.Value, error) {
item, ok := value.(map[string]any)
if !ok {
return reflect.Value{}, fmt.Errorf("unsupported type %T", value)
}
section := reflect.New(t).Elem()
if err := applyDefaultsTo("", section); err != nil {
return reflect.Value{}, err
}
for k, v := range item {
s, f, err := lookup(section, k)
if err != nil {
return reflect.Value{}, fmt.Errorf("%w %q", err, k)
}
if _, isString := v.(string); v != nil && !isString &&
!reflect.TypeOf(v).AssignableTo(f.typ) {
v = fmt.Sprint(v)
}
if err := setField(s, f, v); err != nil {
return reflect.Value{}, fmt.Errorf("%s: %w", k, err)
}
}
if err := validateSection(section); err != nil {
return reflect.Value{}, err
}
return section, nil
}
// validateSection runs the section's validator, if it has one, on every value
// field directly in it, so values that were never set (defaults or zero
// values) are checked too.
func validateSection(section reflect.Value) error {
v, ok := reflect.New(section.Type()).Interface().(validator)
if !ok {
return nil
}
for _, f := range fieldsOf(section.Type()) {
if f.section {
continue
}
if err := v.validate(f.key, section.FieldByIndex(f.index).Interface()); err != nil {
return fmt.Errorf("%s: %w", f.key, err)
}
}
return nil
}
// parseString parses s as a value of type t. It supports strings, bools,
// signed and unsigned integers, floats and time.Duration.
// signed and unsigned integers, floats, time.Duration, ByteSize and []string
// (from a comma-separated list, see splitList).
func parseString(s string, t reflect.Type) (reflect.Value, error) {
v := reflect.New(t).Elem()
if t == durationType {
switch t {
case durationType:
d, err := time.ParseDuration(s)
if err != nil {
return reflect.Value{}, fmt.Errorf("unable to convert to duration: %w", err)
}
v.SetInt(int64(d))
return v, nil
case byteSizeType:
b, err := util.ParseByteSize(s)
if err != nil {
return reflect.Value{}, fmt.Errorf("unable to convert to byte size: %w", err)
}
v.SetInt(int64(b))
return v, nil
case stringSliceType:
v.Set(reflect.ValueOf(splitList(s)))
return v, nil
}
var err error