From 96880519153cba4dbda47302495d97c25c561859 Mon Sep 17 00:00:00 2001 From: "Frank Chiarulli Jr." Date: Wed, 4 Mar 2026 15:03:32 -0700 Subject: [PATCH] support files --- README.md | 50 +- examples/greeter/main.go | 80 +++ examples/vite-app/index.html | 11 +- examples/vite-app/package.json | 9 + examples/vite-app/src/App.tsx | 74 +++ examples/vite-app/src/api.ts | 4 + examples/vite-app/src/main.ts | 42 -- examples/vite-app/src/main.tsx | 14 + examples/vite-app/tsconfig.json | 3 +- examples/vite-app/vite.config.ts | 3 +- form.go | 157 ++++++ handler.go | 23 +- handlerFuncs.go | 18 +- .../shiftapi/src/__tests__/generate.test.ts | 86 +++ packages/shiftapi/src/generate.ts | 23 +- packages/shiftapi/src/templates.ts | 37 +- pnpm-lock.yaml | 522 ++++++++++++----- query.go | 14 +- schema.go | 114 +++- server.go | 14 +- serverOptions.go | 8 + shiftapi_test.go | 530 ++++++++++++++++++ 22 files changed, 1591 insertions(+), 245 deletions(-) create mode 100644 examples/vite-app/src/App.tsx create mode 100644 examples/vite-app/src/api.ts delete mode 100644 examples/vite-app/src/main.ts create mode 100644 examples/vite-app/src/main.tsx create mode 100644 form.go 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 @@ ShiftAPI Example -
-

ShiftAPI + Vite Example

-
- - -
-

-    
- +
+ diff --git a/examples/vite-app/package.json b/examples/vite-app/package.json index a697fcc..d82b081 100644 --- a/examples/vite-app/package.json +++ b/examples/vite-app/package.json @@ -8,8 +8,17 @@ "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "openapi-react-query": "^0.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, "devDependencies": { "@shiftapi/vite-plugin": "workspace:*", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.0.0", "typescript": "^5.5.0", "vite": "^6.0.0" } diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx new file mode 100644 index 0000000..1d4859f --- /dev/null +++ b/examples/vite-app/src/App.tsx @@ -0,0 +1,74 @@ +import { useState, useRef } from "react"; +import { api } from "./api"; + +export default function App() { + const [name, setName] = useState(""); + const fileRef = useRef(null); + const health = api.useQuery("get", "/health"); + const greet = api.useMutation("post", "/greet"); + const upload = api.useMutation("post", "/upload"); + + if (health.isLoading) return

Loading...

; + if (health.error) return

Health check failed: {health.error.message}

; + + return ( +
+

ShiftAPI + Vite Example

+ +

Greet

+
{ + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + greet.mutate( + { body: { name: trimmed } }, + { onSuccess: () => setName("") }, + ); + }} + > + setName(e.target.value)} + placeholder="Enter a name" + /> + +
+ {greet.isPending &&

Loading...

} + {greet.error &&

Error: {greet.error.message}

} + {greet.data &&
Hello: {greet.data.hello}
} + +

Upload

+
{ + e.preventDefault(); + const file = fileRef.current?.files?.[0]; + if (!file) return; + upload.mutate( + { body: { file } }, + { + onSuccess: () => { + if (fileRef.current) fileRef.current.value = ""; + }, + }, + ); + }} + > + + +
+ {upload.isPending &&

Uploading...

} + {upload.error &&

Error: {upload.error.message}

} + {upload.data && ( +
+          Uploaded: {upload.data.filename} ({upload.data.size} bytes)
+        
+ )} +
+ ); +} diff --git a/examples/vite-app/src/api.ts b/examples/vite-app/src/api.ts new file mode 100644 index 0000000..6e38ce9 --- /dev/null +++ b/examples/vite-app/src/api.ts @@ -0,0 +1,4 @@ +import createClient from "openapi-react-query"; +import { client } from "@shiftapi/client"; + +export const api = createClient(client); diff --git a/examples/vite-app/src/main.ts b/examples/vite-app/src/main.ts deleted file mode 100644 index 4dbb3db..0000000 --- a/examples/vite-app/src/main.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { client } from "@shiftapi/client"; - -// ---- Type-safe API calls ---- - -// GET /health — response is typed as { ok?: boolean } -async function checkHealth() { - const { data, error } = await client.GET("/health"); - if (error) { - console.error("Health check failed:", error.message); - return; - } - console.log("Health:", data); -} - -// POST /greet — body is typed as { name: string (required) }, response as { hello?: string } -async function greet(name: string) { - const { data, error } = await client.POST("/greet", { - body: { name }, - }); - if (error) { - return `Error: ${error.message}`; - } - return `Hello response: ${data.hello}`; -} - -// ---- Wire up the UI ---- - -const output = document.getElementById("output")!; -const form = document.getElementById("greet-form")!; -const input = document.getElementById("name-input") as HTMLInputElement; - -checkHealth().then(() => { - output.textContent = "Health check passed. Try greeting someone."; -}); - -form.addEventListener("submit", async (e) => { - e.preventDefault(); - const name = input.value.trim(); - if (!name) return; - output.textContent = "Loading..."; - output.textContent = await greet(name); -}); diff --git a/examples/vite-app/src/main.tsx b/examples/vite-app/src/main.tsx new file mode 100644 index 0000000..6f95f3b --- /dev/null +++ b/examples/vite-app/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById("app")!).render( + + + + + , +); diff --git a/examples/vite-app/tsconfig.json b/examples/vite-app/tsconfig.json index 5683f37..8eb8264 100644 --- a/examples/vite-app/tsconfig.json +++ b/examples/vite-app/tsconfig.json @@ -6,9 +6,10 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "jsx": "react-jsx", "paths": { "@shiftapi/client": [ - "./.shiftapi/client.d.ts" + "./.shiftapi/client" ] } }, diff --git a/examples/vite-app/vite.config.ts b/examples/vite-app/vite.config.ts index 62e025c..8770c76 100644 --- a/examples/vite-app/vite.config.ts +++ b/examples/vite-app/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; import shiftapi from "@shiftapi/vite-plugin"; export default defineConfig({ - plugins: [shiftapi()], + plugins: [react(), shiftapi()], }); diff --git a/form.go b/form.go new file mode 100644 index 0000000..f2edcaf --- /dev/null +++ b/form.go @@ -0,0 +1,157 @@ +package shiftapi + +import ( + "fmt" + "mime/multipart" + "net/http" + "reflect" + "strings" +) + +var ( + fileHeaderType = reflect.TypeFor[*multipart.FileHeader]() + fileHeaderSliceType = reflect.TypeFor[[]*multipart.FileHeader]() +) + +// hasFormTag returns true if the struct field has a `form` tag. +func hasFormTag(f reflect.StructField) bool { + return f.Tag.Get("form") != "" +} + +// formFieldName returns the form field name from the struct tag. +func formFieldName(f reflect.StructField) string { + name, _, _ := strings.Cut(f.Tag.Get("form"), ",") + if name == "" { + return f.Name + } + return name +} + +// isFileField returns true if the field type is *multipart.FileHeader or []*multipart.FileHeader. +func isFileField(f reflect.StructField) bool { + return f.Type == fileHeaderType || f.Type == fileHeaderSliceType +} + +// acceptTypes returns the accepted MIME types from the `accept` struct tag. +// Returns nil if no accept tag is present. +func acceptTypes(f reflect.StructField) []string { + tag := f.Tag.Get("accept") + if tag == "" { + return nil + } + var types []string + for part := range strings.SplitSeq(tag, ",") { + t := strings.TrimSpace(part) + if t != "" { + types = append(types, t) + } + } + return types +} + +// checkFileContentType validates the Content-Type of an uploaded file +// against the accepted types. Returns an error if the type is not allowed. +func checkFileContentType(fh *multipart.FileHeader, name string, allowed []string) error { + if len(allowed) == 0 { + return nil + } + ct := fh.Header.Get("Content-Type") + for _, a := range allowed { + if ct == a { + return nil + } + } + return &formParseError{ + Field: name, + Err: fmt.Errorf("content type %q not allowed, accepted: %s", ct, strings.Join(allowed, ", ")), + } +} + +// parseFormInto parses a multipart form request into struct fields tagged with `form`. +func parseFormInto(rv reflect.Value, r *http.Request, maxMemory int64) error { + if err := r.ParseMultipartForm(maxMemory); err != nil { + return &formParseError{Err: fmt.Errorf("failed to parse multipart form: %w", err)} + } + + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + rv.Set(reflect.New(rv.Type().Elem())) + } + rv = rv.Elem() + } + + rt := rv.Type() + if rt.Kind() != reflect.Struct { + return fmt.Errorf("form type must be a struct, got %s", rt.Kind()) + } + + for i := range rt.NumField() { + field := rt.Field(i) + if !field.IsExported() || !hasFormTag(field) { + continue + } + + name := formFieldName(field) + fv := rv.Field(i) + + if field.Type == fileHeaderType { + // Single file: *multipart.FileHeader + _, fh, err := r.FormFile(name) + if err != nil { + if err == http.ErrMissingFile { + continue + } + return &formParseError{Field: name, Err: err} + } + if allowed := acceptTypes(field); allowed != nil { + if err := checkFileContentType(fh, name, allowed); err != nil { + return err + } + } + fv.Set(reflect.ValueOf(fh)) + continue + } + + if field.Type == fileHeaderSliceType { + // Multiple files: []*multipart.FileHeader + if r.MultipartForm != nil && r.MultipartForm.File != nil { + files := r.MultipartForm.File[name] + if allowed := acceptTypes(field); allowed != nil { + for _, fh := range files { + if err := checkFileContentType(fh, name, allowed); err != nil { + return err + } + } + } + if len(files) > 0 { + fv.Set(reflect.ValueOf(files)) + } + } + continue + } + + // Text form field — use r.FormValue and setScalarValue + raw := r.FormValue(name) + if raw == "" { + continue + } + if err := setScalarValue(fv, raw); err != nil { + return &formParseError{Field: name, Err: err} + } + } + + return nil +} + +// formParseError is returned when a form field cannot be parsed. +type formParseError struct { + Field string + Err error +} + +func (e *formParseError) Error() string { + if e.Field == "" { + return e.Err.Error() + } + return fmt.Sprintf("invalid form field %q: %v", e.Field, e.Err) +} diff --git a/handler.go b/handler.go index 54deb84..0d5e888 100644 --- a/handler.go +++ b/handler.go @@ -15,25 +15,32 @@ import ( // For routes without input, use struct{} as the In type. type HandlerFunc[In, Resp any] func(r *http.Request, in In) (Resp, error) -func adapt[In, Resp any](fn HandlerFunc[In, Resp], status int, validate func(any) error, hasQuery, hasBody bool) http.HandlerFunc { +func adapt[In, Resp any](fn HandlerFunc[In, Resp], status int, validate func(any) error, hasQuery, hasBody, hasForm bool, maxUploadSize int64) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var in In rv := reflect.ValueOf(&in).Elem() - // JSON-decode body if there are body fields - if hasBody { + if hasForm { + // Parse multipart form + if err := parseFormInto(rv, r, maxUploadSize); err != nil { + writeError(w, Error(http.StatusBadRequest, err.Error())) + return + } + rv = reflect.ValueOf(&in).Elem() + } else if hasBody { + // JSON-decode body if there are body fields if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, Error(http.StatusBadRequest, "invalid request body")) return } // Re-point rv after decode (in case In is a pointer that was nil) rv = reflect.ValueOf(&in).Elem() - } - // Reset any query-tagged fields that body decode may have - // inadvertently set, so they only come from URL query params. - if hasBody && hasQuery { - resetQueryFields(rv) + // Reset any query-tagged fields that body decode may have + // inadvertently set, so they only come from URL query params. + if hasQuery { + resetQueryFields(rv) + } } // Parse query params if there are query fields diff --git a/handlerFuncs.go b/handlerFuncs.go index 5b7810e..03ca1bc 100644 --- a/handlerFuncs.go +++ b/handlerFuncs.go @@ -23,7 +23,7 @@ func registerRoute[In, Resp any]( rawInType = rawInType.Elem() } - hasQuery, hasBody := partitionFields(rawInType) + hasQuery, hasBody, hasForm := partitionFields(rawInType) var queryType reflect.Type if hasQuery { @@ -33,24 +33,26 @@ func registerRoute[In, Resp any]( // body decode for these methods — even when the input is struct{}. // This means Post(api, path, func(r, _ struct{}) ...) requires at least "{}". methodRequiresBody := method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch - decodeBody := hasBody || methodRequiresBody + decodeBody := !hasForm && (hasBody || methodRequiresBody) var bodyType reflect.Type - if hasBody { - bodyType = inType - } else if methodRequiresBody { - bodyType = rawInType + if !hasForm { + if hasBody { + bodyType = inType + } else if methodRequiresBody { + bodyType = rawInType + } } var resp Resp outType := reflect.TypeOf(resp) - if err := api.updateSchema(method, path, queryType, bodyType, outType, cfg.info, cfg.status); err != nil { + if err := api.updateSchema(method, path, queryType, bodyType, outType, hasForm, rawInType, cfg.info, cfg.status); err != nil { panic(fmt.Sprintf("shiftapi: schema generation failed for %s %s: %v", method, path, err)) } pattern := fmt.Sprintf("%s %s", method, path) - api.mux.HandleFunc(pattern, adapt(fn, cfg.status, api.validateBody, hasQuery, decodeBody)) + api.mux.HandleFunc(pattern, adapt(fn, cfg.status, api.validateBody, hasQuery, decodeBody, hasForm, api.maxUploadSize)) } // Get registers a GET handler. diff --git a/packages/shiftapi/src/__tests__/generate.test.ts b/packages/shiftapi/src/__tests__/generate.test.ts index a55b1c7..4da7a47 100644 --- a/packages/shiftapi/src/__tests__/generate.test.ts +++ b/packages/shiftapi/src/__tests__/generate.test.ts @@ -24,6 +24,92 @@ describe("generateTypes", () => { expect(types).toContain("Person"); expect(types).toContain("Greeting"); }); + + it("transforms format: binary to File | Blob | Uint8Array", async () => { + const spec = { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + paths: { + "/upload": { + post: { + operationId: "postUpload", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + title: { type: "string" }, + }, + required: ["file"], + }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { filename: { type: "string" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const types = await generateTypes(spec); + + // The file field should be File | Blob | Uint8Array, not string + expect(types).toContain("File | Blob | Uint8Array"); + expect(types).not.toMatch(/file\??\s*:\s*string/); + // title should still be string + expect(types).toContain("title"); + }); + + it("transforms binary array items to File | Blob | Uint8Array", async () => { + const spec = { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + paths: { + "/upload-multi": { + post: { + operationId: "postUploadMulti", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + files: { + type: "array", + items: { type: "string", format: "binary" }, + }, + }, + }, + }, + }, + }, + responses: { + "200": { description: "OK" }, + }, + }, + }, + }, + }; + const types = await generateTypes(spec); + + // Array items should be File | Blob | Uint8Array + expect(types).toContain("File | Blob | Uint8Array"); + }); }); describe("virtualModuleTemplate", () => { diff --git a/packages/shiftapi/src/generate.ts b/packages/shiftapi/src/generate.ts index 7746caa..364be31 100644 --- a/packages/shiftapi/src/generate.ts +++ b/packages/shiftapi/src/generate.ts @@ -1,10 +1,29 @@ -import openapiTS, { astToString } from "openapi-typescript"; +import openapiTS, { + astToString, + stringToAST, +} from "openapi-typescript"; +import type { SchemaObject } from "openapi-typescript"; + +// Build the union type node once from a string to avoid importing typescript directly. +// Importing typescript causes "Dynamic require of fs is not supported" when tsup bundles it into ESM. +const BINARY = (stringToAST("type T = File | Blob | Uint8Array") as any)[0] + .type as import("typescript").TypeNode; /** * Generates TypeScript type definitions from an OpenAPI spec object * using the openapi-typescript programmatic API. */ export async function generateTypes(spec: object): Promise { - const ast = await openapiTS(spec as Parameters[0]); + const ast = await openapiTS(spec as Parameters[0], { + transform(schemaObject: SchemaObject) { + if ( + "format" in schemaObject && + schemaObject.format === "binary" && + schemaObject.type === "string" + ) { + return BINARY; + } + }, + }); return astToString(ast); } diff --git a/packages/shiftapi/src/templates.ts b/packages/shiftapi/src/templates.ts index 759f184..a8c19c9 100644 --- a/packages/shiftapi/src/templates.ts +++ b/packages/shiftapi/src/templates.ts @@ -6,6 +6,31 @@ function indent(text: string, spaces = 2): string { .join("\n"); } +/** + * Inline JS for a bodySerializer that auto-detects File/Blob values + * and wraps the body in FormData. Falls back to JSON.stringify otherwise. + */ +const BODY_SERIALIZER = `(body) => { + if (typeof body !== "object" || body === null) return JSON.stringify(body); + const isBinary = (v) => v instanceof Blob || v instanceof File || v instanceof Uint8Array; + const values = Object.values(body); + const hasFile = values.some( + (v) => isBinary(v) || (Array.isArray(v) && v.some(isBinary)), + ); + if (!hasFile) return JSON.stringify(body); + const toBlob = (v) => v instanceof Uint8Array ? new Blob([v]) : v; + const fd = new FormData(); + for (const [key, value] of Object.entries(body)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) fd.append(key, isBinary(item) ? toBlob(item) : String(item)); + } else { + fd.append(key, isBinary(value) ? toBlob(value) : String(value)); + } + } + return fd; +}`; + export function dtsTemplate(generatedTypes: string): string { return `\ // Auto-generated by shiftapi. Do not edit. @@ -28,6 +53,7 @@ import createClient from "openapi-fetch"; /** Pre-configured, fully-typed API client. */ export const client = createClient({ baseUrl: ${JSON.stringify(baseUrl)}, + bodySerializer: ${BODY_SERIALIZER}, }); export { createClient }; @@ -48,7 +74,10 @@ const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || ${JSON.stringify(baseUrl)}; /** Pre-configured, fully-typed API client. */ -export const client = createClient({ baseUrl }); +export const client = createClient({ + baseUrl, + bodySerializer: ${BODY_SERIALIZER}, +}); export { createClient }; `; @@ -66,7 +95,10 @@ const baseUrl = : ${JSON.stringify(devServerUrl)}); /** Pre-configured, fully-typed API client. */ -export const client = createClient({ baseUrl }); +export const client = createClient({ + baseUrl, + bodySerializer: ${BODY_SERIALIZER}, +}); export { createClient }; `; @@ -87,6 +119,7 @@ import createClient from "openapi-fetch"; /** Pre-configured, fully-typed API client. */ export const client = createClient({ baseUrl: ${baseUrlExpr}, + bodySerializer: ${BODY_SERIALIZER}, }); export { createClient }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df5685b..9c6d2fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,11 +12,14 @@ importers: specifier: ^2 version: 2.8.9 - examples/next-app: + examples/vite-app: dependencies: - next: - specifier: ^16.0.0 - version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-query': + specifier: ^5.0.0 + version: 5.90.21(react@19.2.4) + openapi-react-query: + specifier: ^0.2.0 + version: 0.2.10(@tanstack/react-query@5.90.21(react@19.2.4))(openapi-fetch@0.13.8) react: specifier: ^19.0.0 version: 19.2.4 @@ -24,27 +27,18 @@ importers: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) devDependencies: - '@shiftapi/next': + '@shiftapi/vite-plugin': specifier: workspace:* - version: link:../../packages/next - '@types/node': - specifier: ^20 - version: 20.19.35 + version: link:../../packages/vite-plugin '@types/react': - specifier: ^19 + specifier: ^19.0.0 version: 19.2.14 '@types/react-dom': - specifier: ^19 + specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) - typescript: - specifier: ^5 - version: 5.9.3 - - examples/vite-app: - devDependencies: - '@shiftapi/vite-plugin': - specifier: workspace:* - version: link:../../packages/vite-plugin + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -146,10 +140,85 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@clack/core@1.0.1': resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} @@ -749,6 +818,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -762,105 +834,54 @@ packages: '@next/env@15.5.12': resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} - '@next/swc-darwin-arm64@15.5.12': resolution: {integrity: sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-x64@15.5.12': resolution: {integrity: sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.12': resolution: {integrity: sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-musl@15.5.12': resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-x64-gnu@15.5.12': resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-musl@15.5.12': resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-win32-arm64-msvc@15.5.12': resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-x64-msvc@15.5.12': resolution: {integrity: sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@redocly/ajv@8.17.4': resolution: {integrity: sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA==} @@ -871,6 +892,9 @@ packages: resolution: {integrity: sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -999,12 +1023,29 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@20.19.35': - resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} - '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -1016,6 +1057,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -1082,6 +1129,11 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1131,6 +1183,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1154,6 +1209,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + electron-to-chromium@1.5.307: + resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1172,6 +1230,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1207,6 +1269,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1234,9 +1300,19 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1251,6 +1327,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1293,26 +1372,8 @@ packages: sass: optional: true - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -1321,6 +1382,12 @@ packages: openapi-fetch@0.13.8: resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==} + openapi-react-query@0.2.10: + resolution: {integrity: sha512-DgKmnYGSRm8/0OI5fVGPBYaL/diBlaSo6zIEJsmxZzNcD1sKX+OBjHoy0rZyulTBTXBO989FuWr30YtVYYK6Yw==} + peerDependencies: + '@tanstack/react-query': ^5.25.0 + openapi-fetch: ^0.13.4 + openapi-typescript-helpers@0.0.15: resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} @@ -1393,6 +1460,10 @@ packages: peerDependencies: react: ^19.2.4 + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -1417,6 +1488,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1572,12 +1647,15 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1684,6 +1762,9 @@ packages: engines: {node: '>=8'} hasBin: true + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -1699,8 +1780,112 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@clack/core@1.0.1': dependencies: picocolors: 1.1.1 @@ -2044,6 +2229,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2055,56 +2245,30 @@ snapshots: '@next/env@15.5.12': {} - '@next/env@16.1.6': {} - '@next/swc-darwin-arm64@15.5.12': optional: true - '@next/swc-darwin-arm64@16.1.6': - optional: true - '@next/swc-darwin-x64@15.5.12': optional: true - '@next/swc-darwin-x64@16.1.6': - optional: true - '@next/swc-linux-arm64-gnu@15.5.12': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': - optional: true - '@next/swc-linux-arm64-musl@15.5.12': optional: true - '@next/swc-linux-arm64-musl@16.1.6': - optional: true - '@next/swc-linux-x64-gnu@15.5.12': optional: true - '@next/swc-linux-x64-gnu@16.1.6': - optional: true - '@next/swc-linux-x64-musl@15.5.12': optional: true - '@next/swc-linux-x64-musl@16.1.6': - optional: true - '@next/swc-win32-arm64-msvc@15.5.12': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': - optional: true - '@next/swc-win32-x64-msvc@15.5.12': optional: true - '@next/swc-win32-x64-msvc@16.1.6': - optional: true - '@redocly/ajv@8.17.4': dependencies: fast-deep-equal: 3.1.3 @@ -2128,6 +2292,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -2207,11 +2373,35 @@ snapshots: dependencies: tslib: 2.8.1 - '@types/estree@1.0.8': {} + '@tanstack/query-core@5.90.20': {} - '@types/node@20.19.35': + '@tanstack/react-query@5.90.21(react@19.2.4)': dependencies: - undici-types: 6.21.0 + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} '@types/node@25.2.3': dependencies: @@ -2225,6 +2415,18 @@ snapshots: dependencies: csstype: 3.2.3 + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@25.2.3)(jiti@2.6.1) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -2287,6 +2489,14 @@ snapshots: dependencies: balanced-match: 1.0.2 + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001774 + electron-to-chromium: 1.5.307 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + bundle-require@5.1.0(esbuild@0.27.3): dependencies: esbuild: 0.27.3 @@ -2328,6 +2538,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} csstype@3.2.3: {} @@ -2343,6 +2555,8 @@ snapshots: detect-libc@2.1.2: optional: true + electron-to-chromium@1.5.307: {} + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -2429,6 +2643,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} + esprima@4.0.1: {} estree-walker@3.0.3: @@ -2454,6 +2670,8 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -2475,8 +2693,12 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -2485,6 +2707,10 @@ snapshots: loupe@3.2.1: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2533,29 +2759,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@next/env': 16.1.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001774 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros + node-releases@2.0.36: {} object-assign@4.1.1: {} @@ -2563,6 +2767,12 @@ snapshots: dependencies: openapi-typescript-helpers: 0.0.15 + openapi-react-query@0.2.10(@tanstack/react-query@5.90.21(react@19.2.4))(openapi-fetch@0.13.8): + dependencies: + '@tanstack/react-query': 5.90.21(react@19.2.4) + openapi-fetch: 0.13.8 + openapi-typescript-helpers: 0.0.15 + openapi-typescript-helpers@0.0.15: {} openapi-typescript@7.13.0(typescript@5.9.3): @@ -2625,6 +2835,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-refresh@0.17.0: {} + react@19.2.4: {} readdirp@4.1.2: {} @@ -2666,6 +2878,8 @@ snapshots: scheduler@0.27.0: {} + semver@6.3.1: {} + semver@7.7.4: optional: true @@ -2820,10 +3034,14 @@ snapshots: ufo@1.6.3: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + vite-node@2.1.9(@types/node@25.2.3): dependencies: cac: 6.7.14 @@ -2904,6 +3122,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} yargs-parser@21.1.1: {} diff --git a/query.go b/query.go index d113f83..efd58bc 100644 --- a/query.go +++ b/query.go @@ -14,13 +14,14 @@ func hasQueryTag(f reflect.StructField) bool { } // partitionFields inspects a struct type and reports whether it contains -// query-tagged fields and/or body (json-tagged or untagged non-query) fields. -func partitionFields(t reflect.Type) (hasQuery, hasBody bool) { +// query-tagged fields, body (json-tagged or untagged non-query) fields, +// and/or form-tagged fields. It panics if both body and form fields are present. +func partitionFields(t reflect.Type) (hasQuery, hasBody, hasForm bool) { for t.Kind() == reflect.Pointer { t = t.Elem() } if t.Kind() != reflect.Struct { - return false, false + return false, false, false } for i := range t.NumField() { f := t.Field(i) @@ -29,8 +30,10 @@ func partitionFields(t reflect.Type) (hasQuery, hasBody bool) { } if hasQueryTag(f) { hasQuery = true + } else if hasFormTag(f) { + hasForm = true } else { - // Any exported field without a query tag is a body field + // Any exported field without a query or form tag is a body field jsonTag := f.Tag.Get("json") if jsonTag == "-" { continue @@ -38,6 +41,9 @@ func partitionFields(t reflect.Type) (hasQuery, hasBody bool) { hasBody = true } } + if hasBody && hasForm { + panic("shiftapi: struct has both json and form tags — this is not allowed") + } return } diff --git a/schema.go b/schema.go index 4d6f852..3cf02bc 100644 --- a/schema.go +++ b/schema.go @@ -12,7 +12,7 @@ import ( var pathParamRe = regexp.MustCompile(`\{([^}]+)\}`) -func (a *API) updateSchema(method, path string, queryType, inType, outType reflect.Type, info *RouteInfo, status int) error { +func (a *API) updateSchema(method, path string, queryType, inType, outType reflect.Type, hasForm bool, formType reflect.Type, info *RouteInfo, status int) error { op := &openapi3.Operation{ OperationID: operationID(method, path), Responses: openapi3.NewResponses(), @@ -114,8 +114,27 @@ func (a *API) updateSchema(method, path string, queryType, inType, outType refle }, }) - // Request body schema (only for methods with bodies) - if inType != nil { + // Request body schema + if hasForm { + // multipart/form-data request body + formSchema, formEncoding := generateFormSchema(formType) + mediaType := &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: formSchema, + }, + } + if formEncoding != nil { + mediaType.Encoding = formEncoding + } + op.RequestBody = &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: map[string]*openapi3.MediaType{ + "multipart/form-data": mediaType, + }, + }, + } + } else if inType != nil { inSchema, err := a.generateSchemaRef(inType) if err != nil { return err @@ -284,6 +303,77 @@ func (a *API) generateQueryParams(t reflect.Type) ([]*openapi3.ParameterRef, err return params, nil } +// generateFormSchema builds an inline OpenAPI schema and encoding map for multipart/form-data. +// Only fields with `form` tags are included; query-tagged fields are skipped. +// The encoding map is populated for fields with `accept` tags. +func generateFormSchema(t reflect.Type) (*openapi3.Schema, map[string]*openapi3.Encoding) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: make(openapi3.Schemas), + } + var encoding map[string]*openapi3.Encoding + + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() || !hasFormTag(field) { + continue + } + + name := formFieldName(field) + + var propSchema *openapi3.SchemaRef + switch field.Type { + case fileHeaderType: + // Single file upload + propSchema = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "binary", + }, + } + case fileHeaderSliceType: + // Multiple file upload + propSchema = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "binary", + }, + }, + }, + } + default: + // Text form field + propSchema = fieldToOpenAPISchema(field.Type) + _ = validateSchemaCustomizer(name, field.Type, field.Tag, propSchema.Value) + } + + schema.Properties[name] = propSchema + + // Add encoding entry for fields with accept tags + if accept := field.Tag.Get("accept"); accept != "" && isFileField(field) { + if encoding == nil { + encoding = make(map[string]*openapi3.Encoding) + } + encoding[name] = &openapi3.Encoding{ + ContentType: accept, + } + } + + if hasRule(field.Tag.Get("validate"), "required") { + schema.Required = append(schema.Required, name) + } + } + + return schema, encoding +} + // fieldToOpenAPISchema maps a Go type to an OpenAPI schema. func fieldToOpenAPISchema(t reflect.Type) *openapi3.SchemaRef { // Unwrap pointer @@ -354,15 +444,19 @@ func stripQueryFields(t reflect.Type, schema *openapi3.Schema) { } func scrubRefs(s *openapi3.SchemaRef) { - if s == nil || s.Value == nil || len(s.Value.Properties) == 0 { + if s == nil || s.Value == nil { return } + // Scrub ref on non-object schemas + if s.Value.Type != nil && !s.Value.Type.Is("object") { + s.Ref = "" + } + // Recurse into array items + if s.Value.Items != nil { + scrubRefs(s.Value.Items) + } + // Recurse into properties for _, p := range s.Value.Properties { - if p == nil || p.Value == nil { - continue - } - if !p.Value.Type.Is("object") { - p.Ref = "" - } + scrubRefs(p) } } diff --git a/server.go b/server.go index c7da5c0..ff7b979 100644 --- a/server.go +++ b/server.go @@ -13,10 +13,11 @@ import ( // API collects typed handler registrations, generates an OpenAPI schema, // and implements http.Handler so it can be used with any standard server. type API struct { - spec *openapi3.T - specGen *openapi3gen.Generator - mux *http.ServeMux - validate *validator.Validate + spec *openapi3.T + specGen *openapi3gen.Generator + mux *http.ServeMux + validate *validator.Validate + maxUploadSize int64 } // New creates a new API with the given options. @@ -32,8 +33,9 @@ func New(options ...Option) *API { specGen: openapi3gen.NewGenerator( openapi3gen.SchemaCustomizer(validateSchemaCustomizer), ), - mux: http.NewServeMux(), - validate: validator.New(), + mux: http.NewServeMux(), + validate: validator.New(), + maxUploadSize: 32 << 20, // 32 MB } for _, opt := range options { opt(api) diff --git a/serverOptions.go b/serverOptions.go index 41697c7..8f1068b 100644 --- a/serverOptions.go +++ b/serverOptions.go @@ -59,6 +59,14 @@ func WithInfo(info Info) Option { } } +// WithMaxUploadSize sets the maximum memory used for parsing multipart form data. +// The default is 32 MB. +func WithMaxUploadSize(size int64) Option { + return func(api *API) { + api.maxUploadSize = size + } +} + // WithExternalDocs links to external documentation. func WithExternalDocs(docs ExternalDocs) Option { return func(api *API) { diff --git a/shiftapi_test.go b/shiftapi_test.go index f73ffa7..e229f4e 100644 --- a/shiftapi_test.go +++ b/shiftapi_test.go @@ -1,11 +1,15 @@ package shiftapi_test import ( + "bytes" "encoding/json" "errors" + "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" + "net/textproto" "slices" "strings" "testing" @@ -2493,3 +2497,529 @@ func TestSpecQueryOnlyInputHasNoRequestBody(t *testing.T) { t.Error("GET with query-only input should not have a request body in the spec") } } + +// --- Form upload test types --- + +type UploadInput struct { + File *multipart.FileHeader `form:"file" validate:"required"` + Title string `form:"title" validate:"required"` +} + +type UploadResult struct { + Filename string `json:"filename"` + Title string `json:"title"` + Size int64 `json:"size"` +} + +type MultiUploadInput struct { + Files []*multipart.FileHeader `form:"files" validate:"required"` +} + +type MultiUploadResult struct { + Count int `json:"count"` +} + +type FormWithQueryInput struct { + File *multipart.FileHeader `form:"file"` + Tags string `query:"tags"` +} + +type FormWithQueryResult struct { + HasFile bool `json:"has_file"` + Tags string `json:"tags"` +} + +type FormTextFieldsInput struct { + Name string `form:"name"` + Age int `form:"age"` + Score float64 `form:"score"` + Admin bool `form:"admin"` +} + +type FormTextFieldsResult struct { + Name string `json:"name"` + Age int `json:"age"` + Score float64 `json:"score"` + Admin bool `json:"admin"` +} + +type MixedJsonFormInput struct { + Name string `json:"name"` + File *multipart.FileHeader `form:"file"` +} + +type AcceptUploadInput struct { + Image *multipart.FileHeader `form:"image" accept:"image/png,image/jpeg" validate:"required"` +} + +type AcceptMultiUploadInput struct { + Images []*multipart.FileHeader `form:"images" accept:"image/png,image/jpeg"` +} + +// --- Form upload helpers --- + +func doMultipartRequest(t *testing.T, api http.Handler, method, path string, fields map[string]string, files map[string][]byte) *http.Response { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for name, content := range files { + part, err := w.CreateFormFile(name, name+".txt") + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("failed to write file content: %v", err) + } + } + for name, value := range fields { + if err := w.WriteField(name, value); err != nil { + t.Fatalf("failed to write field: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + api.ServeHTTP(rec, req) + return rec.Result() +} + +// doMultipartRequestMultiFiles sends a multipart request with multiple files under the same field name. +func doMultipartRequestMultiFiles(t *testing.T, api http.Handler, method, path, fieldName string, fileContents [][]byte) *http.Response { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for i, content := range fileContents { + part, err := w.CreateFormFile(fieldName, fmt.Sprintf("file%d.txt", i)) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("failed to write file content: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + api.ServeHTTP(rec, req) + return rec.Result() +} + +// --- Form upload runtime tests --- + +func TestPostFormUpload(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return &UploadResult{ + Filename: in.File.Filename, + Title: in.Title, + Size: in.File.Size, + }, nil + }) + + resp := doMultipartRequest(t, api, http.MethodPost, "/upload", + map[string]string{"title": "My Document"}, + map[string][]byte{"file": []byte("hello world")}, + ) + if resp.StatusCode != http.StatusOK { + body := readBody(t, resp) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + result := decodeJSON[UploadResult](t, resp) + if result.Filename != "file.txt" { + t.Errorf("expected filename %q, got %q", "file.txt", result.Filename) + } + if result.Title != "My Document" { + t.Errorf("expected title %q, got %q", "My Document", result.Title) + } + if result.Size != 11 { + t.Errorf("expected size 11, got %d", result.Size) + } +} + +func TestPostFormUploadMissingRequired(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return &UploadResult{}, nil + }) + + // Send without the required file field + resp := doMultipartRequest(t, api, http.MethodPost, "/upload", + map[string]string{"title": "My Document"}, + nil, + ) + if resp.StatusCode != http.StatusUnprocessableEntity { + body := readBody(t, resp) + t.Fatalf("expected 422, got %d: %s", resp.StatusCode, body) + } +} + +func TestPostFormUploadMultipleFiles(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-multi", func(r *http.Request, in MultiUploadInput) (*MultiUploadResult, error) { + return &MultiUploadResult{Count: len(in.Files)}, nil + }) + + resp := doMultipartRequestMultiFiles(t, api, http.MethodPost, "/upload-multi", "files", + [][]byte{[]byte("file1"), []byte("file2"), []byte("file3")}, + ) + if resp.StatusCode != http.StatusOK { + body := readBody(t, resp) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + result := decodeJSON[MultiUploadResult](t, resp) + if result.Count != 3 { + t.Errorf("expected count 3, got %d", result.Count) + } +} + +func TestPostFormWithQueryParams(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-tags", func(r *http.Request, in FormWithQueryInput) (*FormWithQueryResult, error) { + return &FormWithQueryResult{ + HasFile: in.File != nil, + Tags: in.Tags, + }, nil + }) + + resp := doMultipartRequest(t, api, http.MethodPost, "/upload-tags?tags=a,b,c", + nil, + map[string][]byte{"file": []byte("data")}, + ) + if resp.StatusCode != http.StatusOK { + body := readBody(t, resp) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + result := decodeJSON[FormWithQueryResult](t, resp) + if !result.HasFile { + t.Error("expected HasFile=true") + } + if result.Tags != "a,b,c" { + t.Errorf("expected tags %q, got %q", "a,b,c", result.Tags) + } +} + +func TestPostFormTextFieldTypes(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/form-fields", func(r *http.Request, in FormTextFieldsInput) (*FormTextFieldsResult, error) { + return &FormTextFieldsResult{ + Name: in.Name, + Age: in.Age, + Score: in.Score, + Admin: in.Admin, + }, nil + }) + + resp := doMultipartRequest(t, api, http.MethodPost, "/form-fields", + map[string]string{ + "name": "Alice", + "age": "30", + "score": "9.5", + "admin": "true", + }, + nil, + ) + if resp.StatusCode != http.StatusOK { + body := readBody(t, resp) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + result := decodeJSON[FormTextFieldsResult](t, resp) + if result.Name != "Alice" { + t.Errorf("expected name %q, got %q", "Alice", result.Name) + } + if result.Age != 30 { + t.Errorf("expected age 30, got %d", result.Age) + } + if result.Score != 9.5 { + t.Errorf("expected score 9.5, got %f", result.Score) + } + if !result.Admin { + t.Error("expected admin=true") + } +} + +// --- Form upload OpenAPI spec tests --- + +func TestSpecFormUploadContentType(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload").Post + if op.RequestBody == nil { + t.Fatal("expected request body") + } + if op.RequestBody.Value.Content.Get("multipart/form-data") == nil { + t.Error("expected multipart/form-data content type") + } + if op.RequestBody.Value.Content.Get("application/json") != nil { + t.Error("should not have application/json content type for form upload") + } +} + +func TestSpecFormUploadFileIsBinary(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload").Post + formContent := op.RequestBody.Value.Content.Get("multipart/form-data") + fileSchema := formContent.Schema.Value.Properties["file"] + if fileSchema == nil { + t.Fatal("expected file property in form schema") + } + if !fileSchema.Value.Type.Is("string") { + t.Errorf("expected type string, got %v", fileSchema.Value.Type) + } + if fileSchema.Value.Format != "binary" { + t.Errorf("expected format binary, got %q", fileSchema.Value.Format) + } +} + +func TestSpecFormUploadArrayIsBinaryArray(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-multi", func(r *http.Request, in MultiUploadInput) (*MultiUploadResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload-multi").Post + formContent := op.RequestBody.Value.Content.Get("multipart/form-data") + filesSchema := formContent.Schema.Value.Properties["files"] + if filesSchema == nil { + t.Fatal("expected files property in form schema") + } + if !filesSchema.Value.Type.Is("array") { + t.Errorf("expected type array, got %v", filesSchema.Value.Type) + } + if filesSchema.Value.Items == nil { + t.Fatal("expected items in array schema") + } + if !filesSchema.Value.Items.Value.Type.Is("string") { + t.Errorf("expected items type string, got %v", filesSchema.Value.Items.Value.Type) + } + if filesSchema.Value.Items.Value.Format != "binary" { + t.Errorf("expected items format binary, got %q", filesSchema.Value.Items.Value.Format) + } +} + +func TestSpecFormUploadExcludesQueryFields(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-tags", func(r *http.Request, in FormWithQueryInput) (*FormWithQueryResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload-tags").Post + formContent := op.RequestBody.Value.Content.Get("multipart/form-data") + schema := formContent.Schema.Value + + // file should be in form schema + if schema.Properties["file"] == nil { + t.Error("expected file property in form schema") + } + // tags should NOT be in form schema (it's a query param) + if schema.Properties["tags"] != nil { + t.Error("query field 'tags' should not appear in form schema") + } + + // tags should be a query parameter + var foundTags bool + for _, p := range op.Parameters { + if p.Value.Name == "tags" && p.Value.In == "query" { + foundTags = true + } + } + if !foundTags { + t.Error("expected 'tags' as query parameter") + } +} + +func TestSpecFormUploadRequired(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload").Post + formContent := op.RequestBody.Value.Content.Get("multipart/form-data") + required := formContent.Schema.Value.Required + + if !slices.Contains(required, "file") { + t.Error("expected 'file' in required list") + } + if !slices.Contains(required, "title") { + t.Error("expected 'title' in required list") + } +} + +// --- Form upload edge case tests --- + +func TestMixedJsonAndFormTagsPanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic when mixing json and form tags") + } + msg := fmt.Sprintf("%v", r) + if !strings.Contains(msg, "json and form tags") { + t.Errorf("expected panic message about mixed tags, got: %s", msg) + } + }() + + api := newTestAPI(t) + shiftapi.Post(api, "/bad", func(r *http.Request, in MixedJsonFormInput) (*Empty, error) { + return &Empty{}, nil + }) +} + +func TestFormUploadEmptyBody(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return &UploadResult{}, nil + }) + + // Send request with no multipart body + req := httptest.NewRequest(http.MethodPost, "/upload", nil) + rec := httptest.NewRecorder() + api.ServeHTTP(rec, req) + resp := rec.Result() + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(body)) + } +} + +// --- Accept tag helpers --- + +// doMultipartRequestWithContentType sends a multipart request with a file part that has a specific Content-Type. +func doMultipartRequestWithContentType(t *testing.T, api http.Handler, method, path, fieldName, fileName, contentType string, content []byte) *http.Response { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileName)) + h.Set("Content-Type", contentType) + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("failed to create part: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("failed to write content: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close writer: %v", err) + } + + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + api.ServeHTTP(rec, req) + return rec.Result() +} + +// --- Accept tag runtime tests --- + +func TestPostFormAcceptAllowed(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-image", func(r *http.Request, in AcceptUploadInput) (*UploadResult, error) { + return &UploadResult{Filename: in.Image.Filename}, nil + }) + + resp := doMultipartRequestWithContentType(t, api, http.MethodPost, "/upload-image", + "image", "photo.png", "image/png", []byte("fake png data"), + ) + if resp.StatusCode != http.StatusOK { + body := readBody(t, resp) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + result := decodeJSON[UploadResult](t, resp) + if result.Filename != "photo.png" { + t.Errorf("expected filename %q, got %q", "photo.png", result.Filename) + } +} + +func TestPostFormAcceptRejected(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-image", func(r *http.Request, in AcceptUploadInput) (*UploadResult, error) { + return &UploadResult{}, nil + }) + + resp := doMultipartRequestWithContentType(t, api, http.MethodPost, "/upload-image", + "image", "doc.pdf", "application/pdf", []byte("fake pdf data"), + ) + if resp.StatusCode != http.StatusBadRequest { + body := readBody(t, resp) + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, body) + } +} + +func TestPostFormAcceptMultipleFilesRejected(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-images", func(r *http.Request, in AcceptMultiUploadInput) (*MultiUploadResult, error) { + return &MultiUploadResult{Count: len(in.Images)}, nil + }) + + // Send a file with wrong content type + resp := doMultipartRequestWithContentType(t, api, http.MethodPost, "/upload-images", + "images", "doc.pdf", "application/pdf", []byte("fake pdf data"), + ) + if resp.StatusCode != http.StatusBadRequest { + body := readBody(t, resp) + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, body) + } +} + +// --- Accept tag OpenAPI spec tests --- + +func TestSpecFormAcceptEncoding(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload-image", func(r *http.Request, in AcceptUploadInput) (*UploadResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload-image").Post + formContent := op.RequestBody.Value.Content.Get("multipart/form-data") + + if formContent.Encoding == nil { + t.Fatal("expected encoding map to be set") + } + enc, ok := formContent.Encoding["image"] + if !ok { + t.Fatal("expected encoding entry for 'image'") + } + if enc.ContentType != "image/png,image/jpeg" { + t.Errorf("expected contentType %q, got %q", "image/png,image/jpeg", enc.ContentType) + } +} + +func TestSpecFormNoAcceptNoEncoding(t *testing.T) { + api := newTestAPI(t) + shiftapi.Post(api, "/upload", func(r *http.Request, in UploadInput) (*UploadResult, error) { + return nil, nil + }) + + spec := api.Spec() + op := spec.Paths.Find("/upload").Post + formContent := op.RequestBody.Value.Content.Get("multipart/form-data") + + if formContent.Encoding != nil { + t.Error("expected no encoding map when no accept tags") + } +}