feat: add BVK exchange library
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
coverage.out
|
||||||
|
dist/
|
||||||
|
tmp/
|
||||||
43
README.md
Normal file
43
README.md
Normal file
@@ -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=<R>&str=<S>&tpl_name=<T>
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
```
|
||||||
8
errors.go
Normal file
8
errors.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package mila
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotReady = errors.New("mila: state is not ready")
|
||||||
|
ErrInvalidData = errors.New("mila: invalid content state")
|
||||||
|
)
|
||||||
141
handler.go
Normal file
141
handler.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
66
handler_test.go
Normal file
66
handler_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
19
logger.go
Normal file
19
logger.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
protocol.md
Normal file
63
protocol.md
Normal file
@@ -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=<R>&str=<S>&tpl_name=<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
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": "<R>",
|
||||||
|
"str": "<S>",
|
||||||
|
"tpl_name": "<T>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 |
|
||||||
15
state.go
Normal file
15
state.go
Normal file
@@ -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 == ""
|
||||||
|
}
|
||||||
8
store.go
Normal file
8
store.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package mila
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Get(ctx context.Context) (ContentState, error)
|
||||||
|
Set(ctx context.Context, state ContentState) error
|
||||||
|
}
|
||||||
84
store_file.go
Normal file
84
store_file.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
47
store_memory.go
Normal file
47
store_memory.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
88
validation.go
Normal file
88
validation.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
38
validation_test.go
Normal file
38
validation_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Протокол_обмена_медиакомплекса_и_БВК.pdf
Normal file
BIN
Протокол_обмена_медиакомплекса_и_БВК.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user