Skip to content

Commit

Permalink
Add RouteHandler (#20)
Browse files Browse the repository at this point in the history
* snapshot: start blocking a custom route handler

* snapshot: improved and simpler RouteHandler

* snapshot: start working on docs

* documentation

* format import statements

* only cache matching route patterns, not all request URLs

* update comments

* update RouteHandlerFunc signature to expect contect; context all the things

---------

Co-authored-by: thisisaaronland <devnull@localhost>
Co-authored-by: thisisaaronland <thisisaaronland@localhost>
  • Loading branch information
3 people committed May 30, 2023
1 parent c7e6c6a commit e58669b
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 6 deletions.
3 changes: 2 additions & 1 deletion cmd/example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import (
"context"
"flag"
"fmt"
"github.com/aaronland/go-http-server"
"log"
"net/http"

"github.com/aaronland/go-http-server"
)

func NewHandler() http.Handler {
Expand Down
2 changes: 2 additions & 0 deletions handler/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package handler provides middleware `http.Handler` handlers for use with `server.Server` implementations.
package handler
141 changes: 141 additions & 0 deletions handler/route.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package handler

import (
"context"
"fmt"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
)

// RouteHandlerFunc returns an `http.Handler` instance.
type RouteHandlerFunc func(context.Context) (http.Handler, error)

// RouteHandlerOptions is a struct that contains configuration settings
// for use the RouteHandlerWithOptions method.
type RouteHandlerOptions struct {
// Handlers is a map whose keys are `http.ServeMux` style routing patterns and whose keys
// are functions that when invoked return `http.Handler` instances.
Handlers map[string]RouteHandlerFunc
// Logger is a `log.Logger` instance used for feedback and error-reporting.
Logger *log.Logger
}

// RouteHandler create a new `http.Handler` instance that will serve requests using handlers defined in 'handlers'
// with a `log.Logger` instance that discards all writes. Under the hood this is invoking the `RouteHandlerWithOptions`
// method.
func RouteHandler(handlers map[string]RouteHandlerFunc) (http.Handler, error) {

logger := log.New(io.Discard, "", 0)

opts := &RouteHandlerOptions{
Handlers: handlers,
Logger: logger,
}

return RouteHandlerWithOptions(opts)
}

// RouteHandlerWithOptions create a new `http.Handler` instance that will serve requests using handlers defined
// in 'opts.Handlers'. This is essentially a "middleware" handler than does all the same routing that the default
// `http.ServeMux` handler does but defers initiating the handlers being routed to until they invoked at runtime.
// Only one handler is initialized (or retrieved from an in-memory cache) and served for any given path being by
// a `RouteHandler` request.
//
// The reason this handler exists is for web applications that:
//
// 1. Are deployed as AWS Lambda functions (with an API Gateway integration) using the "lambda://" `server.Server`
// implementation that have more handlers than you need or want to initiate, but never use, for every request.
// 2. You don't want to refactor in to (n) atomic Lambda functions. That is you want to be able to re-use the same
// code in both a plain-vanilla HTTP server configuration as well as Lambda + API Gateway configuration.
func RouteHandlerWithOptions(opts *RouteHandlerOptions) (http.Handler, error) {

matches := new(sync.Map)
patterns := make([]string, 0)

for p, _ := range opts.Handlers {
patterns = append(patterns, p)
}

// Sort longest to shortest
// https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/net/http/server.go;l=2533

sort.Slice(patterns, func(i, j int) bool {
return len(patterns[i]) > len(patterns[j])
})

fn := func(rsp http.ResponseWriter, req *http.Request) {

handler, err := deriveHandler(req, opts.Handlers, matches, patterns)

if err != nil {
opts.Logger.Printf("%v", err)
http.Error(rsp, "Internal server error", http.StatusInternalServerError)
return
}

if handler == nil {
http.Error(rsp, "Not found", http.StatusNotFound)
return
}

handler.ServeHTTP(rsp, req)
return
}

return http.HandlerFunc(fn), nil
}

func deriveHandler(req *http.Request, handlers map[string]RouteHandlerFunc, matches *sync.Map, patterns []string) (http.Handler, error) {

ctx := req.Context()
path := req.URL.Path

// Basically do what the default http.ServeMux does but inflate the
// handler (func) on demand at run-time. Handler is cached above.
// https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/net/http/server.go;l=2363

var matching_pattern string

for _, p := range patterns {
if strings.HasPrefix(path, p) {
matching_pattern = p
break
}
}

if matching_pattern == "" {
return nil, nil
}

var handler http.Handler

v, exists := matches.Load(matching_pattern)

if exists {
handler = v.(http.Handler)
} else {

handler_func, ok := handlers[matching_pattern]

// Don't fill up the matches cache with 404 handlers

if !ok {
return nil, nil
}

h, err := handler_func(ctx)

if err != nil {
return nil, fmt.Errorf("Failed to instantiate handler func for '%s' matching '%s', %v", path, matching_pattern, err)
}

handler = h
matches.Store(matching_pattern, handler)
}

return handler, nil
}
89 changes: 89 additions & 0 deletions handler/route_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package handler

import (
"context"
_ "fmt"
"io"
"net/http"
"testing"

"github.com/aaronland/go-http-server"
)

func TestRouteHandler(t *testing.T) {

ctx := context.Background()

foo_func := func(ctx context.Context) (http.Handler, error) {

fn := func(rsp http.ResponseWriter, req *http.Request) {
rsp.Write([]byte(`foo`))
return
}

return http.HandlerFunc(fn), nil
}

bar_func := func(ctx context.Context) (http.Handler, error) {

fn := func(rsp http.ResponseWriter, req *http.Request) {
rsp.Write([]byte(`bar`))
return
}

return http.HandlerFunc(fn), nil
}

handlers := map[string]RouteHandlerFunc{
"/foo": foo_func,
"/foo/bar": bar_func,
}

route_handler, err := RouteHandler(handlers)

if err != nil {
t.Fatal(err)
}

mux := http.NewServeMux()
mux.Handle("/", route_handler)

s, err := server.NewServer(ctx, "http://localhost:8080")

if err != nil {
t.Fatal(err)
}

go func() {
s.ListenAndServe(ctx, mux)
}()

tests := map[string]string{
"http://localhost:8080/foo": "foo",
"http://localhost:8080/foo/": "foo",
"http://localhost:8080/foo/bar": "bar",
}

for uri, expected := range tests {

rsp, err := http.Get(uri)

if err != nil {
t.Fatalf("Failed to get %s, %v", uri, err)
}

defer rsp.Body.Close()

body, err := io.ReadAll(rsp.Body)

if err != nil {
t.Fatalf("Failed to read body for %s, %v", uri, err)
}

str_body := string(body)

if str_body != expected {
t.Fatalf("Unexpected value for %s. Expected '%s' but got '%s'", uri, expected, str_body)
}
}
}
2 changes: 1 addition & 1 deletion http.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
func init() {
ctx := context.Background()
RegisterServer(ctx, "http", NewHTTPServer)
RegisterServer(ctx, "https", NewHTTPServer)
RegisterServer(ctx, "https", NewHTTPServer)
}

// HTTPServer implements the `Server` interface for a basic `net/http` server.
Expand Down
3 changes: 2 additions & 1 deletion lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package server
import (
"context"
"fmt"
"github.com/akrylysov/algnhsa"
_ "log"
"net/http"
"net/url"

"github.com/akrylysov/algnhsa"
)

func init() {
Expand Down
4 changes: 2 additions & 2 deletions mkcert.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ func init() {
// mkcert://?{PARAMETERS}
//
// Valid parameters are:
// * `root={PATH}` An optional path to specify where `mkcert` certificates and keys should be created. If missing
// the operating system's temporary directory will be used.
// - `root={PATH}` An optional path to specify where `mkcert` certificates and keys should be created. If missing
// the operating system's temporary directory will be used.
func NewMkCertServer(ctx context.Context, uri string) (Server, error) {

u, err := url.Parse(uri)
Expand Down
3 changes: 2 additions & 1 deletion server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package server
import (
"context"
"fmt"
"github.com/aaronland/go-roster"
_ "log"
"net/http"
"net/url"
"sort"

"github.com/aaronland/go-roster"
)

// type Server is an interface for creating server instances that serve requests using a `http.Handler` router.
Expand Down

0 comments on commit e58669b

Please sign in to comment.