diff --git a/Context.go b/Context.go index 46b3b8c..7afb303 100644 --- a/Context.go +++ b/Context.go @@ -9,6 +9,7 @@ type Context interface { Bytes([]byte) error Error(...any) error Next() error + Redirect(int, string) error Request() Request Response() Response Status(int) Context @@ -51,6 +52,14 @@ func (ctx *context) Next() error { return ctx.server.handlers[ctx.handlerCount](ctx) } +// Redirect redirects the client to a different location +// with the specified status code. +func (ctx *context) Redirect(status int, location string) error { + ctx.response.SetStatus(status) + ctx.response.SetHeader("Location", location) + return nil +} + // Request returns the HTTP request. func (ctx *context) Request() Request { return &ctx.request diff --git a/Context_test.go b/Context_test.go index 05bc24f..b3a6005 100644 --- a/Context_test.go +++ b/Context_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/web" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/web" ) func TestBytes(t *testing.T) { @@ -55,3 +55,15 @@ func TestErrorMultiple(t *testing.T) { assert.Equal(t, response.Status(), 401) assert.Equal(t, string(response.Body()), "") } + +func TestRedirect(t *testing.T) { + s := web.NewServer() + + s.Get("/", func(ctx web.Context) error { + return ctx.Redirect(301, "/target") + }) + + response := s.Request("GET", "/", nil, nil) + assert.Equal(t, response.Status(), 301) + assert.Equal(t, response.Header("Location"), "/target") +} diff --git a/README.md b/README.md index 9b912f2..149eb93 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # web -A fast HTTP/1.1 web server that can sit behind a reverse proxy like `caddy` or `nginx` for HTTP 1/2/3 support. +A minimal HTTP/1.1 web server that sits behind a reverse proxy like `caddy` or `nginx` for HTTP 1/2/3 support. ## Features @@ -11,7 +11,7 @@ A fast HTTP/1.1 web server that can sit behind a reverse proxy like `caddy` or ` ## Installation ```shell -go get git.akyoto.dev/go/web +go get git.urbach.dev/go/web ``` ## Usage @@ -26,12 +26,23 @@ s.Get("/", func(ctx web.Context) error { // Parameter route s.Get("/blog/:post", func(ctx web.Context) error { - return ctx.String(ctx.Get("post")) + return ctx.String(ctx.Request().Param("post")) }) // Wildcard route s.Get("/images/*file", func(ctx web.Context) error { - return ctx.String(ctx.Get("file")) + return ctx.String(ctx.Request().Param("file")) +}) + +// Middleware +s.Use(func(ctx web.Context) error { + start := time.Now() + + defer func() { + fmt.Println(ctx.Request().Path(), time.Since(start)) + }() + + return ctx.Next() }) s.Run(":8080") @@ -44,6 +55,7 @@ PASS: TestBytes PASS: TestString PASS: TestError PASS: TestErrorMultiple +PASS: TestRedirect PASS: TestRequest PASS: TestRequestHeader PASS: TestRequestParam @@ -57,7 +69,9 @@ PASS: TestRun PASS: TestBadRequest PASS: TestBadRequestHeader PASS: TestBadRequestMethod +PASS: TestBadRequestPath PASS: TestBadRequestProtocol +PASS: TestConnectionClose PASS: TestEarlyClose PASS: TestUnavailablePort coverage: 100.0% of statements @@ -69,7 +83,7 @@ coverage: 100.0% of statements ## License -Please see the [license documentation](https://akyoto.dev/license). +Please see the [license documentation](https://urbach.dev/license). ## Copyright diff --git a/Request.go b/Request.go index 9ec8ef9..6c7ef3c 100644 --- a/Request.go +++ b/Request.go @@ -3,7 +3,7 @@ package web import ( "bufio" - "git.akyoto.dev/go/router" + "git.urbach.dev/go/router" ) // Request is an interface for HTTP requests. diff --git a/Request_test.go b/Request_test.go index 6a08fd0..1e079a8 100644 --- a/Request_test.go +++ b/Request_test.go @@ -4,8 +4,8 @@ import ( "fmt" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/web" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/web" ) func TestRequest(t *testing.T) { diff --git a/Response_test.go b/Response_test.go index dc62965..9028acf 100644 --- a/Response_test.go +++ b/Response_test.go @@ -6,8 +6,8 @@ import ( "io" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/web" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/web" ) func TestWrite(t *testing.T) { diff --git a/Server.go b/Server.go index f987c51..ba163a0 100644 --- a/Server.go +++ b/Server.go @@ -13,7 +13,7 @@ import ( "sync" "syscall" - "git.akyoto.dev/go/router" + "git.urbach.dev/go/router" ) // Server is the interface for an HTTP server. @@ -121,6 +121,7 @@ func (s *server) handleConnection(conn net.Conn) { ctx = s.contextPool.Get().(*context) method string url string + close bool ) ctx.reader.Reset(conn) @@ -128,7 +129,7 @@ func (s *server) handleConnection(conn net.Conn) { defer conn.Close() defer s.contextPool.Put(ctx) - for { + for !close { // Read the HTTP request line message, err := ctx.reader.ReadString('\n') @@ -156,7 +157,14 @@ func (s *server) handleConnection(conn net.Conn) { lastSpace = len(message) - len("\r\n") } - url = message[space+1 : lastSpace] + space += 1 + + if space > lastSpace { + io.WriteString(conn, "HTTP/1.1 400 Bad Request\r\n\r\n") + return + } + + url = message[space:lastSpace] // Add headers until we meet an empty line for { @@ -176,6 +184,10 @@ func (s *server) handleConnection(conn net.Conn) { continue } + if colon > len(message)-4 { + continue + } + key := message[:colon] value := message[colon+2 : len(message)-2] @@ -183,6 +195,10 @@ func (s *server) handleConnection(conn net.Conn) { Key: key, Value: value, }) + + if value == "close" && strings.EqualFold(key, "connection") { + close = true + } } // Handle the request diff --git a/Server_test.go b/Server_test.go index 9793aef..f2c142b 100644 --- a/Server_test.go +++ b/Server_test.go @@ -7,8 +7,8 @@ import ( "syscall" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/web" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/web" ) func TestPanic(t *testing.T) { @@ -77,7 +77,7 @@ func TestBadRequestHeader(t *testing.T) { assert.Nil(t, err) defer conn.Close() - _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nBadHeader\r\nGood: Header\r\n\r\n") + _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nBad\r\nBad:\r\nGood: Header\r\n\r\n") assert.Nil(t, err) buffer := make([]byte, len("HTTP/1.1 200")) @@ -110,6 +110,27 @@ func TestBadRequestMethod(t *testing.T) { s.Run(":8080") } +func TestBadRequestPath(t *testing.T) { + s := web.NewServer() + + go func() { + defer syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + + conn, err := net.Dial("tcp", ":8080") + assert.Nil(t, err) + defer conn.Close() + + _, err = io.WriteString(conn, "GET \n") + assert.Nil(t, err) + + response, err := io.ReadAll(conn) + assert.Nil(t, err) + assert.Equal(t, string(response), "HTTP/1.1 400 Bad Request\r\n\r\n") + }() + + s.Run(":8080") +} + func TestBadRequestProtocol(t *testing.T) { s := web.NewServer() @@ -136,6 +157,26 @@ func TestBadRequestProtocol(t *testing.T) { s.Run(":8080") } +func TestConnectionClose(t *testing.T) { + s := web.NewServer() + + go func() { + defer syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + + conn, err := net.Dial("tcp", ":8080") + assert.Nil(t, err) + defer conn.Close() + + _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nConnection: close\r\n\r\n") + assert.Nil(t, err) + + _, err = io.ReadAll(conn) + assert.Nil(t, err) + }() + + s.Run(":8080") +} + func TestEarlyClose(t *testing.T) { s := web.NewServer() diff --git a/examples/hello/main.go b/examples/hello/main.go index c9853da..b42db84 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -1,7 +1,7 @@ package main import ( - "git.akyoto.dev/go/web" + "git.urbach.dev/go/web" ) func main() { diff --git a/go.mod b/go.mod index a4d4a7c..f2bf46e 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ -module git.akyoto.dev/go/web +module git.urbach.dev/go/web -go 1.22 +go 1.24 require ( - git.akyoto.dev/go/assert v0.1.3 - git.akyoto.dev/go/router v0.1.4 + git.urbach.dev/go/assert v0.0.0-20250225153414-fc1f84f19edf + git.urbach.dev/go/router v0.0.0-20250601162231-e35d5715d1a5 ) diff --git a/go.sum b/go.sum index a12e1ec..4c3db0c 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= -git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= -git.akyoto.dev/go/router v0.1.4 h1:ZL5HPl4aNn4QKihf3VVs0Mm9R6ZGn2StAHGRQxjEbws= -git.akyoto.dev/go/router v0.1.4/go.mod h1:rbHbkLJlQOafuOuvBalO3O8E0JtMFPT3zzTKX3h9T08= +git.urbach.dev/go/assert v0.0.0-20250225153414-fc1f84f19edf h1:BQWa5GKNUsA5CSUa/+UlFWYCEVe3IDDKRbVqBLK0mAE= +git.urbach.dev/go/assert v0.0.0-20250225153414-fc1f84f19edf/go.mod h1:y9jGII9JFiF1HNIju0u87OyPCt82xKCtqnAFyEreCDo= +git.urbach.dev/go/router v0.0.0-20250601162231-e35d5715d1a5 h1:3qlZjgWbrHw4LWM4uBzOTldbpqCLJdeSgvcYK6f3xpc= +git.urbach.dev/go/router v0.0.0-20250601162231-e35d5715d1a5/go.mod h1:O+doTe0DZdT2XMsTY5pe6qUMqEEIAbwtX6Xp6EqHw34= diff --git a/content/content.go b/send/send.go similarity index 97% rename from content/content.go rename to send/send.go index 2eca461..9c4c2ca 100644 --- a/content/content.go +++ b/send/send.go @@ -1,9 +1,9 @@ -package content +package send import ( "encoding/json" - "git.akyoto.dev/go/web" + "git.urbach.dev/go/web" ) // CSS sends the body with the content type set to `text/css`. diff --git a/content/content_test.go b/send/send_test.go similarity index 79% rename from content/content_test.go rename to send/send_test.go index 5d4d2ca..fec22c3 100644 --- a/content/content_test.go +++ b/send/send_test.go @@ -1,42 +1,42 @@ -package content_test +package send_test import ( "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/web" - "git.akyoto.dev/go/web/content" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/web" + "git.urbach.dev/go/web/send" ) func TestContentTypes(t *testing.T) { s := web.NewServer() s.Get("/css", func(ctx web.Context) error { - return content.CSS(ctx, "body{}") + return send.CSS(ctx, "body{}") }) s.Get("/csv", func(ctx web.Context) error { - return content.CSV(ctx, "ID;Name\n") + return send.CSV(ctx, "ID;Name\n") }) s.Get("/html", func(ctx web.Context) error { - return content.HTML(ctx, "") + return send.HTML(ctx, "") }) s.Get("/js", func(ctx web.Context) error { - return content.JS(ctx, "console.log(42)") + return send.JS(ctx, "console.log(42)") }) s.Get("/json", func(ctx web.Context) error { - return content.JSON(ctx, struct{ Name string }{Name: "User 1"}) + return send.JSON(ctx, struct{ Name string }{Name: "User 1"}) }) s.Get("/text", func(ctx web.Context) error { - return content.Text(ctx, "Hello") + return send.Text(ctx, "Hello") }) s.Get("/xml", func(ctx web.Context) error { - return content.XML(ctx, "") + return send.XML(ctx, "") }) tests := []struct {