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
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

View file

@ -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")
}

View file

@ -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

View file

@ -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.

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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()

View file

@ -1,7 +1,7 @@
package main
import (
"git.akyoto.dev/go/web"
"git.urbach.dev/go/web"
)
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 (
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
)

8
go.sum
View file

@ -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=

View file

@ -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`.

View file

@ -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, "<html></html>")
return send.HTML(ctx, "<html></html>")
})
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, "<xml></xml>")
return send.XML(ctx, "<xml></xml>")
})
tests := []struct {