Skip to content

split out httpserver.middleware into a new sub-package, with improved API #50

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 4, 2025
Merged
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
46 changes: 13 additions & 33 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,21 @@
# go-supervisor Examples
# Examples

This directory contains examples demonstrating how to use the go-supervisor package in real-world scenarios. Each example showcases different aspects of service lifecycle management, configuration, and state handling.
Several working examples of go-supervisor usage.

> **Note:** More examples are coming soon as we add additional runnable implementations to the project. Currently, the HTTP server example demonstrates a complete implementation of the supervisor interfaces.
## [http](./http/)
Basic HTTP server with graceful shutdown and configuration reloading.

## HTTP Server Example
## [custom_middleware](./custom_middleware/)
HTTP server with middleware that transforms responses.

The [http](./http/) directory contains a complete example of an HTTP server managed by the go-supervisor package.
## [composite](./composite/)
Multiple dynamic services managed as a single unit, using Generics.

This example demonstrates:
- Creating a `runnables.HttpServer`, implementing `supervisor.PIDZero` interfaces (Runnable, Reloadable, Stateable)
- Configuration of routes and handlers, from the `httpserver` package
- Logging with an `slog.Handler`
## [httpcluster](./httpcluster/)
Similar to composite, but designed specifically for running several `httpserver` instances, with a channel-based config "siphon" for dynamic updates.

Key components of the HTTP server example:

1. **Route Configuration**: Shows how to create standard and wildcard routes
2. **Configuration Callback**: Demonstrates dynamic configuration loading
3. **State Management**: Uses FSM-based state tracking for the server lifecycle
4. **Supervisor Integration**: Properly initializes and uses the PIDZero supervisor

To run the HTTP server example:
## Running

```bash
cd examples/http
go build
./http
```

Then access the server at:
- `http://localhost:8080/` - Index route
- `http://localhost:8080/status` - Status endpoint
- `http://localhost:8080/api/anything` - Wildcard API route

Press Ctrl+C to trigger a graceful shutdown.

## Implementing Your Own Runnable
For detailed instructions on implementing your own runnable, please refer to the root-level README. The HTTP example in this directory provides a complete implementation that you can use as a reference.

See the [HTTP example](./http/main.go) for a complete implementation.
go run ./examples/<name>
```
2 changes: 2 additions & 0 deletions examples/custom_middleware/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# ignore the compiled binary
custom_middleware
46 changes: 46 additions & 0 deletions examples/custom_middleware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Custom Middleware Example

This example demonstrates how to create custom middleware for the httpserver package.

## What It Shows

- Creating a custom middleware that transforms HTTP responses
- Using built-in middleware from the httpserver package
- Correct middleware ordering and composition
- Separation of concerns between middleware layers

## Key Components

### JSON Enforcer Middleware
A custom middleware that ensures all responses are JSON formatted. Non-JSON responses are wrapped in `{"response": "content"}` while valid JSON passes through unchanged.

### Headers Middleware
Uses the built-in headers middleware to set Content-Type, CORS, and security headers.

## Running the Example

```bash
go run ./examples/custom_middleware
```

The server starts on `:8081` with several endpoints to demonstrate the middleware behavior.

## Endpoints

- `GET /` - Returns plain text (wrapped in JSON)
- `GET /api/data` - Returns JSON (preserved as-is)
- `GET /html` - Returns HTML (wrapped in JSON)
- `GET /error` - Returns 404 error (wrapped in JSON)
- `GET /panic` - Triggers panic recovery middleware

## Middleware Ordering

The example demonstrates why middleware order matters:

1. **Recovery** - Must be first to catch panics
2. **Security** - Set security headers early
3. **Logging** - Log all requests
4. **Metrics** - Collect request metrics
5. **Headers** - Set response headers before handler

See the code comments in `main.go`.
144 changes: 144 additions & 0 deletions examples/custom_middleware/example/jsonenforcer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package example

import (
"bytes"
"encoding/json"
"net/http"

"github.com/robbyt/go-supervisor/runnables/httpserver"
)

// ResponseBuffer captures response data for transformation
//
// This is a simple example for demonstration purposes and is not intended for
// production use. Limitations:
// - Does not preserve optional HTTP interfaces (http.Hijacker, http.Flusher, http.Pusher)
// - Not safe for concurrent writes from multiple goroutines within the same request
// - No memory limits on buffered content
//
// Each request gets its own ResponseBuffer instance, so different requests won't
// interfere with each other.
type ResponseBuffer struct {
buffer *bytes.Buffer
headers http.Header
status int
}

// NewResponseBuffer creates a new response buffer
func NewResponseBuffer() *ResponseBuffer {
return &ResponseBuffer{
buffer: new(bytes.Buffer),
headers: make(http.Header),
status: 0, // 0 means not set yet
}
}

// Header implements http.ResponseWriter
func (rb *ResponseBuffer) Header() http.Header {
return rb.headers
}

// Write implements http.ResponseWriter
func (rb *ResponseBuffer) Write(data []byte) (int, error) {
return rb.buffer.Write(data)
}

// WriteHeader implements http.ResponseWriter
func (rb *ResponseBuffer) WriteHeader(statusCode int) {
if rb.status == 0 {
rb.status = statusCode
}
}

// Status implements httpserver.ResponseWriter
func (rb *ResponseBuffer) Status() int {
if rb.status == 0 && rb.buffer.Len() > 0 {
return http.StatusOK
}
return rb.status
}

// Written implements httpserver.ResponseWriter
func (rb *ResponseBuffer) Written() bool {
return rb.buffer.Len() > 0 || rb.status != 0
}

// Size implements httpserver.ResponseWriter
func (rb *ResponseBuffer) Size() int {
return rb.buffer.Len()
}

// transformToJSON wraps non-JSON content in a JSON response
func transformToJSON(data []byte) ([]byte, error) {
// Use json.Valid for efficient validation without unmarshaling
if json.Valid(data) {
return data, nil // Valid JSON, return as-is
}

// If not valid JSON, wrap it
response := map[string]string{
"response": string(data),
}

return json.Marshal(response)
}

// New creates a middleware that transforms all responses to JSON format.
// Non-JSON responses are wrapped in {"response": "content"}.
// Valid JSON responses are preserved as-is.
func New() httpserver.HandlerFunc {
return func(rp *httpserver.RequestProcessor) {
// Store original writer before buffering
originalWriter := rp.Writer()

// Buffer the response to capture output
buffer := NewResponseBuffer()
rp.SetWriter(buffer)

// Continue to next middleware/handler
rp.Next()

// RESPONSE PHASE: Transform response to JSON
originalData := buffer.buffer.Bytes()
statusCode := buffer.Status()
if statusCode == 0 {
statusCode = http.StatusOK
}

// Copy headers to original writer
for key, values := range buffer.Header() {
for _, value := range values {
originalWriter.Header().Add(key, value)
}
}

// Check if this status code should have no body per HTTP spec
// 204 No Content and 304 Not Modified MUST NOT have a message body
if statusCode == http.StatusNoContent || statusCode == http.StatusNotModified {
originalWriter.WriteHeader(statusCode)
return
}

// Transform captured data to JSON
if len(originalData) == 0 && buffer.status == 0 {
return
}

// Transform to JSON if needed
jsonData, err := transformToJSON(originalData)
if err != nil {
// Fallback: wrap error in JSON
jsonData = []byte(`{"error":"Unable to encode response"}`)
}

// Ensure JSON content type
originalWriter.Header().Set("Content-Type", "application/json")

// Write status and transformed data
originalWriter.WriteHeader(statusCode)
if _, err := originalWriter.Write(jsonData); err != nil {
// Response is already committed, cannot recover from write error
return
}
}
}
Loading