feat: standardize transport contracts across Gin and nethttp

This commit is contained in:
2026-09-21 00:10:44 +08:00
parent affd02d5f7
commit 5561795d80
22 changed files with 1137 additions and 205 deletions

View File

@@ -0,0 +1,119 @@
// Package routing defines registration rules shared by the built-in adapters.
// Request dispatch remains the responsibility of each adapter.
package routing
import (
"net/http"
"strings"
)
var Methods = [...]string{
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodHead, http.MethodOptions, http.MethodDelete, http.MethodConnect, http.MethodTrace,
}
type Segment struct {
Literal string
Name string
Wildcard bool
}
// Parse accepts absolute paths, whole-segment parameters and a terminal
// catch-all. It rejects ambiguous patterns before an adapter registers them.
func Parse(method, path string, handler http.Handler) []Segment {
if handler == nil {
panic("go-omni: nil route handler")
}
if method == "" || strings.IndexFunc(method, func(r rune) bool { return r < 'A' || r > 'Z' }) >= 0 {
panic("go-omni: route method must contain uppercase letters")
}
if path == "" || path[0] != '/' {
panic("go-omni: route path must start with /")
}
parts := strings.Split(path[1:], "/")
segments := make([]Segment, len(parts))
names := make(map[string]bool)
for i, part := range parts {
if part == "." || part == ".." || strings.ContainsAny(part, "?#") || (part == "" && i != len(parts)-1) {
panic("go-omni: invalid route path " + path)
}
if strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*") {
name := part[1:]
if name == "" || strings.ContainsAny(name, ":*") || names[name] {
panic("go-omni: route parameters must have unique, non-empty names")
}
wildcard := part[0] == '*'
if wildcard && i != len(parts)-1 {
panic("go-omni: catch-all must be the final segment")
}
names[name] = true
segments[i] = Segment{Name: name, Wildcard: wildcard}
} else {
if strings.ContainsAny(part, ":*") {
panic("go-omni: parameters must occupy a whole path segment")
}
segments[i] = Segment{Literal: part}
}
}
return segments
}
// CheckConflict enforces Gin-compatible ambiguity rules for one method.
func CheckConflict(existing, next []Segment) {
for i := 0; i < len(existing) && i < len(next); i++ {
a, b := existing[i], next[i]
if a.Wildcard || b.Wildcard {
panic("go-omni: catch-all conflicts with an existing route")
}
if a.Name != "" && b.Name != "" {
if a.Name != b.Name {
panic("go-omni: conflicting parameter names at the same route prefix")
}
continue
}
if a != b {
return
}
}
if len(existing) == len(next) {
panic("go-omni: duplicate route")
}
}
// Join supports relative group paths while preserving a final slash.
func Join(prefix, path string) string {
if path == "" {
if prefix == "" {
return "/"
}
return prefix
}
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
}
// Match uses the decoded URL.Path without slash normalization or redirects.
func Match(segments []Segment, path string) (map[string]string, bool) {
if !strings.HasPrefix(path, "/") {
return nil, false
}
parts := strings.Split(path[1:], "/")
params := make(map[string]string)
for i, current := range segments {
if i >= len(parts) {
return nil, false
}
if current.Wildcard {
params[current.Name] = "/" + strings.Join(parts[i:], "/")
return params, true
}
if current.Name != "" {
if parts[i] == "" {
return nil, false
}
params[current.Name] = parts[i]
} else if current.Literal != parts[i] {
return nil, false
}
}
return params, len(segments) == len(parts)
}