Skip to content
Draft
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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ 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. 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. Query-param variants (`GetWithQuery`, `PostWithQuery`, etc.) add a typed query struct as well.

```go
// POST — body is decoded and passed as *CreateUser
Expand All @@ -96,6 +96,30 @@ shiftapi.Get(api, "/users/{id}", func(r *http.Request) (*User, error) {
})
```

### Typed query parameters

Define a struct with `query` tags and use `GetWithQuery`, `DeleteWithQuery`, `PostWithQuery`, etc. 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.GetWithQuery(api, "/search", func(r *http.Request, query SearchQuery) (*Results, error) {
return doSearch(query.Q, query.Page, query.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`). Use `query:"-"` to skip a field. Parse errors return `400`; validation failures return `422`.

For handlers that need both query parameters and a request body, use `PostWithQuery`, `PutWithQuery`, or `PatchWithQuery`:

```go
shiftapi.PostWithQuery[CreateQuery, CreateBody, *Result](api, "/items", handler)
```

### 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 +222,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
28 changes: 28 additions & 0 deletions examples/greeter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ func greet(r *http.Request, body *Person) (*Greeting, error) {
return &Greeting{Hello: body.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, query SearchQuery) (*SearchResult, error) {
return &SearchResult{
Query: query.Q,
Page: query.Page,
Limit: query.Limit,
}, nil
}

type Status struct {
OK bool `json:"ok"`
}
Expand All @@ -43,6 +63,14 @@ func main() {
}),
)

shiftapi.GetWithQuery(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
55 changes: 55 additions & 0 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ type HandlerFunc[Resp any] func(r *http.Request) (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)

// HandlerFuncWithQuery is a typed handler for methods with typed query parameters.
type HandlerFuncWithQuery[Query, Resp any] func(r *http.Request, query Query) (Resp, error)

// HandlerFuncWithQueryAndBody is a typed handler for methods with both typed query parameters and a request body.
type HandlerFuncWithQueryAndBody[Query, Body, Resp any] func(r *http.Request, query Query, body Body) (Resp, error)

func adapt[Resp any](fn HandlerFunc[Resp], status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp, err := fn(r)
Expand Down Expand Up @@ -44,6 +50,55 @@ func adaptWithBody[Body, Resp any](fn HandlerFuncWithBody[Body, Resp], status in
}
}

func adaptWithQuery[Query, Resp any](fn HandlerFuncWithQuery[Query, Resp], status int, validate func(any) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query, err := parseQuery[Query](r.URL.Query())
if err != nil {
writeError(w, Error(http.StatusBadRequest, err.Error()))
return
}
if err := validate(query); err != nil {
writeError(w, err)
return
}
resp, err := fn(r, query)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, status, resp)
}
}

func adaptWithQueryAndBody[Query, Body, Resp any](fn HandlerFuncWithQueryAndBody[Query, Body, Resp], status int, validate func(any) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query, err := parseQuery[Query](r.URL.Query())
if err != nil {
writeError(w, Error(http.StatusBadRequest, err.Error()))
return
}
if err := validate(query); err != nil {
writeError(w, err)
return
}
var body Body
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, Error(http.StatusBadRequest, "invalid request body"))
return
}
if err := validate(body); err != nil {
writeError(w, err)
return
}
resp, err := fn(r, query, body)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, status, resp)
}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
Expand Down
84 changes: 82 additions & 2 deletions handlerFuncs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func registerRoute[Resp any](
var resp Resp
outType := reflect.TypeOf(resp)

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

Expand All @@ -40,14 +40,60 @@ func registerRouteWithBody[Body, Resp any](
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, nil, inType, 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))
}

func registerRouteWithQuery[Query, Resp any](
api *API,
method string,
path string,
fn HandlerFuncWithQuery[Query, Resp],
options ...RouteOption,
) {
cfg := applyRouteOptions(options)

var query Query
queryType := reflect.TypeOf(query)
var resp Resp
outType := reflect.TypeOf(resp)

if err := api.updateSchema(method, path, queryType, nil, 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, adaptWithQuery(fn, cfg.status, api.validateBody))
}

func registerRouteWithQueryAndBody[Query, Body, Resp any](
api *API,
method string,
path string,
fn HandlerFuncWithQueryAndBody[Query, Body, Resp],
options ...RouteOption,
) {
cfg := applyRouteOptions(options)

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

if err := api.updateSchema(method, path, queryType, inType, 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, adaptWithQueryAndBody(fn, cfg.status, api.validateBody))
}

// No-body methods

// Get registers a GET handler.
Expand Down Expand Up @@ -96,3 +142,37 @@ func Patch[Body, Resp any](api *API, path string, fn HandlerFuncWithBody[Body, R
func Connect[Resp any](api *API, path string, fn HandlerFunc[Resp], options ...RouteOption) {
registerRoute(api, http.MethodConnect, path, fn, options...)
}

// Query methods (no body)

// GetWithQuery registers a GET handler with typed query parameters.
func GetWithQuery[Query, Resp any](api *API, path string, fn HandlerFuncWithQuery[Query, Resp], options ...RouteOption) {
registerRouteWithQuery(api, http.MethodGet, path, fn, options...)
}

// DeleteWithQuery registers a DELETE handler with typed query parameters.
func DeleteWithQuery[Query, Resp any](api *API, path string, fn HandlerFuncWithQuery[Query, Resp], options ...RouteOption) {
registerRouteWithQuery(api, http.MethodDelete, path, fn, options...)
}

// HeadWithQuery registers a HEAD handler with typed query parameters.
func HeadWithQuery[Query, Resp any](api *API, path string, fn HandlerFuncWithQuery[Query, Resp], options ...RouteOption) {
registerRouteWithQuery(api, http.MethodHead, path, fn, options...)
}

// Query + body methods

// PostWithQuery registers a POST handler with typed query parameters and a request body.
func PostWithQuery[Query, Body, Resp any](api *API, path string, fn HandlerFuncWithQueryAndBody[Query, Body, Resp], options ...RouteOption) {
registerRouteWithQueryAndBody(api, http.MethodPost, path, fn, options...)
}

// PutWithQuery registers a PUT handler with typed query parameters and a request body.
func PutWithQuery[Query, Body, Resp any](api *API, path string, fn HandlerFuncWithQueryAndBody[Query, Body, Resp], options ...RouteOption) {
registerRouteWithQueryAndBody(api, http.MethodPut, path, fn, options...)
}

// PatchWithQuery registers a PATCH handler with typed query parameters and a request body.
func PatchWithQuery[Query, Body, Resp any](api *API, path string, fn HandlerFuncWithQueryAndBody[Query, Body, Resp], options ...RouteOption) {
registerRouteWithQueryAndBody(api, http.MethodPatch, path, fn, options...)
}
Loading