From 1e1008d5068b2def245c48d0bf1031f9b5064aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=93=D0=B5=D0=BE=D1=80=D0=B3=D0=B8=D0=B9?= Date: Tue, 14 Jul 2026 22:00:34 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=80=D0=B0=D0=B7=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 ++++++++++++++++- go.mod | 14 ++++- go.sum | 26 ++++++++ handler.go => httpserver/handler.go | 40 +++++++++---- httpserver/handler_test.go | 57 ++++++++++++++++++ logger.go => httpserver/options.go | 10 +++- mila.go | 50 ++++++++++++++++ protocol.md | 4 ++ protocol/config.go | 29 +++++++++ errors.go => protocol/errors.go | 2 +- state.go => protocol/state.go | 2 +- store.go => protocol/store.go | 2 +- protocol/validation.go | 35 +++++++++++ protocol/validation_test.go | 31 ++++++++++ store_file.go => storage/file.go | 20 ++++--- storage/file_test.go | 47 +++++++++++++++ store_memory.go => storage/memory.go | 14 +++-- store_file_test.go | 3 +- validation.go | 88 ---------------------------- validation_test.go | 9 ++- 20 files changed, 409 insertions(+), 128 deletions(-) create mode 100644 go.sum rename handler.go => httpserver/handler.go (75%) create mode 100644 httpserver/handler_test.go rename logger.go => httpserver/options.go (59%) create mode 100644 mila.go create mode 100644 protocol/config.go rename errors.go => protocol/errors.go (89%) rename state.go => protocol/state.go (95%) rename store.go => protocol/store.go (89%) create mode 100644 protocol/validation.go create mode 100644 protocol/validation_test.go rename store_file.go => storage/file.go (69%) create mode 100644 storage/file_test.go rename store_memory.go => storage/memory.go (54%) delete mode 100644 validation.go diff --git a/README.md b/README.md index ec5ed22..3f731c9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Mila намеренно не знает ничего про снапшоты, г go get gitea.unprism.ru/KRBL/mila ``` +Библиотека собирается под актуальную стабильную ветку Go: + +```text +go 1.26.5 +``` + ## Зона Ответственности Mila — библиотека протокола. Ее нужно встраивать в прикладной сервис, например @@ -48,6 +54,36 @@ Mila не делает: - не общается с фронтендом напрямую; - не решает, как именно должен отображаться контент. +## Структура Пакетов + +Корневой пакет `mila` оставлен фасадом для удобной интеграции. Прикладному коду +достаточно импортировать только его: + +```go +import "gitea.unprism.ru/KRBL/mila" +``` + +Внутри реализация разнесена по подпакетам: + +| Пакет | Ответственность | +| --- | --- | +| `protocol` | модели, конфиг, ошибки, валидация, интерфейс хранилища | +| `storage` | `MemoryStore` и `FileStore` | +| `httpserver` | HTTP-handler, options, коды ответов | + +Фасад в корне переэкспортирует основные типы и функции: + +```go +mila.ContentState +mila.Config +mila.Store +mila.NewHandler +mila.NewMemoryStore +mila.NewFileStore +mila.WithLogger +mila.WithOnSet +``` + ## Протокол Медиакомплекс выступает HTTP-сервером. БВК выступает HTTP-клиентом. @@ -141,6 +177,20 @@ mila.Config{ Лимиты для `str` и `tpl_name` нужно подтвердить у интегратора. В переданном PDF текстовый слой неоднозначно распознает исходное значение. +Валидация выполняется через `github.com/go-playground/validator/v10`. Ошибки +валидации заворачиваются в `mila.ErrInvalidData`, поэтому их можно проверять +стандартно: + +```go +if errors.Is(err, mila.ErrInvalidData) { + // обработать невалидные данные от БВК +} +``` + +Для проверки лимитов строка временно конвертируется в `[]rune`, потому что +ограничение задано в символах протокола, а хранить и отдавать данные удобнее как +обычную UTF-8 строку JSON/HTTP. + ## Хранилища `MemoryStore` подходит, когда состояние не должно переживать рестарт процесса: @@ -214,7 +264,7 @@ type ContentState struct { ```json { - "error": "route: must be at most 3 characters" + "error": "mila: invalid content state: route must be at most 3 characters" } ``` @@ -229,6 +279,6 @@ go test ./... - успешный `set-content` + `get-content`; - `get-content` до готовности состояния; - невалидный `route`; -- UTF-8 и лимиты для текста/шаблона; +- лимиты для текста/шаблона; - сохранение и чтение через `FileStore`; - отсутствие файла состояния в `FileStore`. diff --git a/go.mod b/go.mod index 0962d81..2d7a912 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module gitea.unprism.ru/KRBL/mila -go 1.24 +go 1.26.5 + +require github.com/go-playground/validator/v10 v10.30.3 + +require ( + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..470d546 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/httpserver/handler.go similarity index 75% rename from handler.go rename to httpserver/handler.go index b47bc01..a5524f6 100644 --- a/handler.go +++ b/httpserver/handler.go @@ -1,4 +1,4 @@ -package mila +package httpserver import ( "context" @@ -8,22 +8,24 @@ import ( "net/http" "strings" "time" + + "gitea.unprism.ru/KRBL/mila/protocol" ) type Handler struct { - cfg Config - store Store + cfg protocol.Config + store protocol.Store logger *slog.Logger - onSet func(ContentState) + onSet func(protocol.ContentState) } -func NewHandler(cfg Config, store Store, opts ...Option) *Handler { +func NewHandler(cfg protocol.Config, store protocol.Store, opts ...Option) *Handler { if store == nil { - store = NewMemoryStore() + store = nilStore{} } h := &Handler{ - cfg: normalizeConfig(cfg), + cfg: protocol.NormalizeConfig(cfg), store: store, logger: slog.Default(), } @@ -66,16 +68,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int { q := r.URL.Query() - state := ContentState{ + state := protocol.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 := protocol.Validate(h.cfg, state); err != nil { + if errors.Is(err, protocol.ErrInvalidData) { + writeError(w, http.StatusBadRequest, err.Error()) + return http.StatusBadRequest + } + writeError(w, http.StatusInternalServerError, err.Error()) + return http.StatusInternalServerError } if err := h.store.Set(r.Context(), state); err != nil { @@ -94,7 +100,7 @@ func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int { 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) { + if errors.Is(err, protocol.ErrNotReady) { writeError(w, http.StatusServiceUnavailable, "content state is not ready") return http.StatusServiceUnavailable } @@ -120,6 +126,16 @@ type protocolContentResponse struct { Template string `json:"tpl_name"` } +type nilStore struct{} + +func (nilStore) Get(context.Context) (protocol.ContentState, error) { + return protocol.ContentState{}, protocol.ErrNotReady +} + +func (nilStore) Set(context.Context, protocol.ContentState) error { + return errors.New("mila: store is not configured") +} + func cleanPath(path string) string { if path == "" { return "/" diff --git a/httpserver/handler_test.go b/httpserver/handler_test.go new file mode 100644 index 0000000..ea8fae5 --- /dev/null +++ b/httpserver/handler_test.go @@ -0,0 +1,57 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "gitea.unprism.ru/KRBL/mila/protocol" + "gitea.unprism.ru/KRBL/mila/storage" +) + +func TestHandlerSetAndGetContent(t *testing.T) { + handler := NewHandler(protocol.DefaultConfig(), storage.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 protocol.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(protocol.DefaultConfig(), storage.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()) + } +} diff --git a/logger.go b/httpserver/options.go similarity index 59% rename from logger.go rename to httpserver/options.go index c0a9a98..cd09b9a 100644 --- a/logger.go +++ b/httpserver/options.go @@ -1,6 +1,10 @@ -package mila +package httpserver -import "log/slog" +import ( + "log/slog" + + "gitea.unprism.ru/KRBL/mila/protocol" +) type Option func(*Handler) @@ -12,7 +16,7 @@ func WithLogger(logger *slog.Logger) Option { } } -func WithOnSet(fn func(ContentState)) Option { +func WithOnSet(fn func(protocol.ContentState)) Option { return func(h *Handler) { h.onSet = fn } diff --git a/mila.go b/mila.go new file mode 100644 index 0000000..efc0e61 --- /dev/null +++ b/mila.go @@ -0,0 +1,50 @@ +package mila + +import ( + "log/slog" + + "gitea.unprism.ru/KRBL/mila/httpserver" + "gitea.unprism.ru/KRBL/mila/protocol" + "gitea.unprism.ru/KRBL/mila/storage" +) + +type ContentState = protocol.ContentState +type Config = protocol.Config +type Store = protocol.Store +type Handler = httpserver.Handler +type Option = httpserver.Option +type MemoryStore = storage.MemoryStore +type FileStore = storage.FileStore + +var ( + ErrNotReady = protocol.ErrNotReady + ErrInvalidData = protocol.ErrInvalidData +) + +func DefaultConfig() Config { + return protocol.DefaultConfig() +} + +func Validate(cfg Config, state ContentState) error { + return protocol.Validate(cfg, state) +} + +func NewHandler(cfg Config, store Store, opts ...Option) *Handler { + return httpserver.NewHandler(cfg, store, opts...) +} + +func WithLogger(logger *slog.Logger) Option { + return httpserver.WithLogger(logger) +} + +func WithOnSet(fn func(ContentState)) Option { + return httpserver.WithOnSet(fn) +} + +func NewMemoryStore() *MemoryStore { + return storage.NewMemoryStore() +} + +func NewFileStore(path string) *FileStore { + return storage.NewFileStore(path) +} diff --git a/protocol.md b/protocol.md index 2a443bc..a45faee 100644 --- a/protocol.md +++ b/protocol.md @@ -34,6 +34,10 @@ GET /set-content?route=&str=&tpl_name= Точные лимиты для `str` и `tpl_name` нужно подтвердить у интегратора, потому что текстовый слой PDF неоднозначно распознает исходное значение. +Валидация реализована через `go-playground/validator`. Ошибки невалидных данных +заворачиваются в `mila.ErrInvalidData`, поэтому прикладной код может использовать +обычный `errors.Is`. + ## Запрос Контента ```text diff --git a/protocol/config.go b/protocol/config.go new file mode 100644 index 0000000..d36956a --- /dev/null +++ b/protocol/config.go @@ -0,0 +1,29 @@ +package protocol + +type Config struct { + MaxRouteLen int + MaxTextLen int + MaxTemplateLen int +} + +func DefaultConfig() Config { + return Config{ + MaxRouteLen: 3, + MaxTextLen: 200, + MaxTemplateLen: 200, + } +} + +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 +} diff --git a/errors.go b/protocol/errors.go similarity index 89% rename from errors.go rename to protocol/errors.go index af81fb5..2836454 100644 --- a/errors.go +++ b/protocol/errors.go @@ -1,4 +1,4 @@ -package mila +package protocol import "errors" diff --git a/state.go b/protocol/state.go similarity index 95% rename from state.go rename to protocol/state.go index 112694f..38c0a6b 100644 --- a/state.go +++ b/protocol/state.go @@ -1,4 +1,4 @@ -package mila +package protocol import "time" diff --git a/store.go b/protocol/store.go similarity index 89% rename from store.go rename to protocol/store.go index 4afd9d5..bdb2b9b 100644 --- a/store.go +++ b/protocol/store.go @@ -1,4 +1,4 @@ -package mila +package protocol import "context" diff --git a/protocol/validation.go b/protocol/validation.go new file mode 100644 index 0000000..4c4b778 --- /dev/null +++ b/protocol/validation.go @@ -0,0 +1,35 @@ +package protocol + +import ( + "fmt" + "strings" + + "github.com/go-playground/validator/v10" +) + +var validate = validator.New(validator.WithRequiredStructEnabled()) + +func Validate(cfg Config, state ContentState) error { + cfg = NormalizeConfig(cfg) + + route := strings.TrimSpace(state.Route) + template := strings.TrimSpace(state.Template) + + if err := validate.Var(route, "required"); err != nil { + return fmt.Errorf("%w: route is required", ErrInvalidData) + } + if err := validate.Var([]rune(route), fmt.Sprintf("max=%d", cfg.MaxRouteLen)); err != nil { + return fmt.Errorf("%w: route must be at most %d characters", ErrInvalidData, cfg.MaxRouteLen) + } + if err := validate.Var([]rune(state.Text), fmt.Sprintf("max=%d", cfg.MaxTextLen)); err != nil { + return fmt.Errorf("%w: str must be at most %d characters", ErrInvalidData, cfg.MaxTextLen) + } + if err := validate.Var(template, "required"); err != nil { + return fmt.Errorf("%w: tpl_name is required", ErrInvalidData) + } + if err := validate.Var([]rune(template), fmt.Sprintf("max=%d", cfg.MaxTemplateLen)); err != nil { + return fmt.Errorf("%w: tpl_name must be at most %d characters", ErrInvalidData, cfg.MaxTemplateLen) + } + + return nil +} diff --git a/protocol/validation_test.go b/protocol/validation_test.go new file mode 100644 index 0000000..ca907a5 --- /dev/null +++ b/protocol/validation_test.go @@ -0,0 +1,31 @@ +package protocol + +import ( + "errors" + "testing" +) + +func TestValidateAcceptsValidState(t *testing.T) { + err := Validate(DefaultConfig(), ContentState{ + Route: "3", + Text: "Гостиный двор - Gostiny dvor", + Template: "Гостиный двор", + }) + if err != nil { + t.Fatalf("Validate returned error: %v", err) + } +} + +func TestValidateWrapsInvalidData(t *testing.T) { + err := Validate(DefaultConfig(), ContentState{ + Route: "1234", + Text: "ok", + Template: "tpl", + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrInvalidData) { + t.Fatalf("expected ErrInvalidData, got %v", err) + } +} diff --git a/store_file.go b/storage/file.go similarity index 69% rename from store_file.go rename to storage/file.go index a50faf1..ed12ecc 100644 --- a/store_file.go +++ b/storage/file.go @@ -1,4 +1,4 @@ -package mila +package storage import ( "context" @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "sync" + + "gitea.unprism.ru/KRBL/mila/protocol" ) type FileStore struct { @@ -22,7 +24,7 @@ func NewFileStore(path string) *FileStore { } } -func (s *FileStore) Get(ctx context.Context) (ContentState, error) { +func (s *FileStore) Get(ctx context.Context) (protocol.ContentState, error) { if state, err := s.mem.Get(ctx); err == nil { return state, nil } @@ -33,26 +35,26 @@ func (s *FileStore) Get(ctx context.Context) (ContentState, error) { data, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { - return ContentState{}, ErrNotReady + return protocol.ContentState{}, protocol.ErrNotReady } - return ContentState{}, err + return protocol.ContentState{}, err } - var state ContentState + var state protocol.ContentState if err := json.Unmarshal(data, &state); err != nil { - return ContentState{}, err + return protocol.ContentState{}, err } if state.IsZero() { - return ContentState{}, ErrNotReady + return protocol.ContentState{}, protocol.ErrNotReady } if err := s.mem.Set(ctx, state); err != nil { - return ContentState{}, err + return protocol.ContentState{}, err } return state, nil } -func (s *FileStore) Set(ctx context.Context, state ContentState) error { +func (s *FileStore) Set(ctx context.Context, state protocol.ContentState) error { select { case <-ctx.Done(): return ctx.Err() diff --git a/storage/file_test.go b/storage/file_test.go new file mode 100644 index 0000000..7c60ec3 --- /dev/null +++ b/storage/file_test.go @@ -0,0 +1,47 @@ +package storage + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "gitea.unprism.ru/KRBL/mila/protocol" +) + +func TestFileStorePersistsAndLoadsState(t *testing.T) { + path := filepath.Join(t.TempDir(), "bvk_content.json") + ctx := context.Background() + + first := NewFileStore(path) + state := protocol.ContentState{ + Route: "3", + Text: "Гостиный двор", + Template: "main", + } + if err := first.Set(ctx, state); err != nil { + t.Fatalf("set state: %v", err) + } + + second := NewFileStore(path) + got, err := second.Get(ctx) + if err != nil { + t.Fatalf("get state: %v", err) + } + + if got.Route != state.Route || got.Text != state.Text || got.Template != state.Template { + t.Fatalf("unexpected state: %+v", got) + } +} + +func TestFileStoreMissingFileReturnsNotReady(t *testing.T) { + store := NewFileStore(filepath.Join(t.TempDir(), "missing.json")) + + _, err := store.Get(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, protocol.ErrNotReady) { + t.Fatalf("expected ErrNotReady, got %v", err) + } +} diff --git a/store_memory.go b/storage/memory.go similarity index 54% rename from store_memory.go rename to storage/memory.go index 7f36740..9a3bafb 100644 --- a/store_memory.go +++ b/storage/memory.go @@ -1,13 +1,15 @@ -package mila +package storage import ( "context" "sync" + + "gitea.unprism.ru/KRBL/mila/protocol" ) type MemoryStore struct { mu sync.RWMutex - state ContentState + state protocol.ContentState ready bool } @@ -15,10 +17,10 @@ func NewMemoryStore() *MemoryStore { return &MemoryStore{} } -func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) { +func (s *MemoryStore) Get(ctx context.Context) (protocol.ContentState, error) { select { case <-ctx.Done(): - return ContentState{}, ctx.Err() + return protocol.ContentState{}, ctx.Err() default: } @@ -26,12 +28,12 @@ func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) { defer s.mu.RUnlock() if !s.ready { - return ContentState{}, ErrNotReady + return protocol.ContentState{}, protocol.ErrNotReady } return s.state, nil } -func (s *MemoryStore) Set(ctx context.Context, state ContentState) error { +func (s *MemoryStore) Set(ctx context.Context, state protocol.ContentState) error { select { case <-ctx.Done(): return ctx.Err() diff --git a/store_file_test.go b/store_file_test.go index 9446756..618e0ae 100644 --- a/store_file_test.go +++ b/store_file_test.go @@ -2,6 +2,7 @@ package mila import ( "context" + "errors" "path/filepath" "testing" ) @@ -41,7 +42,7 @@ func TestFileStoreMissingFileReturnsNotReady(t *testing.T) { if err == nil { t.Fatal("expected error") } - if err != ErrNotReady { + if !errors.Is(err, ErrNotReady) { t.Fatalf("expected ErrNotReady, got %v", err) } } diff --git a/validation.go b/validation.go deleted file mode 100644 index 108daaa..0000000 --- a/validation.go +++ /dev/null @@ -1,88 +0,0 @@ -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 index c038ddb..3577abe 100644 --- a/validation_test.go +++ b/validation_test.go @@ -1,6 +1,9 @@ package mila -import "testing" +import ( + "errors" + "testing" +) func TestValidateAcceptsValidUTF8State(t *testing.T) { err := Validate(DefaultConfig(), ContentState{ @@ -22,8 +25,8 @@ func TestValidateRejectsTooLongRoute(t *testing.T) { if err == nil { t.Fatal("expected validation error") } - if !IsValidationError(err) { - t.Fatalf("expected ValidationError, got %T", err) + if !errors.Is(err, ErrInvalidData) { + t.Fatalf("expected ErrInvalidData, got %v", err) } }