diff --git a/README.md b/README.md index a186dbb..8800a9d 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ That's it. ShiftAPI reflects your Go types into an OpenAPI 3.1 spec at `/openapi ### Generic type-safe handlers -Generic free functions capture your request and response types at compile time. Every method uses a single function — struct tags discriminate query params (`query:"..."`) from body fields (`json:"..."`). For routes without input, use `_ struct{}`. +Generic free functions capture your request and response types at compile time. Every method uses a single function — struct tags discriminate query params (`query:"..."`), body fields (`json:"..."`), and form fields (`form:"..."`). For routes without input, use `_ struct{}`. ```go // POST with body — input is decoded and passed as *CreateUser @@ -129,6 +129,48 @@ shiftapi.Post(api, "/items", func(r *http.Request, in CreateInput) (*Result, err }) ``` +### 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. + +```go +type UploadInput struct { + File *multipart.FileHeader `form:"file" validate:"required"` + Title string `form:"title" validate:"required"` + Tags string `query:"tags"` +} + +shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*Result, error) { + f, err := in.File.Open() + if err != nil { + return nil, shiftapi.Error(http.StatusBadRequest, "failed to open file") + } + 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: + +```go +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`: + +```go +api := shiftapi.New(shiftapi.WithMaxUploadSize(64 << 20)) // 64 MB +``` + ### Validation Built-in validation via [go-playground/validator](https://github.com/go-playground/validator). Struct tags are enforced at runtime *and* reflected into the OpenAPI schema. @@ -261,6 +303,12 @@ 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 ``` In dev mode the plugins start the Go server, proxy API requests, watch `.go` files, and regenerate types on changes. diff --git a/examples/greeter/main.go b/examples/greeter/main.go index 8faa07b..8d1492b 100644 --- a/examples/greeter/main.go +++ b/examples/greeter/main.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "log" + "mime/multipart" "net/http" "github.com/fcjr/shiftapi" @@ -50,6 +52,60 @@ func health(r *http.Request, _ struct{}) (*Status, error) { return &Status{OK: true}, nil } +type UploadInput struct { + File *multipart.FileHeader `form:"file" validate:"required"` +} + +type UploadResult struct { + Filename string `json:"filename"` + Size int64 `json:"size"` +} + +func upload(r *http.Request, in UploadInput) (*UploadResult, error) { + return &UploadResult{ + Filename: in.File.Filename, + Size: in.File.Size, + }, nil +} + +type ImageUploadInput struct { + Image *multipart.FileHeader `form:"image" accept:"image/png,image/jpeg" validate:"required"` +} + +type ImageUploadResult struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` +} + +func uploadImage(r *http.Request, in ImageUploadInput) (*ImageUploadResult, error) { + return &ImageUploadResult{ + Filename: in.Image.Filename, + ContentType: in.Image.Header.Get("Content-Type"), + Size: in.Image.Size, + }, nil +} + +type MultiUploadInput struct { + Files []*multipart.FileHeader `form:"files" validate:"required"` +} + +type MultiUploadResult struct { + Count int `json:"count"` + Filenames []string `json:"filenames"` +} + +func uploadMulti(r *http.Request, in MultiUploadInput) (*MultiUploadResult, error) { + names := make([]string, len(in.Files)) + for i, f := range in.Files { + names[i] = fmt.Sprintf("%s (%d bytes)", f.Filename, f.Size) + } + return &MultiUploadResult{ + Count: len(in.Files), + Filenames: names, + }, nil +} + func main() { api := shiftapi.New(shiftapi.WithInfo(shiftapi.Info{ Title: "Greeter Demo API", @@ -78,6 +134,30 @@ func main() { }), ) + shiftapi.Post(api, "/upload", upload, + shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "Upload a file", + Description: "Upload a single file", + Tags: []string{"uploads"}, + }), + ) + + shiftapi.Post(api, "/upload-image", uploadImage, + shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "Upload an image", + Description: "Upload a single image (PNG or JPEG only)", + Tags: []string{"uploads"}, + }), + ) + + shiftapi.Post(api, "/upload-multi", uploadMulti, + shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "Upload multiple files", + Description: "Upload multiple files at once", + Tags: []string{"uploads"}, + }), + ) + log.Println("listening on :8080") log.Fatal(shiftapi.ListenAndServe(":8080", api)) // docs at http://localhost:8080/docs diff --git a/examples/vite-app/index.html b/examples/vite-app/index.html index 142527d..a9453f2 100644 --- a/examples/vite-app/index.html +++ b/examples/vite-app/index.html @@ -6,14 +6,7 @@