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)
|
||||
}
|
||||
Reference in new issue
Block a user