Compare commits

..

2 Commits

Author SHA1 Message Date
5a3ed49ea4 fixed panic if loadfunc returns nil with no error 2026-01-24 15:17:19 +11:00
2f49063432 added multiple method support for routes 2026-01-24 14:44:38 +11:00
6 changed files with 169 additions and 11 deletions

View File

@@ -51,6 +51,12 @@ func main() {
Method: hws.MethodGET,
Handler: http.HandlerFunc(getUserHandler),
},
{
// Single route handling multiple HTTP methods
Path: "/api/resource",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST, hws.MethodPUT},
Handler: http.HandlerFunc(resourceHandler),
},
}
// Add routes and middleware
@@ -73,6 +79,18 @@ func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.Write([]byte("User ID: " + id))
}
func resourceHandler(w http.ResponseWriter, r *http.Request) {
// Handle GET, POST, and PUT for the same path
switch r.Method {
case "GET":
w.Write([]byte("Getting resource"))
case "POST":
w.Write([]byte("Creating resource"))
case "PUT":
w.Write([]byte("Updating resource"))
}
}
```
## Documentation

View File

@@ -74,6 +74,18 @@
// },
// }
//
// A single route can handle multiple HTTP methods using the Methods field:
//
// routes := []hws.Route{
// {
// Path: "/api/resource",
// Methods: []hws.Method{hws.MethodGET, hws.MethodPOST, hws.MethodPUT},
// Handler: http.HandlerFunc(resourceHandler),
// },
// }
//
// Note: The Methods field takes precedence over Method if both are provided.
//
// Path parameters can be accessed using r.PathValue():
//
// func getUser(w http.ResponseWriter, r *http.Request) {

View File

@@ -4,11 +4,15 @@ import (
"errors"
"fmt"
"net/http"
"slices"
)
type Route struct {
Path string // Absolute path to the requested resource
Method Method // HTTP Method
Path string // Absolute path to the requested resource
Method Method // HTTP Method
// Methods is an optional slice of Methods to use, if more than one can use the same handler.
// Will take precedence over the Method field if provided
Methods []Method
Handler http.Handler // Handler to use for the request
}
@@ -28,21 +32,33 @@ const (
// Server.AddRoutes registers the page handlers for the server.
// At least one route must be provided.
// If any route patterns (path + method) are defined multiple times, the first
// instance will be added and any additional conflicts will be discarded.
func (server *Server) AddRoutes(routes ...Route) error {
if len(routes) == 0 {
return errors.New("No routes provided")
}
patterns := []string{}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(http.ResponseWriter, *http.Request) {})
for _, route := range routes {
if !validMethod(route.Method) {
return fmt.Errorf("Invalid method %s for path %s", route.Method, route.Path)
if len(route.Methods) == 0 {
route.Methods = []Method{route.Method}
}
if route.Handler == nil {
return fmt.Errorf("No handler provided for %s %s", route.Method, route.Path)
for _, method := range route.Methods {
if !validMethod(method) {
return fmt.Errorf("Invalid method %s for path %s", method, route.Path)
}
if route.Handler == nil {
return fmt.Errorf("No handler provided for %s %s", method, route.Path)
}
pattern := fmt.Sprintf("%s %s", method, route.Path)
if slices.Contains(patterns, pattern) {
continue
}
patterns = append(patterns, pattern)
mux.Handle(pattern, route.Handler)
}
pattern := fmt.Sprintf("%s %s", route.Method, route.Path)
mux.Handle(pattern, route.Handler)
}
server.server.Handler = mux

View File

@@ -122,6 +122,111 @@ func Test_AddRoutes(t *testing.T) {
})
}
func Test_AddRoutes_MultipleMethods(t *testing.T) {
var buf bytes.Buffer
t.Run("Single route with multiple methods", func(t *testing.T) {
server := createTestServer(t, &buf)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(r.Method + " response"))
})
err := server.AddRoutes(hws.Route{
Path: "/api/resource",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST, hws.MethodPUT},
Handler: handler,
})
require.NoError(t, err)
// Test GET request
req := httptest.NewRequest("GET", "/api/resource", nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "GET response", rr.Body.String())
// Test POST request
req = httptest.NewRequest("POST", "/api/resource", nil)
rr = httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "POST response", rr.Body.String())
// Test PUT request
req = httptest.NewRequest("PUT", "/api/resource", nil)
rr = httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "PUT response", rr.Body.String())
})
t.Run("Methods field takes precedence over Method field", func(t *testing.T) {
server := createTestServer(t, &buf)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
err := server.AddRoutes(hws.Route{
Path: "/test",
Method: hws.MethodGET, // This should be ignored
Methods: []hws.Method{hws.MethodPOST, hws.MethodPUT},
Handler: handler,
})
require.NoError(t, err)
// GET should not work (Method field ignored)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
// POST should work (from Methods field)
req = httptest.NewRequest("POST", "/test", nil)
rr = httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// PUT should work (from Methods field)
req = httptest.NewRequest("PUT", "/test", nil)
rr = httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
})
t.Run("Invalid method in Methods slice", func(t *testing.T) {
server := createTestServer(t, &buf)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
err := server.AddRoutes(hws.Route{
Path: "/test",
Methods: []hws.Method{hws.MethodGET, hws.Method("INVALID")},
Handler: handler,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid method")
})
t.Run("Empty Methods slice falls back to Method field", func(t *testing.T) {
server := createTestServer(t, &buf)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
err := server.AddRoutes(hws.Route{
Path: "/test",
Method: hws.MethodGET,
Methods: []hws.Method{}, // Empty slice
Handler: handler,
})
require.NoError(t, err)
// GET should work (from Method field)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
})
}
func Test_Routes_EndToEnd(t *testing.T) {
var buf bytes.Buffer
server := createTestServer(t, &buf)
@@ -146,7 +251,7 @@ func Test_Routes_EndToEnd(t *testing.T) {
req := httptest.NewRequest("GET", "/get", nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "GET response", rr.Body.String())
@@ -154,7 +259,7 @@ func Test_Routes_EndToEnd(t *testing.T) {
req = httptest.NewRequest("POST", "/post", nil)
rr = httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, "POST response", rr.Body.String())
}

View File

@@ -2,6 +2,7 @@ package hwsauth
import (
"net/http"
"reflect"
"time"
"git.haelnorr.com/h/golib/jwt"
@@ -45,6 +46,9 @@ func (auth *Authenticator[T, TX]) getAuthenticatedUser(
if err != nil {
return authenticatedModel[T]{}, errors.Wrap(err, "auth.load")
}
if reflect.ValueOf(model).IsNil() {
return authenticatedModel[T]{}, errors.New("no user matching JWT in database")
}
authUser := authenticatedModel[T]{
model: model,
fresh: aT.Fresh,

View File

@@ -2,6 +2,7 @@ package hwsauth
import (
"net/http"
"reflect"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
@@ -18,7 +19,9 @@ func (auth *Authenticator[T, TX]) refreshAuthTokens(
if err != nil {
return getNil[T](), errors.Wrap(err, "auth.load")
}
if reflect.ValueOf(model).IsNil() {
return getNil[T](), errors.New("no user matching JWT in database")
}
rememberMe := map[string]bool{
"session": false,
"exp": true,