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
52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ type Greeting struct {
Hello string `json:"hello"`
}

func greet(r *http.Request, body *Person) (*Greeting, error) {
return &Greeting{Hello: body.Name}, nil
func greet(r *http.Request, in *Person) (*Greeting, error) {
return &Greeting{Hello: in.Name}, nil
}

func main() {
Expand All @@ -82,20 +82,51 @@ That's it. ShiftAPI reflects your Go types into an OpenAPI 3.1 spec at `/openapi

### Generic type-safe handlers

Generic free functions capture your request and response types at compile time. Handlers with a body (`Post`, `Put`, `Patch`) receive the decoded request as a typed value. Handlers without a body (`Get`, `Delete`, `Head`) just receive the request.
Generic free functions capture your request and response types at compile time. Every method uses a single function — struct tags discriminate query params (`query:"..."`) from body fields (`json:"..."`). For routes without input, use `_ struct{}`.

```go
// POST body is decoded and passed as *CreateUser
shiftapi.Post(api, "/users", func(r *http.Request, body *CreateUser) (*User, error) {
return db.CreateUser(r.Context(), body)
// POST with body — input is decoded and passed as *CreateUser
shiftapi.Post(api, "/users", func(r *http.Request, in *CreateUser) (*User, error) {
return db.CreateUser(r.Context(), in)
}, shiftapi.WithStatus(http.StatusCreated))

// GET — standard *http.Request, use PathValue for path params
shiftapi.Get(api, "/users/{id}", func(r *http.Request) (*User, error) {
// GET without input — use _ struct{}
shiftapi.Get(api, "/users/{id}", func(r *http.Request, _ struct{}) (*User, error) {
return db.GetUser(r.Context(), r.PathValue("id"))
})
```

### Typed query parameters

Define a struct with `query` tags. Query params are parsed, validated, and documented in the OpenAPI spec automatically.

```go
type SearchQuery struct {
Q string `query:"q" validate:"required"`
Page int `query:"page" validate:"min=1"`
Limit int `query:"limit" validate:"min=1,max=100"`
}

shiftapi.Get(api, "/search", func(r *http.Request, in SearchQuery) (*Results, error) {
return doSearch(in.Q, in.Page, in.Limit), nil
})
```

Supports `string`, `bool`, `int*`, `uint*`, `float*` scalars, `*T` pointers for optional params, and `[]T` slices for repeated params (e.g. `?tag=a&tag=b`). Parse errors return `400`; validation failures return `422`.

For handlers that need both query parameters and a request body, combine them in a single struct — fields with `query` tags become query params, fields with `json` tags become the body:

```go
type CreateInput struct {
DryRun bool `query:"dry_run"`
Name string `json:"name"`
}

shiftapi.Post(api, "/items", func(r *http.Request, in CreateInput) (*Result, error) {
return createItem(in.Name, in.DryRun), nil
})
```

### Validation

Built-in validation via [go-playground/validator](https://github.com/go-playground/validator). Struct tags are enforced at runtime *and* reflected into the OpenAPI schema.
Expand Down Expand Up @@ -198,6 +229,11 @@ const { data: greeting } = await client.POST("/greet", {
body: { name: "frank" },
});
// body and response are fully typed from your Go structs

const { data: results } = await client.GET("/search", {
params: { query: { q: "hello", page: 1, limit: 10 } },
});
// query params are fully typed too — { q: string, page?: number, limit?: number }
```

In dev mode the plugin also starts the Go server, proxies API requests through Vite, watches `.go` files, and hot-reloads the frontend when types change.
Expand Down
36 changes: 32 additions & 4 deletions examples/greeter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,38 @@ type Greeting struct {
Hello string `json:"hello"`
}

func greet(r *http.Request, body *Person) (*Greeting, error) {
if body.Name != "frank" {
func greet(r *http.Request, in *Person) (*Greeting, error) {
if in.Name != "frank" {
return nil, shiftapi.Error(http.StatusBadRequest, "wrong name, I only greet frank")
}
return &Greeting{Hello: body.Name}, nil
return &Greeting{Hello: in.Name}, nil
}

type SearchQuery struct {
Q string `query:"q" validate:"required"`
Page int `query:"page" validate:"min=1"`
Limit int `query:"limit" validate:"min=1,max=100"`
}

type SearchResult struct {
Query string `json:"query"`
Page int `json:"page"`
Limit int `json:"limit"`
}

func search(r *http.Request, in SearchQuery) (*SearchResult, error) {
return &SearchResult{
Query: in.Q,
Page: in.Page,
Limit: in.Limit,
}, nil
}

type Status struct {
OK bool `json:"ok"`
}

func health(r *http.Request) (*Status, error) {
func health(r *http.Request, _ struct{}) (*Status, error) {
return &Status{OK: true}, nil
}

Expand All @@ -43,6 +63,14 @@ func main() {
}),
)

shiftapi.Get(api, "/search", search,
shiftapi.WithRouteInfo(shiftapi.RouteInfo{
Summary: "Search for things",
Description: "Search with typed query parameters",
Tags: []string{"search"},
}),
)

shiftapi.Get(api, "/health", health,
shiftapi.WithRouteInfo(shiftapi.RouteInfo{
Summary: "Health check",
Expand Down
56 changes: 35 additions & 21 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,51 @@ import (
"errors"
"log"
"net/http"
"reflect"
)

// HandlerFunc is a typed handler for methods without a request body (GET, DELETE, HEAD, etc.).
type HandlerFunc[Resp any] func(r *http.Request) (Resp, error)
// HandlerFunc is a typed handler for routes.
// The In struct's fields are discriminated by struct tags:
// fields with `query:"..."` tags are parsed from query parameters,
// and fields with `json:"..."` tags (or no query tag) are parsed from the request body.
// For routes without input, use struct{} as the In type.
type HandlerFunc[In, Resp any] func(r *http.Request, in In) (Resp, error)

// HandlerFuncWithBody is a typed handler for methods with a request body (POST, PUT, PATCH, etc.).
type HandlerFuncWithBody[Body, Resp any] func(r *http.Request, body Body) (Resp, error)

func adapt[Resp any](fn HandlerFunc[Resp], status int) http.HandlerFunc {
func adapt[In, Resp any](fn HandlerFunc[In, Resp], status int, validate func(any) error, hasQuery, hasBody bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp, err := fn(r)
if err != nil {
writeError(w, err)
return
var in In
rv := reflect.ValueOf(&in).Elem()

// JSON-decode body if there are body fields
if hasBody {
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, Error(http.StatusBadRequest, "invalid request body"))
return
}
// Re-point rv after decode (in case In is a pointer that was nil)
rv = reflect.ValueOf(&in).Elem()
}
writeJSON(w, status, resp)
}
}

func adaptWithBody[Body, Resp any](fn HandlerFuncWithBody[Body, Resp], status int, validate func(any) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body Body
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, Error(http.StatusBadRequest, "invalid request body"))
return
// Reset any query-tagged fields that body decode may have
// inadvertently set, so they only come from URL query params.
if hasBody && hasQuery {
resetQueryFields(rv)
}
if err := validate(body); err != nil {

// Parse query params if there are query fields
if hasQuery {
if err := parseQueryInto(rv, r.URL.Query()); err != nil {
writeError(w, Error(http.StatusBadRequest, err.Error()))
return
}
}

if err := validate(in); err != nil {
writeError(w, err)
return
}
resp, err := fn(r, body)

resp, err := fn(r, in)
if err != nil {
writeError(w, err)
return
Expand Down
95 changes: 48 additions & 47 deletions handlerFuncs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,93 +6,94 @@ import (
"reflect"
)

func registerRoute[Resp any](
func registerRoute[In, Resp any](
api *API,
method string,
path string,
fn HandlerFunc[Resp],
fn HandlerFunc[In, Resp],
options ...RouteOption,
) {
cfg := applyRouteOptions(options)

var resp Resp
outType := reflect.TypeOf(resp)

if err := api.updateSchema(method, path, nil, outType, cfg.info, cfg.status); err != nil {
panic(fmt.Sprintf("shiftapi: schema generation failed for %s %s: %v", method, path, err))
var in In
inType := reflect.TypeOf(in)
// Dereference pointer to get the underlying struct type
rawInType := inType
for rawInType != nil && rawInType.Kind() == reflect.Pointer {
rawInType = rawInType.Elem()
}

pattern := fmt.Sprintf("%s %s", method, path)
api.mux.HandleFunc(pattern, adapt(fn, cfg.status))
}
hasQuery, hasBody := partitionFields(rawInType)

func registerRouteWithBody[Body, Resp any](
api *API,
method string,
path string,
fn HandlerFuncWithBody[Body, Resp],
options ...RouteOption,
) {
cfg := applyRouteOptions(options)
var queryType reflect.Type
if hasQuery {
queryType = rawInType
}
// POST/PUT/PATCH conventionally carry a request body, so always attempt
// body decode for these methods — even when the input is struct{}.
// This means Post(api, path, func(r, _ struct{}) ...) requires at least "{}".
methodRequiresBody := method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch
decodeBody := hasBody || methodRequiresBody

var bodyType reflect.Type
if hasBody {
bodyType = inType
} else if methodRequiresBody {
bodyType = rawInType
}

var body Body
inType := reflect.TypeOf(body)
var resp Resp
outType := reflect.TypeOf(resp)

if err := api.updateSchema(method, path, inType, outType, cfg.info, cfg.status); err != nil {
if err := api.updateSchema(method, path, queryType, bodyType, outType, cfg.info, cfg.status); err != nil {
panic(fmt.Sprintf("shiftapi: schema generation failed for %s %s: %v", method, path, err))
}

pattern := fmt.Sprintf("%s %s", method, path)
api.mux.HandleFunc(pattern, adaptWithBody(fn, cfg.status, api.validateBody))
api.mux.HandleFunc(pattern, adapt(fn, cfg.status, api.validateBody, hasQuery, decodeBody))
}

// No-body methods

// Get registers a GET handler.
func Get[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
func Get[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodGet, path, fn, options...)
}

// Post registers a POST handler.
func Post[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodPost, path, fn, options...)
}

// Put registers a PUT handler.
func Put[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodPut, path, fn, options...)
}

// Patch registers a PATCH handler.
func Patch[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodPatch, path, fn, options...)
}

// Delete registers a DELETE handler.
func Delete[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
func Delete[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodDelete, path, fn, options...)
}

// Head registers a HEAD handler.
func Head[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
func Head[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodHead, path, fn, options...)
}

// Options registers an OPTIONS handler.
func Options[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
func Options[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodOptions, path, fn, options...)
}

// Trace registers a TRACE handler.
func Trace[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
func Trace[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodTrace, path, fn, options...)
}

// Body methods

// Post registers a POST handler.
func Post[Body, Resp any](api *API, path string, fn HandlerFuncWithBody[Body, Resp], options ...RouteOption) {
registerRouteWithBody(api, http.MethodPost, path, fn, options...)
}

// Put registers a PUT handler.
func Put[Body, Resp any](api *API, path string, fn HandlerFuncWithBody[Body, Resp], options ...RouteOption) {
registerRouteWithBody(api, http.MethodPut, path, fn, options...)
}

// Patch registers a PATCH handler.
func Patch[Body, Resp any](api *API, path string, fn HandlerFuncWithBody[Body, Resp], options ...RouteOption) {
registerRouteWithBody(api, http.MethodPatch, path, fn, options...)
}

// Connect registers a CONNECT handler.
func Connect[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
func Connect[In, Resp any](api *API, path string, fn HandlerFunc[In, Resp], options ...RouteOption) {
registerRoute(api, http.MethodConnect, path, fn, options...)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ type EchoResponse struct {
Message string `json:"message"`
}

func echo(r *http.Request, body *EchoRequest) (*EchoResponse, error) {
return &EchoResponse{Message: body.Message}, nil
func echo(r *http.Request, in *EchoRequest) (*EchoResponse, error) {
return &EchoResponse{Message: in.Message}, nil
}

type Status struct {
OK bool `json:"ok"`
}

func health(r *http.Request) (*Status, error) {
func health(r *http.Request, _ struct{}) (*Status, error) {
return &Status{OK: true}, nil
}

Expand Down
Loading