Compare commits
1 Commits
feature/bv
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1008d506 |
54
README.md
54
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`.
|
||||
|
||||
14
go.mod
14
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
|
||||
)
|
||||
|
||||
26
go.sum
Normal file
26
go.sum
Normal file
@@ -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=
|
||||
@@ -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 "/"
|
||||
57
httpserver/handler_test.go
Normal file
57
httpserver/handler_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
50
mila.go
Normal file
50
mila.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -34,6 +34,10 @@ GET /set-content?route=<R>&str=<S>&tpl_name=<T>
|
||||
Точные лимиты для `str` и `tpl_name` нужно подтвердить у интегратора, потому что
|
||||
текстовый слой PDF неоднозначно распознает исходное значение.
|
||||
|
||||
Валидация реализована через `go-playground/validator`. Ошибки невалидных данных
|
||||
заворачиваются в `mila.ErrInvalidData`, поэтому прикладной код может использовать
|
||||
обычный `errors.Is`.
|
||||
|
||||
## Запрос Контента
|
||||
|
||||
```text
|
||||
|
||||
29
protocol/config.go
Normal file
29
protocol/config.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mila
|
||||
package protocol
|
||||
|
||||
import "errors"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mila
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mila
|
||||
package protocol
|
||||
|
||||
import "context"
|
||||
|
||||
35
protocol/validation.go
Normal file
35
protocol/validation.go
Normal file
@@ -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
|
||||
}
|
||||
31
protocol/validation_test.go
Normal file
31
protocol/validation_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
47
storage/file_test.go
Normal file
47
storage/file_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user