refactor: разнести пакеты и обновить валидацию
This commit is contained in:
54
README.md
54
README.md
@@ -22,6 +22,12 @@ Mila намеренно не знает ничего про снапшоты, г
|
|||||||
go get gitea.unprism.ru/KRBL/mila
|
go get gitea.unprism.ru/KRBL/mila
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Библиотека собирается под актуальную стабильную ветку Go:
|
||||||
|
|
||||||
|
```text
|
||||||
|
go 1.26.5
|
||||||
|
```
|
||||||
|
|
||||||
## Зона Ответственности
|
## Зона Ответственности
|
||||||
|
|
||||||
Mila — библиотека протокола. Ее нужно встраивать в прикладной сервис, например
|
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-клиентом.
|
Медиакомплекс выступает HTTP-сервером. БВК выступает HTTP-клиентом.
|
||||||
@@ -141,6 +177,20 @@ mila.Config{
|
|||||||
Лимиты для `str` и `tpl_name` нужно подтвердить у интегратора. В переданном PDF
|
Лимиты для `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` подходит, когда состояние не должно переживать рестарт процесса:
|
`MemoryStore` подходит, когда состояние не должно переживать рестарт процесса:
|
||||||
@@ -214,7 +264,7 @@ type ContentState struct {
|
|||||||
|
|
||||||
```json
|
```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`;
|
- успешный `set-content` + `get-content`;
|
||||||
- `get-content` до готовности состояния;
|
- `get-content` до готовности состояния;
|
||||||
- невалидный `route`;
|
- невалидный `route`;
|
||||||
- UTF-8 и лимиты для текста/шаблона;
|
- лимиты для текста/шаблона;
|
||||||
- сохранение и чтение через `FileStore`;
|
- сохранение и чтение через `FileStore`;
|
||||||
- отсутствие файла состояния в `FileStore`.
|
- отсутствие файла состояния в `FileStore`.
|
||||||
|
|||||||
14
go.mod
14
go.mod
@@ -1,3 +1,15 @@
|
|||||||
module gitea.unprism.ru/KRBL/mila
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -8,22 +8,24 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.unprism.ru/KRBL/mila/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg Config
|
cfg protocol.Config
|
||||||
store Store
|
store protocol.Store
|
||||||
logger *slog.Logger
|
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 {
|
if store == nil {
|
||||||
store = NewMemoryStore()
|
store = nilStore{}
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
cfg: normalizeConfig(cfg),
|
cfg: protocol.NormalizeConfig(cfg),
|
||||||
store: store,
|
store: store,
|
||||||
logger: slog.Default(),
|
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 {
|
func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
state := ContentState{
|
state := protocol.ContentState{
|
||||||
Route: strings.TrimSpace(q.Get("route")),
|
Route: strings.TrimSpace(q.Get("route")),
|
||||||
Text: q.Get("str"),
|
Text: q.Get("str"),
|
||||||
Template: strings.TrimSpace(q.Get("tpl_name")),
|
Template: strings.TrimSpace(q.Get("tpl_name")),
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := Validate(h.cfg, state); err != nil {
|
if err := protocol.Validate(h.cfg, state); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
if errors.Is(err, protocol.ErrInvalidData) {
|
||||||
return http.StatusBadRequest
|
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 {
|
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 {
|
func (h *Handler) handleGetContent(w http.ResponseWriter, r *http.Request) int {
|
||||||
state, err := h.store.Get(r.Context())
|
state, err := h.store.Get(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotReady) {
|
if errors.Is(err, protocol.ErrNotReady) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "content state is not ready")
|
writeError(w, http.StatusServiceUnavailable, "content state is not ready")
|
||||||
return http.StatusServiceUnavailable
|
return http.StatusServiceUnavailable
|
||||||
}
|
}
|
||||||
@@ -120,6 +126,16 @@ type protocolContentResponse struct {
|
|||||||
Template string `json:"tpl_name"`
|
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 {
|
func cleanPath(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return "/"
|
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)
|
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) {
|
return func(h *Handler) {
|
||||||
h.onSet = fn
|
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` нужно подтвердить у интегратора, потому что
|
Точные лимиты для `str` и `tpl_name` нужно подтвердить у интегратора, потому что
|
||||||
текстовый слой PDF неоднозначно распознает исходное значение.
|
текстовый слой PDF неоднозначно распознает исходное значение.
|
||||||
|
|
||||||
|
Валидация реализована через `go-playground/validator`. Ошибки невалидных данных
|
||||||
|
заворачиваются в `mila.ErrInvalidData`, поэтому прикладной код может использовать
|
||||||
|
обычный `errors.Is`.
|
||||||
|
|
||||||
## Запрос Контента
|
## Запрос Контента
|
||||||
|
|
||||||
```text
|
```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"
|
import "errors"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package mila
|
package protocol
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package mila
|
package protocol
|
||||||
|
|
||||||
import "context"
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gitea.unprism.ru/KRBL/mila/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileStore struct {
|
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 {
|
if state, err := s.mem.Get(ctx); err == nil {
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
@@ -33,26 +35,26 @@ func (s *FileStore) Get(ctx context.Context) (ContentState, error) {
|
|||||||
data, err := os.ReadFile(s.path)
|
data, err := os.ReadFile(s.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
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 {
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
return ContentState{}, err
|
return protocol.ContentState{}, err
|
||||||
}
|
}
|
||||||
if state.IsZero() {
|
if state.IsZero() {
|
||||||
return ContentState{}, ErrNotReady
|
return protocol.ContentState{}, protocol.ErrNotReady
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.mem.Set(ctx, state); err != nil {
|
if err := s.mem.Set(ctx, state); err != nil {
|
||||||
return ContentState{}, err
|
return protocol.ContentState{}, err
|
||||||
}
|
}
|
||||||
return state, nil
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gitea.unprism.ru/KRBL/mila/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MemoryStore struct {
|
type MemoryStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
state ContentState
|
state protocol.ContentState
|
||||||
ready bool
|
ready bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,10 +17,10 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
return &MemoryStore{}
|
return &MemoryStore{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) {
|
func (s *MemoryStore) Get(ctx context.Context) (protocol.ContentState, error) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ContentState{}, ctx.Err()
|
return protocol.ContentState{}, ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,12 +28,12 @@ func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
if !s.ready {
|
if !s.ready {
|
||||||
return ContentState{}, ErrNotReady
|
return protocol.ContentState{}, protocol.ErrNotReady
|
||||||
}
|
}
|
||||||
return s.state, nil
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -2,6 +2,7 @@ package mila
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -41,7 +42,7 @@ func TestFileStoreMissingFileReturnsNotReady(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
if err != ErrNotReady {
|
if !errors.Is(err, ErrNotReady) {
|
||||||
t.Fatalf("expected ErrNotReady, got %v", err)
|
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
|
package mila
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateAcceptsValidUTF8State(t *testing.T) {
|
func TestValidateAcceptsValidUTF8State(t *testing.T) {
|
||||||
err := Validate(DefaultConfig(), ContentState{
|
err := Validate(DefaultConfig(), ContentState{
|
||||||
@@ -22,8 +25,8 @@ func TestValidateRejectsTooLongRoute(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected validation error")
|
t.Fatal("expected validation error")
|
||||||
}
|
}
|
||||||
if !IsValidationError(err) {
|
if !errors.Is(err, ErrInvalidData) {
|
||||||
t.Fatalf("expected ValidationError, got %T", err)
|
t.Fatalf("expected ErrInvalidData, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user