Skip to content
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
97 changes: 72 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ native performance untouched. Simple, not simplistic.

## Features

**Core**
### Core

- Zero dependencies — only the Go standard library
- Method-based routing: `Get`, `Post`, `Put`, `Delete`, `Patch`,
Expand All @@ -27,14 +27,13 @@ native performance untouched. Simple, not simplistic.
context type to learn
- Wrapper composition with `Chain` and `ChainFunc`
- Conditional wrapper application with `Selector`
- Response helpers: `JSON` (with `EscapeForHTML` / `Indented` options),
`XML`, `Text`, `Bytes`, `Stream` for chunked streaming, and
`Attachment` for file downloads
- Request binding into structs with `query`, `form`, `path`, `header`
and `JSON`/`XML` tags
- [Request binding](#request-binding): `BindJSON`, `BindXML`, `BindQuery`,
`BindForm`, `BindPath` and `BindHeader`
- [Response helpers](#response-helpers): `JSON`, `XML`, `Text`, `Bytes`,
`Stream` and `Attachment`
- Graceful shutdown with `Run`

**Built-in wrappers**
### Built-in wrappers

| Wrapper | What it does |
|----------------------|-----------------------------------------------------------------------------------------|
Expand All @@ -44,18 +43,26 @@ native performance untouched. Simple, not simplistic.

`Default` bundles all three wrappers, ready to use with no configuration.

**Request binding**
### Request binding

- Bind requests into your own structs: `BindJSON`, `BindXML`,
`BindQuery`, `BindForm`, `BindPath` and `BindHeader`
- Bind incoming request data into your own structs from JSON, XML, query,
form, path, and header values.
- Struct tags with `default=` values, embedded structs, multipart file
uploads and map targets
uploads, and map targets
- Validation via `Validator`, custom formats via `Decoder`
- Read the request body more than once with `BufferBody`

See the [package documentation](https://pkg.go.dev/github.com/qm012/sim)
for the full struct-tag rules.

### Response helpers

- Write JSON, XML, text, byte, streaming, and attachment responses
- JSON options: `EscapeForHTML` for safe HTML embedding, `Indented`
for readable output
- `Stream` for large or in-progress bodies without loading them into
memory, `Attachment` for file downloads

## Installation

Requires Go 1.26+.
Expand All @@ -79,7 +86,7 @@ import (
func main() {
app := sim.Default()
app.Get("/", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello, sim"))
_ = sim.Text(w, http.StatusOK, "hello, sim")
})
_ = app.Run(context.Background(), ":8080")
}
Expand All @@ -94,7 +101,6 @@ package main

import (
"context"
"fmt"
"log"
"net/http"
"os"
Expand All @@ -120,10 +126,10 @@ func main() {
)

app.Get("/", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("welcome"))
_ = sim.Text(w, http.StatusOK, "welcome")
})
app.Any("/ping", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("pong"))
_ = sim.Text(w, http.StatusOK, "pong")
})

// Group routes under a common prefix.
Expand Down Expand Up @@ -164,15 +170,30 @@ func listUsers(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, _ = fmt.Fprintf(w, "list users, page %d\n", q.Page)
_ = sim.JSON(w, http.StatusOK, struct {
Page int `json:"page"`
Users []user `json:"users"`
}{q.Page, []user{
{Name: "alice", Age: 30},
{Name: "bob", Age: 25},
}})
}

func getUser(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "user %s", r.PathValue("id"))
// BindPath fills a struct from path values.
p, err := sim.BindPath[struct {
ID string `path:"id"`
}](r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = sim.JSON(w, http.StatusOK, user{ID: p.ID, Name: "alice", Age: 30})
}

// user is the payload createUser decodes from the request body.
// user is the payload the API exchanges with its clients.
type user struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Age int `json:"age"`
}
Expand All @@ -183,20 +204,33 @@ func createUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, "user %s created\n", u.Name)
_ = sim.JSON(w, http.StatusCreated, u)
}

func updateUser(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "user %s updated", r.PathValue("id"))
p, err := sim.BindPath[struct {
ID string `path:"id"`
}](r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u, err := sim.BindJSON[user](r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u.ID = p.ID

_ = sim.JSON(w, http.StatusOK, u)
}

func deleteUser(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}

func adminPanel(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintln(w, "admin")
_ = sim.Text(w, http.StatusOK, "admin")
}
```

Expand All @@ -207,12 +241,25 @@ go run main.go
```

Open http://localhost:8080/ to see "welcome", and
http://localhost:8080/api/users for the user list. The endpoints speak
HTTP, so try binding too:
http://localhost:8080/api/users for the user list. The endpoints return
JSON. Try them:

```bash
curl 'localhost:8080/api/users?page=2'
curl -X POST localhost:8080/api/users -d '{"name":"alice","age":30}'
# {"page":2,"users":[{"name":"alice","age":30},{"name":"bob","age":25}]}

curl localhost:8080/api/users/1
# {"id":"1","name":"alice","age":30}

curl -X POST localhost:8080/api/users \
-H 'Content-Type: application/json' \
-d '{"name":"alice","age":30}'
# {"name":"alice","age":30}

curl -X PUT localhost:8080/api/users/1 \
-H 'Content-Type: application/json' \
-d '{"name":"alice","age":31}'
# {"id":"1","name":"alice","age":31}
```

## Contributing
Expand Down
92 changes: 92 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -80,3 +81,94 @@ func ExampleDecoderFunc() {
// valid: order.paid evt_9f3a
// tampered: bad signature
}

type exampleUser struct {
XMLName xml.Name `json:"-" xml:"user"`
Name string `json:"name" xml:"name"`
Age int `json:"age" xml:"age"`
}

func ExampleJSON() {
w := httptest.NewRecorder()
_ = sim.JSON(w, http.StatusOK, exampleUser{Name: "alice", Age: 30})
fmt.Println(w.Code, w.Header().Get("Content-Type"))
fmt.Print(w.Body)
// Output:
// 200 application/json; charset=utf-8
// {"name":"alice","age":30}
}

func ExampleJSON_escapeForHTML() {
data := map[string]string{"url": "a<b&c"}

escaped := httptest.NewRecorder()
_ = sim.JSON(escaped, http.StatusOK, data)
fmt.Print(escaped.Body)

raw := httptest.NewRecorder()
_ = sim.JSON(raw, http.StatusOK, data, sim.EscapeForHTML(false))
fmt.Print(raw.Body)
// Output:
// {"url":"a\u003cb\u0026c"}
// {"url":"a<b&c"}
}

func ExampleJSON_indented() {
w := httptest.NewRecorder()
_ = sim.JSON(w, http.StatusOK, exampleUser{Name: "alice", Age: 30}, sim.Indented(true))
fmt.Print(w.Body)
// Output:
// {
// "name": "alice",
// "age": 30
// }
}

func ExampleXML() {
w := httptest.NewRecorder()
_ = sim.XML(w, http.StatusOK, exampleUser{Name: "alice", Age: 30})
fmt.Println(w.Header().Get("Content-Type"))
fmt.Print(w.Body)
// Output:
// application/xml; charset=utf-8
// <?xml version="1.0" encoding="UTF-8"?>
// <user><name>alice</name><age>30</age></user>
}

func ExampleText() {
w := httptest.NewRecorder()
_ = sim.Text(w, http.StatusOK, "hello, sim")
fmt.Println(w.Header().Get("Content-Type"))
fmt.Print(w.Body)
// Output:
// text/plain; charset=utf-8
// hello, sim
}

func ExampleBytes() {
w := httptest.NewRecorder()
_ = sim.Bytes(w, http.StatusOK, "text/csv", []byte("name,age\nalice,30\n"))
fmt.Println(w.Header().Get("Content-Type"))
fmt.Print(w.Body)
// Output:
// text/csv
// name,age
// alice,30
}

func ExampleStream() {
w := httptest.NewRecorder()
_ = sim.Stream(w, http.StatusOK, "text/plain", strings.NewReader("hello"))
fmt.Print(w.Body)
// Output: hello
}

func ExampleAttachment() {
w := httptest.NewRecorder()
_ = sim.Attachment(w, "report.txt", strings.NewReader("hello"))
fmt.Println(w.Header().Get("Content-Disposition"))
fmt.Print(w.Body)
// Output:
// attachment; filename=report.txt
// hello
}
34 changes: 26 additions & 8 deletions sim.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//
// import (
// "context"
// "log/slog"
// "log"
// "net/http"
//
// "github.com/qm012/sim"
Expand All @@ -22,19 +22,19 @@
// app := sim.Default()
//
// app.Get("/", func(w http.ResponseWriter, _ *http.Request) {
// w.Write([]byte("root."))
// _ = sim.Text(w, http.StatusOK, "root.")
// })
// app.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
// w.Write([]byte("user " + r.PathValue("id")))
// _ = sim.Text(w, http.StatusOK, "user "+r.PathValue("id"))
// })
// app.Group("/api", func(r sim.Router) {
// r.Post("/users", func(w http.ResponseWriter, _ *http.Request) {
// w.WriteHeader(http.StatusCreated)
// })
// })
//
// if err := app.Run(context.Background(), ":3333"); err != nil {
// slog.Error("server failed", "err", err)
// if err := app.Run(context.Background(), ":8080"); err != nil {
// log.Fatal(err)
// }
// }
//
Expand Down Expand Up @@ -107,6 +107,23 @@
// map[string][]string as the target type; the other binders require a
// struct.
//
// # Responding
//
// [JSON], [XML], [Text], [Bytes], [Stream] and [Attachment] write
// responses with a single call:
//
// - [JSON] encodes data as JSON. [EscapeForHTML] and [Indented]
// control escaping and formatting.
// - [XML] encodes data as XML, prepending the standard XML header.
// - [Text] writes a plain-text string.
// - [Bytes] writes raw bytes with a caller-supplied content type.
// - [Stream] copies an [io.Reader] to the response, suitable for large
// or streaming bodies such as proxied responses.
// - [Attachment] streams a body with a Content-Disposition header for download.
//
// For static files that need Range requests or caching, prefer
// [http.ServeFile] or [http.ServeFileFS].
//
// See the documentation of [App] for the full routing API.
package sim

Expand Down Expand Up @@ -144,9 +161,10 @@ type Router interface {
// with the same behavior as [http.ServeMux.HandleFunc] and [http.HandleFunc].
HandleFunc(pattern string, handler http.HandlerFunc)

// Any Get Post Delete Patch Put Options Head Connect and Trace
// register handlerFunc on the given pattern for their respective HTTP
// methods; Any matches all methods.
// Any matches all HTTP methods. Get, Post, Delete, Patch, Put, Options,
// Head, Connect, and Trace register handlerFunc for their respective HTTP
// methods. Unlike Handle and HandleFunc, these helpers take a path without
// a method prefix.
Any(path string, handlerFunc http.HandlerFunc)
Get(path string, handlerFunc http.HandlerFunc)
Post(path string, handlerFunc http.HandlerFunc)
Expand Down