Compare commits

...

10 commits

13 changed files with 132 additions and 40 deletions

View file

@ -9,6 +9,7 @@ type Context interface {
Bytes([]byte) error Bytes([]byte) error
Error(...any) error Error(...any) error
Next() error Next() error
Redirect(int, string) error
Request() Request Request() Request
Response() Response Response() Response
Status(int) Context Status(int) Context
@ -51,6 +52,14 @@ func (ctx *context) Next() error {
return ctx.server.handlers[ctx.handlerCount](ctx) 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. // Request returns the HTTP request.
func (ctx *context) Request() Request { func (ctx *context) Request() Request {
return &ctx.request return &ctx.request

View file

@ -4,8 +4,8 @@ import (
"errors" "errors"
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
) )
func TestBytes(t *testing.T) { func TestBytes(t *testing.T) {
@ -55,3 +55,15 @@ func TestErrorMultiple(t *testing.T) {
assert.Equal(t, response.Status(), 401) assert.Equal(t, response.Status(), 401)
assert.Equal(t, string(response.Body()), "") 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")
}

View file

@ -1,6 +1,6 @@
# web # 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 ## Features
@ -11,7 +11,7 @@ A fast HTTP/1.1 web server that can sit behind a reverse proxy like `caddy` or `
## Installation ## Installation
```shell ```shell
go get git.akyoto.dev/go/web go get git.urbach.dev/go/web
``` ```
## Usage ## Usage
@ -26,12 +26,23 @@ s.Get("/", func(ctx web.Context) error {
// Parameter route // Parameter route
s.Get("/blog/:post", func(ctx web.Context) error { s.Get("/blog/:post", func(ctx web.Context) error {
return ctx.String(ctx.Get("post")) return ctx.String(ctx.Request().Param("post"))
}) })
// Wildcard route // Wildcard route
s.Get("/images/*file", func(ctx web.Context) error { 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") s.Run(":8080")
@ -44,6 +55,7 @@ PASS: TestBytes
PASS: TestString PASS: TestString
PASS: TestError PASS: TestError
PASS: TestErrorMultiple PASS: TestErrorMultiple
PASS: TestRedirect
PASS: TestRequest PASS: TestRequest
PASS: TestRequestHeader PASS: TestRequestHeader
PASS: TestRequestParam PASS: TestRequestParam
@ -57,7 +69,9 @@ PASS: TestRun
PASS: TestBadRequest PASS: TestBadRequest
PASS: TestBadRequestHeader PASS: TestBadRequestHeader
PASS: TestBadRequestMethod PASS: TestBadRequestMethod
PASS: TestBadRequestPath
PASS: TestBadRequestProtocol PASS: TestBadRequestProtocol
PASS: TestConnectionClose
PASS: TestEarlyClose PASS: TestEarlyClose
PASS: TestUnavailablePort PASS: TestUnavailablePort
coverage: 100.0% of statements coverage: 100.0% of statements
@ -69,7 +83,7 @@ coverage: 100.0% of statements
## License ## License
Please see the [license documentation](https://akyoto.dev/license). Please see the [license documentation](https://urbach.dev/license).
## Copyright ## Copyright

View file

@ -3,7 +3,7 @@ package web
import ( import (
"bufio" "bufio"
"git.akyoto.dev/go/router" "git.urbach.dev/go/router"
) )
// Request is an interface for HTTP requests. // Request is an interface for HTTP requests.

View file

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
) )
func TestRequest(t *testing.T) { func TestRequest(t *testing.T) {

View file

@ -6,8 +6,8 @@ import (
"io" "io"
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
) )
func TestWrite(t *testing.T) { func TestWrite(t *testing.T) {

View file

@ -13,7 +13,7 @@ import (
"sync" "sync"
"syscall" "syscall"
"git.akyoto.dev/go/router" "git.urbach.dev/go/router"
) )
// Server is the interface for an HTTP server. // Server is the interface for an HTTP server.
@ -121,6 +121,7 @@ func (s *server) handleConnection(conn net.Conn) {
ctx = s.contextPool.Get().(*context) ctx = s.contextPool.Get().(*context)
method string method string
url string url string
close bool
) )
ctx.reader.Reset(conn) ctx.reader.Reset(conn)
@ -128,7 +129,7 @@ func (s *server) handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
defer s.contextPool.Put(ctx) defer s.contextPool.Put(ctx)
for { for !close {
// Read the HTTP request line // Read the HTTP request line
message, err := ctx.reader.ReadString('\n') message, err := ctx.reader.ReadString('\n')
@ -156,7 +157,14 @@ func (s *server) handleConnection(conn net.Conn) {
lastSpace = len(message) - len("\r\n") 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 // Add headers until we meet an empty line
for { for {
@ -176,6 +184,10 @@ func (s *server) handleConnection(conn net.Conn) {
continue continue
} }
if colon > len(message)-4 {
continue
}
key := message[:colon] key := message[:colon]
value := message[colon+2 : len(message)-2] value := message[colon+2 : len(message)-2]
@ -183,6 +195,10 @@ func (s *server) handleConnection(conn net.Conn) {
Key: key, Key: key,
Value: value, Value: value,
}) })
if value == "close" && strings.EqualFold(key, "connection") {
close = true
}
} }
// Handle the request // Handle the request

View file

@ -7,8 +7,8 @@ import (
"syscall" "syscall"
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
) )
func TestPanic(t *testing.T) { func TestPanic(t *testing.T) {
@ -77,7 +77,7 @@ func TestBadRequestHeader(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
defer conn.Close() 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) assert.Nil(t, err)
buffer := make([]byte, len("HTTP/1.1 200")) buffer := make([]byte, len("HTTP/1.1 200"))
@ -110,6 +110,27 @@ func TestBadRequestMethod(t *testing.T) {
s.Run(":8080") 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) { func TestBadRequestProtocol(t *testing.T) {
s := web.NewServer() s := web.NewServer()
@ -136,6 +157,26 @@ func TestBadRequestProtocol(t *testing.T) {
s.Run(":8080") 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) { func TestEarlyClose(t *testing.T) {
s := web.NewServer() s := web.NewServer()

View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
) )
func main() { func main() {

8
go.mod
View file

@ -1,8 +1,8 @@
module git.akyoto.dev/go/web module git.urbach.dev/go/web
go 1.22 go 1.24
require ( require (
git.akyoto.dev/go/assert v0.1.3 git.urbach.dev/go/assert v0.0.0-20250225153414-fc1f84f19edf
git.akyoto.dev/go/router v0.1.4 git.urbach.dev/go/router v0.0.0-20250601162231-e35d5715d1a5
) )

8
go.sum
View file

@ -1,4 +1,4 @@
git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= git.urbach.dev/go/assert v0.0.0-20250225153414-fc1f84f19edf h1:BQWa5GKNUsA5CSUa/+UlFWYCEVe3IDDKRbVqBLK0mAE=
git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= git.urbach.dev/go/assert v0.0.0-20250225153414-fc1f84f19edf/go.mod h1:y9jGII9JFiF1HNIju0u87OyPCt82xKCtqnAFyEreCDo=
git.akyoto.dev/go/router v0.1.4 h1:ZL5HPl4aNn4QKihf3VVs0Mm9R6ZGn2StAHGRQxjEbws= git.urbach.dev/go/router v0.0.0-20250601162231-e35d5715d1a5 h1:3qlZjgWbrHw4LWM4uBzOTldbpqCLJdeSgvcYK6f3xpc=
git.akyoto.dev/go/router v0.1.4/go.mod h1:rbHbkLJlQOafuOuvBalO3O8E0JtMFPT3zzTKX3h9T08= git.urbach.dev/go/router v0.0.0-20250601162231-e35d5715d1a5/go.mod h1:O+doTe0DZdT2XMsTY5pe6qUMqEEIAbwtX6Xp6EqHw34=

View file

@ -1,9 +1,9 @@
package content package send
import ( import (
"encoding/json" "encoding/json"
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
) )
// CSS sends the body with the content type set to `text/css`. // CSS sends the body with the content type set to `text/css`.

View file

@ -1,42 +1,42 @@
package content_test package send_test
import ( import (
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/web" "git.urbach.dev/go/web"
"git.akyoto.dev/go/web/content" "git.urbach.dev/go/web/send"
) )
func TestContentTypes(t *testing.T) { func TestContentTypes(t *testing.T) {
s := web.NewServer() s := web.NewServer()
s.Get("/css", func(ctx web.Context) error { 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 { 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 { s.Get("/html", func(ctx web.Context) error {
return content.HTML(ctx, "<html></html>") return send.HTML(ctx, "<html></html>")
}) })
s.Get("/js", func(ctx web.Context) error { 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 { 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 { 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 { s.Get("/xml", func(ctx web.Context) error {
return content.XML(ctx, "<xml></xml>") return send.XML(ctx, "<xml></xml>")
}) })
tests := []struct { tests := []struct {