Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2020+ Ahmed W.
Copyright (c) 2019-2026 Ahmed W.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Expand Down
215 changes: 215 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# gserv

[![Go Reference](https://pkg.go.dev/badge/go.oneofone.dev/gserv.svg)](https://pkg.go.dev/go.oneofone.dev/gserv)

A simple, fast, and flexible HTTP server framework for Go.

## Features

- **Zero dependencies on HTTP routing** -- ships with its own lightweight router (`gserv/router`)
- **HTTP/2 support** -- enabled automatically via H2C
- **Multiple codecs** -- built-in JSON and MessagePack serialization
- **SSE (Server-Sent Events)** -- first-class support via `gserv/sse`
- **Gzip compression** -- automatic when the client accepts gzip
- **Caching middleware** -- ETag-based response caching with configurable TTL
- **Rate limiting middleware** -- per-key limits at second, minute, and hour granularity
- **Group-based routing** -- organized route registration with inherited middleware chains
- **Panic recovery** -- optional panic handler integration via `oerrs` frame capture

## Installation

```bash
go get go.oneofone.dev/gserv
```

## Quick Start

### Basic Server

```go
package main

import (
"context"
"net/http"
"os"
"os/signal"
"syscall"

"go.oneofone.dev/gserv"
)

func main() {
srv := gserv.New()

srv.GET("/health", func(ctx *gserv.Context) gserv.Response {
return gserv.NewJSONResponse("OK")
})

srv.GET("/users/:id", func(ctx *gserv.Context) gserv.Response {
id := ctx.Param("id")
return gserv.NewJSONResponse(map[string]string{"id": id})
})

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

go func() {
if err := srv.Run(ctx, ":8080"); err != nil {
println(err.Error())
}
}()

<-ctx.Done()
srv.Shutdown(5 * time.Second)
}
```

### Grouped Routes with Middleware

```go
api := srv.SubGroup("api", "/api", gserv.LogRequests(false))

users := api.SubGroup("users", "/users")
users.Use(authMiddleware) // inherited by all routes in this subgroup

users.GET("", listUsers)
users.GET("/:id", getUser)
users.POST("", createUser)
users.DELETE("/:id", deleteUser)
```

### Typed JSON Responses

```go
func getUser(ctx *gserv.Context) gserv.Response {
id := ctx.Param("id")

user, err := db.FindUser(id)
if err != nil {
return gserv.NewJSONErrorResponse(http.StatusInternalServerError, err)
}

if user == nil {
return gserv.NewJSONErrorResponse(http.StatusNotFound, "user not found")
}

return gserv.NewJSONResponse(user)
}
```

### Request Binding (JSON and MessagePack)

```go
func createUser(ctx *gserv.Context) gserv.Response {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}

if err := ctx.Bind(&req); err != nil {
return gserv.NewJSONErrorResponse(http.StatusBadRequest, err)
}

// ... handle request
return gserv.NewJSONResponse(req)
}
```

### Server-Sent Events (SSE)

```go
import "go.oneofone.dev/gserv/sse"

sseRouter := sse.NewRouter()

srv.GET("/stream", func(ctx *gserv.Context) gserv.Response {
return sseRouter.Handle("channel1", 256, ctx)
})

// Publish events from anywhere:
go func() {
for {
sseRouter.Send("channel1", "", "message", map[string]string{"text": "hello"})
time.Sleep(time.Second)
}
}()
```

### Caching Middleware

```go
srv.GET("/products", gserv.CacheHandler(
func(ctx *gserv.Context) string {
return fmt.Sprintf("products:lang=%s", ctx.QueryDefault("lang", "en"))
},
5*time.Minute, // cache TTL
listProducts, // cached handler
))
```

### Rate Limiting Middleware

```go
// Limits: 10/second, 100/minute, 1000/hour per client IP
rateLimiter := gserv.RateLimiter(ctx, nil, 10, 100, 1000, true)

users.Use(rateLimiter)
```

### Static Files

```go
srv.Static("/static", "./public", false) // directory serving
srv.StaticFile("/favicon.ico", "./assets/ico") // single file
```

## Response Types

| Type | Content-Type | Usage |
|------|-------------|-------|
| `gserv.NewJSONResponse(data)` | `application/json` | Standard JSON API response |
| `gserv.NewMsgpResponse(data)` | `application/msgpack` | MessagePack serialization |
| `gserv.NewJSONErrorResponse(code, err)` | `application/json` | Error response with stack |
| `gserv.RespOK` | `text/plain` | Cached 200 OK |
| `gserv.RespNotFound` | `application/json` | Cached 404 |
| `gserv.File(ct, path)` | varies | Serve a file |

## Context API

| Method | Description |
|--------|-------------|
| `ctx.Param(key)` | URL path parameter |
| `ctx.Query(key)` | Query string parameter |
| `ctx.Bind(&v)` | Bind request body (auto-detects JSON/MsgPack) |
| `ctx.JSON(code, v)` | Write JSON response directly |
| `ctx.Msgpack(code, v)` | Write MsgPack response directly |
| `ctx.Get(key)`, `ctx.Set(key, val)` | Typed context values |
| `ctx.ClientIP()` | Client IP (respects X-Real-Ip / X-Forwarded-For) |
| `ctx.File(path)` | Serve a file |
| `ctx.SetCookie(...)` | Set signed http-only cookie |

## Server Configuration

```go
srv := gserv.New(
gserv.ReadTimeout(time.Second*30),
gserv.WriteTimeout(time.Minute),
gserv.MaxHeaderBytes(1<<20),
gserv.SetErrLogger(myLogger),
gserv.SetCatchPanics(true),
)
```

## Trusted By

- Powering [![AIQ](https://cdn.prod.website-files.com/68222f6c9ed2892e3e806c2e/68223378851997e82ba5a2df_aiq-favicon.png)](https://aiq.com) since 2019.


## Disclaimer

- AI was used to generate this README and some of the package's documentation.

## License

[MIT](LICENSE)
5 changes: 5 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ func cleanCache(m *cacheMap, ttl int64) {
}
}

// CacheHandler returns a caching middleware that caches responses based on an ETag.
// It checks the Cache-Control header for "no-cache" or "max-age=0" and bypasses the cache if found.
// The etag function generates a unique identifier per request, which is used as the cache key.
// If the ttlDuration is greater than 0, a background goroutine periodically cleans expired cache items.
// Cached responses must implement the CacheableResponse interface to be stored.
func CacheHandler(etag func(ctx *Context) string, ttlDuration time.Duration, handler Handler) Handler {
c := cacheMap{}
ttl := int64(ttlDuration.Seconds())
Expand Down
13 changes: 12 additions & 1 deletion codecs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"go.oneofone.dev/genh"
)

// Common mime-types
// Common MIME types used by the codecs.
const (
MimeJSON = "application/json"
MimeEvent = "text/event-stream"
Expand All @@ -28,20 +28,24 @@ var (
_ Codec = (*MixedCodec[JSONCodec, MsgpCodec])(nil)
)

// Encoder is the interface for encoding data.
type Encoder interface {
Encode(v any) error
}

// Decoder is the interface for decoding data.
type Decoder interface {
Decode(v any) error
}

// Codec is the interface for codecs that encode and decode data with a content type.
type Codec interface {
ContentType() string
Decode(r io.Reader, body any) error
Encode(w io.Writer, v any) error
}

// PlainTextCodec encodes and decodes plain text (string or byte slice).
type PlainTextCodec struct{}

func (PlainTextCodec) ContentType() string { return "" }
Expand All @@ -61,6 +65,7 @@ func (PlainTextCodec) Decode(r io.Reader, out any) error {
return err
}

// Encode encodes plain text data to the writer.
func (PlainTextCodec) Encode(w io.Writer, v any) (err2 error) {
switch v := v.(type) {
case string:
Expand All @@ -75,6 +80,7 @@ func (PlainTextCodec) Encode(w io.Writer, v any) (err2 error) {
return
}

// JSONCodec encodes and decodes data as JSON.
type JSONCodec struct{ Indent bool }

func (JSONCodec) ContentType() string { return MimeJSON }
Expand All @@ -83,6 +89,7 @@ func (JSONCodec) Decode(r io.Reader, out any) error {
return json.NewDecoder(r).Decode(&out)
}

// Encode encodes data as JSON to the writer.
func (j JSONCodec) Encode(w io.Writer, v any) error {
enc := json.NewEncoder(w)
if j.Indent {
Expand All @@ -92,6 +99,7 @@ func (j JSONCodec) Encode(w io.Writer, v any) error {
return enc.Encode(v)
}

// MsgpCodec encodes and decodes data as msgpack.
type MsgpCodec struct{}

func (MsgpCodec) ContentType() string { return MimeMsgPack }
Expand All @@ -100,10 +108,12 @@ func (MsgpCodec) Decode(r io.Reader, out any) error {
return genh.DecodeMsgpack(r, out)
}

// Encode encodes data as msgpack to the writer.
func (c MsgpCodec) Encode(w io.Writer, v any) error {
return genh.EncodeMsgpack(w, v)
}

// MixedCodec uses one codec for decoding and another for encoding.
type MixedCodec[Dec, Enc Codec] struct {
dec Dec
enc Enc
Expand All @@ -115,6 +125,7 @@ func (m MixedCodec[Dec, Enc]) Decode(r io.Reader, out any) error {
return m.dec.Decode(r, out)
}

// Encode encodes data using the encoding codec.
func (m MixedCodec[Dec, Enc]) Encode(w io.Writer, v any) error {
return m.enc.Encode(w, v)
}
Expand Down
3 changes: 2 additions & 1 deletion compression.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const (
gzEnc = "gzip"
)

var gzPool = sync.Pool{
var gzPool = sync.Pool{ // gzipRW pool for efficient reuse of gzip writers
New: func() any {
w := gzip.NewWriter(io.Discard)
return &gzipRW{nil, w, false}
Expand All @@ -32,6 +32,7 @@ func getGzipRW(rw http.ResponseWriter) *gzipRW {
return grw
}

// gzipRW wraps an http.ResponseWriter with gzip compression support.
type gzipRW struct {
http.ResponseWriter
gz *gzip.Writer
Expand Down
Loading
Loading