From 26fda02c781ee320d4ed6b568a9fc01097d377fe Mon Sep 17 00:00:00 2001 From: OneOfOne Date: Sun, 24 May 2026 16:41:46 -0500 Subject: [PATCH 1/3] chore: add README.md --- LICENSE | 2 +- README.md | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 README.md diff --git a/LICENSE b/LICENSE index e7536f4..02d0125 100644 --- a/LICENSE +++ b/LICENSE @@ -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: diff --git a/README.md b/README.md new file mode 100644 index 0000000..027f820 --- /dev/null +++ b/README.md @@ -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) From 8a63ae39903ed127354025e27ebd6cc49cd89c4e Mon Sep 17 00:00:00 2001 From: OneOfOne Date: Mon, 25 May 2026 12:26:10 -0500 Subject: [PATCH 2/3] feat: documentation Mostly written by opencode and verified by me --- cache.go | 5 +++ codecs.go | 13 +++++- compression.go | 3 +- ctx.go | 106 ++++++++++++++++++++++++------------------------- errors.go | 21 +++++++--- gen.go | 17 ++++++++ genresp.go | 44 ++++++++++++-------- group.go | 28 +++++++------ log.go | 1 + mw.go | 8 ++-- options.go | 21 +++++----- proxy.go | 2 + ratelimit.go | 14 ++++++- resp.go | 27 +++++++------ server.go | 29 +++++++------- ssl.go | 25 +++++++----- utils.go | 37 +++++++++-------- 17 files changed, 244 insertions(+), 157 deletions(-) diff --git a/cache.go b/cache.go index 6878576..44234b7 100644 --- a/cache.go +++ b/cache.go @@ -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()) diff --git a/codecs.go b/codecs.go index a5d920e..009933e 100644 --- a/codecs.go +++ b/codecs.go @@ -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" @@ -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 "" } @@ -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: @@ -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 } @@ -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 { @@ -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 } @@ -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 @@ -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) } diff --git a/compression.go b/compression.go index a393657..93f6825 100644 --- a/compression.go +++ b/compression.go @@ -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} @@ -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 diff --git a/ctx.go b/ctx.go index 1cd12d8..e6c9634 100644 --- a/ctx.go +++ b/ctx.go @@ -27,21 +27,20 @@ var ( ) const ( - // ErrDir is Returned from ctx.File when the path is a directory not a file. + // ErrDir is returned from ctx.File when the path is a directory, not a file. ErrDir = oerrs.String("file is a directory") - // ErrInvalidURL gets returned on invalid redirect urls. + // ErrInvalidURL is returned on invalid redirect URLs. ErrInvalidURL = oerrs.String("invalid redirect error") - // ErrEmptyCallback is returned when a callback is empty + // ErrEmptyCallback is returned when a callback is empty. ErrEmptyCallback = oerrs.String("empty callback") - // ErrEmptyData is returned when the data payload is empty + // ErrEmptyData is returned when the data payload is empty. ErrEmptyData = oerrs.String("payload data is empty") ) -// Context is the default context passed to handlers -// it is not thread safe and should never be used outside the handler +// Context is the default context passed to handlers. It is not thread safe and should never be used outside the handler. type Context struct { http.ResponseWriter Codec Codec @@ -60,21 +59,22 @@ type Context struct { done bool } +// Route returns the current route. func (ctx *Context) Route() *router.Route { return router.RouteFromRequest(ctx.Req) } -// Param is a shorthand for ctx.Params.Get(name). +// Param returns a path parameter by key name. func (ctx *Context) Param(key string) string { return ctx.Params.Get(key) } -// Query is a shorthand for ctx.Req.URL.Query().Get(key). +// Query returns a query parameter value by key name. func (ctx *Context) Query(key string) string { return ctx.ReqQuery.Get(key) } -// QueryDefault returns the query key or a default value. +// QueryDefault returns the query parameter value for key, or the default value if missing. func (ctx *Context) QueryDefault(key, def string) string { if v := ctx.ReqQuery.Get(key); v != "" { return v @@ -82,12 +82,12 @@ func (ctx *Context) QueryDefault(key, def string) string { return def } -// Get returns a context value +// Get retrieves a value stored in the context by key. func (ctx *Context) Get(key string) any { return ctx.data[key] } -// Set sets a context value, useful in passing data to other handlers down the chain +// Set stores a value in the context under the given key, useful for passing data to other handlers down the chain. func (ctx *Context) Set(key string, val any) { if ctx.data == nil { ctx.data = make(M) @@ -95,7 +95,7 @@ func (ctx *Context) Set(key string, val any) { ctx.data[key] = val } -// WriteReader outputs the data from the passed reader with optional content-type. +// WriteReader writes the data from the given reader to the response with an optional content-type. func (ctx *Context) WriteReader(contentType string, r io.Reader) (int64, error) { if contentType != "" { ctx.SetContentType(contentType) @@ -104,8 +104,7 @@ func (ctx *Context) WriteReader(contentType string, r io.Reader) (int64, error) return io.Copy(ctx, r) } -// File serves a file using http.ServeContent. -// See http.ServeContent. +// File serves a file using http.ServeContent. See http.ServeContent. func (ctx *Context) File(fp string) error { ctx.hijackServeContent = true http.ServeFile(ctx, ctx.Req, fp) @@ -113,12 +112,12 @@ func (ctx *Context) File(fp string) error { return nil } -// Path is a shorthand for ctx.Req.URL.EscapedPath(). +// Path returns the escaped path of the current request. func (ctx *Context) Path() string { return ctx.Req.URL.EscapedPath() } -// SetContentType sets the responses's content-type. +// SetContentType sets the response's content-type header. func (ctx *Context) SetContentType(typ string) { if typ == "" { return @@ -127,7 +126,7 @@ func (ctx *Context) SetContentType(typ string) { h.Set(contentTypeHeader, typ) } -// ReqHeader returns the request header. +// ReqHeader returns a request header value by key name. func (ctx *Context) ReqHeader(key string) string { return ctx.Req.Header.Get(key) } @@ -137,8 +136,7 @@ func (ctx *Context) ContentType() string { return ctx.ReqHeader(contentTypeHeader) } -// Read is a QoL shorthand for ctx.Req.Body.Read. -// Context implements io.Reader +// Read reads from the request body, implementing io.Reader. func (ctx *Context) Read(p []byte) (int, error) { return ctx.Req.Body.Read(p) } @@ -148,19 +146,19 @@ func (ctx *Context) CloseBody() error { return ctx.Req.Body.Close() } -// BindJSON parses the request's body as json, and closes the body. +// BindJSON parses the request's body as JSON and closes the body. // Note that unlike gin.Context.Bind, this does NOT verify the fields using special tags. func (ctx *Context) BindJSON(out any) error { return ctx.BindCodec(JSONCodec{}, out) } -// BindMsgpack parses the request's body as msgpack, and closes the body. +// BindMsgpack parses the request's body as msgpack and closes the body. // Note that unlike gin.Context.Bind, this does NOT verify the fields using special tags. func (ctx *Context) BindMsgpack(out any) error { return ctx.BindCodec(MsgpCodec{}, out) } -// BindCodec parses the request's body as msgpack, and closes the body. +// BindCodec parses the request's body using the given codec and closes the body. // Note that unlike gin.Context.BindCodec, this does NOT verify the fields using special tags. func (ctx *Context) BindCodec(c Codec, out any) error { c = genh.FirstNonZero(c, ctx.Codec, DefaultCodec) @@ -172,7 +170,7 @@ func (ctx *Context) BindCodec(c Codec, out any) error { return err } -// Bind parses the request's body as msgpack, and closes the body. +// Bind parses the request's body based on its content type and closes the body. // Note that unlike gin.Context.Bind, this does NOT verify the fields using special tags. func (ctx *Context) Bind(out any) error { var c Codec @@ -194,8 +192,8 @@ func (ctx *Context) Bind(out any) error { return err } -// Printf is a QoL function to handle outputting plain strings with optional fmt.Printf-style formatting. -// calling this function marks the Context as done, meaning any returned responses won't be written out. +// Printf writes a formatted string to the response with the given status code and content type. +// Calling this function marks the Context as done, meaning any returned responses won't be written out. func (ctx *Context) Printf(code int, contentType, s string, args ...any) (int, error) { ctx.done = true @@ -212,18 +210,19 @@ func (ctx *Context) Printf(code int, contentType, s string, args ...any) (int, e return fmt.Fprintf(ctx, s, args...) } -// JSON outputs a json object, it is highly recommended to return *Response rather than use this directly. -// calling this function marks the Context as done, meaning any returned responses won't be written out. +// JSON encodes data as JSON and writes it to the response with the given status code. +// Calling this function marks the Context as done, meaning any returned responses won't be written out. func (ctx *Context) JSON(code int, indent bool, v any) error { return ctx.EncodeCodec(JSONCodec{indent}, code, v) } -// Msgpack outputs a msgp object, it is highly recommended to return *Response rather than use this directly. -// calling this function marks the Context as done, meaning any returned responses won't be written out. +// Msgpack encodes data as msgpack and writes it to the response with the given status code. +// Calling this function marks the Context as done, meaning any returned responses won't be written out. func (ctx *Context) Msgpack(code int, v any) error { return ctx.EncodeCodec(MsgpCodec{}, code, v) } +// EncodeCodec encodes data using the given codec and writes it to the response with the given status code. func (ctx *Context) EncodeCodec(c Codec, code int, v any) error { c = genh.FirstNonZero(c, ctx.Codec, DefaultCodec) ctx.done = true @@ -235,6 +234,7 @@ func (ctx *Context) EncodeCodec(c Codec, code int, v any) error { return c.Encode(ctx, v) } +// Encode encodes data using the content type of the request and writes it to the response with the given status code. func (ctx *Context) Encode(code int, v any) error { var c Codec ct := ctx.ContentType() @@ -256,7 +256,7 @@ func (ctx *Context) Encode(code int, v any) error { return c.Encode(ctx, v) } -// ClientIP returns the current client ip, accounting for X-Real-Ip and X-forwarded-For headers as well. +// ClientIP returns the client's IP address, accounting for X-Real-Ip and X-Forwarded-For headers. func (ctx *Context) ClientIP() string { h := ctx.Req.Header @@ -284,30 +284,28 @@ func (ctx *Context) ClientIP() string { return "" } -// NextMiddleware is a middleware-only func to execute all the other middlewares in the group and return before the handlers. -// will panic if called from a handler. +// NextMiddleware executes all remaining middlewares in the group, returning before the handlers run. +// It will panic if called from a handler. func (ctx *Context) NextMiddleware() { if ctx.nextMW != nil { ctx.nextMW() } } -// NextHandler is a func to execute all the handlers in the group up until one returns a Response. +// NextHandler executes all remaining handlers in the group up until one returns a Response. func (ctx *Context) NextHandler() { if ctx.next != nil { ctx.next() } } -// Next is a QoL function that calls NextMiddleware() then NextHandler() if NextMiddleware() didn't return a response. +// Next executes NextMiddleware() then NextHandler() if NextMiddleware() didn't return a response. func (ctx *Context) Next() { ctx.NextMiddleware() ctx.NextHandler() } -// WriteHeader and Write are to implement ResponseWriter and allows ghetto hijacking of http.ServeContent errors, -// without them we'd end up with plain text errors, we wouldn't want that, would we? -// WriteHeader implements http.ResponseWriter +// WriteHeader writes the HTTP status code to the response. func (ctx *Context) WriteHeader(s int) { if ctx.status = s; ctx.hijackServeContent && ctx.status >= http.StatusBadRequest { return @@ -316,7 +314,7 @@ func (ctx *Context) WriteHeader(s int) { ctx.ResponseWriter.WriteHeader(s) } -// Write implements http.ResponseWriter +// Write writes bytes to the response, implementing http.ResponseWriter. func (ctx *Context) Write(p []byte) (int, error) { if ctx.hijackServeContent && ctx.status >= http.StatusBadRequest { ctx.hijackServeContent = false @@ -328,29 +326,29 @@ func (ctx *Context) Write(p []byte) (int, error) { return ctx.ResponseWriter.Write(p) } -// LimitRead limits the request body to the passed size. +// LimitRead limits the request body to the given size. func (ctx *Context) LimitRead(sz int64) { ctx.Req.Body = http.MaxBytesReader(ctx, ctx.Req.Body, sz) } -// BytesWritten is the amount of bytes written from the body. +// BytesWritten returns the number of bytes written to the response body. func (ctx *Context) BytesWritten() int { return ctx.bytesWritten } -// WriteString implements io.StringWriter +// WriteString writes a string to the response, implementing io.StringWriter. func (ctx *Context) WriteString(p string) (int, error) { return ctx.ResponseWriter.Write(otk.UnsafeBytes(p)) } -// Flush implements http.Flusher +// Flush flushes any buffered data to the connection, implementing http.Flusher. func (ctx *Context) Flush() { if f, ok := ctx.ResponseWriter.(http.Flusher); ok { f.Flush() } } -// Status returns last value written using WriteHeader. +// Status returns the last HTTP status code written via WriteHeader. func (ctx *Context) Status() int { if ctx.status == 0 { ctx.status = http.StatusOK @@ -359,7 +357,8 @@ func (ctx *Context) Status() int { return ctx.status } -// MultipartReader is like Request.MultipartReader but supports multipart/*, not just form-data +// MultipartReader parses the request as a multipart body and returns a reader for its parts. +// Unlike Request.MultipartReader, it supports multipart/* content types, not just form-data. func (ctx *Context) MultipartReader() (*multipart.Reader, error) { req := ctx.Req @@ -381,17 +380,16 @@ func (ctx *Context) MultipartReader() (*multipart.Reader, error) { return multipart.NewReader(req.Body, boundary), nil } -// Finished returns wither the context is marked as done or not. +// Finished returns true if the context has been marked as done. func (ctx *Context) Finished() bool { return ctx.done } -// SetCookie sets an http-only cookie using the passed name, value and domain. +// SetCookie sets an HTTP-only cookie with the given name, value, domain, and secure flag. // Returns an error if there was a problem encoding the value. -// if forceSecure is true, it will set the Secure flag to true, otherwise it sets it based on the connection. -// if duration == -1, it sets expires to 10 years in the past, if 0 it gets ignored (aka session-only cookie), -// if duration > 0, the expiration date gets set to now() + duration. -// Note that for more complex options, you can use http.SetCookie(ctx, &http.Cookie{...}). +// If forceSecure is true, it sets the Secure flag; otherwise it sets it based on the connection (TLS). +// If duration == -1, it sets expires to 10 years in the past. If duration == 0, it is ignored (session-only cookie). +// If duration > 0, the expiration date is set to now() + duration. func (ctx *Context) SetCookie(name string, value any, domain string, forceHTTPS bool, duration time.Duration) (err error) { var encValue string if sc := GetSecureCookie(ctx); sc != nil { @@ -430,7 +428,7 @@ func (ctx *Context) SetCookie(name string, value any, domain string, forceHTTPS return err } -// RemoveCookie deletes the given cookie and sets its expires date in the past. +// RemoveCookie deletes the given cookie by setting its expiration date in the past. func (ctx *Context) RemoveCookie(name string) { http.SetCookie(ctx, &http.Cookie{ Path: "/", @@ -441,7 +439,7 @@ func (ctx *Context) RemoveCookie(name string) { }) } -// GetCookie returns the given cookie's value. +// GetCookie retrieves the value of a cookie by name, returning it and whether it was found. func (ctx *Context) GetCookie(name string) (out string, ok bool) { c, err := ctx.Req.Cookie(name) if err != nil { @@ -454,7 +452,7 @@ func (ctx *Context) GetCookie(name string) (out string, ok bool) { return c.Value, true } -// GetCookieValue unmarshals a cookie, only needed if you stored an object for the cookie not a string. +// GetCookieValue retrieves and unmarshals a cookie value into the given destination. func (ctx *Context) GetCookieValue(name string, valDst any) error { c, err := ctx.Req.Cookie(name) if err != nil { @@ -468,10 +466,12 @@ func (ctx *Context) GetCookieValue(name string, valDst any) error { return internal.UnmarshalString(c.Value, valDst) } +// Logf logs a formatted message using the server's logger. func (ctx *Context) Logf(format string, v ...any) { ctx.s.logfStack(1, format, v...) } +// LogSkipf logs a formatted message with the given skip frame offset for caller info. func (ctx *Context) LogSkipf(skip int, format string, v ...any) { ctx.s.logfStack(skip+1, format, v...) } diff --git a/errors.go b/errors.go index f3b554e..6e0327f 100644 --- a/errors.go +++ b/errors.go @@ -7,22 +7,31 @@ import ( "go.oneofone.dev/otk" ) +// HTTPError is the interface for HTTP errors with a status code and message. type HTTPError interface { Status() int Error() string } var ( - ErrBadRequest = NewError(http.StatusBadRequest, "bad request") + // ErrBadRequest indicates a bad request (400). + ErrBadRequest = NewError(http.StatusBadRequest, "bad request") + // ErrUnauthorized indicates an unauthorized request (401). ErrUnauthorized = NewError(http.StatusUnauthorized, "unauthorized") - ErrForbidden = NewError(http.StatusForbidden, "the gates of time are closed") - ErrNotFound = NewError(http.StatusNotFound, "not found") - ErrTeaPot = NewError(http.StatusTeapot, "I'm a teapot") + // ErrForbidden indicates a forbidden request (403). + ErrForbidden = NewError(http.StatusForbidden, "the gates of time are closed") + // ErrNotFound indicates a resource not found (404). + ErrNotFound = NewError(http.StatusNotFound, "not found") + // ErrTeaPot is a fun 418 error. + ErrTeaPot = NewError(http.StatusTeapot, "I'm a teapot") + // ErrInternal indicates an internal server error (500). ErrInternal = NewError(http.StatusInternalServerError, "internal error") - ErrNotImpl = NewError(http.StatusNotImplemented, "not implemented") + // ErrNotImpl indicates a not implemented error (501). + ErrNotImpl = NewError(http.StatusNotImplemented, "not implemented") ) +// Error is a standard HTTP error with an optional caller info. type Error struct { Caller *callerInfo `json:"caller,omitempty"` Message string `json:"message,omitempty"` @@ -35,6 +44,7 @@ type callerInfo struct { Line int `json:"line,omitempty"` } +// NewError creates a new HTTPError with the given status code and message. func NewError(status int, msg any) HTTPError { e := Error{ Code: status, @@ -43,6 +53,7 @@ func NewError(status int, msg any) HTTPError { return e } +// NewErrorWithCaller creates a new HTTPError with the given status code, message, and caller information. func NewErrorWithCaller(status int, msg string, skip int) HTTPError { e := Error{ Code: status, diff --git a/gen.go b/gen.go index 1631cbd..6d7c513 100644 --- a/gen.go +++ b/gen.go @@ -6,66 +6,83 @@ import ( "net/http" ) +// GroupType is the interface that groups must satisfy for route generation functions. type GroupType interface { AddRoute(method, path string, handlers ...Handler) Route } +// Get creates a GET route with automatic request/response handling. +// If wrapResp is true, the response is wrapped in a GenResponse[CodecT] with success and code fields. func Get[CodecT Codec, Resp any, HandlerFn func(ctx *Context) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return handleOutOnly[CodecT](g, http.MethodGet, path, handler, wrapResp) } +// JSONGet creates a GET route with automatic JSON request/response handling. func JSONGet[Resp any, HandlerFn func(ctx *Context) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Get[JSONCodec](g, path, handler, wrapResp) } +// MsgpGet creates a GET route with automatic msgpack request/response handling. func MsgpGet[Resp any, HandlerFn func(ctx *Context) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Get[MsgpCodec](g, path, handler, wrapResp) } +// Delete creates a DELETE route with automatic request/response handling. func Delete[CodecT Codec, Resp any, HandlerFn func(ctx *Context) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return handleOutOnly[CodecT](g, http.MethodDelete, path, handler, wrapResp) } +// JSONDelete creates a DELETE route with automatic JSON request/response handling. func JSONDelete[Resp any, HandlerFn func(ctx *Context) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Delete[JSONCodec](g, path, handler, wrapResp) } +// MsgpDelete creates a DELETE route with automatic msgpack request/response handling. func MsgpDelete[Resp any, HandlerFn func(ctx *Context) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Delete[MsgpCodec](g, path, handler, wrapResp) } +// Post creates a POST route with automatic request/response handling. func Post[CodecT Codec, Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return handleInOut[CodecT](g, http.MethodPost, path, handler, wrapResp) } +// JSONPost creates a POST route with automatic JSON request/response handling. func JSONPost[Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Post[JSONCodec](g, path, handler, wrapResp) } +// MsgpPost creates a POST route with automatic msgpack request/response handling. func MsgpPost[Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Post[MsgpCodec](g, path, handler, wrapResp) } +// Put creates a PUT route with automatic request/response handling. func Put[CodecT Codec, Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return handleInOut[CodecT](g, http.MethodPut, path, handler, wrapResp) } +// JSONPut creates a PUT route with automatic JSON request/response handling. func JSONPut[Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Put[JSONCodec](g, path, handler, wrapResp) } +// MsgpPut creates a PUT route with automatic msgpack request/response handling. func MsgpPut[Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Put[MsgpCodec](g, path, handler, wrapResp) } +// Patch creates a PATCH route with automatic request/response handling. func Patch[CodecT Codec, Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return handleInOut[CodecT](g, http.MethodPatch, path, handler, wrapResp) } +// JSONPatch creates a PATCH route with automatic JSON request/response handling. func JSONPatch[Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Patch[JSONCodec](g, path, handler, wrapResp) } +// MsgpPatch creates a PATCH route with automatic msgpack request/response handling. func MsgpPatch[Req, Resp any, HandlerFn func(ctx *Context, reqBody Req) (resp Resp, err error)](g GroupType, path string, handler HandlerFn, wrapResp bool) Route { return Patch[MsgpCodec](g, path, handler, wrapResp) } diff --git a/genresp.go b/genresp.go index 9b0115a..4c5436f 100644 --- a/genresp.go +++ b/genresp.go @@ -8,6 +8,7 @@ import ( "go.oneofone.dev/oerrs" ) +// NewResponse creates a new successful response with status code 200 and the given data. func NewResponse[CodecT Codec](data any) *GenResponse[CodecT] { return &GenResponse[CodecT]{ Code: http.StatusOK, @@ -16,14 +17,14 @@ func NewResponse[CodecT Codec](data any) *GenResponse[CodecT] { } } -// NewErrorResponse returns a new error response. -// each err can be: -// 1. string or []byte -// 2. error -// 3. Error / *Error -// 4. another response, its Errors will be appended to the returned Response. -// 5. MultiError -// 6. if errs is empty, it will call http.StatusText(code) and set that as the error. +// NewErrorResponse creates a new error response with the given status code. +// Each err argument can be: +// 1. string or []byte — used as the error message. +// 2. error — its Error() method is used. +// 3. Error or *Error — appended directly. +// 4. another Response — its Errors are appended to this response. +// 5. MultiError — each error is recursively appended. +// If errs is empty, http.StatusText(code) is used as the error message. func NewErrorResponse[CodecT Codec](code int, errs ...any) (r *GenResponse[CodecT]) { if len(errs) == 0 { errs = append(errs, http.StatusText(code)) @@ -41,7 +42,7 @@ func NewErrorResponse[CodecT Codec](code int, errs ...any) (r *GenResponse[Codec return r } -// GenResponse is the default standard api response +// GenResponse is the default standard API response type, generic over the codec used for encoding. type GenResponse[CodecT Codec] struct { Data any `json:"data,omitempty"` Errors []Error `json:"errors,omitempty"` @@ -49,6 +50,7 @@ type GenResponse[CodecT Codec] struct { Success bool `json:"success"` } +// Status returns the HTTP status code for this response. If Code is 0, it defaults to BadRequest when there are errors, or OK otherwise. func (r GenResponse[CodecT]) Status() int { if r.Code == 0 { if len(r.Errors) > 0 { @@ -60,7 +62,7 @@ func (r GenResponse[CodecT]) Status() int { return r.Code } -// WriteToCtx writes the response to a ResponseWriter +// WriteToCtx writes the response's headers and body to the given Context. func (r GenResponse[CodecT]) WriteToCtx(ctx *Context) error { switch r.Code { case 0: @@ -84,6 +86,7 @@ func (r GenResponse[CodecT]) WriteToCtx(ctx *Context) error { return c.Encode(ctx, &r) } +// Cached returns a cached version of this response for use with the CacheableResponse interface. func (r GenResponse[CodecT]) Cached() Response { var c CodecT var buf bytes.Buffer @@ -92,7 +95,7 @@ func (r GenResponse[CodecT]) Cached() Response { } // ErrorList returns an errors.ErrorList of this response's errors or nil. -// Deprecated: handled using MultiError +// Deprecated: use MultiError instead. func (r *GenResponse[CodecT]) ErrorList() *oerrs.ErrorList { if len(r.Errors) == 0 { return nil @@ -128,38 +131,47 @@ func (r *GenResponse[CodecT]) appendErr(err any) { } type ( + // PlainTextResponse is a GenResponse using the PlainTextCodec. PlainTextResponse = GenResponse[PlainTextCodec] - JSONResponse = GenResponse[JSONCodec] - MsgpResponse = GenResponse[MsgpCodec] + // JSONResponse is a GenResponse using the JSONCodec. + JSONResponse = GenResponse[JSONCodec] + + // MsgpResponse is a GenResponse using the MsgpCodec. + MsgpResponse = GenResponse[MsgpCodec] + + // CacheableResponse is an interface for responses that can be cached. CacheableResponse interface { Cached() Response } ) -// NewPlainResponse returns a new (json) success response (code 200) with the specific data +// NewPlainResponse creates a new successful (code 200) plain text response with the given data. func NewPlainResponse(data any) *PlainTextResponse { return NewResponse[PlainTextCodec](data) } +// NewPlainErrorResponse creates a new error plain text response with the given status code and errors. func NewPlainErrorResponse(code int, errs ...any) *PlainTextResponse { return NewErrorResponse[PlainTextCodec](code, errs...) } -// NewJSONResponse returns a new (json) success response (code 200) with the specific data +// NewJSONResponse creates a new successful (code 200) JSON response with the given data. func NewJSONResponse(data any) *JSONResponse { return NewResponse[JSONCodec](data) } +// NewJSONErrorResponse creates a new error JSON response with the given status code and errors. func NewJSONErrorResponse(code int, errs ...any) *JSONResponse { return NewErrorResponse[JSONCodec](code, errs...) } -// NewMsgpResponse returns a new (msgpack) success response (code 200) with the specific data +// NewMsgpResponse creates a new successful (code 200) msgpack response with the given data. func NewMsgpResponse(data any) *MsgpResponse { return NewResponse[MsgpCodec](data) } +// NewMsgpErrorResponse creates a new error msgpack response with the given status code and errors. func NewMsgpErrorResponse(code int, errs ...any) *MsgpResponse { return NewErrorResponse[MsgpCodec](code, errs...) } diff --git a/group.go b/group.go index 752a247..11f254b 100644 --- a/group.go +++ b/group.go @@ -9,15 +9,16 @@ import ( ) type ( + // Route is a type alias for the router's Route type. Route = *router.Route ) -var DefaultCodec Codec = &JSONCodec{} +var DefaultCodec Codec = &JSONCodec{} // DefaultCodec is the default codec used by the framework, set to JSONCodec. -// Handler is the default server Handler -// In a handler chain, returning a non-nil breaks the chain. +// Handler is the default server handler type. In a handler chain, returning a non-nil Response breaks the chain. type Handler = func(ctx *Context) Response +// Group is a collection of routes with shared middleware and path prefix. type Group struct { s *Server nm string @@ -30,14 +31,13 @@ func (g *Group) Use(mw ...Handler) { g.mw = append(g.mw, mw...) } -// Routes returns the current routes set. -// Each route is returned in the order of group name, method, path. +// Routes returns all registered routes. Each route is returned as [group name, method, path]. func (g *Group) Routes() [][3]string { return g.s.r.GetRoutes() } -// AddRoute adds a handler (or more) to the specific method and path -// it is NOT safe to call this once you call one of the run functions +// AddRoute adds one or more handlers for the given HTTP method and path to this group. +// It is NOT safe to call this after starting the server. func (g *Group) AddRoute(method, path string, handlers ...Handler) Route { ghc := groupHandlerChain{ hc: handlers, @@ -47,27 +47,27 @@ func (g *Group) AddRoute(method, path string, handlers ...Handler) Route { return g.s.r.AddRoute(g.nm, method, p, ghc.Serve) } -// GET is an alias for AddRoute("GET", path, handlers...). +// GET registers a GET route for the given path with the specified handlers. func (g *Group) GET(path string, handlers ...Handler) Route { return g.AddRoute(http.MethodGet, path, handlers...) } -// PUT is an alias for AddRoute("PUT", path, handlers...). +// PUT registers a PUT route for the given path with the specified handlers. func (g *Group) PUT(path string, handlers ...Handler) Route { return g.AddRoute(http.MethodPut, path, handlers...) } -// POST is an alias for AddRoute("POST", path, handlers...). +// POST registers a POST route for the given path with the specified handlers. func (g *Group) POST(path string, handlers ...Handler) Route { return g.AddRoute(http.MethodPost, path, handlers...) } -// DELETE is an alias for AddRoute("DELETE", path, handlers...). +// DELETE registers a DELETE route for the given path with the specified handlers. func (g *Group) DELETE(path string, handlers ...Handler) Route { return g.AddRoute(http.MethodDelete, path, handlers...) } -// OPTIONS is an alias for AddRoute("OPTIONS", path, handlers...). +// OPTIONS registers an OPTIONS route for the given path with the specified handlers. func (g *Group) OPTIONS(path string, handlers ...Handler) Route { return g.AddRoute(http.MethodOptions, path, handlers...) } @@ -76,12 +76,14 @@ func (g *Group) DisableRoute(method, path string, disabled bool) bool { return g.s.r.DisableRoute(method, joinPath(g.path, path), disabled) } +// Static registers a GET route that serves static files from the given local path. func (g *Group) Static(path, localPath string, allowListing bool) Route { path = strings.TrimSuffix(path, "/") return g.AddRoute(http.MethodGet, joinPath(path, "*fp"), StaticDirStd(path, localPath, allowListing)) } +// StaticFile registers a GET route that serves a single static file. func (g *Group) StaticFile(path, localPath string) Route { return g.AddRoute(http.MethodGet, path, func(ctx *Context) Response { _ = ctx.File(localPath) @@ -89,7 +91,7 @@ func (g *Group) StaticFile(path, localPath string) Route { }) } -// SubGroup returns a sub-handler group based on the current group's middleware +// SubGroup creates a new sub-group inheriting the current group's middleware. func (g *Group) SubGroup(name, path string, mw ...Handler) *Group { return &Group{ nm: name, diff --git a/log.go b/log.go index d5f8288..24be066 100644 --- a/log.go +++ b/log.go @@ -21,6 +21,7 @@ func (fl *filteredLogger) Write(p []byte) (n int, err error) { return fl.w.Write(p) } +// FilteredLogger returns a logger that filters out log messages containing any of the given substrings. func FilteredLogger(flags int, msgs ...string) *log.Logger { fl := &filteredLogger{w: os.Stderr} for _, m := range msgs { diff --git a/mw.go b/mw.go index 3d9be32..da8356e 100644 --- a/mw.go +++ b/mw.go @@ -14,8 +14,8 @@ import ( var reqID uint64 -// LogRequests is a request logger middleware. -// If logJSONRequests is true, it'll attempt to parse the incoming request's body and output it to the log. +// LogRequests returns a middleware that logs each request with its method, path, status code, duration, and client IP. +// If logJSONRequests is true, it also attempts to parse and log the incoming request body for JSON content types. func LogRequests(logJSONRequests bool) Handler { return func(ctx *Context) Response { var ( @@ -70,7 +70,7 @@ func LogRequests(logJSONRequests bool) Handler { const secureCookieKey = ":SC:" -// SecureCookie is a middleware to enable SecureCookies. +// SecureCookie is a middleware that enables SecureCookies for the context. // For more details check `go doc securecookie.New` func SecureCookie(hashKey, blockKey []byte) Handler { return func(ctx *Context) Response { @@ -79,7 +79,7 @@ func SecureCookie(hashKey, blockKey []byte) Handler { } } -// GetSecureCookie returns the *securecookie.SecureCookie associated with the Context, or nil. +// GetSecureCookie retrieves the SecureCookie from the context, or nil if not set. func GetSecureCookie(ctx *Context) *securecookie.SecureCookie { sc, ok := ctx.Get(secureCookieKey).(*securecookie.SecureCookie) if ok { diff --git a/options.go b/options.go index cbbc571..ec1a4b9 100644 --- a/options.go +++ b/options.go @@ -7,7 +7,7 @@ import ( "go.oneofone.dev/gserv/router" ) -// Options allows finer control over the gserv +// Options contains configuration options for the Server. type Options struct { Logger *log.Logger RouterOptions *router.Options @@ -18,60 +18,59 @@ type Options struct { CatchPanics bool } -// Option is a func to set internal server Options. +// Option is a functional option type to configure the Server. type Option = func(opt *Options) -// ReadTimeout sets the read timeout on the server. -// see http.Server.ReadTimeout +// ReadTimeout sets the read timeout on the server, equivalent to http.Server.ReadTimeout. func ReadTimeout(v time.Duration) Option { return func(opt *Options) { opt.ReadTimeout = v } } -// WriteTimeout sets the write timeout on the server. -// see http.Server.WriteTimeout +// WriteTimeout sets the write timeout on the server, equivalent to http.Server.WriteTimeout. func WriteTimeout(v time.Duration) Option { return func(opt *Options) { opt.WriteTimeout = v } } -// MaxHeaderBytes sets the max size of headers on the server. -// see http.Server.MaxHeaderBytes +// MaxHeaderBytes sets the maximum size of request headers on the server, equivalent to http.Server.MaxHeaderBytes. func MaxHeaderBytes(v int) Option { return func(opt *Options) { opt.MaxHeaderBytes = v } } -// SetErrLogger sets the error logger on the server. +// SetErrLogger sets the error logger for the server, equivalent to http.Server.ErrorLog. func SetErrLogger(v *log.Logger) Option { return func(opt *Options) { opt.Logger = v } } -// SetRouterOptions sets gserv/router.Options on the server. +// SetRouterOptions configures the underlying router options. func SetRouterOptions(v *router.Options) Option { return func(opt *Options) { opt.RouterOptions = v } } -// SetCatchPanics toggles catching panics in handlers. +// SetCatchPanics enables or disables panic recovery in handlers. func SetCatchPanics(enable bool) Option { return func(opt *Options) { opt.CatchPanics = enable } } +// SetProfileLabels enables or disables profile labels on the underlying router. func SetProfileLabels(enable bool) Option { return func(opt *Options) { opt.RouterOptions.ProfileLabels = enable } } +// SetOnReqDone sets a callback to be invoked after each request is processed by the router. func SetOnReqDone(fn router.OnRequestDone) Option { return func(opt *Options) { opt.RouterOptions.OnRequestDone = fn diff --git a/proxy.go b/proxy.go index b0ed4e6..e3a93a6 100644 --- a/proxy.go +++ b/proxy.go @@ -18,6 +18,8 @@ var hopHeaders = []string{ "Upgrade", } +// ProxyHandler creates a reverse proxy handler for the given host. +// The pathFn, if provided, allows modifying the request URL path before forwarding. func ProxyHandler(host string, pathFn func(ctx *Context, path string) (string, error)) Handler { rp := &httputil.ReverseProxy{} diff --git a/ratelimit.go b/ratelimit.go index b756fc2..bc8df14 100644 --- a/ratelimit.go +++ b/ratelimit.go @@ -11,8 +11,12 @@ import ( "go.oneofone.dev/genh" ) +// LimitKeyFn is a function that generates a rate limit key from the request context. type LimitKeyFn = func(ctx *Context) string +// RateLimiter returns a middleware that enforces rate limits per key derived from the context. +// If limitKey is nil, it defaults to using the client IP address. +// If setHeaders is true, it sets X-Rate-Limit-Limit, X-Rate-Limit-Remaining, and Retry-After headers on responses. func RateLimiter(ctx context.Context, limitKey LimitKeyFn, maxPerSecond, maxPerMinute, maxPerHour int, setHeaders bool) Handler { ls := NewLimiters(ctx, maxPerSecond, maxPerMinute, maxPerHour) limitsHeader := fmt.Sprintf(`%ds, %dm, %dh`, maxPerSecond, maxPerMinute, maxPerHour) @@ -52,6 +56,7 @@ func RateLimiter(ctx context.Context, limitKey LimitKeyFn, maxPerSecond, maxPerM } } +// Limiter enforces rate limits per second, minute, and hour. type Limiter struct { mux sync.RWMutex @@ -73,6 +78,7 @@ type Limiter struct { totalBlocked int64 } +// NewLimiter creates a new rate limiter with the given limits per second, minute, and hour. func NewLimiter(maxPerSecond, maxPerMinute, maxPerHour int) *Limiter { ts := time.Now().Unix() return &Limiter{ @@ -86,7 +92,8 @@ func NewLimiter(maxPerSecond, maxPerMinute, maxPerHour int) *Limiter { } } -// Allowed returns the duration until the next action is allowed and an error if it's longer than 0 +// Allowed checks if a request is allowed under the current rate limits. +// It returns the duration until the next request can be made and an error if the limit is exceeded. func (l *Limiter) Allowed() (d time.Duration, err error) { now := time.Now().Unix() @@ -133,6 +140,7 @@ func (l *Limiter) Allowed() (d time.Duration, err error) { return 0, nil } +// LastAction returns the time of the last allowed or blocked action. func (l *Limiter) LastAction() (t time.Time) { l.mux.RLock() t = time.Unix(max(l.lastSec, l.lastMin, l.lastHour), 0) @@ -150,6 +158,7 @@ func max(vs ...int64) int64 { return m } +// RequestsLeft returns the remaining allowed requests per second, minute, and hour. func (l *Limiter) RequestsLeft() (perSecond, perMinute, perHour int64) { l.mux.RLock() perHour, perMinute, perSecond = max(0, l.maxPerHour-l.reqPerHour), max(0, l.maxPerMinute-l.reqPerMinute), max(0, l.maxPerSecond-l.reqPerSecond) @@ -157,6 +166,7 @@ func (l *Limiter) RequestsLeft() (perSecond, perMinute, perHour int64) { return } +// NewLimiters creates a new pool of rate limiters with the given limits per second, minute, and hour. func NewLimiters(ctx context.Context, maxPerSecond, maxPerMinute, maxPerHour int) *Limiters { ls := &Limiters{ maxPerSecond: maxPerSecond, @@ -167,6 +177,7 @@ func NewLimiters(ctx context.Context, maxPerSecond, maxPerMinute, maxPerHour int return ls } +// Limiters is a pool of rate limiters keyed by an arbitrary string. type Limiters struct { ctx context.Context m genh.LMap[string, *Limiter] @@ -195,6 +206,7 @@ func (ls *Limiters) clean() { } } +// Get returns the rate limiter for the given key, creating a new one if it doesn't exist. func (ls *Limiters) Get(key string) *Limiter { return ls.m.MustGet(key, func() *Limiter { return NewLimiter(ls.maxPerSecond, ls.maxPerMinute, ls.maxPerHour) diff --git a/resp.go b/resp.go index ed9187f..9f6b3ea 100644 --- a/resp.go +++ b/resp.go @@ -13,7 +13,7 @@ import ( "go.oneofone.dev/otk" ) -// Common responses +// Common responses are pre-built response values for frequent use cases. var ( RespMethodNotAllowed Response = NewJSONErrorResponse(http.StatusMethodNotAllowed).Cached() RespNotFound Response = NewJSONErrorResponse(http.StatusNotFound).Cached() @@ -24,22 +24,23 @@ var ( RespPlainOK Response = CachedResponse(http.StatusOK, "", nil) RespRedirectRoot Response = Redirect("/", false) - // Break can be returned from a handler to break a handler chain. - // It doesn't write anything to the connection. - // if you reassign this, a wild animal will devour your face. + // Break can be returned from a handler to break the handler chain. + // It does not write anything to the connection. Break Response = &cachedResp{code: -1} ) -// Response represents a generic return type for http responses. +// Response represents a generic return type for HTTP responses, with methods to determine the status code and write to the context. type Response interface { Status() int WriteToCtx(ctx *Context) error } +// PlainResponse returns a cached response with status 200 and the given content type and body. func PlainResponse(contentType string, body any) Response { return CachedResponse(http.StatusOK, contentType, body) } +// CachedResponse returns a cached response with the given HTTP status code, content type, and body. func CachedResponse(code int, contentType string, body any) Response { if body == nil && code != http.StatusNoContent { body = http.StatusText(code) @@ -69,6 +70,7 @@ func CachedResponse(code int, contentType string, body any) Response { } } +// cachedResp is an internal cached response type. type cachedResp struct { ct string body []byte @@ -97,8 +99,8 @@ func (r *cachedResp) MarshalMsgPack() ([]byte, error) { func (r *cachedResp) Cached() Response { return r } -// ReadJSONResponse reads a response from an io.ReadCloser and closes the body. -// dataValue is the data type you're expecting, for example: +// ReadJSONResponse reads and decodes a JSON response from an io.ReadCloser, closing the body. +// dataValue is the target type for the response data field, for example: // // r, err := ReadJSONResponse(res.Body, &map[string]*Stats{}) func ReadJSONResponse(rc io.ReadCloser, dataValue any) (r *JSONResponse, err error) { @@ -128,6 +130,7 @@ func ReadJSONResponse(rc io.ReadCloser, dataValue any) (r *JSONResponse, err err return } +// JSONRequest makes an HTTP request and decodes the response as JSON into respData. func JSONRequest(method, url string, reqData, respData any) (err error) { return otk.Request(method, "", url, reqData, func(r *http.Response) error { _, err := ReadJSONResponse(r.Body, respData) @@ -135,8 +138,7 @@ func JSONRequest(method, url string, reqData, respData any) (err error) { }) } -// Redirect returns a redirect Response. -// if perm is false it uses http.StatusFound (302), otherwise http.StatusMovedPermanently (302) +// Redirect returns a redirect response, using 302 if perm is false or 301 if perm is true. func Redirect(url string, perm bool) Response { code := http.StatusFound if perm { @@ -145,11 +147,12 @@ func Redirect(url string, perm bool) Response { return RedirectWithCode(url, code) } -// RedirectWithCode returns a redirect Response with the specified status code. +// RedirectWithCode returns a redirect response with the specified HTTP status code. func RedirectWithCode(url string, code int) Response { return redirResp{url, code} } +// redirResp is an internal redirect response type. type redirResp struct { url string code int @@ -164,8 +167,7 @@ func (r redirResp) WriteToCtx(ctx *Context) error { return nil } -// File returns a file response. -// example: return File("plain/html", "index.html") +// File returns a response that serves the file at the given path with the specified content type. func File(contentType, fp string) Response { if contentType == "" { contentType = mime.TypeByExtension(filepath.Ext(fp)) @@ -173,6 +175,7 @@ func File(contentType, fp string) Response { return fileResp{contentType, fp} } +// fileResp is an internal file response type. type fileResp struct { ct string fp string diff --git a/server.go b/server.go index 9774e06..a8d69de 100644 --- a/server.go +++ b/server.go @@ -23,6 +23,7 @@ import ( "golang.org/x/net/http2/h2c" ) +// DefaultPanicHandler is the default panic recovery handler that logs the panic and returns a JSON 500 error response. var DefaultPanicHandler = func(ctx *Context, v any, fr *oerrs.Frame) { msg, info := fmt.Sprintf("PANIC in %s %s: %v", ctx.Req.Method, ctx.Path(), v), fmt.Sprintf("at %s %s:%d", fr.Function, fr.File, fr.Line) ctx.Logf("%s (%s)", msg, info) @@ -42,7 +43,7 @@ var DefaultOpts = Options{ Logger: log.New(os.Stderr, "gserv: ", 0), } -// New returns a new server with the specified options. +// New creates a new Server with the given options. func New(opts ...Option) *Server { o := DefaultOpts @@ -53,7 +54,7 @@ func New(opts ...Option) *Server { return NewWithOpts(&o) } -// NewWithOpts allows passing the Options struct directly +// NewWithOpts creates a new Server from an Options struct. func NewWithOpts(opts *Options) *Server { srv := &Server{} @@ -94,7 +95,7 @@ type ( PanicHandler = func(ctx *Context, v any, fr *oerrs.Frame) ) -// Server is the main server +// Server is the main HTTP server type. type Server struct { Group r *router.Router @@ -110,7 +111,7 @@ type Server struct { NoCompression bool // used by proxies } -// ServeHTTP allows using the server in custom scenarios that expects an http.Handler. +// ServeHTTP implements http.Handler, allowing the server to be used in custom scenarios. func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { s.r.ServeHTTP(w, req) } @@ -149,7 +150,8 @@ func (s *Server) newHTTPServer(ctx context.Context, addr string, forceHTTP2 bool return srv } -// Run starts the server on the specific address +// Run starts the server on the given address with HTTP/2 support. +// If addr is empty, it defaults to ":http". func (s *Server) Run(ctx context.Context, addr string) error { if addr == "" { addr = ":http" @@ -172,8 +174,7 @@ func (s *Server) Run(ctx context.Context, addr string) error { return err } -// SetKeepAlivesEnabled controls whether HTTP keep-alives are enabled. -// By default, keep-alives are always enabled. +// SetKeepAlivesEnabled enables or disables HTTP keep-alives on all underlying servers. func (s *Server) SetKeepAlivesEnabled(v bool) { s.serversMux.Lock() for _, srv := range s.servers { @@ -182,7 +183,7 @@ func (s *Server) SetKeepAlivesEnabled(v bool) { s.serversMux.Unlock() } -// Addrs returns all the listening addresses used by the underlying http.Server(s). +// Addrs returns all the listening addresses of the underlying http.Servers. func (s *Server) Addrs() (out []string) { s.serversMux.Lock() out = make([]string, len(s.servers)) @@ -193,12 +194,12 @@ func (s *Server) Addrs() (out []string) { return out } -// Closed returns true if the server is already shutdown/closed +// Closed returns true if the server has been shut down or closed. func (s *Server) Closed() bool { return atomic.LoadInt32(&s.closed) == 1 } -// Logf logs to the default server logger if set +// Logf logs a formatted message using the server's logger. func (s *Server) Logf(f string, args ...any) { s.logfStack(2, f, args...) } @@ -224,16 +225,17 @@ func (s *Server) logfStack(n int, f string, args ...any) { lg.Printf(strings.Join(parts, "/")+":"+strconv.Itoa(line)+": "+f, args...) } -// AllowCORS is an alias for s.AddRoute("OPTIONS", path, AllowCORS(allowedMethods...)) +// AllowCORS adds an OPTIONS route for CORS support using the given allowed methods. func (s *Server) AllowCORS(path string, allowedMethods ...string) { s.AddRoute(http.MethodOptions, path, AllowCORS(allowedMethods, nil, nil)) } +// Swagger returns the swagger documentation instance for this server. func (s *Server) Swagger() *router.Swagger { return s.r.Swagger() } -// Close immediately closes all the active underlying http servers and connections. +// Close immediately closes all the active underlying http servers and connections without graceful shutdown. func (s *Server) Close() error { if !atomic.CompareAndSwapInt32(&s.closed, 0, 1) { return http.ErrServerClosed @@ -255,8 +257,7 @@ func (s *Server) Close() error { return me.Err() } -// Shutdown gracefully shutdown all the underlying http servers. -// You can optionally set a timeout. +// Shutdown gracefully shuts down all the underlying http servers, optionally with a timeout. func (s *Server) Shutdown(timeout time.Duration) error { if !atomic.CompareAndSwapInt32(&s.closed, 0, 1) { return http.ErrServerClosed diff --git a/ssl.go b/ssl.go index 731d6d4..376b7c8 100644 --- a/ssl.go +++ b/ssl.go @@ -18,6 +18,7 @@ import ( "golang.org/x/net/idna" ) +// NewCertPair reads a certificate and key file pair from disk. func NewCertPair(certFile, keyFile string) (cp CertPair, err error) { var cert, key []byte if cert, err = os.ReadFile(certFile); err != nil { @@ -31,16 +32,16 @@ func NewCertPair(certFile, keyFile string) (cp CertPair, err error) { return CertPair{Cert: cert, Key: key}, nil } -// CertPair is a pair of (cert, key) files to listen on TLS +// CertPair holds a TLS certificate and key pair along with optional root CA certificates. type CertPair struct { Cert []byte `json:"cert"` Key []byte `json:"key"` Roots [][]byte `json:"roots"` } -// RunAutoCert enables automatic support for LetsEncrypt, using the optional passed domains list. -// certCacheDir is where the certificates will be cached, defaults to "./autocert". -// Note that it must always run on *BOTH* ":80" and ":443" so the addr param is omitted. +// RunAutoCert enables automatic LetsEncrypt support using the given domains as a whitelist. +// certCacheDir is where certificates are cached, defaulting to "./autocert". +// It must always be run on both ":80" and ":443", so the addr parameter is omitted. func (s *Server) RunAutoCert(ctx context.Context, certCacheDir string, domains ...string) error { var hbFn autocert.HostPolicy if len(domains) > 0 { @@ -53,6 +54,7 @@ func (s *Server) RunAutoCert(ctx context.Context, certCacheDir string, domains . }) } +// AutoCertOpts configures automatic LetsEncrypt certificate management. type AutoCertOpts struct { Hosts autocert.HostPolicy `json:"hosts"` @@ -98,9 +100,9 @@ func (aco *AutoCertOpts) manager() (*autocert.Manager, error) { return m, nil } -// RunAutoCertDyn enables automatic support for LetsEncrypt, using a dynamic HostPolicy. -// certCacheDir is where the certificates will be cached, defaults to "./autocert". -// Note that it must always run on *BOTH* ":80" and ":443" so the addr param is omitted. +// RunAutoCertDyn enables automatic LetsEncrypt support using a dynamic HostPolicy for domain validation. +// certCacheDir is where certificates are cached, defaulting to "./autocert". +// It must always be run on both ":80" and ":443", so the addr parameter is omitted. func (s *Server) RunAutoCertDyn(ctx context.Context, opts *AutoCertOpts) error { m, err := opts.manager() if err != nil { @@ -128,17 +130,20 @@ func (s *Server) RunAutoCertDyn(ctx context.Context, opts *AutoCertOpts) error { return err } +// NewAutoCertHosts creates a new AutoCertHosts instance from the given hostnames. func NewAutoCertHosts(hosts ...string) *AutoCertHosts { var ach AutoCertHosts ach.appendHosts(hosts...) return &ach } +// AutoCertHosts provides a dynamic host whitelist for LetsEncrypt autocert. type AutoCertHosts struct { m otk.Set mux sync.RWMutex } +// Set updates the set of allowed hosts. func (a *AutoCertHosts) Set(hosts ...string) { a.mux.Lock() a.appendHosts(hosts...) @@ -155,6 +160,7 @@ func (a *AutoCertHosts) appendHosts(hosts ...string) (m map[string]struct{}) { return } +// Contains checks if the given host is in the whitelist. func (a *AutoCertHosts) Contains(host string) bool { host = strings.ToLower(host) if h, err := idna.Lookup.ToASCII(host); err == nil { @@ -168,6 +174,7 @@ func (a *AutoCertHosts) Contains(host string) bool { return ok } +// IsAllowed implements autocert.HostPolicy, returning an error if the host is not allowed. func (a *AutoCertHosts) IsAllowed(_ context.Context, host string) error { if a.Contains(host) { return nil @@ -175,8 +182,8 @@ func (a *AutoCertHosts) IsAllowed(_ context.Context, host string) error { return fmt.Errorf("gserv/autocert: host %q not configured in AutoCertHosts", host) } -// RunTLSAndAuto allows using custom certificates and autocert together. -// It will always listen on both :80 and :443 +// RunTLSAndAuto enables TLS with custom certificates alongside LetsEncrypt autocert. +// It always listens on both :80 and :443. func (s *Server) RunTLSAndAuto(ctx context.Context, certPairs []CertPair, opts *AutoCertOpts) (err error) { srv := s.newHTTPServer(ctx, ":https", false) diff --git a/utils.go b/utils.go index 477a0f8..e36c1eb 100644 --- a/utils.go +++ b/utils.go @@ -17,12 +17,12 @@ import ( var nukeCookieDate = time.Date(1991, time.August, 6, 0, 0, 0, 0, time.UTC) -// HTTPHandler returns a Handler from an http.Handler. +// HTTPHandler converts an http.Handler into a gserv Handler. func HTTPHandler(h http.Handler) Handler { return HTTPHandlerFunc(h.ServeHTTP) } -// HTTPHandlerFunc returns a Handler from an http.Handler. +// HTTPHandlerFunc converts an http.HandlerFunc into a gserv Handler. func HTTPHandlerFunc(h http.HandlerFunc) Handler { return func(ctx *Context) Response { h(ctx, ctx.Req) @@ -30,7 +30,8 @@ func HTTPHandlerFunc(h http.HandlerFunc) Handler { } } -// StaticDirStd is a QoL wrapper for http.FileServer(http.Dir(dir)). +// StaticDirStd returns a handler that serves static files from the given directory with an optional prefix. +// If allowListing is false, directory listing is disabled and index.html is served for directories. func StaticDirStd(prefix, dir string, allowListing bool) Handler { var fs http.FileSystem if allowListing { @@ -41,16 +42,15 @@ func StaticDirStd(prefix, dir string, allowListing bool) Handler { return HTTPHandler(http.StripPrefix(prefix, http.FileServer(fs))) } -// StaticDir is a shorthand for StaticDirWithLimit(dir, paramName, -1). +// StaticDir returns a handler that serves static files from the given directory without prefix or listing. func StaticDir(dir, paramName string) Handler { return StaticDirStd("", dir, false) // return StaticDirWithLimit(dir, paramName, -1) } -// StaticDirWithLimit returns a handler that handles serving static files. -// paramName is the path param, for example: s.GET("/s/*fp", StaticDirWithLimit("./static/", "fp", 1000)). -// if limit is > 0, it will only ever serve N files at a time. -// BUG: returns 0 size for some reason +// StaticDirWithLimit returns a handler that serves static files from the given directory. +// The paramName is the path param used to extract the file path, for example: s.GET("/s/*fp", StaticDirWithLimit("./static/", "fp", 1000)). +// If limit is greater than 0, at most N requests can be served simultaneously. func StaticDirWithLimit(dir, paramName string, limit int) Handler { var ( sem chan struct{} @@ -125,11 +125,11 @@ func matchStarOrigin(set otk.Set, keys []string, origin string) bool { return false } -// AllowCORS allows CORS responses. -// If methods is empty, it will respond with the requested method. -// If headers is empty, it will respond with the requested headers. -// If origins is empty, it will respond with the requested origin. -// will automatically install an OPTIONS handler to each passed group. +// AllowCORS returns a CORS middleware that allows cross-origin requests. +// If methods is empty, the requested method from Access-Control-Request-Method is used. +// If headers is empty, the requested headers from Access-Control-Request-Headers are used. +// If origins is empty, any origin is allowed via wildcard matching for subdomains. +// It automatically installs an OPTIONS preflight handler to each passed group. func AllowCORS(methods, headers, origins []string, groups ...GroupType) Handler { ms := strings.Join(methods, ", ") hs := strings.Join(headers, ", ") @@ -179,9 +179,10 @@ func AllowCORS(methods, headers, origins []string, groups ...GroupType) Handler return fn } +// M is a shorthand type for map[string]any, used for context values. type M map[string]any -// ToJSON returns a string json representation of M, mostly for debugging. +// ToJSON returns a JSON string representation of M, primarily for debugging. func (m M) ToJSON(indent bool) string { if len(m) == 0 { return "{}" @@ -196,17 +197,17 @@ func (m M) ToJSON(indent bool) string { return string(j) } -// MultiError handles returning multiple errors. +// MultiError accumulates multiple errors and can be returned as a single error. type MultiError []error -// Push adds an error to the MultiError slice if err != nil. +// Push adds an error to the MultiError slice if err is not nil. func (me *MultiError) Push(err error) { if err != nil { *me = append(*me, err) } } -// Err returns nil if me is empty. +// Err returns nil if there are no accumulated errors, or a combined error otherwise. func (me MultiError) Err() error { if len(me) == 0 { return nil @@ -228,6 +229,7 @@ func (me MultiError) Error() string { return "multiple errors returned:\n\t" + strings.Join(errs, "\n\t") } +// H2Client returns an HTTP client configured for HTTP/2 with cleartext (h2c) support. func H2Client() *http.Client { return &http.Client{ Transport: &http2.Transport{ @@ -239,6 +241,7 @@ func H2Client() *http.Client { } } +// DummyResponseWriter is a response writer that buffers output for inspection. type DummyResponseWriter struct { h http.Header buf bytes.Buffer From 8343779884750dfa86194d3c57bb7693248decd9 Mon Sep 17 00:00:00 2001 From: OneOfOne Date: Mon, 25 May 2026 18:10:31 -0500 Subject: [PATCH 3/3] typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 027f820..03b9e39 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ srv := gserv.New( - 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.