Documentation
¶
Overview ¶
Package shiftapi provides end-to-end type safety from Go to TypeScript.
Define your API with typed Go handler functions and shiftapi automatically generates an OpenAPI 3.1 spec, validates requests, and produces a fully-typed TypeScript client — all from a single source of truth.
Quick start ¶
api := shiftapi.New()
shiftapi.Handle(api, "POST /greet", greet)
shiftapi.ListenAndServe(":8080", api)
where greet is a typed handler:
type GreetRequest struct {
Name string `json:"name" validate:"required"`
}
type GreetResponse struct {
Hello string `json:"hello"`
}
func greet(r *http.Request, in *GreetRequest) (*GreetResponse, error) {
return &GreetResponse{Hello: in.Name}, nil
}
Struct tag conventions ¶
ShiftAPI discriminates input struct fields by their struct tags:
- path:"name" — parsed from URL path parameters (e.g. /users/{id})
- json:"name" — parsed from the JSON request body (default for POST/PUT/PATCH)
- query:"name" — parsed from URL query parameters
- header:"name" — parsed from HTTP request headers (input) or set as HTTP response headers (output)
- form:"name" — parsed from multipart/form-data (for file uploads)
- validate:"rules" — validated using github.com/go-playground/validator/v10 rules and reflected into the OpenAPI schema
- accept:"mime/type" — constrains accepted MIME types on form file fields
A single input struct can mix path, query, and body fields:
type GetUserRequest struct {
ID int `path:"id" validate:"required,gt=0"`
Fields string `query:"fields"`
}
Enums ¶
Use WithEnum to register the allowed values for a named type. The values are reflected as an enum constraint in the OpenAPI schema for every field of that type — no validate:"oneof=..." tag required:
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
StatusPending Status = "pending"
)
api := shiftapi.New(
shiftapi.WithEnum[Status](StatusActive, StatusInactive, StatusPending),
)
Enum values apply to body fields, query parameters, path parameters, and header parameters. If a field also carries a validate:"oneof=..." tag, the tag takes precedence over the registered enum values.
The type parameter must satisfy the Scalar constraint (~string, ~int*, ~uint*, ~float*).
File uploads ¶
Use *multipart.FileHeader fields with the form tag for file uploads:
type UploadInput struct {
File *multipart.FileHeader `form:"file" validate:"required"`
Docs []*multipart.FileHeader `form:"docs"`
}
Response headers ¶
Use the header tag on the Resp struct to set HTTP response headers. Header-tagged fields are written as response headers and automatically excluded from the JSON response body. They are also documented as response headers in the OpenAPI spec.
type CachedResponse struct {
CacheControl string `header:"Cache-Control"`
ETag *string `header:"ETag"` // optional — omitted when nil
Items []Item `json:"items"`
}
Non-pointer fields are always sent, even with a zero value. Use a pointer field for optional headers that should only be sent when set. Supported types are the same scalars as request headers (string, bool, int*, uint*, float*).
For static response headers, use WithResponseHeader. Headers are applied in the following order — later sources override earlier ones for the same header name:
- Middleware-set headers (outermost, applied before the handler)
- Static headers via WithResponseHeader (API → Group → Route)
- Dynamic headers via header struct tags (innermost, applied last)
No-body responses ¶
For status codes that forbid a response body (204 No Content, 304 Not Modified), use WithStatus with struct{} or a header-only response type. No JSON body or Content-Type header will be written. Response headers (both static and dynamic) are still sent.
shiftapi.Handle(api, "DELETE /items/{id}", deleteItem,
shiftapi.WithStatus(http.StatusNoContent),
)
Registering a route with status 204 or 304 and a response type that has JSON body fields panics at startup — this catches misconfigurations early.
Route groups ¶
Use API.Group to create a sub-router with a shared path prefix and options. Groups can be nested, and error types and middleware are inherited by child groups:
v1 := api.Group("/api/v1",
shiftapi.WithMiddleware(auth),
)
shiftapi.Handle(v1, "GET /users", listUsers) // registers GET /api/v1/users
admin := v1.Group("/admin",
shiftapi.WithError[*ForbiddenError](http.StatusForbidden),
)
shiftapi.Handle(admin, "GET /stats", getStats) // registers GET /api/v1/admin/stats
Middleware ¶
Use WithMiddleware to apply standard HTTP middleware at any level:
api := shiftapi.New(
shiftapi.WithMiddleware(cors, logging), // all routes
)
v1 := api.Group("/api/v1",
shiftapi.WithMiddleware(auth), // group routes
)
shiftapi.Handle(v1, "GET /admin", getAdmin,
shiftapi.WithMiddleware(adminOnly), // single route
)
Middleware is applied from outermost to innermost in the order: API → parent Group → child Group → Route → handler. Within a single WithMiddleware call, the first argument wraps outermost.
Context values ¶
Use NewContextKey, SetContext, and FromContext to pass typed data from middleware to handlers without untyped context keys or type assertions:
var userKey = shiftapi.NewContextKey[User]("user")
// Middleware stores the value:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := authenticate(r)
next.ServeHTTP(w, shiftapi.SetContext(r, userKey, user))
})
}
// Handler retrieves it — fully typed, no assertion needed:
func getProfile(r *http.Request, _ struct{}) (*Profile, error) {
user, ok := shiftapi.FromContext(r, userKey)
if !ok {
return nil, errUnauthorized
}
return &Profile{Name: user.Name}, nil
}
Each ContextKey has pointer identity, so two keys for the same type T will never collide. The type parameter ensures that SetContext and FromContext agree on the value type at compile time.
Error handling ¶
Use WithError to declare that a specific error type may be returned at a given HTTP status code. The error type must implement the [error] interface and its struct fields are reflected into the OpenAPI schema. WithError works at all three levels: New, API.Group/Group.Group, and route functions.
api := shiftapi.New(
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
)
shiftapi.Handle(api, "GET /users/{id}", getUser,
shiftapi.WithError[*NotFoundError](http.StatusNotFound),
)
At runtime, if the handler returns an error matching a registered type (via errors.As), it is serialized as JSON with the declared status code. Multiple error types can be declared per route. Wrapped errors are matched automatically.
Validation failures automatically return 422 with structured ValidationError responses. Unrecognized errors return 500 Internal Server Error to prevent leaking implementation details.
Use WithBadRequestError and WithInternalServerError to customize the default 400 and 500 response bodies.
Options ¶
Option is the primary option type. It works at all three levels: New, API.Group/Group.Group, and Handle. WithError, WithMiddleware, and WithResponseHeader all return Option.
Some options are level-specific: WithInfo and WithBadRequestError only work with New (APIOption), while WithStatus and WithRouteInfo only work with Handle (RouteOption).
Use ComposeOptions to bundle multiple Option values into a reusable option:
func WithAuth() shiftapi.Option {
return shiftapi.ComposeOptions(
shiftapi.WithMiddleware(authMiddleware),
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
)
}
ComposeAPIOptions, ComposeGroupOptions, and ComposeRouteOptions can mix shared and level-specific options at their respective levels.
Built-in endpoints ¶
Every API automatically serves:
- GET /openapi.json — the generated OpenAPI 3.1 spec
- GET /docs — interactive API documentation (Scalar UI)
http.Handler compatibility ¶
API implements http.Handler, so it works with any standard middleware, router, or test framework:
mux := http.NewServeMux()
mux.Handle("/api/", http.StripPrefix("/api", api))
Example ¶
package main
import (
"log"
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New(shiftapi.WithInfo(shiftapi.Info{
Title: "My API",
Version: "1.0.0",
}))
type HelloRequest struct {
Name string `json:"name" validate:"required"`
}
type HelloResponse struct {
Message string `json:"message"`
}
shiftapi.Handle(api, "POST /hello", func(r *http.Request, in HelloRequest) (*HelloResponse, error) {
return &HelloResponse{Message: "Hello, " + in.Name + "!"}, nil
})
log.Fatal(shiftapi.ListenAndServe(":8080", api))
}
Output:
Index ¶
- func FromContext[T any](r *http.Request, key *ContextKey[T]) (T, bool)
- func Handle[In, Resp any](router Router, pattern string, fn HandlerFunc[In, Resp], ...)
- func HandleRaw[In any](router Router, pattern string, fn RawHandlerFunc[In], options ...RouteOption)
- func ListenAndServe(addr string, api *API) error
- func SetContext[T any](r *http.Request, key *ContextKey[T], val T) *http.Request
- func WithBadRequestError[T any](fn func(error) T) apiOptionFunc
- func WithContentType(contentType string, opts ...ResponseSchemaOption) routeOptionFunc
- func WithEnum[T Scalar](values ...T) apiOptionFunc
- func WithExternalDocs(docs ExternalDocs) apiOptionFunc
- func WithInfo(info Info) apiOptionFunc
- func WithInternalServerError[T any](fn func(error) T) apiOptionFunc
- func WithMaxUploadSize(size int64) apiOptionFunc
- func WithRouteInfo(info RouteInfo) routeOptionFunc
- func WithStatus(status int) routeOptionFunc
- func WithValidator(v *validator.Validate) apiOptionFunc
- type API
- type APIOption
- type Contact
- type ContextKey
- type ExternalDocs
- type FieldError
- type Group
- type GroupOption
- type HandlerFunc
- type Info
- type License
- type Option
- type RawHandlerFunc
- type ResponseSchemaOption
- type RouteInfo
- type RouteOption
- type Router
- type Scalar
- type ValidationError
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func FromContext ¶ added in v0.0.28
func FromContext[T any](r *http.Request, key *ContextKey[T]) (T, bool)
FromContext retrieves a typed value from the request's context. The second return value reports whether the key was present.
user, ok := shiftapi.FromContext(r, userKey)
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
userKey := shiftapi.NewContextKey[string]("user")
authMiddleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, shiftapi.SetContext(r, userKey, "alice"))
})
}
api := shiftapi.New(shiftapi.WithMiddleware(authMiddleware))
shiftapi.Handle(api, "GET /whoami", func(r *http.Request, _ struct{}) (*struct {
User string `json:"user"`
}, error) {
user, _ := shiftapi.FromContext(r, userKey)
return &struct {
User string `json:"user"`
}{User: user}, nil
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/whoami", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Body.String())
}
Output: {"user":"alice"}
func Handle ¶ added in v0.0.28
func Handle[In, Resp any](router Router, pattern string, fn HandlerFunc[In, Resp], options ...RouteOption)
Handle registers a typed handler for the given pattern. The pattern follows net/http.ServeMux conventions: "METHOD /path", e.g. "GET /users/{id}".
Path parameters can be declared on the input struct with path:"name" tags for automatic parsing and validation, or accessed via http.Request.PathValue.
For POST, PUT, and PATCH methods, the request body is automatically decoded from JSON (or multipart/form-data if the In type has form-tagged fields). Validation is applied before the handler runs.
shiftapi.Handle(api, "GET /users/{id}", getUser)
shiftapi.Handle(api, "POST /users", createUser)
shiftapi.Handle(api, "DELETE /items/{id}", deleteItem,
shiftapi.WithStatus(http.StatusNoContent),
)
Example (FileUpload) ¶
package main
import (
"mime/multipart"
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type UploadInput struct {
File *multipart.FileHeader `form:"file" validate:"required"`
}
type UploadResult struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
}
shiftapi.Handle(api, "POST /upload", func(r *http.Request, in UploadInput) (*UploadResult, error) {
return &UploadResult{
Filename: in.File.Filename,
Size: in.File.Size,
}, nil
})
}
Output:
Example (Get) ¶
package main
import (
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type UserQuery struct {
ID int `query:"id" validate:"required"`
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
shiftapi.Handle(api, "GET /user", func(r *http.Request, in UserQuery) (*User, error) {
return &User{ID: in.ID, Name: "Alice"}, nil
})
}
Output:
Example (NoContent) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
shiftapi.Handle(api, "DELETE /items/{id}", func(r *http.Request, _ struct{}) (struct{}, error) {
return struct{}{}, nil
}, shiftapi.WithStatus(http.StatusNoContent))
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/items/42", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Code)
fmt.Println(w.Body.String())
}
Output: 204
Example (PathParameter) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
shiftapi.Handle(api, "GET /users/{id}", func(r *http.Request, _ struct{}) (*User, error) {
id := r.PathValue("id")
return &User{ID: id, Name: "Alice"}, nil
})
// Make a request to verify.
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/users/42", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Body.String())
}
Output: {"id":"42","name":"Alice"}
Example (Post) ¶
package main
import (
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type CreateInput struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
type CreateOutput struct {
ID int `json:"id"`
}
shiftapi.Handle(api, "POST /users", func(r *http.Request, in CreateInput) (*CreateOutput, error) {
return &CreateOutput{ID: 1}, nil
}, shiftapi.WithStatus(http.StatusCreated))
}
Output:
Example (QueryAndBody) ¶
package main
import (
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type Request struct {
Version string `query:"v"`
Name string `json:"name"`
}
type Response struct {
Result string `json:"result"`
}
shiftapi.Handle(api, "POST /action", func(r *http.Request, in Request) (*Response, error) {
return &Response{Result: in.Name + " (v" + in.Version + ")"}, nil
})
_ = api
}
Output:
Example (ResponseHeaders) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type CachedItem struct {
CacheControl string `header:"Cache-Control"`
ETag *string `header:"ETag"`
Name string `json:"name"`
}
shiftapi.Handle(api, "GET /item", func(r *http.Request, _ struct{}) (*CachedItem, error) {
etag := `"v1"`
return &CachedItem{
CacheControl: "max-age=3600",
ETag: &etag,
Name: "Widget",
}, nil
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/item", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Header().Get("Cache-Control"))
fmt.Println(w.Header().Get("ETag"))
fmt.Println(w.Body.String())
}
Output: max-age=3600 "v1" {"name":"Widget"}
func HandleRaw ¶ added in v0.0.28
func HandleRaw[In any](router Router, pattern string, fn RawHandlerFunc[In], options ...RouteOption)
HandleRaw registers a raw handler for the given pattern. Unlike Handle, the handler receives the http.ResponseWriter directly and is responsible for writing the response. Input parsing, validation, and middleware work identically to Handle.
Use HandleRaw for responses that cannot be expressed as a typed struct: Server-Sent Events, file downloads, WebSocket upgrades, etc.
shiftapi.HandleRaw(api, "GET /events", sseHandler,
shiftapi.WithContentType("text/event-stream"),
)
func ListenAndServe ¶
ListenAndServe starts the HTTP server on the given address.
In production builds this is a direct call to http.ListenAndServe with zero additional overhead.
When built with -tags shiftapidev (used automatically by the Vite plugin), the following environment variables are supported:
- SHIFTAPI_EXPORT_SPEC=<path>: write the OpenAPI spec to the given file and exit without starting the server.
- SHIFTAPI_PORT=<port>: override the port in addr, allowing the Vite plugin to automatically assign a free port.
func SetContext ¶ added in v0.0.28
SetContext returns a shallow copy of r with the given typed value stored in its context. Use this in middleware to pass data to downstream handlers:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := authenticate(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, shiftapi.SetContext(r, userKey, user))
})
}
func WithBadRequestError ¶ added in v0.0.26
WithBadRequestError customizes the 400 Bad Request response returned when the framework cannot parse the request (malformed JSON, invalid query parameters, invalid form data). The function receives the parse error and returns the value to serialize as the response body. T's type determines the BadRequestError schema in the OpenAPI spec.
api := shiftapi.New(
shiftapi.WithBadRequestError(func(err error) *MyBadRequest {
return &MyBadRequest{Code: "BAD_REQUEST", Message: err.Error()}
}),
)
func WithContentType ¶ added in v0.0.28
func WithContentType(contentType string, opts ...ResponseSchemaOption) routeOptionFunc
WithContentType sets a custom response content type for the route's OpenAPI spec. An optional ResponseSchemaOption produced by ResponseSchema can be passed to include a schema under the specified media type.
For HandleRaw routes, this determines how the response appears in the OpenAPI spec. For Handle routes, this overrides the default "application/json" media type key.
shiftapi.HandleRaw(api, "GET /events", sseHandler,
shiftapi.WithContentType("text/event-stream"),
)
shiftapi.HandleRaw(api, "GET /events", sseHandler,
shiftapi.WithContentType("text/event-stream", shiftapi.ResponseSchema[Event]()),
)
func WithEnum ¶ added in v0.0.28
func WithEnum[T Scalar](values ...T) apiOptionFunc
WithEnum registers the given values as the complete set of allowed enum values for type T. When T appears as a struct field in a request or response type, the generated OpenAPI schema will include an enum constraint with these values — no validate:"oneof=..." tag required.
If a field also carries a validate:"oneof=..." tag, that tag takes precedence over the registered values.
type Status string const ( StatusActive Status = "active" StatusInactive Status = "inactive" StatusPending Status = "pending" ) api := shiftapi.New( shiftapi.WithEnum[Status](StatusActive, StatusInactive, StatusPending), )
func WithExternalDocs ¶
func WithExternalDocs(docs ExternalDocs) apiOptionFunc
WithExternalDocs links to external documentation.
func WithInfo ¶
func WithInfo(info Info) apiOptionFunc
WithInfo configures the API metadata that appears in the OpenAPI spec and documentation UI.
api := shiftapi.New(shiftapi.WithInfo(shiftapi.Info{
Title: "My API",
Version: "1.0.0",
}))
func WithInternalServerError ¶ added in v0.0.26
WithInternalServerError customizes the 500 Internal Server Error response returned when a handler returns an error that doesn't match any registered error type. The function receives the unhandled error and returns the value to serialize as the response body. T's type determines the InternalServerError schema in the OpenAPI spec.
api := shiftapi.New(
shiftapi.WithInternalServerError(func(err error) *MyServerError {
log.Error("unhandled", "err", err)
return &MyServerError{Code: "INTERNAL_ERROR", Message: "internal server error"}
}),
)
func WithMaxUploadSize ¶ added in v0.0.24
func WithMaxUploadSize(size int64) apiOptionFunc
WithMaxUploadSize sets the maximum memory used for parsing multipart form data. The default is 32 MB.
func WithRouteInfo ¶
func WithRouteInfo(info RouteInfo) routeOptionFunc
WithRouteInfo sets the route's OpenAPI metadata (summary, description, tags).
shiftapi.Handle(api, "POST /greet", greet, shiftapi.WithRouteInfo(shiftapi.RouteInfo{
Summary: "Greet a person",
Tags: []string{"greetings"},
}))
Example ¶
package main
import (
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
shiftapi.Handle(api, "GET /health", func(r *http.Request, _ struct{}) (*struct {
OK bool `json:"ok"`
}, error) {
return &struct {
OK bool `json:"ok"`
}{OK: true}, nil
}, shiftapi.WithRouteInfo(shiftapi.RouteInfo{
Summary: "Health check",
Description: "Returns the health status of the service.",
Tags: []string{"monitoring"},
}))
}
Output:
func WithStatus ¶
func WithStatus(status int) routeOptionFunc
WithStatus sets the success HTTP status code for the route (default: 200). Use this for routes that should return 201 Created, 204 No Content, etc.
Example ¶
package main
import (
"net/http"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
type Item struct {
Name string `json:"name"`
}
type Created struct {
ID int `json:"id"`
}
shiftapi.Handle(api, "POST /items", func(r *http.Request, in Item) (*Created, error) {
return &Created{ID: 1}, nil
}, shiftapi.WithStatus(http.StatusCreated))
_ = api
}
Output:
func WithValidator ¶
WithValidator sets a custom github.com/go-playground/validator/v10 instance on the API. Use this to register custom validations or override default behavior.
Types ¶
type API ¶
type API struct {
// contains filtered or unexported fields
}
API is the central type that collects typed handler registrations, generates an OpenAPI 3.1 schema, and implements http.Handler. Create one with New and register routes with [Get], [Post], [Put], [Patch], [Delete], etc.
API automatically serves the OpenAPI spec at GET /openapi.json and interactive documentation at GET /docs.
func New ¶
New creates a new API with the given options. By default the API uses a 32 MB upload limit and the standard github.com/go-playground/validator/v10 instance. Use WithInfo, WithMaxUploadSize, WithValidator, and WithExternalDocs to customize behavior.
Example ¶
package main
import (
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New(
shiftapi.WithInfo(shiftapi.Info{
Title: "Pet Store",
Version: "2.0.0",
Description: "A sample pet store API",
}),
shiftapi.WithMaxUploadSize(10<<20), // 10 MB
)
_ = api
}
Output:
func (*API) Group ¶ added in v0.0.26
func (a *API) Group(prefix string, opts ...GroupOption) *Group
Group creates a sub-router with the given path prefix and options. Routes registered on the returned Group are prefixed with the given path. Error types and middleware registered via options apply to all routes in the group.
v1 := api.Group("/api/v1",
shiftapi.WithError[*RateLimitError](http.StatusTooManyRequests),
shiftapi.WithMiddleware(auth, logging),
)
shiftapi.Handle(v1, "GET /users", listUsers) // registers GET /api/v1/users
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
v1 := api.Group("/api/v1")
shiftapi.Handle(v1, "GET /users", func(r *http.Request, _ struct{}) (*struct {
Name string `json:"name"`
}, error) {
return &struct {
Name string `json:"name"`
}{Name: "Alice"}, nil
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/v1/users", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Body.String())
}
Output: {"name":"Alice"}
func (*API) ServeHTTP ¶
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP implements http.Handler.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New()
shiftapi.Handle(api, "GET /ping", func(r *http.Request, _ struct{}) (*struct {
Pong bool `json:"pong"`
}, error) {
return &struct {
Pong bool `json:"pong"`
}{Pong: true}, nil
})
// Use as http.Handler in tests.
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/ping", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Body.String())
}
Output: {"pong":true}
type APIOption ¶ added in v0.0.26
type APIOption interface {
// contains filtered or unexported methods
}
APIOption configures an API created with New. Both Option and API-specific options (like WithInfo) implement this interface.
type ContextKey ¶ added in v0.0.28
type ContextKey[T any] struct { // contains filtered or unexported fields }
ContextKey is a type-safe key for storing and retrieving values from a request's context. The type parameter T determines the type of value associated with this key, eliminating the need for type assertions when reading values back.
Create keys with NewContextKey and use them with SetContext and FromContext:
var userKey = shiftapi.NewContextKey[User]("user")
// In middleware:
r = shiftapi.SetContext(r, userKey, authenticatedUser)
// In handler:
user, ok := shiftapi.FromContext(r, userKey)
func NewContextKey ¶ added in v0.0.28
func NewContextKey[T any](name string) *ContextKey[T]
NewContextKey creates a new typed context key. The name is used only for debugging; uniqueness is guaranteed by the key's pointer identity, not its name.
func (*ContextKey[T]) String ¶ added in v0.0.28
func (k *ContextKey[T]) String() string
String returns the key's name for debugging purposes.
type ExternalDocs ¶
ExternalDocs links to external documentation.
type FieldError ¶
FieldError describes a single field validation failure.
type Group ¶ added in v0.0.26
type Group struct {
// contains filtered or unexported fields
}
Group is a sub-router that registers routes under a common path prefix with shared error types and middleware. Create one with API.Group or nest with Group.Group.
type GroupOption ¶ added in v0.0.26
type GroupOption interface {
// contains filtered or unexported methods
}
GroupOption configures a Group created with API.Group or Group.Group. Option implements this interface.
func ComposeGroupOptions ¶ added in v0.0.26
func ComposeGroupOptions(opts ...GroupOption) GroupOption
ComposeGroupOptions combines multiple GroupOption values into a single GroupOption. Since Option implements GroupOption, both shared and group-specific options can be mixed.
type HandlerFunc ¶
HandlerFunc is a typed handler function for API routes. The type parameters In and Resp are the request and response types — both are automatically reflected into the OpenAPI schema.
The In struct's fields are discriminated by struct tags:
- path:"name" — parsed from URL path parameters (e.g. /users/{id})
- query:"name" — parsed from URL query parameters
- header:"name" — parsed from HTTP request headers
- json:"name" — parsed from the JSON request body (default for POST/PUT/PATCH)
- form:"name" — parsed from multipart/form-data (for file uploads)
The Resp struct's fields may also use the header tag to set response headers:
- header:"name" — written as an HTTP response header (excluded from JSON body)
Header-tagged fields on the response are automatically stripped from the JSON body and documented as response headers in the OpenAPI spec. Use a pointer field (e.g. *string) for optional response headers that may not always be set.
Use struct{} as In for routes that take no input, or as Resp for routes that return no body (e.g. health checks that only need a status code).
The *http.Request parameter gives access to cookies, path parameters, and other request metadata.
type Info ¶
type Info struct {
Title string
Description string
TermsOfService string
Contact *Contact
License *License
Version string
}
Info describes the API and is rendered into the OpenAPI spec's info object.
type Option ¶
type Option func(sharedConfig)
Option is the primary option type. It works at all levels: New, API.Group/Group.Group, and route registration functions ([Get], [Post], etc.). Options are composable via ComposeOptions.
func ComposeOptions ¶ added in v0.0.26
ComposeOptions combines multiple Option values into a single Option. Use this to create reusable option bundles that work at any level.
func WithAuth() shiftapi.Option {
return shiftapi.ComposeOptions(
shiftapi.WithMiddleware(authMiddleware),
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
)
}
func WithError ¶ added in v0.0.26
WithError declares that an error of type T may be returned at the given HTTP status code. T must implement [error] and its struct fields are reflected into the OpenAPI schema. At runtime, if a handler returns an error matching T (via errors.As), it is serialized as JSON with the declared status code.
WithError returns an Option that works at any level:
New — applies to all routes (API-level)
API.Group / Group.Group — applies to all routes in the group
Handle — applies to a single route
api := shiftapi.New( shiftapi.WithError[*AuthError](http.StatusUnauthorized), ) v1 := api.Group("/api/v1", shiftapi.WithError[*RateLimitError](http.StatusTooManyRequests), ) shiftapi.Handle(v1, "GET /users/{id}", getUser, shiftapi.WithError[*NotFoundError](http.StatusNotFound), )
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
type exampleNotFoundError struct {
Message string `json:"message"`
Detail string `json:"detail"`
}
func (e *exampleNotFoundError) Error() string { return e.Message }
func main() {
api := shiftapi.New()
shiftapi.Handle(api, "GET /users/{id}", func(r *http.Request, _ struct{}) (*struct {
Name string `json:"name"`
}, error) {
return nil, &exampleNotFoundError{Message: "user not found", Detail: "no user with that ID"}
}, shiftapi.WithError[*exampleNotFoundError](http.StatusNotFound))
// Make a request to verify.
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/users/42", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Code)
fmt.Println(w.Body.String())
}
Output: 404 {"message":"user not found","detail":"no user with that ID"}
Example (Auth) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
type exampleAuthError struct {
Message string `json:"message"`
}
func (e *exampleAuthError) Error() string { return e.Message }
func main() {
api := shiftapi.New()
type Empty struct{}
shiftapi.Handle(api, "GET /secret", func(r *http.Request, _ struct{}) (*Empty, error) {
token := r.Header.Get("Authorization")
if token == "" {
return nil, &exampleAuthError{Message: "missing auth token"}
}
return &Empty{}, nil
}, shiftapi.WithError[*exampleAuthError](http.StatusUnauthorized))
// Make a request without auth to verify.
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/secret", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Code)
fmt.Println(w.Body.String())
}
Output: 401 {"message":"missing auth token"}
func WithMiddleware ¶ added in v0.0.26
WithMiddleware applies standard HTTP middleware. Middleware functions are applied in order: the first argument wraps outermost.
WithMiddleware returns an Option that works at any level:
New — applies to all routes (API-level)
API.Group / Group.Group — applies to all routes in the group
Handle — applies to a single route
api := shiftapi.New( shiftapi.WithMiddleware(cors, logging), ) v1 := api.Group("/api/v1", shiftapi.WithMiddleware(auth), ) shiftapi.Handle(v1, "GET /admin", getAdmin, shiftapi.WithMiddleware(adminOnly), )
func WithResponseHeader ¶ added in v0.0.27
WithResponseHeader sets a static response header on every response. The header is also documented in the OpenAPI spec for each affected route.
WithResponseHeader returns an Option that works at any level:
- New — applies to all routes (API-level)
- API.Group / Group.Group — applies to all routes in the group
- Handle — applies to a single route
Static headers are applied in API → Group → Route order. If the same header name is declared at multiple levels, the later level wins. Dynamic headers (header struct tags on the response type) are applied after static headers and take precedence for the same name.
api := shiftapi.New(
shiftapi.WithResponseHeader("X-Content-Type-Options", "nosniff"),
)
v1 := api.Group("/api/v1",
shiftapi.WithResponseHeader("X-API-Version", "1"),
)
shiftapi.Handle(v1, "GET /users", listUsers,
shiftapi.WithResponseHeader("Cache-Control", "max-age=3600"),
)
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/fcjr/shiftapi"
)
func main() {
api := shiftapi.New(
shiftapi.WithResponseHeader("X-Content-Type-Options", "nosniff"),
)
shiftapi.Handle(api, "GET /item", func(r *http.Request, _ struct{}) (*struct {
Name string `json:"name"`
}, error) {
return &struct {
Name string `json:"name"`
}{Name: "Widget"}, nil
})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/item", nil)
api.ServeHTTP(w, r)
fmt.Println(w.Header().Get("X-Content-Type-Options"))
fmt.Println(w.Body.String())
}
Output: nosniff {"name":"Widget"}
type RawHandlerFunc ¶ added in v0.0.28
RawHandlerFunc is a handler function that writes directly to the http.ResponseWriter. Unlike HandlerFunc it has only one type parameter for the input — the handler owns the response lifecycle entirely, which makes it suitable for streaming (SSE), file downloads, WebSocket upgrades, and other use cases where JSON encoding is inappropriate.
The input struct In is parsed and validated identically to HandlerFunc: path, query, header, json, and form tags all work as expected. For POST/PUT/PATCH methods the body is decoded only when the input struct contains json or form-tagged fields, leaving r.Body available otherwise.
type ResponseSchemaOption ¶ added in v0.0.28
type ResponseSchemaOption struct {
// contains filtered or unexported fields
}
ResponseSchemaOption carries a type for deferred OpenAPI schema generation with WithContentType.
func ResponseSchema ¶ added in v0.0.28
func ResponseSchema[T any]() ResponseSchemaOption
ResponseSchema captures the type T for OpenAPI schema generation. The actual schema is generated at registration time using the API's configured schema customizer, so enum lookups and validation constraints are applied correctly.
type RouteInfo ¶
RouteInfo provides metadata for a route that appears in the OpenAPI spec and the generated documentation UI.
type RouteOption ¶
type RouteOption interface {
// contains filtered or unexported methods
}
RouteOption configures a route registered with [Get], [Post], [Put], etc. Both Option and route-specific options (like WithStatus) implement this interface.
func ComposeRouteOptions ¶ added in v0.0.26
func ComposeRouteOptions(opts ...RouteOption) RouteOption
ComposeRouteOptions combines multiple RouteOption values into a single RouteOption. Since Option implements RouteOption, both shared and route-specific options can be mixed.
createOpts := shiftapi.ComposeRouteOptions(
shiftapi.WithStatus(http.StatusCreated),
shiftapi.WithError[*ConflictError](http.StatusConflict),
)
type Router ¶ added in v0.0.26
type Router interface {
// contains filtered or unexported methods
}
Router is the common interface for registering routes. Both *API and *Group implement Router, so [Get], [Post], [Put], etc. accept either.
type Scalar ¶ added in v0.0.28
type Scalar interface {
~string |
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
Scalar is a constraint that permits any Go type whose underlying type is a string, integer, or float — the kinds commonly used as enum carriers.
type ValidationError ¶
type ValidationError struct {
Message string `json:"message"`
Errors []FieldError `json:"errors"`
}
ValidationError is returned when request validation fails. It is serialized as a 422 Unprocessable Entity response with a structured list of per-field errors. Validation rules are specified using validate struct tags from github.com/go-playground/validator/v10 and are also reflected into the generated OpenAPI schema.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string