Compare commits
10 commits
fdd6e7b213
...
c5ad01daee
Author | SHA1 | Date | |
---|---|---|---|
c5ad01daee | |||
cdad24e2f7 | |||
934e8e7206 | |||
e68e5ca911 | |||
bb5d849cd9 | |||
d58f68f3fe | |||
b3271e03b7 | |||
b6fec17b49 | |||
1f62562d70 | |||
3f303d8e09 |
13 changed files with 132 additions and 40 deletions
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
24
README.md
24
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
22
Server.go
22
Server.go
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
8
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 (
|
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
8
go.sum
|
@ -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=
|
||||||
|
|
|
@ -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`.
|
|
@ -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 {
|
Loading…
Add table
Add a link
Reference in a new issue