Skip to content

Commit ff3182a

Browse files
committed
feat: implement opinionated router
1 parent 4d6740e commit ff3182a

7 files changed

Lines changed: 544 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ require (
66
github.com/google/uuid v1.6.0
77
github.com/jmoiron/sqlx v1.4.0
88
github.com/json-iterator/go v1.1.12
9+
github.com/julienschmidt/httprouter v1.3.0
910
github.com/justinas/alice v1.2.0
1011
github.com/kernle32dll/emissione-go v1.1.0
1112
github.com/kernle32dll/keybox-go v1.2.0
13+
github.com/klauspost/compress v1.18.1
1214
github.com/lestrrat-go/jwx/v3 v3.0.12
15+
github.com/rs/cors v1.11.1
1316
github.com/rs/zerolog v1.34.0
1417
github.com/stretchr/testify v1.11.1
1518
go.opentelemetry.io/otel v1.38.0

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
2525
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
2626
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
2727
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
28+
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
29+
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
2830
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
2931
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
3032
github.com/kernle32dll/emissione-go v1.1.0 h1:ecsp58tVs8sJA0FJftIVHHPv/hagtjQJuih8VP2i+ao=
3133
github.com/kernle32dll/emissione-go v1.1.0/go.mod h1:h3zrmXUggdVPQW7hHv0WUGHoO4VwTieXvUpC/Go95kE=
3234
github.com/kernle32dll/keybox-go v1.2.0 h1:4bfv3uilJi8y971G2m62W2NV+n9OoYryT5Z9ULgzT6Q=
3335
github.com/kernle32dll/keybox-go v1.2.0/go.mod h1:+avlBw/jrVKyR/tHaWsA8YMT9zLsbnhPqmZH+a94sRY=
36+
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
37+
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
3438
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3539
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3640
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -72,6 +76,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
7276
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7377
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
7478
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
79+
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
80+
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
7581
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
7682
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
7783
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=

router.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package turtleware
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"sync"
7+
"time"
8+
9+
"github.com/julienschmidt/httprouter"
10+
"github.com/justinas/alice"
11+
"github.com/klauspost/compress/gzhttp"
12+
"github.com/rs/cors"
13+
"github.com/rs/zerolog"
14+
"go.opentelemetry.io/otel"
15+
"go.opentelemetry.io/otel/propagation"
16+
"go.opentelemetry.io/otel/trace"
17+
)
18+
19+
type endpointType string
20+
21+
type endpoint struct {
22+
methods []string
23+
exposedHeaders []string
24+
allowedHeaders []string
25+
}
26+
27+
const (
28+
endpointTypeEntity = "entity"
29+
endpointTypeList = "list"
30+
endpointTypeUpdate = "update"
31+
endpointTypeCreate = "create"
32+
endpointTypeReplace = "replace"
33+
)
34+
35+
var endpointMapping = map[endpointType]endpoint{
36+
endpointTypeEntity: {methods: []string{http.MethodGet, http.MethodHead}},
37+
endpointTypeList: {methods: []string{http.MethodGet, http.MethodHead}, exposedHeaders: []string{"X-Total-Count"}},
38+
endpointTypeUpdate: {methods: []string{http.MethodPatch}, allowedHeaders: []string{"Content-Type", "If-Unmodified-Since"}},
39+
endpointTypeCreate: {methods: []string{http.MethodPost}, allowedHeaders: []string{"Content-Type"}},
40+
endpointTypeReplace: {methods: []string{http.MethodPut}, allowedHeaders: []string{"Content-Type"}},
41+
}
42+
43+
type Router struct {
44+
endpoints map[string]map[endpointType]http.Handler
45+
middlewares []alice.Constructor
46+
prefix string
47+
mutex *sync.Mutex
48+
}
49+
50+
func NewRouter(prefix string) *Router {
51+
return &Router{
52+
endpoints: make(map[string]map[endpointType]http.Handler),
53+
prefix: prefix,
54+
mutex: &sync.Mutex{},
55+
}
56+
}
57+
58+
func (r *Router) addEndpoint(path string, epT endpointType, h http.Handler) {
59+
r.mutex.Lock()
60+
defer r.mutex.Unlock()
61+
62+
if _, exists := r.endpoints[path]; !exists {
63+
r.endpoints[path] = map[endpointType]http.Handler{}
64+
}
65+
66+
r.endpoints[path][epT] = h
67+
}
68+
69+
func (r *Router) Entity(path string, h http.Handler) {
70+
r.addEndpoint(path, endpointTypeEntity, h)
71+
}
72+
73+
func (r *Router) List(path string, h http.Handler) {
74+
r.addEndpoint(path, endpointTypeList, h)
75+
}
76+
77+
func (r *Router) Update(path string, h http.Handler) {
78+
r.addEndpoint(path, endpointTypeUpdate, h)
79+
}
80+
81+
func (r *Router) Create(path string, h http.Handler) {
82+
r.addEndpoint(path, endpointTypeCreate, h)
83+
}
84+
85+
func (r *Router) Replace(path string, h http.Handler) {
86+
r.addEndpoint(path, endpointTypeReplace, h)
87+
}
88+
89+
func (r *Router) AddMiddleware(constructor alice.Constructor) {
90+
r.mutex.Lock()
91+
defer r.mutex.Unlock()
92+
93+
r.middlewares = append(r.middlewares, constructor)
94+
}
95+
96+
func (r *Router) Build() http.Handler {
97+
r.mutex.Lock()
98+
defer r.mutex.Unlock()
99+
100+
rtr := httprouter.New()
101+
102+
rtr.PanicHandler = func(w http.ResponseWriter, r *http.Request, panic interface{}) {
103+
wireCtx := propagation.TraceContext{}.Extract(
104+
r.Context(),
105+
propagation.HeaderCarrier(r.Header),
106+
)
107+
if spanContext := trace.SpanContextFromContext(wireCtx); !spanContext.HasTraceID() && !spanContext.HasSpanID() {
108+
logger := zerolog.Ctx(r.Context())
109+
logger.Trace().Msg("Missing span context")
110+
}
111+
112+
spanCtx, span := otel.Tracer(TracerName).Start(wireCtx, "Panic")
113+
defer span.End()
114+
115+
// Update logger in context to use correct span
116+
logger := WrapZerologTracing(spanCtx)
117+
spanCtx = logger.WithContext(spanCtx)
118+
119+
panicErr := fmt.Errorf("panic in http handler: %v", panic)
120+
WriteError(spanCtx, w, r, http.StatusInternalServerError, panicErr)
121+
}
122+
123+
loggingOptions := []LoggingOption{
124+
LogHeaders(true),
125+
LogHeaderBlacklist("Authorization"),
126+
}
127+
128+
middleware := alice.New(
129+
func(handler http.Handler) http.Handler {
130+
return gzhttp.GzipHandler(handler)
131+
},
132+
RequestTimingMiddleware(),
133+
RequestLoggerMiddleware(loggingOptions...),
134+
)
135+
136+
if len(r.middlewares) > 0 {
137+
middleware.Append(
138+
r.middlewares...,
139+
)
140+
}
141+
142+
rtr.NotFound = middleware.Then(RequestNotFoundHandler(loggingOptions...))
143+
rtr.MethodNotAllowed = middleware.Then(RequestNotAllowedHandler(loggingOptions...))
144+
145+
corsMiddleware := map[string]alice.Chain{}
146+
for path, corsHandler := range r.assembleCors() {
147+
corsMiddleware[path] = middleware.Append(corsHandler.Handler)
148+
149+
rtr.Handler(http.MethodOptions, r.prefix+path, middleware.ThenFunc(corsHandler.HandlerFunc))
150+
}
151+
152+
for path, endpointTypes := range r.endpoints {
153+
for epType, handler := range endpointTypes {
154+
for _, method := range endpointMapping[epType].methods {
155+
rtr.Handler(method, r.prefix+path, corsMiddleware[path].Then(handler))
156+
}
157+
}
158+
}
159+
160+
return rtr
161+
}
162+
163+
func (r *Router) assembleCors() map[string]*cors.Cors {
164+
corsHandlers := make(map[string]*cors.Cors, len(r.endpoints))
165+
166+
alwaysExposedHeaders := []string{
167+
// Tracing
168+
"Uber-Trace-Id",
169+
170+
// Response style
171+
"Accept",
172+
}
173+
174+
alwaysAllowedHeaders := []string{
175+
"Authorization",
176+
}
177+
178+
for path, endpointTypes := range r.endpoints {
179+
// Note, len of endpointTypes must not necessarily match methods, so it's a best guess
180+
allowedMethods := make(map[string]struct{}, len(endpointTypes))
181+
allowedHeaders := make(map[string]struct{})
182+
exposedHeaders := make(map[string]struct{})
183+
for epType := range endpointTypes {
184+
ep := endpointMapping[epType]
185+
186+
allowedMethods = addSliceToSet(allowedMethods, endpointMapping[epType].methods)
187+
allowedHeaders = addSliceToSet(allowedHeaders, ep.allowedHeaders)
188+
exposedHeaders = addSliceToSet(exposedHeaders, ep.exposedHeaders)
189+
}
190+
191+
corsHandlers[path] = cors.New(cors.Options{
192+
AllowOriginFunc: func(origin string) bool {
193+
return true
194+
},
195+
AllowedMethods: keySet(allowedMethods),
196+
AllowCredentials: true,
197+
AllowedHeaders: append(alwaysAllowedHeaders, keySet(allowedHeaders)...),
198+
ExposedHeaders: append(alwaysExposedHeaders, keySet(exposedHeaders)...),
199+
MaxAge: int(time.Hour * 24 / time.Second),
200+
})
201+
}
202+
203+
return corsHandlers
204+
}
205+
206+
func keySet[K comparable, V any](m map[K]V) []K {
207+
keys := make([]K, 0, len(m))
208+
for k := range m {
209+
keys = append(keys, k)
210+
}
211+
return keys
212+
}
213+
214+
func addSliceToSet[T comparable](set map[T]struct{}, array []T) map[T]struct{} {
215+
for _, item := range array {
216+
set[item] = struct{}{}
217+
}
218+
return set
219+
}

0 commit comments

Comments
 (0)