diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bda158f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +coverage.out +dist/ +tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4a0f56 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Mila + +Mila is a small Go library for BVK media-complex content exchange. + +It implements the HTTP protocol where BVK pushes the currently displayed +content state to the media complex: + +```text +GET /set-content?route=&str=&tpl_name= +GET /get-content +``` + +The library intentionally does not know anything about snapshots, route +progress, geolocation, frontend state, or media playback rules. It only parses, +validates, stores, logs, and returns BVK content state. + +## Usage + +```go +store := mila.NewFileStore("/srv/persistence/client_logs/bvk_content.json") + +handler := mila.NewHandler(mila.DefaultConfig(), store, + mila.WithOnSet(func(state mila.ContentState) { + // Application-specific integration goes here. + }), +) + +srv := &http.Server{ + Addr: "0.0.0.0:13280", + Handler: handler, +} +``` + +## State + +```json +{ + "route": "3", + "str": "Гостиный двор - Gostiny dvor", + "tpl_name": "Гостиный двор", + "updated_at": "2026-07-12T12:00:00Z" +} +``` diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..af81fb5 --- /dev/null +++ b/errors.go @@ -0,0 +1,8 @@ +package mila + +import "errors" + +var ( + ErrNotReady = errors.New("mila: state is not ready") + ErrInvalidData = errors.New("mila: invalid content state") +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0962d81 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.unprism.ru/KRBL/mila + +go 1.24 diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..b47bc01 --- /dev/null +++ b/handler.go @@ -0,0 +1,141 @@ +package mila + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" +) + +type Handler struct { + cfg Config + store Store + logger *slog.Logger + onSet func(ContentState) +} + +func NewHandler(cfg Config, store Store, opts ...Option) *Handler { + if store == nil { + store = NewMemoryStore() + } + + h := &Handler{ + cfg: normalizeConfig(cfg), + store: store, + logger: slog.Default(), + } + for _, opt := range opts { + opt(h) + } + return h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + status := http.StatusOK + defer func() { + h.logger.Info("mila request", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + slog.Int("status", status), + slog.Duration("duration", time.Since(start)), + ) + }() + + if r.Method != http.MethodGet { + status = http.StatusMethodNotAllowed + w.Header().Set("Allow", http.MethodGet) + writeError(w, status, "method not allowed") + return + } + + switch cleanPath(r.URL.Path) { + case "/set-content": + status = h.handleSetContent(w, r) + case "/get-content": + status = h.handleGetContent(w, r) + default: + status = http.StatusNotFound + writeError(w, status, "path not found") + } +} + +func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int { + q := r.URL.Query() + state := ContentState{ + Route: strings.TrimSpace(q.Get("route")), + Text: q.Get("str"), + Template: strings.TrimSpace(q.Get("tpl_name")), + UpdatedAt: time.Now().UTC(), + } + + if err := Validate(h.cfg, state); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return http.StatusBadRequest + } + + if err := h.store.Set(r.Context(), state); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return http.StatusInternalServerError + } + + if h.onSet != nil { + h.onSet(state) + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "OK"}) + return http.StatusOK +} + +func (h *Handler) handleGetContent(w http.ResponseWriter, r *http.Request) int { + state, err := h.store.Get(r.Context()) + if err != nil { + if errors.Is(err, ErrNotReady) { + writeError(w, http.StatusServiceUnavailable, "content state is not ready") + return http.StatusServiceUnavailable + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return http.StatusServiceUnavailable + } + writeError(w, http.StatusInternalServerError, err.Error()) + return http.StatusInternalServerError + } + + writeJSON(w, http.StatusOK, protocolContentResponse{ + Route: state.Route, + Text: state.Text, + Template: state.Template, + }) + return http.StatusOK +} + +type protocolContentResponse struct { + Route string `json:"route"` + Text string `json:"str"` + Template string `json:"tpl_name"` +} + +func cleanPath(path string) string { + if path == "" { + return "/" + } + if len(path) > 1 { + path = strings.TrimRight(path, "/") + } + return path +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..34a2992 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,66 @@ +package mila + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestHandlerSetAndGetContent(t *testing.T) { + handler := NewHandler(DefaultConfig(), NewMemoryStore()) + + params := url.Values{} + params.Set("route", "3") + params.Set("str", "Гостиный двор - Gostiny dvor") + params.Set("tpl_name", "Гостиный двор") + + req := httptest.NewRequest(http.MethodGet, "/set-content?"+params.Encode(), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("set-content status=%d body=%s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/get-content", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("get-content status=%d body=%s", rec.Code, rec.Body.String()) + } + + var state ContentState + if err := json.NewDecoder(rec.Body).Decode(&state); err != nil { + t.Fatalf("decode response: %v", err) + } + if state.Route != "3" || state.Text != "Гостиный двор - Gostiny dvor" || state.Template != "Гостиный двор" { + t.Fatalf("unexpected state: %+v", state) + } +} + +func TestHandlerGetBeforeSetReturns503(t *testing.T) { + handler := NewHandler(DefaultConfig(), NewMemoryStore()) + + req := httptest.NewRequest(http.MethodGet, "/get-content", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandlerRejectsInvalidSetContent(t *testing.T) { + handler := NewHandler(DefaultConfig(), NewMemoryStore()) + + req := httptest.NewRequest(http.MethodGet, "/set-content?route=1234&str=ok&tpl_name=tpl", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..c0a9a98 --- /dev/null +++ b/logger.go @@ -0,0 +1,19 @@ +package mila + +import "log/slog" + +type Option func(*Handler) + +func WithLogger(logger *slog.Logger) Option { + return func(h *Handler) { + if logger != nil { + h.logger = logger + } + } +} + +func WithOnSet(fn func(ContentState)) Option { + return func(h *Handler) { + h.onSet = fn + } +} diff --git a/protocol.md b/protocol.md new file mode 100644 index 0000000..f652245 --- /dev/null +++ b/protocol.md @@ -0,0 +1,63 @@ +# BVK Exchange Protocol + +Based on `Протокол_обмена_медиакомплекса_и_БВК.pdf`. + +## Role + +- BVK is the HTTP client. +- Media complex is the HTTP server. +- Default address from the document: `172.16.16.2:13280`. + +## Set Content + +```text +GET /set-content?route=&str=&tpl_name= +``` + +Parameters are URL-encoded. + +| Parameter | Meaning | +| --- | --- | +| `route` | Route number, 1 to 3 characters | +| `str` | Ticker/running text | +| `tpl_name` | Media content template name | + +Current library defaults: + +| Field | Default limit | +| --- | ---: | +| `route` | 3 characters | +| `str` | 200 characters | +| `tpl_name` | 200 characters | + +The exact text/template limits must be confirmed with the integrator because the +PDF text layer is ambiguous for the original value. + +## Get Content + +```text +GET /get-content +``` + +Response: + +```json +{ + "route": "", + "str": "", + "tpl_name": "" +} +``` + +Mila keeps `updated_at` internally, but the HTTP response is strict and exposes +only the fields defined by the protocol. + +## Status Codes + +| Code | Meaning | +| ---: | --- | +| 200 | OK | +| 400 | Bad request / validation error | +| 404 | Path not found | +| 500 | Internal server error | +| 503 | State is not ready or dependency unavailable | diff --git a/state.go b/state.go new file mode 100644 index 0000000..112694f --- /dev/null +++ b/state.go @@ -0,0 +1,15 @@ +package mila + +import "time" + +// ContentState is the current display state received from BVK. +type ContentState struct { + Route string `json:"route"` + Text string `json:"str"` + Template string `json:"tpl_name"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +func (s ContentState) IsZero() bool { + return s.Route == "" && s.Text == "" && s.Template == "" +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..4afd9d5 --- /dev/null +++ b/store.go @@ -0,0 +1,8 @@ +package mila + +import "context" + +type Store interface { + Get(ctx context.Context) (ContentState, error) + Set(ctx context.Context, state ContentState) error +} diff --git a/store_file.go b/store_file.go new file mode 100644 index 0000000..a50faf1 --- /dev/null +++ b/store_file.go @@ -0,0 +1,84 @@ +package mila + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" +) + +type FileStore struct { + path string + mu sync.Mutex + mem *MemoryStore +} + +func NewFileStore(path string) *FileStore { + return &FileStore{ + path: path, + mem: NewMemoryStore(), + } +} + +func (s *FileStore) Get(ctx context.Context) (ContentState, error) { + if state, err := s.mem.Get(ctx); err == nil { + return state, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ContentState{}, ErrNotReady + } + return ContentState{}, err + } + + var state ContentState + if err := json.Unmarshal(data, &state); err != nil { + return ContentState{}, err + } + if state.IsZero() { + return ContentState{}, ErrNotReady + } + + if err := s.mem.Set(ctx, state); err != nil { + return ContentState{}, err + } + return state, nil +} + +func (s *FileStore) Set(ctx context.Context, state ContentState) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, s.path); err != nil { + _ = os.Remove(tmp) + return err + } + + return s.mem.Set(ctx, state) +} diff --git a/store_memory.go b/store_memory.go new file mode 100644 index 0000000..7f36740 --- /dev/null +++ b/store_memory.go @@ -0,0 +1,47 @@ +package mila + +import ( + "context" + "sync" +) + +type MemoryStore struct { + mu sync.RWMutex + state ContentState + ready bool +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{} +} + +func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) { + select { + case <-ctx.Done(): + return ContentState{}, ctx.Err() + default: + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if !s.ready { + return ContentState{}, ErrNotReady + } + return s.state, nil +} + +func (s *MemoryStore) Set(ctx context.Context, state ContentState) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.state = state + s.ready = true + return nil +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..108daaa --- /dev/null +++ b/validation.go @@ -0,0 +1,88 @@ +package mila + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +type Config struct { + MaxRouteLen int + MaxTextLen int + MaxTemplateLen int +} + +func DefaultConfig() Config { + return Config{ + MaxRouteLen: 3, + MaxTextLen: 200, + MaxTemplateLen: 200, + } +} + +type ValidationError struct { + Field string `json:"field"` + Msg string `json:"message"` +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Msg) +} + +func Validate(cfg Config, state ContentState) error { + cfg = normalizeConfig(cfg) + + state.Route = strings.TrimSpace(state.Route) + state.Template = strings.TrimSpace(state.Template) + + if state.Route == "" { + return ValidationError{Field: "route", Msg: "is required"} + } + if runeLen(state.Route) > cfg.MaxRouteLen { + return ValidationError{Field: "route", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxRouteLen)} + } + if !utf8.ValidString(state.Route) { + return ValidationError{Field: "route", Msg: "must be valid UTF-8"} + } + if !utf8.ValidString(state.Text) { + return ValidationError{Field: "str", Msg: "must be valid UTF-8"} + } + if runeLen(state.Text) > cfg.MaxTextLen { + return ValidationError{Field: "str", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxTextLen)} + } + if state.Template == "" { + return ValidationError{Field: "tpl_name", Msg: "is required"} + } + if !utf8.ValidString(state.Template) { + return ValidationError{Field: "tpl_name", Msg: "must be valid UTF-8"} + } + if runeLen(state.Template) > cfg.MaxTemplateLen { + return ValidationError{Field: "tpl_name", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxTemplateLen)} + } + + return nil +} + +func IsValidationError(err error) bool { + var target ValidationError + return errors.As(err, &target) +} + +func normalizeConfig(cfg Config) Config { + def := DefaultConfig() + if cfg.MaxRouteLen <= 0 { + cfg.MaxRouteLen = def.MaxRouteLen + } + if cfg.MaxTextLen <= 0 { + cfg.MaxTextLen = def.MaxTextLen + } + if cfg.MaxTemplateLen <= 0 { + cfg.MaxTemplateLen = def.MaxTemplateLen + } + return cfg +} + +func runeLen(s string) int { + return utf8.RuneCountInString(s) +} diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..c038ddb --- /dev/null +++ b/validation_test.go @@ -0,0 +1,38 @@ +package mila + +import "testing" + +func TestValidateAcceptsValidUTF8State(t *testing.T) { + err := Validate(DefaultConfig(), ContentState{ + Route: "3", + Text: "Гостиный двор - Gostiny dvor", + Template: "Гостиный двор", + }) + if err != nil { + t.Fatalf("Validate returned error: %v", err) + } +} + +func TestValidateRejectsTooLongRoute(t *testing.T) { + err := Validate(DefaultConfig(), ContentState{ + Route: "1234", + Text: "ok", + Template: "tpl", + }) + if err == nil { + t.Fatal("expected validation error") + } + if !IsValidationError(err) { + t.Fatalf("expected ValidationError, got %T", err) + } +} + +func TestValidateRejectsMissingTemplate(t *testing.T) { + err := Validate(DefaultConfig(), ContentState{ + Route: "3", + Text: "ok", + }) + if err == nil { + t.Fatal("expected validation error") + } +} diff --git a/Протокол_обмена_медиакомплекса_и_БВК.pdf b/Протокол_обмена_медиакомплекса_и_БВК.pdf new file mode 100644 index 0000000..9914b82 Binary files /dev/null and b/Протокол_обмена_медиакомплекса_и_БВК.pdf differ