feat: custom content types backend
This commit is contained in:
1 parent
ed67b7e70d
commit
58d58f5a04
41 files changed
+4086
-2
No files matched your search
@@ -0,0 +1,64 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrContentTypeNotFound = errors.New("content type not found")
|
||||
ErrKeyTaken = errors.New("a content type with that key already exists")
|
||||
ErrInvalidName = errors.New("invalid content type name")
|
||||
ErrInvalidKey = errors.New("invalid content type key")
|
||||
ErrInvalidField = errors.New("invalid field")
|
||||
ErrHasEntries = errors.New("the content type still has entries")
|
||||
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
|
||||
)
|
||||
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
TypeText FieldType = "text"
|
||||
TypeRichText FieldType = "richtext"
|
||||
TypeNumber FieldType = "number"
|
||||
TypeBoolean FieldType = "boolean"
|
||||
TypeDate FieldType = "date"
|
||||
TypeSelect FieldType = "select"
|
||||
)
|
||||
|
||||
const (
|
||||
RuleType = "type"
|
||||
RuleMin = "min"
|
||||
RuleMax = "max"
|
||||
RuleOptions = "options"
|
||||
)
|
||||
|
||||
const (
|
||||
MinKeyRunes = 2
|
||||
MaxKeyRunes = 40
|
||||
MaxNameRunes = 100
|
||||
MinFields = 1
|
||||
MaxFields = 50
|
||||
DateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
type Field struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type FieldType `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Unique bool `json:"unique"`
|
||||
Default any `json:"default"`
|
||||
Min any `json:"min"`
|
||||
Max any `json:"max"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type ContentType struct {
|
||||
ID uint32 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Fields []Field `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package contenttype
|
||||
|
||||
type Repository interface {
|
||||
Create(contentType ContentType) (ContentType, error)
|
||||
GetByKey(key string) (ContentType, error)
|
||||
List() ([]ContentType, error)
|
||||
Update(contentType ContentType) (ContentType, error)
|
||||
Delete(id uint32) error
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type kind struct {
|
||||
accepts func(value any) bool
|
||||
measure func(value any) (float64, bool)
|
||||
bound func(bound any) (float64, bool)
|
||||
unique bool
|
||||
}
|
||||
|
||||
var kinds = map[FieldType]kind{
|
||||
TypeText: {accepts: isString, measure: runeCount, bound: wholeLength, unique: true},
|
||||
TypeRichText: {accepts: isString, measure: runeCount, bound: wholeLength},
|
||||
TypeNumber: {accepts: isNumber, measure: asNumber, bound: asNumber, unique: true},
|
||||
TypeBoolean: {accepts: isBoolean},
|
||||
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
|
||||
TypeSelect: {accepts: isString, unique: true},
|
||||
}
|
||||
|
||||
func isString(value any) bool {
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isBoolean(value any) bool {
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isNumber(value any) bool {
|
||||
_, ok := asNumber(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isDate(value any) bool {
|
||||
_, ok := dateDays(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func runeCount(value any) (float64, bool) {
|
||||
text, ok := value.(string)
|
||||
return float64(utf8.RuneCountInString(text)), ok
|
||||
}
|
||||
|
||||
func wholeLength(bound any) (float64, bool) {
|
||||
number, ok := asNumber(bound)
|
||||
return number, ok && number >= 0 && number == math.Trunc(number)
|
||||
}
|
||||
|
||||
func asNumber(value any) (float64, bool) {
|
||||
var number float64
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
number = typed
|
||||
case int:
|
||||
number = float64(typed)
|
||||
case int64:
|
||||
number = float64(typed)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return number, !math.IsNaN(number) && !math.IsInf(number, 0)
|
||||
}
|
||||
|
||||
func dateDays(value any) (float64, bool) {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := time.Parse(DateLayout, text)
|
||||
if err != nil || parsed.Format(DateLayout) != text {
|
||||
return 0, false
|
||||
}
|
||||
return float64(parsed.Unix()), true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/slug"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
|
||||
|
||||
func ValidateKey(key string) error {
|
||||
length := utf8.RuneCountInString(key)
|
||||
if length < MinKeyRunes || length > MaxKeyRunes || !slug.IsValid(key) {
|
||||
return ErrInvalidKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateName(name string) error {
|
||||
if strings.TrimSpace(name) == "" || utf8.RuneCountInString(name) > MaxNameRunes {
|
||||
return ErrInvalidName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateFields(fields []Field) ([]Field, error) {
|
||||
if len(fields) < MinFields || len(fields) > MaxFields {
|
||||
return nil, ErrInvalidField
|
||||
}
|
||||
seen := make(map[string]bool, len(fields))
|
||||
normalized := make([]Field, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if seen[field.Key] || !field.validSchema() {
|
||||
return nil, ErrInvalidField
|
||||
}
|
||||
seen[field.Key] = true
|
||||
if field.Options == nil {
|
||||
field.Options = []string{}
|
||||
}
|
||||
normalized = append(normalized, field)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (f Field) validSchema() bool {
|
||||
fieldKind, known := kinds[f.Type]
|
||||
if !known || !fieldKeyPattern.MatchString(f.Key) {
|
||||
return false
|
||||
}
|
||||
if f.Unique && !fieldKind.unique {
|
||||
return false
|
||||
}
|
||||
return f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
|
||||
}
|
||||
|
||||
func (f Field) validOptions() bool {
|
||||
if f.Type != TypeSelect {
|
||||
return len(f.Options) == 0
|
||||
}
|
||||
if len(f.Options) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]bool, len(f.Options))
|
||||
for _, option := range f.Options {
|
||||
if option == "" || seen[option] {
|
||||
return false
|
||||
}
|
||||
seen[option] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (f Field) validBounds(fieldKind kind) bool {
|
||||
if f.Min == nil && f.Max == nil {
|
||||
return true
|
||||
}
|
||||
if fieldKind.bound == nil {
|
||||
return false
|
||||
}
|
||||
lower, lowerOk := f.optionalBound(fieldKind, f.Min)
|
||||
upper, upperOk := f.optionalBound(fieldKind, f.Max)
|
||||
if !lowerOk || !upperOk {
|
||||
return false
|
||||
}
|
||||
return f.Min == nil || f.Max == nil || lower <= upper
|
||||
}
|
||||
|
||||
func (f Field) optionalBound(fieldKind kind, bound any) (float64, bool) {
|
||||
if bound == nil {
|
||||
return 0, true
|
||||
}
|
||||
return fieldKind.bound(bound)
|
||||
}
|
||||
|
||||
func (f Field) validDefault() bool {
|
||||
if f.Default == nil {
|
||||
return true
|
||||
}
|
||||
return !f.Unique && f.Violation(f.Default) == ""
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package contenttype
|
||||
|
||||
import "slices"
|
||||
|
||||
func (f Field) Violation(value any) string {
|
||||
fieldKind, known := kinds[f.Type]
|
||||
if !known || !fieldKind.accepts(value) {
|
||||
return RuleType
|
||||
}
|
||||
if rule := f.boundViolation(fieldKind, value); rule != "" {
|
||||
return rule
|
||||
}
|
||||
if len(f.Options) > 0 {
|
||||
text, _ := value.(string)
|
||||
if !slices.Contains(f.Options, text) {
|
||||
return RuleOptions
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f Field) boundViolation(fieldKind kind, value any) string {
|
||||
if fieldKind.measure == nil {
|
||||
return ""
|
||||
}
|
||||
measured, _ := fieldKind.measure(value)
|
||||
if lower, set := f.measuredBound(fieldKind, f.Min); set && measured < lower {
|
||||
return RuleMin
|
||||
}
|
||||
if upper, set := f.measuredBound(fieldKind, f.Max); set && measured > upper {
|
||||
return RuleMax
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f Field) measuredBound(fieldKind kind, bound any) (float64, bool) {
|
||||
if bound == nil {
|
||||
return 0, false
|
||||
}
|
||||
return fieldKind.bound(bound)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package entry
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func SameValue(left any, right any) bool {
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
type UniqueValue struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
func ApplyDefaults(fields []contenttype.Field, data map[string]any) map[string]any {
|
||||
filled := make(map[string]any, len(data))
|
||||
for key, value := range data {
|
||||
filled[key] = value
|
||||
}
|
||||
for _, field := range fields {
|
||||
if _, present := filled[field.Key]; !present && field.Default != nil {
|
||||
filled[field.Key] = field.Default
|
||||
}
|
||||
}
|
||||
return filled
|
||||
}
|
||||
|
||||
func Project(fields []contenttype.Field, data map[string]any) map[string]any {
|
||||
projected := make(map[string]any, len(data))
|
||||
for _, field := range fields {
|
||||
if value, present := data[field.Key]; present {
|
||||
projected[field.Key] = value
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
func Validate(fields []contenttype.Field, data map[string]any) error {
|
||||
for _, field := range fields {
|
||||
if err := validateField(field, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstUnknownField(fields, data)
|
||||
}
|
||||
|
||||
func validateField(field contenttype.Field, data map[string]any) error {
|
||||
value := data[field.Key]
|
||||
if value == nil {
|
||||
if field.Required {
|
||||
return invalidField(field.Key, RuleRequired)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rule := field.Violation(value); rule != "" {
|
||||
return invalidField(field.Key, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
|
||||
known := make(map[string]bool, len(fields))
|
||||
for _, field := range fields {
|
||||
known[field.Key] = true
|
||||
}
|
||||
var unknown []string
|
||||
for key := range data {
|
||||
if !known[key] {
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
return invalidField(unknown[0], RuleUnknownField)
|
||||
}
|
||||
|
||||
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
|
||||
var values []UniqueValue
|
||||
for _, field := range fields {
|
||||
if value := data[field.Key]; field.Unique && value != nil {
|
||||
values = append(values, UniqueValue{Key: field.Key, Value: value})
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package entry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEntryNotFound = errors.New("entry not found")
|
||||
ErrInvalidEntry = errors.New("invalid entry")
|
||||
ErrUniqueViolation = errors.New("a unique value is already used")
|
||||
ErrInvalidSort = errors.New("invalid sort")
|
||||
ErrInvalidPage = errors.New("invalid page")
|
||||
)
|
||||
|
||||
const (
|
||||
MaxPage = 1000000
|
||||
MaxPageSize = 100
|
||||
)
|
||||
|
||||
func ValidatePage(page int, size int) error {
|
||||
if page > MaxPage || size > MaxPageSize {
|
||||
return ErrInvalidPage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
RuleRequired = "required"
|
||||
RuleUnknownField = "unknown_field"
|
||||
RuleUnique = "unique"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID uint32 `json:"id"`
|
||||
ContentTypeID uint32 `json:"content_type_id"`
|
||||
Data map[string]any `json:"data"`
|
||||
CreatedBy uint32 `json:"created_by"`
|
||||
UpdatedBy uint32 `json:"updated_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FieldError struct {
|
||||
Key string
|
||||
Rule string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e FieldError) Error() string {
|
||||
return e.Key + ": " + e.Rule
|
||||
}
|
||||
|
||||
func (e FieldError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func invalidField(key string, rule string) error {
|
||||
return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
|
||||
}
|
||||
|
||||
func UniqueViolation(key string) error {
|
||||
return FieldError{Key: key, Rule: RuleUnique, Cause: ErrUniqueViolation}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package entry
|
||||
|
||||
import "strings"
|
||||
|
||||
type SortField string
|
||||
|
||||
type SortDirection string
|
||||
|
||||
const (
|
||||
SortByID SortField = "id"
|
||||
SortByCreatedAt SortField = "created_at"
|
||||
SortByUpdatedAt SortField = "updated_at"
|
||||
|
||||
SortAsc SortDirection = "asc"
|
||||
SortDesc SortDirection = "desc"
|
||||
)
|
||||
|
||||
type Sort struct {
|
||||
Field SortField
|
||||
Direction SortDirection
|
||||
}
|
||||
|
||||
func (s Sort) Valid() bool {
|
||||
switch s.Field {
|
||||
case SortByID, SortByCreatedAt, SortByUpdatedAt:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return s.Direction == SortAsc || s.Direction == SortDesc
|
||||
}
|
||||
|
||||
func (s Sort) String() string {
|
||||
return string(s.Field) + ":" + string(s.Direction)
|
||||
}
|
||||
|
||||
func ParseSort(value string) (Sort, error) {
|
||||
field, direction, found := strings.Cut(value, ":")
|
||||
parsed := Sort{Field: SortField(field), Direction: SortDirection(direction)}
|
||||
if !found || !parsed.Valid() {
|
||||
return Sort{}, ErrInvalidSort
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func DefaultSort() Sort {
|
||||
return Sort{Field: SortByUpdatedAt, Direction: SortDesc}
|
||||
}
|
||||
|
||||
type Filters struct {
|
||||
ContentTypeID uint32
|
||||
Page int
|
||||
Size int
|
||||
Sort Sort
|
||||
Unpaged bool
|
||||
}
|
||||
|
||||
type PageResult struct {
|
||||
Items []Entry
|
||||
Total int
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Create(entry Entry) (Entry, error)
|
||||
Get(contentTypeID uint32, id uint32) (Entry, error)
|
||||
ListPage(filters Filters) (PageResult, error)
|
||||
Update(entry Entry) (Entry, error)
|
||||
Delete(contentTypeID uint32, id uint32) error
|
||||
DeleteByContentType(contentTypeID uint32) error
|
||||
CountByContentType(contentTypeID uint32) (int, error)
|
||||
FindByFieldValue(contentTypeID uint32, fieldKey string, value any, excludeID uint32) ([]Entry, error)
|
||||
}
|
||||
Reference in new issue
Block a user