routing
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const defaultProductRule = "/product/{rewrite}"
|
||||
const defaultCategoryRule = "/{id}-{rewrite}"
|
||||
const optionalLanguagePrefix = "(?:/[a-zA-Z]{2}(?:-[a-zA-Z]{2})?)?"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
}
|
||||
|
||||
type ProductRoute struct {
|
||||
Rule string
|
||||
Prefix string
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
type ProductMatch struct {
|
||||
ID int64
|
||||
Slug string
|
||||
}
|
||||
|
||||
type ProductURLData struct {
|
||||
ID int64
|
||||
Slug string
|
||||
CategoryPath string
|
||||
ProductAttributeID int64
|
||||
EAN13 string
|
||||
LanguagePrefix string
|
||||
}
|
||||
|
||||
type CategoryRoute struct {
|
||||
Rule string
|
||||
Prefix string
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
type CategoryMatch struct {
|
||||
ID int64
|
||||
Slug string
|
||||
}
|
||||
|
||||
type CategoryURLData struct {
|
||||
ID int64
|
||||
Slug string
|
||||
LanguagePrefix string
|
||||
}
|
||||
|
||||
type RouteMatcher interface {
|
||||
Owns(path string) bool
|
||||
}
|
||||
|
||||
type combinedMatcher struct {
|
||||
matchers []RouteMatcher
|
||||
}
|
||||
|
||||
var fallbackProductSegment = regexp.MustCompile(`^(?P<id>\d+)(?:-\d+)?-(?P<rewrite>.+?)(?:-[^-/.]*)?\.html$`)
|
||||
var fallbackCategorySegment = regexp.MustCompile(`^(?P<id>\d+)-(?P<rewrite>[^/]+)$`)
|
||||
|
||||
func NewService(db *gorm.DB, prefix string) *Service {
|
||||
return &Service{db: db, prefix: prefix}
|
||||
}
|
||||
|
||||
func CombineMatchers(matchers ...RouteMatcher) RouteMatcher {
|
||||
return combinedMatcher{matchers: matchers}
|
||||
}
|
||||
|
||||
func (s *Service) LoadProductRoute(ctx context.Context) (*ProductRoute, error) {
|
||||
rule, err := s.loadRouteRule(ctx, "PS_ROUTE_product_rule", defaultProductRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CompileProductRoute(rule)
|
||||
}
|
||||
|
||||
func (s *Service) LoadCategoryRoute(ctx context.Context) (*CategoryRoute, error) {
|
||||
rule, err := s.loadRouteRule(ctx, "PS_ROUTE_category_rule", defaultCategoryRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CompileCategoryRoute(rule)
|
||||
}
|
||||
|
||||
func (s *Service) loadRouteRule(ctx context.Context, key, fallback string) (string, error) {
|
||||
rule := fallback
|
||||
if s != nil && s.db != nil {
|
||||
var row struct {
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
query := fmt.Sprintf("SELECT value FROM %sconfiguration WHERE name = ? LIMIT 1", s.prefix)
|
||||
if err := s.db.WithContext(ctx).Raw(query, key).Scan(&row).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(row.Value) != "" {
|
||||
rule = row.Value
|
||||
}
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func CompileCategoryRoute(rule string) (*CategoryRoute, error) {
|
||||
compiled, prefix, err := compileRouteRule(rule, defaultCategoryRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CategoryRoute{
|
||||
Rule: normalizeRule(rule, defaultCategoryRule),
|
||||
Prefix: prefix,
|
||||
regex: compiled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *CategoryRoute) Match(path string) (slug string, ok bool) {
|
||||
match, ok := r.MatchInfo(path)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return match.Slug, true
|
||||
}
|
||||
|
||||
func (r *CategoryRoute) MatchInfo(path string) (*CategoryMatch, bool) {
|
||||
if r == nil || r.regex == nil {
|
||||
return fallbackCategoryMatch(path)
|
||||
}
|
||||
matches := r.regex.FindStringSubmatch(path)
|
||||
if matches != nil {
|
||||
out := &CategoryMatch{}
|
||||
for idx, name := range r.regex.SubexpNames() {
|
||||
if idx >= len(matches) || matches[idx] == "" {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "rewrite":
|
||||
out.Slug = matches[idx]
|
||||
case "id", "id_category":
|
||||
out.ID = parseInt64(matches[idx])
|
||||
}
|
||||
}
|
||||
if out.ID != 0 {
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
return fallbackCategoryMatch(path)
|
||||
}
|
||||
|
||||
func (r *CategoryRoute) Owns(path string) bool {
|
||||
match, ok := r.MatchInfo(path)
|
||||
return ok && match.ID != 0
|
||||
}
|
||||
|
||||
func (r *CategoryRoute) BuildPath(data CategoryURLData) string {
|
||||
rule := defaultCategoryRule
|
||||
if r != nil {
|
||||
rule = normalizeRule(r.Rule, defaultCategoryRule)
|
||||
}
|
||||
|
||||
var path strings.Builder
|
||||
path.Grow(len(rule) + len(data.Slug) + 16)
|
||||
path.WriteString(normalizeLanguagePrefix(data.LanguagePrefix))
|
||||
for i := 0; i < len(rule); {
|
||||
if rule[i] != '{' {
|
||||
path.WriteByte(rule[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end := strings.IndexByte(rule[i:], '}')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
end += i
|
||||
token := rule[i+1 : end]
|
||||
name, before, after := parseToken(token)
|
||||
value := categoryTokenValue(name, data)
|
||||
if value == "" {
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
path.WriteString(before)
|
||||
path.WriteString(value)
|
||||
path.WriteString(after)
|
||||
i = end + 1
|
||||
}
|
||||
|
||||
result := path.String()
|
||||
if result == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(result, "/") {
|
||||
return "/" + result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m combinedMatcher) Owns(path string) bool {
|
||||
for _, matcher := range m.matchers {
|
||||
if matcher != nil && matcher.Owns(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func CompileProductRoute(rule string) (*ProductRoute, error) {
|
||||
compiled, prefix, err := compileRouteRule(rule, defaultProductRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProductRoute{
|
||||
Rule: normalizeRule(rule, defaultProductRule),
|
||||
Prefix: prefix,
|
||||
regex: compiled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compileRouteRule(rule, fallback string) (*regexp.Regexp, string, error) {
|
||||
rule = normalizeRule(rule, fallback)
|
||||
var pattern strings.Builder
|
||||
pattern.WriteString("^")
|
||||
pattern.WriteString(optionalLanguagePrefix)
|
||||
for i := 0; i < len(rule); {
|
||||
if rule[i] != '{' {
|
||||
pattern.WriteString(regexp.QuoteMeta(string(rule[i])))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end := strings.IndexByte(rule[i:], '}')
|
||||
if end < 0 {
|
||||
return nil, "", fmt.Errorf("invalid product route rule %q", rule)
|
||||
}
|
||||
end += i
|
||||
token := rule[i+1 : end]
|
||||
pattern.WriteString(tokenRegex(token))
|
||||
i = end + 1
|
||||
}
|
||||
pattern.WriteString("$")
|
||||
|
||||
compiled, err := regexp.Compile(pattern.String())
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("compile route rule %q: %w", rule, err)
|
||||
}
|
||||
return compiled, staticPrefix(rule), nil
|
||||
}
|
||||
|
||||
func (r *ProductRoute) Match(path string) (slug string, ok bool) {
|
||||
match, ok := r.MatchInfo(path)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return match.Slug, true
|
||||
}
|
||||
|
||||
func (r *ProductRoute) MatchInfo(path string) (*ProductMatch, bool) {
|
||||
if r == nil || r.regex == nil {
|
||||
return fallbackProductMatch(path)
|
||||
}
|
||||
matches := r.regex.FindStringSubmatch(path)
|
||||
if matches != nil {
|
||||
out := &ProductMatch{}
|
||||
for idx, name := range r.regex.SubexpNames() {
|
||||
if idx >= len(matches) || matches[idx] == "" {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "rewrite":
|
||||
out.Slug = matches[idx]
|
||||
case "id", "id_product":
|
||||
out.ID = parseInt64(matches[idx])
|
||||
}
|
||||
}
|
||||
if out.ID != 0 {
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
return fallbackProductMatch(path)
|
||||
}
|
||||
|
||||
func (r *ProductRoute) Owns(path string) bool {
|
||||
match, ok := r.MatchInfo(path)
|
||||
return ok && match.ID != 0
|
||||
}
|
||||
|
||||
func (r *ProductRoute) BuildPath(data ProductURLData) string {
|
||||
rule := defaultProductRule
|
||||
if r != nil {
|
||||
rule = normalizeRule(r.Rule, defaultProductRule)
|
||||
}
|
||||
|
||||
var path strings.Builder
|
||||
path.Grow(len(rule) + len(data.Slug) + len(data.CategoryPath) + len(data.EAN13) + 16)
|
||||
path.WriteString(normalizeLanguagePrefix(data.LanguagePrefix))
|
||||
for i := 0; i < len(rule); {
|
||||
if rule[i] != '{' {
|
||||
path.WriteByte(rule[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end := strings.IndexByte(rule[i:], '}')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
end += i
|
||||
token := rule[i+1 : end]
|
||||
name, before, after := parseToken(token)
|
||||
value := productTokenValue(name, data)
|
||||
if value == "" {
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
path.WriteString(before)
|
||||
path.WriteString(value)
|
||||
path.WriteString(after)
|
||||
i = end + 1
|
||||
}
|
||||
|
||||
result := path.String()
|
||||
if result == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(result, "/") {
|
||||
return "/" + result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeRule(rule, fallback string) string {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
rule = fallback
|
||||
}
|
||||
if !strings.HasPrefix(rule, "/") {
|
||||
rule = "/" + rule
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func staticPrefix(rule string) string {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(rule, "/") {
|
||||
rule = "/" + rule
|
||||
}
|
||||
if idx := strings.IndexByte(rule, '{'); idx >= 0 {
|
||||
prefix := rule[:idx]
|
||||
if prefix == "" {
|
||||
return "/"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func tokenRegex(token string) string {
|
||||
name, before, after := parseToken(token)
|
||||
if name == "category" && after == "/" {
|
||||
return "(?:[^/]+/)+"
|
||||
}
|
||||
if name == "ean13" {
|
||||
pattern := regexp.QuoteMeta(before) + "[^/]*" + regexp.QuoteMeta(after)
|
||||
return "(?:" + pattern + ")?"
|
||||
}
|
||||
pattern := tokenPattern(name)
|
||||
fragment := regexp.QuoteMeta(before) + pattern + regexp.QuoteMeta(after)
|
||||
if name != "rewrite" && strings.Contains(token, ":") {
|
||||
return "(?:" + fragment + ")?"
|
||||
}
|
||||
return fragment
|
||||
}
|
||||
|
||||
func parseToken(token string) (name, before, after string) {
|
||||
known := []string{
|
||||
"id_product_attribute",
|
||||
"id_product",
|
||||
"id_category",
|
||||
"id_manufacturer",
|
||||
"id_supplier",
|
||||
"id_shop",
|
||||
"id_lang",
|
||||
"categories",
|
||||
"category",
|
||||
"rewrite",
|
||||
"ean13",
|
||||
"reference",
|
||||
"meta_title",
|
||||
"manufacturer",
|
||||
"supplier",
|
||||
"price",
|
||||
"id",
|
||||
}
|
||||
sort.SliceStable(known, func(i, j int) bool {
|
||||
return len(known[i]) > len(known[j])
|
||||
})
|
||||
for _, candidate := range known {
|
||||
if idx := strings.Index(token, candidate); idx >= 0 {
|
||||
before = trimTokenAffix(token[:idx])
|
||||
after = trimTokenAffix(token[idx+len(candidate):])
|
||||
return candidate, before, after
|
||||
}
|
||||
}
|
||||
return strings.Trim(token, ":"), "", ""
|
||||
}
|
||||
|
||||
func trimTokenAffix(value string) string {
|
||||
return strings.Trim(value, ":")
|
||||
}
|
||||
|
||||
func tokenPattern(name string) string {
|
||||
switch name {
|
||||
case "rewrite":
|
||||
return "(?P<rewrite>[^/]+)"
|
||||
case "category", "manufacturer", "supplier", "reference", "meta_title":
|
||||
return "[^/]+"
|
||||
case "categories":
|
||||
return "(?:.+?/)?"
|
||||
case "id", "id_product", "id_category", "id_manufacturer", "id_supplier", "id_shop", "id_lang", "id_product_attribute":
|
||||
return "[0-9]+"
|
||||
case "ean13", "price":
|
||||
return "[^/]+"
|
||||
default:
|
||||
return "[^/]+"
|
||||
}
|
||||
}
|
||||
|
||||
func productTokenValue(name string, data ProductURLData) string {
|
||||
switch name {
|
||||
case "id", "id_product":
|
||||
if data.ID == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", data.ID)
|
||||
case "rewrite":
|
||||
return strings.Trim(data.Slug, "/")
|
||||
case "category", "categories":
|
||||
value := strings.Trim(data.CategoryPath, "/")
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
case "id_product_attribute":
|
||||
if data.ProductAttributeID == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", data.ProductAttributeID)
|
||||
case "ean13":
|
||||
return strings.TrimSpace(data.EAN13)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func categoryTokenValue(name string, data CategoryURLData) string {
|
||||
switch name {
|
||||
case "id", "id_category":
|
||||
if data.ID == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", data.ID)
|
||||
case "rewrite":
|
||||
return strings.Trim(data.Slug, "/")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLanguagePrefix(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
value = "/" + value
|
||||
}
|
||||
return strings.TrimRight(value, "/")
|
||||
}
|
||||
|
||||
func fallbackProductSlug(path string) (string, bool) {
|
||||
match, ok := fallbackProductMatch(path)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return match.Slug, true
|
||||
}
|
||||
|
||||
func fallbackProductMatch(path string) (*ProductMatch, bool) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, false
|
||||
}
|
||||
if hasExcludedContentSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
segment := path
|
||||
if lastSlash >= 0 {
|
||||
segment = path[lastSlash+1:]
|
||||
}
|
||||
matches := fallbackProductSegment.FindStringSubmatch(segment)
|
||||
if matches == nil {
|
||||
return nil, false
|
||||
}
|
||||
out := &ProductMatch{}
|
||||
for idx, name := range fallbackProductSegment.SubexpNames() {
|
||||
if idx >= len(matches) || matches[idx] == "" {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "rewrite":
|
||||
out.Slug = matches[idx]
|
||||
case "id":
|
||||
out.ID = parseInt64(matches[idx])
|
||||
}
|
||||
}
|
||||
if out.ID == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func fallbackCategorySlug(path string) (string, bool) {
|
||||
match, ok := fallbackCategoryMatch(path)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return match.Slug, true
|
||||
}
|
||||
|
||||
func fallbackCategoryMatch(path string) (*CategoryMatch, bool) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, false
|
||||
}
|
||||
if hasExcludedContentSegment(path) {
|
||||
return nil, false
|
||||
}
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
segment := path
|
||||
if lastSlash >= 0 {
|
||||
segment = path[lastSlash+1:]
|
||||
}
|
||||
matches := fallbackCategorySegment.FindStringSubmatch(segment)
|
||||
if matches == nil {
|
||||
return nil, false
|
||||
}
|
||||
out := &CategoryMatch{}
|
||||
for idx, name := range fallbackCategorySegment.SubexpNames() {
|
||||
if idx >= len(matches) || matches[idx] == "" {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "rewrite":
|
||||
out.Slug = matches[idx]
|
||||
case "id":
|
||||
out.ID = parseInt64(matches[idx])
|
||||
}
|
||||
}
|
||||
if out.ID == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func parseInt64(value string) int64 {
|
||||
var n int64
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int64(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasExcludedContentSegment(path string) bool {
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
segments := strings.Split(path, "/")
|
||||
start := 0
|
||||
if len(segments) > 0 && len(segments[0]) >= 2 && len(segments[0]) <= 5 {
|
||||
start = 1
|
||||
}
|
||||
for i := start; i < len(segments); i++ {
|
||||
if strings.EqualFold(strings.TrimSpace(segments[i]), "content") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user