shiftapi

package module
v0.0.42 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 14, 2026 License: MIT Imports: 22 Imported by: 0

README

ShiftAPI Logo

End-to-end type safety from Go structs to TypeScript frontend.

ShiftAPI is a Go framework that generates an OpenAPI 3.1 spec from your handler types at runtime, then uses a Vite or Next.js plugin to turn that spec into a fully-typed TypeScript client — so your frontend stays in sync with your API automatically.

Go Reference GolangCI Go Report Card npm shiftapi

Go structs ──→ OpenAPI 3.1 spec ──→ TypeScript types ──→ Typed fetch client
   (compile time)     (runtime)         (build time)        (your frontend)

Getting Started

Scaffold a full-stack app (Go + React, Svelte, or Next.js):

npm create shiftapi@latest

Or add ShiftAPI to an existing Go project:

go get github.com/fcjr/shiftapi

Quick Start

package main

import (
    "log"
    "net/http"

    "github.com/fcjr/shiftapi"
)

type Person struct {
    Name string `json:"name" validate:"required"`
}

type Greeting struct {
    Hello string `json:"hello"`
}

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

func main() {
    api := shiftapi.New(shiftapi.WithInfo(shiftapi.Info{
        Title:   "Greeter API",
        Version: "1.0.0",
    }))

    shiftapi.Handle(api, "POST /greet", greet)

    log.Println("listening on :8080")
    log.Fatal(shiftapi.ListenAndServe(":8080", api))
    // interactive docs at http://localhost:8080/docs
}

That's it. ShiftAPI reflects your Go types into an OpenAPI 3.1 spec at /openapi.json and serves interactive docs at /docs — no code generation step, no annotations.

Features

Generic type-safe handlers

Generic free functions capture your request and response types at compile time. Every method uses a single function — struct tags discriminate query params (query:"..."), HTTP headers (header:"..."), body fields (json:"..."), and form fields (form:"..."). For routes without input, use _ struct{}.

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

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

Typed path parameters

Use path tags to declare typed path parameters. They are parsed from the URL, validated, and documented in the OpenAPI spec automatically:

type GetUserInput struct {
    ID int `path:"id" validate:"required,gt=0"`
}

shiftapi.Handle(api, "GET /users/{id}", func(r *http.Request, in GetUserInput) (*User, error) {
    return db.GetUser(r.Context(), in.ID) // in.ID is already an int
})

Supports the same scalar types as query params: string, bool, int*, uint*, float*. Use validate:"uuid" on a string field for UUID path params. Parse errors return 400; validation failures return 422.

Path parameters are always required and always scalar — pointers and slices on path-tagged fields panic at registration time. You can still use r.PathValue("id") directly for routes that don't need typed path params.

Typed query parameters

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

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.Handle(api, "GET /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:

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

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

Typed HTTP headers

Define a struct with header tags. Headers are parsed, validated, and documented in the OpenAPI spec automatically — just like query params.

type AuthInput struct {
    Token string `header:"Authorization" validate:"required"`
    Q     string `query:"q"`
}

shiftapi.Handle(api, "GET /search", func(r *http.Request, in AuthInput) (*Results, error) {
    // in.Token parsed from the Authorization header
    // in.Q parsed from ?q= query param
    return doSearch(in.Token, in.Q), nil
})

Supports string, bool, int*, uint*, float* scalars and *T pointers for optional headers. Parse errors return 400; validation failures return 422. Header, query, and body fields can be freely combined in one struct.

File uploads (multipart/form-data)

Use form tags to declare file upload endpoints. The form tag drives OpenAPI spec generation — the generated TypeScript client gets the correct multipart/form-data types automatically. At runtime, the request body is parsed via ParseMultipartForm and form-tagged fields are populated.

type UploadInput struct {
    File  *multipart.FileHeader   `form:"file" validate:"required"`
    Title string                  `form:"title" validate:"required"`
    Tags  string                  `query:"tags"`
}

shiftapi.Handle(api, "POST /upload", func(r *http.Request, in UploadInput) (*Result, error) {
    f, err := in.File.Open()
    if err != nil {
        return nil, fmt.Errorf("failed to open file: %w", err)
    }
    defer f.Close()
    // read from f, save to disk/S3/etc.
    return &Result{Filename: in.File.Filename, Title: in.Title}, nil
})
  • *multipart.FileHeader — single file (type: string, format: binary in OpenAPI, File | Blob | Uint8Array in TypeScript)
  • []*multipart.FileHeader — multiple files (type: array, items: {type: string, format: binary})
  • Scalar types with form tag — text form fields
  • query tags work alongside form tags
  • Mixing json and form tags on the same struct panics at registration time

Restrict accepted file types with the accept tag. This validates the Content-Type at runtime (returns 400 if rejected) and documents the constraint in the OpenAPI spec via the encoding map:

type ImageUpload struct {
    Avatar *multipart.FileHeader `form:"avatar" accept:"image/png,image/jpeg" validate:"required"`
}

The default max upload size is 32 MB. Configure it with WithMaxUploadSize:

api := shiftapi.New(shiftapi.WithMaxUploadSize(64 << 20)) // 64 MB

Validation

Built-in validation via go-playground/validator. Struct tags are enforced at runtime and reflected into the OpenAPI schema.

type CreateUser struct {
    Name  string `json:"name"  validate:"required,min=2,max=50"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"gte=0,lte=150"`
    Role  string `json:"role"  validate:"oneof=admin user guest"`
}

Invalid requests return 422 with per-field errors:

{
    "message": "validation failed",
    "errors": [
        { "field": "Name",  "message": "this field is required" },
        { "field": "Email", "message": "must be a valid email address" }
    ]
}

Supported tags: required, email, url/uri, uuid, datetime, min, max, gte, lte, gt, lt, len, oneof — all mapped to their OpenAPI equivalents (format, minimum, maxLength, enum, etc.). Use WithValidator() to supply a custom validator instance.

Route groups

Use Group to create a sub-router with a shared path prefix and options. Groups can be nested:

v1 := api.Group("/api/v1",
    shiftapi.WithError[*RateLimitError](http.StatusTooManyRequests),
    shiftapi.WithMiddleware(auth),
)

shiftapi.Handle(v1, "GET /users", listUsers)   // GET /api/v1/users
shiftapi.Handle(v1, "POST /users", createUser) // POST /api/v1/users

admin := v1.Group("/admin",
    shiftapi.WithError[*ForbiddenError](http.StatusForbidden),
    shiftapi.WithMiddleware(adminOnly),
)
shiftapi.Handle(admin, "GET /stats", getStats) // GET /api/v1/admin/stats

Middleware

Use WithMiddleware to apply standard HTTP middleware at any level — API, group, or route:

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 resolves from outermost to innermost: API → parent Group → child Group → Route → handler. Within a single WithMiddleware(a, b) call, the first argument wraps outermost.

Context values

Use NewContextKey, SetContext, and FromContext to pass typed data from middleware to handlers — no untyped context.Value keys or type assertions needed:

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, err := authenticate(r)
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, shiftapi.SetContext(r, userKey, user))
    })
}

// Handler retrieves it — fully typed, no assertion needed:
shiftapi.Handle(authed, "GET /me", func(r *http.Request, _ struct{}) (*Profile, error) {
    user, ok := shiftapi.FromContext(r, userKey)
    if !ok {
        return nil, fmt.Errorf("missing user context")
    }
    return &Profile{Name: user.Name}, nil
})

Each ContextKey has pointer identity, so two keys for the same type never collide. The type parameter ensures SetContext and FromContext agree on the value type at compile time.

Error handling

Use WithError to declare that a handler may return a specific error type at a given HTTP status code. Works at any level — API, group, or route:

api := shiftapi.New(
    shiftapi.WithError[*AuthError](http.StatusUnauthorized),         // all routes
)
shiftapi.Handle(api, "GET /users/{id}", getUser,
    shiftapi.WithError[*NotFoundError](http.StatusNotFound),         // single route
)

The error type must implement error — its struct fields are reflected into the OpenAPI schema. At runtime, if the handler returns a matching error (via errors.As), it is serialized as JSON with the declared status code. Wrapped errors work automatically. Unrecognized errors return 500.

Customize the default 400/500 responses with WithBadRequestError and WithInternalServerError:

api := shiftapi.New(
    shiftapi.WithBadRequestError(func(err error) *MyBadRequest {
        return &MyBadRequest{Code: "BAD_REQUEST", Message: err.Error()}
    }),
    shiftapi.WithInternalServerError(func(err error) *MyServerError {
        log.Error("unhandled", "err", err)
        return &MyServerError{Code: "INTERNAL_ERROR", Message: "internal server error"}
    }),
)

Every route automatically includes 400, 422 (ValidationError), and 500 responses in the generated OpenAPI spec.

Option composition

WithError and WithMiddleware are Option values — they work at all three levels. Use ComposeOptions to bundle them into reusable options:

func WithAuth() shiftapi.Option {
    return shiftapi.ComposeOptions(
        shiftapi.WithMiddleware(authMiddleware),
        shiftapi.WithError[*AuthError](http.StatusUnauthorized),
    )
}

For level-specific composition (mixing shared and level-specific options), use ComposeAPIOptions, ComposeGroupOptions, or ComposeRouteOptions:

createOpts := shiftapi.ComposeRouteOptions(
    shiftapi.WithStatus(http.StatusCreated),
    shiftapi.WithError[*ConflictError](http.StatusConflict),
)
shiftapi.Handle(api, "POST /users", createUser, createOpts)

Route metadata

Add OpenAPI summaries, descriptions, and tags per route:

shiftapi.Handle(api, "POST /greet", greet,
    shiftapi.WithRouteInfo(shiftapi.RouteInfo{
        Summary:     "Greet a person",
        Description: "Returns a personalized greeting.",
        Tags:        []string{"greetings"},
    }),
)

Standard http.Handler

API implements http.Handler, so it works with any middleware, httptest, and ServeMux mounting:

// middleware
wrapped := loggingMiddleware(corsMiddleware(api))
http.ListenAndServe(":8080", wrapped)

// mount under a prefix
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))

TypeScript Integration

ShiftAPI ships npm packages for the frontend:

  • shiftapi — CLI and codegen core. Extracts the OpenAPI spec from your Go server, generates TypeScript types via openapi-typescript, and writes a pre-configured openapi-fetch client.
  • @shiftapi/vite-plugin — Vite plugin for dev-time HMR, proxy, and Go server management.
  • @shiftapi/next — Next.js integration with the same DX (webpack/Turbopack aliases, rewrites proxy, Go server management).

shiftapi.config.ts (project root):

import { defineConfig } from "shiftapi";

export default defineConfig({
    server: "./cmd/server", // Go entry point
});

Vite

npm install shiftapi @shiftapi/vite-plugin
// vite.config.ts
import shiftapi from "@shiftapi/vite-plugin";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [shiftapi()],
});

Next.js

npm install shiftapi @shiftapi/next
// next.config.ts
import type { NextConfig } from "next";
import { withShiftAPI } from "@shiftapi/next";

const nextConfig: NextConfig = {};

export default withShiftAPI(nextConfig);

Use the typed client

import { client } from "@shiftapi/client";

const { data } = await client.GET("/health");
// data: { ok?: boolean }

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 }

const { data: upload } = await client.POST("/upload", {
    body: { file: new File(["content"], "doc.txt"), title: "My Doc" },
    params: { query: { tags: "important" } },
});
// file uploads are typed as File | Blob | Uint8Array — generated from format: binary in the spec

const { data: authResults } = await client.GET("/search", {
    params: {
        query: { q: "hello" },
        header: { Authorization: "Bearer token" },
    },
});
// header params are fully typed as well

In dev mode the plugins start the Go server, proxy API requests, watch .go files, and regenerate types on changes.

CLI usage (without Vite/Next.js):

shiftapi prepare

This extracts the spec and generates .shiftapi/client.d.ts and .shiftapi/client.js. Useful in postinstall scripts or CI.

Config options:

Option Default Description
server (required) Go entry point (e.g. "./cmd/server")
baseUrl "/" Fallback base URL for the API client
url "http://localhost:8080" Go server address for dev proxy

For production, set VITE_SHIFTAPI_BASE_URL (Vite) or NEXT_PUBLIC_SHIFTAPI_BASE_URL (Next.js) to point at your API host. The plugins automatically update tsconfig.json with the required path mapping for IDE autocomplete.

Development

This is a pnpm + Turborepo monorepo.

pnpm install    # install dependencies
pnpm build      # build all packages
pnpm dev        # start example Vite + Go app
pnpm test       # run all tests

Go tests can also be run directly:

go test -count=1 -tags shiftapidev ./...

Made with love for types at the Recurse Center

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:

  1. Middleware-set headers (outermost, applied before the handler)
  2. Static headers via WithResponseHeader (API → Group → Route)
  3. 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.

Server-Sent Events

Use HandleSSE for Server-Sent Events with a typed event writer:

type ChatEvent struct {
    User    string `json:"user"`
    Message string `json:"message"`
}

shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter) error {
    for event := range events(r.Context()) {
        if err := sse.Send(event); err != nil {
            return err
        }
    }
    return nil
}, shiftapi.SSESends(
    shiftapi.SSEEventType[ChatEvent]("chat"),
))

SSEWriter automatically sets Content-Type, Cache-Control, and Connection headers on the first write. SSEWriter.Send automatically determines the event name from the concrete Go type registered via SSESends.

SSESends is required for HandleSSE. It registers event types for auto-wrap and OpenAPI schema generation. For multiple event types, pass multiple SSEEventType descriptors:

shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter) error {
    sse.Send(MessageData{User: "alice", Text: "hi"})
    return sse.Send(JoinData{User: "bob"})
}, shiftapi.SSESends(
    shiftapi.SSEEventType[MessageData]("message"),
    shiftapi.SSEEventType[JoinData]("join"),
))

SSESends generates a oneOf schema with a discriminator in the OpenAPI spec, which produces TypeScript discriminated unions in the generated client.

The generated TypeScript client includes a typed subscribe function constrained to SSE paths. It handles path/query/header parameter substitution, SSE stream parsing, and yields typed events as an async iterable.

For custom SSE framing or non-standard behavior, use HandleRaw with WithContentType("text/event-stream") instead.

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))
}

Index

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
	})
}
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
	})
}
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))
}
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
}
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 HandleSSE added in v0.0.29

func HandleSSE[In any](router Router, pattern string, fn SSEHandlerFunc[In], options ...SSEOption)

HandleSSE registers a Server-Sent Events handler for the given pattern. The handler receives an SSEWriter for sending events to the client. Input parsing, validation, and middleware work identically to Handle.

The OpenAPI spec automatically uses "text/event-stream" as the response content type, with the event types declared via SSESends generating the event schema.

shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, in struct{}, sse *shiftapi.SSEWriter) error {
    for msg := range messages(r.Context()) {
        if err := sse.Send(msg); err != nil {
            return err
        }
    }
    return nil
}, shiftapi.SSESends(
    shiftapi.SSEEventType[Message]("message"),
))
Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/fcjr/shiftapi"
)

func main() {
	api := shiftapi.New()

	type Message struct {
		Text string `json:"text"`
	}

	shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter) error {
		for _, msg := range []string{"hello", "world"} {
			if err := sse.Send(Message{Text: msg}); err != nil {
				return err
			}
		}
		return nil
	}, shiftapi.SSESends(
		shiftapi.SSEEventType[Message]("message"),
	))

	w := httptest.NewRecorder()
	r := httptest.NewRequest("GET", "/events", nil)
	api.ServeHTTP(w, r)
	fmt.Println(w.Body.String())
}
Output:
event: message
data: {"text":"hello"}

event: message
data: {"text":"world"}

func HandleWS added in v0.0.29

func HandleWS[In any](router Router, pattern string, msgs *WSMessages[In], options ...WSOption)

HandleWS registers a WebSocket endpoint for the given pattern. Message handling is defined by WSOn handlers collected in a Websocket block. The framework manages the receive loop, dispatching incoming messages to the matching handler.

Input parsing, validation, and middleware work identically to Handle. WebSocket endpoints are documented in an AsyncAPI 2.4 spec served at GET /asyncapi.json.

shiftapi.HandleWS(api, "GET /chat",
    shiftapi.Websocket(
        func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
        shiftapi.WSSends(shiftapi.WSMessageType[ChatMessage]("chat")),
        shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error {
            return s.Send(ChatMessage{User: "echo", Text: m.Text})
        }),
    ),
)
Example
package main

import (
	"net/http"

	"github.com/fcjr/shiftapi"
)

func main() {
	api := shiftapi.New()

	type ServerMsg struct {
		Text string `json:"text"`
	}
	type ClientMsg struct {
		Text string `json:"text"`
	}

	shiftapi.HandleWS(api, "GET /echo",
		shiftapi.Websocket(
			func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
			shiftapi.WSSends(shiftapi.WSMessageType[ServerMsg]("server")),
			shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error {
				return s.Send(ServerMsg{Text: "echo: " + msg.Text})
			}),
		),
	)

	_ = api
}
Example (MultiType)
package main

import (
	"net/http"

	"github.com/fcjr/shiftapi"
)

type exChatMessage struct {
	User string `json:"user"`
	Text string `json:"text"`
}

type exSystemMessage struct {
	Info string `json:"info"`
}

type exUserMessage struct {
	Text string `json:"text"`
}

func main() {
	api := shiftapi.New()

	shiftapi.HandleWS(api, "GET /chat",
		shiftapi.Websocket(
			func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
			shiftapi.WSSends(
				shiftapi.WSMessageType[exChatMessage]("chat"),
				shiftapi.WSMessageType[exSystemMessage]("system"),
			),
			shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m exUserMessage) error {
				return s.Send(exChatMessage{User: "server", Text: m.Text})
			}),
		),
	)

	_ = api
}

func ListenAndServe

func ListenAndServe(addr string, api *API) error

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 SSESends added in v0.0.29

func SSESends(variants ...SSEEventVariant) sseOptionFunc

SSESends registers named SSE event types for auto-wrap and OpenAPI schema generation. Each SSEEventVariant maps an event name to a payload type, producing a oneOf schema with a discriminator in the OpenAPI spec. The generated TypeScript client yields a discriminated union type. SSESends is required for HandleSSE.

When SSESends is used, SSEWriter.Send automatically determines the event name from the concrete Go type.

shiftapi.HandleSSE(api, "GET /chat", chatHandler,
    shiftapi.SSESends(
        shiftapi.SSEEventType[MessageData]("message"),
        shiftapi.SSEEventType[JoinData]("join"),
    ),
)
Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/fcjr/shiftapi"
)

type exMessageData struct {
	User string `json:"user"`
	Text string `json:"text"`
}

type exJoinData struct {
	User string `json:"user"`
}

func main() {
	api := shiftapi.New()

	shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter) error {
		if err := sse.Send(exMessageData{User: "alice", Text: "hi"}); err != nil {
			return err
		}
		return sse.Send(exJoinData{User: "bob"})
	}, shiftapi.SSESends(
		shiftapi.SSEEventType[exMessageData]("message"),
		shiftapi.SSEEventType[exJoinData]("join"),
	))

	w := httptest.NewRecorder()
	r := httptest.NewRequest("GET", "/chat", nil)
	api.ServeHTTP(w, r)
	fmt.Println(w.Body.String())
}
Output:
event: message
data: {"user":"alice","text":"hi"}

event: join
data: {"user":"bob"}

func SetContext added in v0.0.28

func SetContext[T any](r *http.Request, key *ContextKey[T], val T) *http.Request

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

func WithBadRequestError[T any](fn func(error) T) apiOptionFunc

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

func WithInternalServerError[T any](fn func(error) T) apiOptionFunc

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) routeAndWSAndSSEOption

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"},
	}))
}

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
}

func WithValidator

func WithValidator(v *validator.Validate) apiOptionFunc

WithValidator sets a custom github.com/go-playground/validator/v10 instance on the API. Use this to register custom validations or override default behavior.

func WithWSAcceptOptions added in v0.0.29

func WithWSAcceptOptions(opts WSAcceptOptions) wsOptionFunc

WithWSAcceptOptions sets the WebSocket upgrade options for HandleWS routes. Use this to configure subprotocols, allowed origins, etc.

shiftapi.HandleWS(api, "GET /ws", ws,
    shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{
        Subprotocols:   []string{"graphql-ws"},
        OriginPatterns: []string{"example.com"},
    }),
)

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

func New(options ...APIOption) *API

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
}

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.

func ComposeAPIOptions added in v0.0.26

func ComposeAPIOptions(opts ...APIOption) APIOption

ComposeAPIOptions combines multiple APIOption values into a single APIOption. Since Option implements APIOption, both shared and API-specific options can be mixed.

type Contact

type Contact struct {
	Name  string
	URL   string
	Email string
}

Contact describes the API contact information.

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

type ExternalDocs struct {
	Description string
	URL         string
}

ExternalDocs links to external documentation.

type FieldError

type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

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.

func (*Group) Group added in v0.0.26

func (g *Group) Group(prefix string, opts ...GroupOption) *Group

Group creates a nested sub-router. The prefix is appended to the parent group's prefix, and error types and middleware are inherited from the parent.

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

type HandlerFunc[In, Resp any] func(r *http.Request, in In) (Resp, error)

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 License

type License struct {
	Name string
	URL  string
}

License describes the API license.

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

func ComposeOptions(opts ...Option) Option

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

func WithError[T error](status int) Option

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

func WithMiddleware(mw ...func(http.Handler) http.Handler) Option

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

func WithResponseHeader(name, value string) Option

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:

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

type RawHandlerFunc[In any] func(w http.ResponseWriter, r *http.Request, in In) error

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

type RouteInfo struct {
	Summary     string
	Description string
	Tags        []string
}

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 SSEEventVariant added in v0.0.29

type SSEEventVariant interface {
	// contains filtered or unexported methods
}

SSEEventVariant describes a named SSE event type for OpenAPI schema generation. Created by SSEEventType and passed to SSESends.

func SSEEventType added in v0.0.29

func SSEEventType[T any](name string) SSEEventVariant

SSEEventType creates an SSEEventVariant that maps an SSE event name to a payload type T. Use with SSESends to register event types for a HandleSSE endpoint. The OpenAPI spec will contain a oneOf schema with a discriminator, and the generated TypeScript client will yield a discriminated union type.

shiftapi.HandleSSE(api, "GET /chat", chatHandler,
    shiftapi.SSESends(
        shiftapi.SSEEventType[MessageData]("message"),
        shiftapi.SSEEventType[JoinData]("join"),
    ),
)

type SSEHandlerFunc added in v0.0.29

type SSEHandlerFunc[In any] func(r *http.Request, in In, sse *SSEWriter) error

SSEHandlerFunc is a handler function for Server-Sent Events. It receives the parsed input and an SSEWriter for sending events to the client.

The handler should send events via SSEWriter.Send and return nil when the stream is complete. If the handler returns an error before any events have been sent, a JSON error response is written. If the error occurs after events have been sent the error is logged (the response has already started).

type SSEOption added in v0.0.29

type SSEOption interface {
	// contains filtered or unexported methods
}

SSEOption configures a HandleSSE route. General options like WithRouteInfo, WithError, and WithMiddleware implement both RouteOption and SSEOption. SSE-specific options like SSESends implement only SSEOption.

type SSEWriter added in v0.0.29

type SSEWriter struct {
	// contains filtered or unexported fields
}

SSEWriter writes Server-Sent Events to the client. It is created internally by HandleSSE and should not be constructed directly.

SSEWriter.Send automatically determines the event name from the concrete Go type registered via SSESends. On the first call, SSEWriter sets the required SSE headers (Content-Type, Cache-Control, Connection).

func (*SSEWriter) Send added in v0.0.29

func (s *SSEWriter) Send(v any) error

Send writes an SSE event. The event name is automatically determined from the concrete Go type registered via SSESends:

event: {name}\ndata: {json}\n\n

The response is flushed after each event.

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

type WSAcceptOptions added in v0.0.29

type WSAcceptOptions struct {
	// Subprotocols lists the WebSocket subprotocols to negotiate with the
	// client. The empty subprotocol is always negotiated per RFC 6455.
	Subprotocols []string

	// OriginPatterns lists host patterns for authorized cross-origin requests.
	// The request host is always authorized. Each pattern is matched case
	// insensitively with [path.Match]. Include a URI scheme ("://") to match
	// against "scheme://host".
	//
	// In dev mode (shiftapidev build tag), all origins are allowed by default.
	OriginPatterns []string
}

WSAcceptOptions configures the WebSocket upgrade for HandleWS routes.

type WSDecodeError added in v0.0.29

type WSDecodeError struct {
	// contains filtered or unexported fields
}

WSDecodeError is returned when a WebSocket message payload cannot be decoded into the expected type. Decode errors are non-fatal — the framework logs them and continues reading.

func (*WSDecodeError) Error added in v0.0.29

func (e *WSDecodeError) Error() string

func (*WSDecodeError) MessageType added in v0.0.29

func (e *WSDecodeError) MessageType() string

MessageType returns the name of the message type that failed to decode.

func (*WSDecodeError) Unwrap added in v0.0.29

func (e *WSDecodeError) Unwrap() error

type WSHandler added in v0.0.29

type WSHandler[State any] struct {
	// contains filtered or unexported fields
}

WSHandler is a typed configuration unit for a Websocket endpoint. Create one with WSOn, WSOnDecodeError, or WSOnUnknownMessage. The State type parameter must match the setup function's return type.

func WSOn added in v0.0.29

func WSOn[State, Msg any](name string, fn func(sender *WSSender, state State, msg Msg) error) WSHandler[State]

WSOn creates a typed message handler for a Websocket endpoint. The State and Msg type parameters are inferred from the handler function. State must match the setup function's return type.

shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *Room, msg UserMessage) error {
    state.Broadcast(msg)
    return nil
})

func WSOnDecodeError added in v0.0.29

func WSOnDecodeError[State any](fn func(sender *WSSender, state State, err *WSDecodeError)) WSHandler[State]

WSOnDecodeError creates a handler that is called when a message payload cannot be decoded into the expected type. If not registered, the framework logs the error and continues reading. The connection is never closed for decode errors. The State type parameter must match the setup function's return type.

shiftapi.WSOnDecodeError(func(s *shiftapi.WSSender, state *Room, err *shiftapi.WSDecodeError) {
    log.Printf("bad payload in room %s: %v", state.Name, err)
})

func WSOnUnknownMessage added in v0.0.29

func WSOnUnknownMessage[State any](fn func(sender *WSSender, state State, msgType string, data json.RawMessage)) WSHandler[State]

WSOnUnknownMessage creates a handler that is called when the client sends a message whose "type" field does not match any registered WSOn handler. If not registered, the framework logs the unknown type and continues reading. The State type parameter must match the setup function's return type.

shiftapi.WSOnUnknownMessage(func(s *shiftapi.WSSender, state *Room, msgType string, data json.RawMessage) {
    log.Printf("unknown message in room %s: %s", state.Name, msgType)
})

type WSMessageVariant added in v0.0.29

type WSMessageVariant interface {
	// contains filtered or unexported methods
}

WSMessageVariant describes a named WebSocket message type for AsyncAPI schema generation. Created by WSMessageType and passed to WSSends.

func WSMessageType added in v0.0.29

func WSMessageType[T any](name string) WSMessageVariant

WSMessageType creates a WSMessageVariant that maps a message type name to a payload type T. Use with WSSends to register discriminated server-to-client message types for a WebSocket endpoint.

shiftapi.WSSends(
    shiftapi.WSMessageType[ChatMessage]("chat"),
    shiftapi.WSMessageType[SystemMessage]("system"),
)

func WSSends added in v0.0.29

func WSSends(variants ...WSMessageVariant) []WSMessageVariant

WSSends declares the named server-to-client message types for a WebSocket endpoint. Pass WSMessageType values to register each type. WSSender.Send automatically wraps messages in a discriminated {"type", "data"} envelope based on the concrete Go type.

shiftapi.WSSends(
    shiftapi.WSMessageType[ChatMessage]("chat"),
    shiftapi.WSMessageType[SystemMessage]("system"),
)

type WSMessages added in v0.0.29

type WSMessages[In any] struct {
	// contains filtered or unexported fields
}

WSMessages holds the WebSocket endpoint configuration. Create one with Websocket, passing a setup function, WSSends, and WSOn handlers. Pass it to HandleWS to register the route.

func Websocket added in v0.0.29

func Websocket[In, State any](setup func(r *http.Request, sender *WSSender, in In) (State, error), sends []WSMessageVariant, handlers ...WSHandler[State]) *WSMessages[In]

Websocket creates a new WebSocket endpoint configuration. The type parameters In and State are both inferred from the setup function: In from the input parameter, State from the return value. Handlers receive the State value returned by setup on each connection.

Use a pointer type for State (e.g. *MyState) when handlers need to mutate shared state across messages. Value types are copied per handler call, so mutations would be lost.

Use struct{} for both In and State when no input or state is needed.

shiftapi.HandleWS(api, "GET /echo",
    shiftapi.Websocket(
        func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) {
            return struct{}{}, nil
        },
        shiftapi.WSSends(shiftapi.WSMessageType[ServerMsg]("server")),
        shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error {
            return s.Send(ServerMsg{Text: msg.Text})
        }),
    ),
)

type WSOption added in v0.0.29

type WSOption interface {
	// contains filtered or unexported methods
}

WSOption configures a HandleWS route. General options like WithRouteInfo, WithError, and WithMiddleware implement both RouteOption and WSOption. WebSocket-specific options like WithWSAcceptOptions implement only WSOption.

type WSSender added in v0.0.29

type WSSender struct {
	// contains filtered or unexported fields
}

WSSender is the send-only WebSocket connection passed to WSOn message handlers. It provides WSSender.Send for writing messages and WSSender.Close for closing the connection.

WSSender.Send automatically wraps the value in a discriminated {"type", "data"} envelope based on the concrete Go type registered via WSSends.

func (*WSSender) Close added in v0.0.29

func (ws *WSSender) Close(status WSStatusCode, reason string) error

Close closes the WebSocket connection with the given status code and reason.

func (*WSSender) Context added in v0.0.29

func (ws *WSSender) Context() context.Context

Context returns the connection's context. It is cancelled when the WebSocket connection is closed.

func (*WSSender) Send added in v0.0.29

func (ws *WSSender) Send(v any) error

Send writes a JSON-encoded message to the WebSocket connection. The value is automatically wrapped in a {"type": name, "data": value} envelope based on its concrete Go type, using the types registered via WSSends.

type WSStatusCode added in v0.0.29

type WSStatusCode int

WSStatusCode represents a WebSocket close status code as defined in RFC 6455 section 7.4.

const (
	WSStatusNormalClosure   WSStatusCode = 1000
	WSStatusGoingAway       WSStatusCode = 1001
	WSStatusProtocolError   WSStatusCode = 1002
	WSStatusUnsupportedData WSStatusCode = 1003
	WSStatusInternalError   WSStatusCode = 1011
)

Standard WebSocket close status codes.

func WSCloseStatus added in v0.0.29

func WSCloseStatus(err error) WSStatusCode

WSCloseStatus extracts the WebSocket close status code from an error. Returns -1 if the error is nil or not a WebSocket close error.

Directories

Path Synopsis
examples
greeter command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL