Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions examples/greeter/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main

import (
"fmt"
"log"
"mime/multipart"
"net/http"

"github.com/fcjr/shiftapi"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
11 changes: 2 additions & 9 deletions examples/vite-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,7 @@
<title>ShiftAPI Example</title>
</head>
<body>
<div id="app">
<h1>ShiftAPI + Vite Example</h1>
<form id="greet-form">
<input type="text" id="name-input" placeholder="Enter a name" />
<button type="submit">Greet</button>
</form>
<pre id="output"></pre>
</div>
<script type="module" src="/src/main.ts"></script>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
9 changes: 9 additions & 0 deletions examples/vite-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
74 changes: 74 additions & 0 deletions examples/vite-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useState, useRef } from "react";
import { api } from "./api";

export default function App() {
const [name, setName] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const health = api.useQuery("get", "/health");
const greet = api.useMutation("post", "/greet");
const upload = api.useMutation("post", "/upload");

if (health.isLoading) return <p>Loading...</p>;
if (health.error) return <p>Health check failed: {health.error.message}</p>;

return (
<div>
<h1>ShiftAPI + Vite Example</h1>

<h2>Greet</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
greet.mutate(
{ body: { name: trimmed } },
{ onSuccess: () => setName("") },
);
}}
>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter a name"
/>
<button type="submit" disabled={greet.isPending}>
Greet
</button>
</form>
{greet.isPending && <p>Loading...</p>}
{greet.error && <p>Error: {greet.error.message}</p>}
{greet.data && <pre>Hello: {greet.data.hello}</pre>}

<h2>Upload</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
upload.mutate(
{ body: { file } },
{
onSuccess: () => {
if (fileRef.current) fileRef.current.value = "";
},
},
);
}}
>
<input ref={fileRef} type="file" />
<button type="submit" disabled={upload.isPending}>
Upload
</button>
</form>
{upload.isPending && <p>Uploading...</p>}
{upload.error && <p>Error: {upload.error.message}</p>}
{upload.data && (
<pre>
Uploaded: {upload.data.filename} ({upload.data.size} bytes)
</pre>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions examples/vite-app/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import createClient from "openapi-react-query";
import { client } from "@shiftapi/client";

export const api = createClient(client);
42 changes: 0 additions & 42 deletions examples/vite-app/src/main.ts

This file was deleted.

14 changes: 14 additions & 0 deletions examples/vite-app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
3 changes: 2 additions & 1 deletion examples/vite-app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"paths": {
"@shiftapi/client": [
"./.shiftapi/client.d.ts"
"./.shiftapi/client"
]
}
},
Expand Down
3 changes: 2 additions & 1 deletion examples/vite-app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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()],
});
Loading