diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0136aae..4b47ee7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **OpenAPI schemas from type-safe handlers** — `router.GET/POST/...` and `RouteGroup` methods now record `Req`/`Res` types and emit request-body/response schemas
+- **Named component schemas** — inferred types are registered once in `components.schemas` and referenced with `$ref` (removes inline duplication; recursive types terminate)
+- **Auto-generated `operationId`** — operations without an explicit id get a deterministic, unique id from method + path (e.g. `getUsersById`); explicit ids are preserved
+- **`RouteOptions.SuccessStatus`** — set the inferred success status (e.g. 201/204; 204 emits no body)
+- **`RouteOptions.OptionalRequestBody`** — mark the inferred request body as not required
+- **Auto-declared path parameters** — `:id` templates are emitted as required `in: path` parameters
+- **`RouteGroup.HandleWithOptions`** — OpenAPI metadata for grouped plain handlers
+- **New example** — `examples/03-rest-api-with-openapi/` demonstrates generated OpenAPI (schemas + `$ref`, status codes, groups, deprecation)
+
+### Changed
+- **Variadic `*RouteOptions`** on generic route methods (source-compatible; existing two-argument calls unchanged)
+- **Default 400/500 responses** no longer overwrite user-supplied `RouteOptions.Responses`
+
+### Fixed
+- **`generateSchema` cycle detection** — recursive/mutually-recursive types no longer recurse infinitely
+- **Deprecation markers** on package-level `GET`/`POST`/`PUT`/`DELETE`/`PATCH`/`HEAD`/`OPTIONS` now recognized by staticcheck/gopls
+
### Planned
- ozzo-routing compatibility layer (lowercase `Get`/`Post` methods) — deferred, see ADR-001
- Radix tree edge cases (root path + param routes) — tracked by differential fuzz
diff --git a/README.md b/README.md
index 100c8c5..52dcb30 100644
--- a/README.md
+++ b/README.md
@@ -104,12 +104,25 @@ router.POST("/users", func(box *fursy.Box[CreateUserRequest, UserResponse]) erro
### Built-in OpenAPI 3.1 Generation
+Request/response schemas are inferred from type-safe handlers (`Box[Req, Res]`);
+add summaries, tags, and status codes via `RouteOptions`.
+
```go
-spec := r.OpenAPI(fursy.OpenAPIConfig{
- Title: "My API",
+router := fursy.New()
+
+router.WithInfo(fursy.Info{
+ Title: "My API",
Version: "1.0.0",
})
-// Complete OpenAPI 3.1 spec from code!
+
+router.POST("/users", createUser, &fursy.RouteOptions{
+ Summary: "Create user",
+ Tags: []string{"users"},
+ SuccessStatus: 201, // 204 emits no body
+})
+
+// Serve the generated OpenAPI 3.1 document at GET /openapi.json.
+router.ServeOpenAPI("/openapi.json")
```
### Minimal Dependencies
diff --git a/examples/03-rest-api-with-openapi/README.md b/examples/03-rest-api-with-openapi/README.md
new file mode 100644
index 0000000..50f4da8
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/README.md
@@ -0,0 +1,107 @@
+# 03 — REST API with generated OpenAPI
+
+A small bookstore API that showcases how fursy's **type-safe handlers** drive
+**OpenAPI 3.1** generation. Unlike `02-rest-api-crud` (which focuses on CRUD
+plumbing), this example focuses on the documentation surface you get for free.
+
+## Features showcased
+
+- **Schemas from types** — `Box[Req, Res]` request/response types become JSON Schemas.
+- **Named components + `$ref`** — named types are registered once in
+ `components.schemas` and referenced, not duplicated.
+- **Nested composition** — `Book.publisher` → `$ref Publisher`;
+ `BookList.books` → array of `$ref Book`.
+- **Accurate status codes** — `SuccessStatus: 201` (POST) and `204` (DELETE, no body).
+- **Required vs optional** — pointer fields in `UpdateBookRequest` make them optional.
+- **Optional request body** — `OptionalRequestBody` on `POST /books/search`.
+- **Auto path parameters** — `/books/:id` is declared as a required `in: path` param.
+- **Auto `operationId`** — e.g. `getBooks`, `getBooksById`, `postBooks`.
+- **Summaries, tags, servers, deprecation** — via `RouteOptions`, `WithInfo`, `WithServer`.
+- **Route groups** — `/admin/stats` is registered through a `RouteGroup` and documented.
+
+## Running the example
+
+```bash
+# From this directory
+go run .
+```
+
+- Swagger UI: http://localhost:8080/
+- OpenAPI document: http://localhost:8080/openapi.json
+
+## Endpoints
+
+| Method | Path | Success | Notes |
+|--------|------|---------|-------|
+| GET | `/books` | 200 | `BookList` |
+| POST | `/books` | 201 | `CreateBookRequest` → `Book`, `Location` header |
+| GET | `/books/:id` | 200 | `Book` |
+| PATCH | `/books/:id` | 200 | `UpdateBookRequest` (partial) |
+| DELETE | `/books/:id` | 204 | no body |
+| POST | `/books/search` | 200 | optional body; send `{}` to match all |
+| GET | `/admin/stats` | 200 | grouped under `/admin` |
+| GET | `/legacy/books` | 200 | marked `deprecated: true` |
+
+## Try it
+
+```bash
+# List books (seeded with two)
+curl http://localhost:8080/books
+
+# Create a book
+curl -X POST http://localhost:8080/books \
+ -H "Content-Type: application/json" \
+ -d '{"title":"The Art of Computer Programming","author":"Donald Knuth","publisher_id":2,"year":1968,"tags":["computing"]}'
+
+# Partial update
+curl -X PATCH http://localhost:8080/books/1 \
+ -H "Content-Type: application/json" \
+ -d '{"year":1844}'
+
+# Search (body is optional in the spec; at runtime send at least {})
+curl -X POST http://localhost:8080/books/search \
+ -H "Content-Type: application/json" \
+ -d '{"tag":"computing"}'
+
+# Delete
+curl -i -X DELETE http://localhost:8080/books/2
+```
+
+## What to look for in `/openapi.json`
+
+- **`components.schemas`** contains `Book`, `Publisher`, `BookList`,
+ `CreateBookRequest`, `UpdateBookRequest`, `SearchBooksRequest`, `Stats`, and
+ `Problem`.
+- **`$ref` reuse** — `Book.publisher` references `Publisher`; `BookList.books`
+ items reference `Book`; the same `Book` component is reused by every operation.
+- **Status codes** — `POST /books` documents `201`, `DELETE /books/:id`
+ documents `204` (no content), and errors document `400`/`500` as `Problem`.
+- **Optionality** — `CreateBookRequest` lists all non-`omitempty` fields as
+ `required`; `UpdateBookRequest` (pointer fields) has no required fields.
+- **`parameters`** — `GET /books/{id}` declares `id` as `required: true`, `in: path`.
+- **`operationId`** — deterministic and unique, e.g. `getBooksById`, `postBooksSearch`.
+- **`deprecated: true`** on `GET /legacy/books`.
+
+## Note on `OptionalRequestBody`
+
+`OptionalRequestBody` affects the **document** (`required: false` on the request
+body). fursy's request binder still expects a JSON body at runtime, so send `{}`
+when you have no filters.
+
+## Files
+
+```
+03-rest-api-with-openapi/
+├── main.go - server setup, routes, and OpenAPI metadata
+├── models.go - request/response types (schemas are inferred from these)
+├── handlers.go - type-safe handlers
+├── store.go - in-memory, thread-safe store
+├── index.html - Swagger UI
+├── go.mod
+└── README.md
+```
+
+## Related
+
+- [`02-rest-api-crud`](../02-rest-api-crud/) — CRUD without OpenAPI annotation.
+- [`01-hello-world`](../01-hello-world/) — minimal setup.
diff --git a/examples/03-rest-api-with-openapi/go.mod b/examples/03-rest-api-with-openapi/go.mod
new file mode 100644
index 0000000..c036d17
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/go.mod
@@ -0,0 +1,12 @@
+module example.com/rest-api-with-openapi
+
+go 1.27
+
+replace github.com/coregx/fursy => ../..
+
+require github.com/coregx/fursy v0.5.3
+
+require (
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ golang.org/x/time v0.16.0 // indirect
+)
diff --git a/examples/03-rest-api-with-openapi/go.sum b/examples/03-rest-api-with-openapi/go.sum
new file mode 100644
index 0000000..c0b4c1b
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/go.sum
@@ -0,0 +1,4 @@
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
diff --git a/examples/03-rest-api-with-openapi/handlers.go b/examples/03-rest-api-with-openapi/handlers.go
new file mode 100644
index 0000000..6df0a30
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/handlers.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+ "errors"
+ "strconv"
+
+ "github.com/coregx/fursy"
+)
+
+// Handlers contains the HTTP handlers for the bookstore API.
+type Handlers struct {
+ store *BookStore
+}
+
+// NewHandlers creates a Handlers bound to store.
+func NewHandlers(store *BookStore) *Handlers {
+ return &Handlers{store: store}
+}
+
+// ListBooks handles GET /books.
+func (h *Handlers) ListBooks(c *fursy.Box[fursy.Empty, BookList]) error {
+ return c.OK(h.store.List())
+}
+
+// CreateBook handles POST /books and returns 201 with a Location header.
+func (h *Handlers) CreateBook(c *fursy.Box[CreateBookRequest, Book]) error {
+ req := c.ReqBody
+ if req.Title == "" {
+ return c.Problem(fursy.BadRequest("title is required"))
+ }
+ if req.Author == "" {
+ return c.Problem(fursy.BadRequest("author is required"))
+ }
+ if req.Year < 1450 || req.Year > 2100 {
+ return c.Problem(fursy.BadRequest("year must be between 1450 and 2100"))
+ }
+
+ book := h.store.Create(*req)
+ return c.Created("/books/"+strconv.Itoa(book.ID), book)
+}
+
+// GetBook handles GET /books/:id.
+func (h *Handlers) GetBook(c *fursy.Box[fursy.Empty, Book]) error {
+ id, ok := bookID(c.Context)
+ if !ok {
+ return c.Problem(fursy.BadRequest("invalid book id"))
+ }
+
+ book, err := h.store.Get(id)
+ if err != nil {
+ if errors.Is(err, ErrBookNotFound) {
+ return c.Problem(fursy.NotFound("Book not found"))
+ }
+ return c.Problem(fursy.InternalServerError("Failed to get book"))
+ }
+ return c.OK(book)
+}
+
+// UpdateBook handles PATCH /books/:id (partial update).
+func (h *Handlers) UpdateBook(c *fursy.Box[UpdateBookRequest, Book]) error {
+ id, ok := bookID(c.Context)
+ if !ok {
+ return c.Problem(fursy.BadRequest("invalid book id"))
+ }
+
+ book, err := h.store.Update(id, *c.ReqBody)
+ if err != nil {
+ if errors.Is(err, ErrBookNotFound) {
+ return c.Problem(fursy.NotFound("Book not found"))
+ }
+ return c.Problem(fursy.InternalServerError("Failed to update book"))
+ }
+ return c.OK(book)
+}
+
+// DeleteBook handles DELETE /books/:id and returns 204.
+func (h *Handlers) DeleteBook(c *fursy.Box[fursy.Empty, fursy.Empty]) error {
+ id, ok := bookID(c.Context)
+ if !ok {
+ return c.Problem(fursy.BadRequest("invalid book id"))
+ }
+
+ if err := h.store.Delete(id); err != nil {
+ if errors.Is(err, ErrBookNotFound) {
+ return c.Problem(fursy.NotFound("Book not found"))
+ }
+ return c.Problem(fursy.InternalServerError("Failed to delete book"))
+ }
+ return c.NoContentSuccess()
+}
+
+// SearchBooks handles POST /books/search.
+//
+// The request body is marked optional in the generated OpenAPI spec
+// (OptionalRequestBody). At runtime fursy still requires a JSON body, so send
+// {} to search with no filters.
+func (h *Handlers) SearchBooks(c *fursy.Box[SearchBooksRequest, BookList]) error {
+ return c.OK(h.store.Search(*c.ReqBody))
+}
+
+// Stats handles GET /admin/stats.
+func (h *Handlers) Stats(c *fursy.Box[fursy.Empty, Stats]) error {
+ return c.OK(h.store.Stats())
+}
+
+// bookID parses the :id path parameter.
+func bookID(c *fursy.Context) (int, bool) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil || id <= 0 {
+ return 0, false
+ }
+ return id, true
+}
diff --git a/examples/03-rest-api-with-openapi/index.html b/examples/03-rest-api-with-openapi/index.html
new file mode 100644
index 0000000..eda6839
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Bookstore API — API Docs
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/03-rest-api-with-openapi/main.go b/examples/03-rest-api-with-openapi/main.go
new file mode 100644
index 0000000..041e4c5
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/main.go
@@ -0,0 +1,112 @@
+// Package main demonstrates how fursy's type-safe handlers drive OpenAPI 3.1
+// generation: request/response schemas, named components with $ref, status
+// codes, path parameters, operationIds, tags, groups, and deprecation.
+package main
+
+import (
+ "log/slog"
+ "net/http"
+ "os"
+
+ "github.com/coregx/fursy"
+ "github.com/coregx/fursy/middleware"
+)
+
+func main() {
+ router := fursy.New()
+
+ // Use middleware.
+ router.Use(middleware.Logger())
+ router.Use(middleware.Recovery())
+
+ // API metadata for the generated OpenAPI document.
+ router.WithInfo(fursy.Info{
+ Title: "Bookstore API",
+ Version: "1.0.0",
+ Description: "Example API showcasing fursy's type-safe handlers and generated OpenAPI 3.1.",
+ })
+ router.WithServer(fursy.Server{
+ URL: "http://localhost:8080",
+ Description: "Local development",
+ })
+
+ // Create store and handlers.
+ store := NewBookStore()
+ handlers := NewHandlers(store)
+
+ // Register routes.
+ setupRoutes(router, handlers)
+
+ // Serve the generated OpenAPI 3.1 document.
+ router.ServeOpenAPI("/openapi.json")
+
+ // Serve Swagger UI at the root.
+ router.Handle("GET", "/", func(c *fursy.Context) error {
+ html, err := os.ReadFile("index.html")
+ if err != nil {
+ return c.Problem(fursy.InternalServerError("Failed to load Swagger UI"))
+ }
+ c.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
+ c.Response.WriteHeader(http.StatusOK)
+ _, err = c.Response.Write(html)
+ return err
+ })
+
+ slog.Info("Bookstore API listening on http://localhost:8080")
+ slog.Info("Swagger UI", "url", "http://localhost:8080/")
+ slog.Info("OpenAPI spec", "url", "http://localhost:8080/openapi.json")
+
+ if err := http.ListenAndServe(":8080", router); err != nil {
+ slog.Error("server failed", "error", err)
+ os.Exit(1)
+ }
+}
+
+func setupRoutes(router *fursy.Router, h *Handlers) {
+ books := []string{"books"}
+
+ // Schemas for Book, BookList, CreateBookRequest, etc. are inferred from the
+ // Box[Req, Res] type parameters; the named types become components.schemas.
+ router.GET("/books", h.ListBooks, &fursy.RouteOptions{
+ Summary: "List all books",
+ Tags: books,
+ })
+ router.POST("/books", h.CreateBook, &fursy.RouteOptions{
+ Summary: "Create a book",
+ Tags: books,
+ SuccessStatus: http.StatusCreated, // 201 Created (with a response schema)
+ })
+ router.GET("/books/:id", h.GetBook, &fursy.RouteOptions{
+ Summary: "Get a book by ID",
+ Tags: books, // :id is auto-declared as a required path parameter
+ })
+ router.PATCH("/books/:id", h.UpdateBook, &fursy.RouteOptions{
+ Summary: "Partially update a book",
+ Tags: books,
+ })
+ router.DELETE("/books/:id", h.DeleteBook, &fursy.RouteOptions{
+ Summary: "Delete a book",
+ Tags: books,
+ SuccessStatus: http.StatusNoContent, // 204 No Content (no response body)
+ })
+ router.POST("/books/search", h.SearchBooks, &fursy.RouteOptions{
+ Summary: "Search books",
+ Description: "All filter fields are optional.",
+ Tags: books,
+ OptionalRequestBody: true, // required: false in the spec
+ })
+
+ // Grouped routes are documented too (prefixed path + optional metadata).
+ admin := router.Group("/admin")
+ admin.GET("/stats", h.Stats, &fursy.RouteOptions{
+ Summary: "Get store statistics",
+ Tags: []string{"admin"},
+ })
+
+ // Deprecated operations are flagged in the spec.
+ router.GET("/legacy/books", h.ListBooks, &fursy.RouteOptions{
+ Summary: "List all books (legacy)",
+ Tags: books,
+ Deprecated: true,
+ })
+}
diff --git a/examples/03-rest-api-with-openapi/models.go b/examples/03-rest-api-with-openapi/models.go
new file mode 100644
index 0000000..f06b3dd
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/models.go
@@ -0,0 +1,60 @@
+package main
+
+// Publisher is a nested object used to demonstrate $ref composition in the
+// generated OpenAPI document (Book.publisher -> #/components/schemas/Publisher).
+type Publisher struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+}
+
+// Book is the primary response model.
+type Book struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Author string `json:"author"`
+ Publisher Publisher `json:"publisher"`
+ Year int `json:"year"`
+ ISBN string `json:"isbn,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+// BookList is a named wrapper: it becomes its own component whose "books" field
+// is an array of $ref Book.
+type BookList struct {
+ Books []Book `json:"books"`
+ Total int `json:"total"`
+}
+
+// CreateBookRequest is the POST /books request body. Fields without omitempty
+// are emitted as required in the schema.
+type CreateBookRequest struct {
+ Title string `json:"title"`
+ Author string `json:"author"`
+ PublisherID int `json:"publisher_id"`
+ Year int `json:"year"`
+ ISBN string `json:"isbn,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+// UpdateBookRequest is the PATCH /books/:id body. Pointer fields let the handler
+// tell "omitted" from "set to a zero value", so the schema marks none required.
+type UpdateBookRequest struct {
+ Title *string `json:"title,omitempty"`
+ Author *string `json:"author,omitempty"`
+ PublisherID *int `json:"publisher_id,omitempty"`
+ Year *int `json:"year,omitempty"`
+ ISBN *string `json:"isbn,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+// SearchBooksRequest is the optional POST /books/search body.
+type SearchBooksRequest struct {
+ TitleContains string `json:"title_contains,omitempty"`
+ Tag string `json:"tag,omitempty"`
+}
+
+// Stats is returned by the grouped admin endpoint.
+type Stats struct {
+ TotalBooks int `json:"total_books"`
+ TotalTags int `json:"total_tags"`
+}
diff --git a/examples/03-rest-api-with-openapi/store.go b/examples/03-rest-api-with-openapi/store.go
new file mode 100644
index 0000000..2cacf9b
--- /dev/null
+++ b/examples/03-rest-api-with-openapi/store.go
@@ -0,0 +1,188 @@
+package main
+
+import (
+ "errors"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// ErrBookNotFound is returned when a book does not exist.
+var ErrBookNotFound = errors.New("book not found")
+
+// publishers is static reference data so the example needs no external database.
+var publishers = map[int]Publisher{
+ 1: {ID: 1, Name: "Analytical Press"},
+ 2: {ID: 2, Name: "Mind & Machine"},
+}
+
+func publisherByID(id int) Publisher {
+ if p, ok := publishers[id]; ok {
+ return p
+ }
+ return Publisher{ID: id, Name: "Unknown"}
+}
+
+// BookStore is a small in-memory, thread-safe book store.
+type BookStore struct {
+ mu sync.RWMutex
+ books map[int]Book
+ nextID int
+}
+
+// NewBookStore creates a store seeded with two books.
+func NewBookStore() *BookStore {
+ s := &BookStore{books: make(map[int]Book), nextID: 1}
+ s.seed("The Analytical Engine", "Ada Lovelace", 1, 1843, "978-0-00-000001-0", []string{"history", "computing"})
+ s.seed("Computing Machinery and Intelligence", "Alan Turing", 2, 1950, "", []string{"computing", "ai"})
+ return s
+}
+
+func (s *BookStore) seed(title, author string, publisherID, year int, isbn string, tags []string) {
+ book := Book{
+ ID: s.nextID,
+ Title: title,
+ Author: author,
+ Publisher: publisherByID(publisherID),
+ Year: year,
+ ISBN: isbn,
+ Tags: tags,
+ }
+ s.books[book.ID] = book
+ s.nextID++
+}
+
+// List returns all books ordered by ID.
+func (s *BookStore) List() BookList {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return newBookList(s.books)
+}
+
+// Search returns books matching the (all-optional) filter.
+func (s *BookStore) Search(filter SearchBooksRequest) BookList {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ matches := make(map[int]Book)
+ for id, book := range s.books {
+ if filter.TitleContains != "" &&
+ !strings.Contains(strings.ToLower(book.Title), strings.ToLower(filter.TitleContains)) {
+ continue
+ }
+ if filter.Tag != "" && !hasTag(book.Tags, filter.Tag) {
+ continue
+ }
+ matches[id] = book
+ }
+ return newBookList(matches)
+}
+
+// Stats returns aggregate counts.
+func (s *BookStore) Stats() Stats {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ tags := make(map[string]bool)
+ for _, book := range s.books {
+ for _, tag := range book.Tags {
+ tags[tag] = true
+ }
+ }
+ return Stats{TotalBooks: len(s.books), TotalTags: len(tags)}
+}
+
+// Get returns a single book by ID.
+func (s *BookStore) Get(id int) (Book, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ book, ok := s.books[id]
+ if !ok {
+ return Book{}, ErrBookNotFound
+ }
+ return book, nil
+}
+
+// Create inserts a new book and returns it.
+func (s *BookStore) Create(req CreateBookRequest) Book {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ book := Book{
+ ID: s.nextID,
+ Title: req.Title,
+ Author: req.Author,
+ Publisher: publisherByID(req.PublisherID),
+ Year: req.Year,
+ ISBN: req.ISBN,
+ Tags: req.Tags,
+ }
+ s.books[book.ID] = book
+ s.nextID++
+ return book
+}
+
+// Update applies the provided (non-nil) fields and returns the updated book.
+func (s *BookStore) Update(id int, req UpdateBookRequest) (Book, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ book, ok := s.books[id]
+ if !ok {
+ return Book{}, ErrBookNotFound
+ }
+
+ if req.Title != nil {
+ book.Title = *req.Title
+ }
+ if req.Author != nil {
+ book.Author = *req.Author
+ }
+ if req.PublisherID != nil {
+ book.Publisher = publisherByID(*req.PublisherID)
+ }
+ if req.Year != nil {
+ book.Year = *req.Year
+ }
+ if req.ISBN != nil {
+ book.ISBN = *req.ISBN
+ }
+ if req.Tags != nil {
+ book.Tags = req.Tags
+ }
+
+ s.books[id] = book
+ return book, nil
+}
+
+// Delete removes a book by ID.
+func (s *BookStore) Delete(id int) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if _, ok := s.books[id]; !ok {
+ return ErrBookNotFound
+ }
+ delete(s.books, id)
+ return nil
+}
+
+// newBookList builds a deterministic BookList from a book map.
+func newBookList(books map[int]Book) BookList {
+ list := make([]Book, 0, len(books))
+ for _, book := range books {
+ list = append(list, book)
+ }
+ sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
+ return BookList{Books: list, Total: len(list)}
+}
+
+func hasTag(tags []string, want string) bool {
+ for _, tag := range tags {
+ if tag == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/group.go b/group.go
index d2c5c59..31a3977 100644
--- a/group.go
+++ b/group.go
@@ -80,38 +80,61 @@ func (g *RouteGroup) Group(prefix string, middleware ...HandlerFunc) *RouteGroup
// GET registers a type-safe GET route on the group.
// Type parameters are inferred from the handler signature.
-func (g *RouteGroup) GET[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("GET", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) GET[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("GET", path, handler, firstRouteOptions(opts))
}
// POST registers a type-safe POST route on the group.
-func (g *RouteGroup) POST[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("POST", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) POST[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("POST", path, handler, firstRouteOptions(opts))
}
// PUT registers a type-safe PUT route on the group.
-func (g *RouteGroup) PUT[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("PUT", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) PUT[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("PUT", path, handler, firstRouteOptions(opts))
}
// DELETE registers a type-safe DELETE route on the group.
-func (g *RouteGroup) DELETE[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("DELETE", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) DELETE[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("DELETE", path, handler, firstRouteOptions(opts))
}
// PATCH registers a type-safe PATCH route on the group.
-func (g *RouteGroup) PATCH[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("PATCH", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) PATCH[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("PATCH", path, handler, firstRouteOptions(opts))
}
// HEAD registers a type-safe HEAD route on the group.
-func (g *RouteGroup) HEAD[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("HEAD", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) HEAD[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("HEAD", path, handler, firstRouteOptions(opts))
}
// OPTIONS registers a type-safe OPTIONS route on the group.
-func (g *RouteGroup) OPTIONS[Req, Res any](path string, handler Handler[Req, Res]) {
- g.Handle("OPTIONS", path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (g *RouteGroup) OPTIONS[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ g.registerGeneric("OPTIONS", path, handler, firstRouteOptions(opts))
+}
+
+// registerGeneric registers a type-safe handler on the group, recording the
+// Req/Res body types as route metadata for OpenAPI generation.
+func (g *RouteGroup) registerGeneric[Req, Res any](method, path string, handler Handler[Req, Res], opts *RouteOptions) {
+ fullPath := g.prefix + path
+ groupHandlers := g.combineMiddleware(adaptGenericHandler(handler))
+ g.router.handleWithGroupMiddleware(method, fullPath, groupHandlers, opts,
+ genericBodyType[Req](), genericBodyType[Res]())
}
// Handle registers a route with the given HTTP method, path, and handler.
@@ -125,6 +148,20 @@ func (g *RouteGroup) OPTIONS[Req, Res any](path string, handler Handler[Req, Res
// api := router.Group("/api")
// api.Handle("GET", "/users", handler) // Registers GET /api/users
func (g *RouteGroup) Handle(method, path string, handler HandlerFunc) {
+ g.HandleWithOptions(method, path, handler, nil)
+}
+
+// HandleWithOptions registers a route with documentation metadata for OpenAPI
+// generation, in addition to the group prefix and middleware.
+//
+// Example:
+//
+// api := router.Group("/api")
+// api.HandleWithOptions("GET", "/users/:id", handler, &RouteOptions{
+// Summary: "Get user by ID",
+// Tags: []string{"users"},
+// })
+func (g *RouteGroup) HandleWithOptions(method, path string, handler HandlerFunc, opts *RouteOptions) {
// Combine group prefix with route path
fullPath := g.prefix + path
@@ -133,7 +170,7 @@ func (g *RouteGroup) Handle(method, path string, handler HandlerFunc) {
// Register route on parent router with group handlers
// The router will combine its own middleware with these handlers in ServeHTTP
- g.router.handleWithGroupMiddleware(method, fullPath, groupHandlers)
+ g.router.handleWithGroupMiddleware(method, fullPath, groupHandlers, opts, nil, nil)
}
// combineMiddleware combines group middleware and the handler.
diff --git a/handler_generic.go b/handler_generic.go
index 87b9181..b336437 100644
--- a/handler_generic.go
+++ b/handler_generic.go
@@ -4,7 +4,10 @@
package fursy
-import "net/http"
+import (
+ "net/http"
+ "reflect"
+)
// Handler is a type-safe handler function for HTTP requests with typed request/response bodies.
//
@@ -69,3 +72,16 @@ func adaptGenericHandler[Req, Res any](handler Handler[Req, Res]) HandlerFunc {
return handler(ctx)
}
}
+
+// genericBodyType returns the reflect.Type for the generic parameter T, or nil
+// if T is the Empty sentinel (meaning "no body").
+//
+// It mirrors the Empty detection performed in Box.Bind so that the route
+// metadata recorded at registration matches runtime binding behavior.
+func genericBodyType[T any]() reflect.Type {
+ var zero T
+ if _, ok := any(zero).(Empty); ok {
+ return nil
+ }
+ return reflect.TypeFor[T]()
+}
diff --git a/llms.md b/llms.md
index b3b3d7f..7e13799 100644
--- a/llms.md
+++ b/llms.md
@@ -257,12 +257,22 @@ type Problem struct {
**Automatic generation** from code:
```go
-spec := router.OpenAPI(fursy.OpenAPIConfig{
+// Configure API metadata once.
+router.WithInfo(fursy.Info{
Title: "My API",
Version: "1.0.0",
Description: "API description",
})
-// Returns complete OpenAPI 3.1 spec
+
+// Document type-safe handlers inline; schemas are inferred from Box[Req, Res].
+router.POST("/users", createUser, &fursy.RouteOptions{
+ Summary: "Create user",
+ Tags: []string{"users"},
+ SuccessStatus: 201,
+})
+
+// Serve the generated spec at GET /openapi.json.
+router.ServeOpenAPI("/openapi.json")
```
**How it works**:
diff --git a/openapi.go b/openapi.go
index 2462cff..356432a 100644
--- a/openapi.go
+++ b/openapi.go
@@ -10,6 +10,7 @@ import (
"net/http"
"reflect"
"strings"
+ "unicode"
)
// OpenAPI schema type constants.
@@ -23,8 +24,12 @@ const (
const (
openapiVersion = "3.1.0"
mimeApplicationProblemJSON = "application/problem+json"
- refProblemSchema = "#/components/schemas/Problem"
+ schemaRefPrefix = "#/components/schemas/"
+ refProblemSchema = schemaRefPrefix + "Problem"
descSuccess = "Success"
+ descCreated = "Created"
+ descAccepted = "Accepted"
+ descNoContent = "No Content"
descBadRequest = "Bad Request"
descInternalServerError = "Internal Server Error"
)
@@ -350,15 +355,118 @@ type Tag struct {
Description string `json:"description,omitempty"`
}
-// generateSchema generates a JSON Schema from a Go type using reflection.
+// schemaRegistry builds JSON Schemas for a single OpenAPI document.
//
-//nolint:gocognit,gocyclo,cyclop // Schema generation requires complex type introspection.
+// Named package types are registered once in components.schemas and referenced
+// with $ref, which removes duplication and lets recursive types terminate.
+// When defs is nil, components are disabled and every schema is inlined (used
+// by generateSchema for standalone introspection).
+type schemaRegistry struct {
+ // defs maps component name -> schema. Nil disables component generation.
+ defs map[string]*Schema
+ // nameByType caches the component name assigned to each type.
+ nameByType map[reflect.Type]string
+ // nameOwner tracks which type owns each component name, for disambiguation.
+ nameOwner map[string]reflect.Type
+ // seen tracks structs currently being inlined, to break inline cycles.
+ seen map[reflect.Type]bool
+}
+
+// newSchemaRegistry creates a registry that emits named component schemas.
+func newSchemaRegistry() *schemaRegistry {
+ reg := &schemaRegistry{
+ defs: make(map[string]*Schema),
+ nameByType: make(map[reflect.Type]string),
+ nameOwner: make(map[string]reflect.Type),
+ seen: make(map[reflect.Type]bool),
+ }
+ // Reserve the built-in schema name so a user type named "Problem" is
+ // disambiguated rather than overwriting it.
+ reg.nameOwner["Problem"] = nil
+ return reg
+}
+
+// generateSchema generates an inline JSON Schema from a Go type using reflection.
+//
+// Named types are expanded inline (no components); use a schemaRegistry for
+// $ref-based generation.
func generateSchema(t reflect.Type) *Schema {
- // Handle pointer types.
+ return (&schemaRegistry{seen: make(map[reflect.Type]bool)}).schemaFor(t)
+}
+
+// schemaFor returns a schema for t. Named package types yield a $ref to a
+// component (registering the definition on first use); all other types are
+// expanded inline, recursing through schemaFor so nested named types become
+// refs as well.
+func (r *schemaRegistry) schemaFor(t reflect.Type) *Schema {
+ if t == nil {
+ return &Schema{Type: schemaTypeObject}
+ }
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
+ if r.isComponentType(t) {
+ name := r.nameFor(t)
+ if _, defined := r.defs[name]; !defined {
+ // Reserve the name before building so recursive references resolve
+ // to this component instead of recursing forever.
+ r.defs[name] = &Schema{Type: schemaTypeObject}
+ r.defs[name] = r.build(t)
+ }
+ return &Schema{Ref: schemaRefPrefix + name}
+ }
+
+ return r.build(t)
+}
+
+// isComponentType reports whether t should be emitted as a named component.
+//
+// Only user-defined (package-qualified) named types qualify: predeclared types
+// such as int or string (empty package path) and anonymous types (empty name)
+// are inlined.
+func (r *schemaRegistry) isComponentType(t reflect.Type) bool {
+ return r.defs != nil && t.Name() != "" && t.PkgPath() != ""
+}
+
+// nameFor returns the component name for t, disambiguating same-named types
+// from different packages with a package qualifier.
+func (r *schemaRegistry) nameFor(t reflect.Type) string {
+ if name, ok := r.nameByType[t]; ok {
+ return name
+ }
+
+ name := t.Name()
+ if owner, taken := r.nameOwner[name]; taken && owner != t {
+ pkg := t.PkgPath()
+ if i := strings.LastIndexByte(pkg, '/'); i >= 0 {
+ pkg = pkg[i+1:]
+ }
+ base := pkg + "." + t.Name()
+ name = base
+ for i := 2; ; i++ {
+ if owner, taken := r.nameOwner[name]; !taken || owner == t {
+ break
+ }
+ name = fmt.Sprintf("%s.%d", base, i)
+ }
+ }
+
+ r.nameOwner[name] = t
+ r.nameByType[t] = name
+ return name
+}
+
+// build constructs the schema body for t without applying a top-level $ref.
+//
+//nolint:gocognit,gocyclo,cyclop // Schema generation requires complex type introspection.
+func (r *schemaRegistry) build(t reflect.Type) *Schema {
+ // Inline cycle guard: if this type is already being expanded on the current
+ // path (possible when components are disabled), stop with a placeholder.
+ if r.seen[t] {
+ return &Schema{Type: schemaTypeObject}
+ }
+
schema := &Schema{}
switch t.Kind() {
@@ -375,15 +483,19 @@ func generateSchema(t reflect.Type) *Schema {
schema.Type = "boolean"
case reflect.Slice, reflect.Array:
schema.Type = "array"
- schema.Items = generateSchema(t.Elem())
+ schema.Items = r.schemaFor(t.Elem())
case reflect.Map:
schema.Type = schemaTypeObject
- schema.AdditionalProperties = generateSchema(t.Elem())
+ schema.AdditionalProperties = r.schemaFor(t.Elem())
case reflect.Struct:
schema.Type = schemaTypeObject
schema.Properties = make(map[string]*Schema)
required := []string{}
+ // Mark this struct as in-progress so self-referential fields terminate.
+ r.seen[t] = true
+ defer delete(r.seen, t)
+
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
@@ -413,13 +525,7 @@ func generateSchema(t reflect.Type) *Schema {
}
}
- // Generate schema for field.
- fieldSchema := generateSchema(field.Type)
-
- // Add description from comment (if available).
- // Note: We can't easily get comments via reflection.
-
- schema.Properties[fieldName] = fieldSchema
+ schema.Properties[fieldName] = r.schemaFor(field.Type)
// Check if required.
if !omitempty && field.Type.Kind() != reflect.Pointer {
@@ -452,7 +558,7 @@ func generateSchema(t reflect.Type) *Schema {
// Version: "1.0.0",
// })
//
-//nolint:gocognit,gocyclo,cyclop,gocritic,funlen // OpenAPI generation requires complex route introspection.
+//nolint:gocognit,gocyclo,cyclop,gocritic,funlen,maintidx // OpenAPI generation requires complex route introspection.
func (r *Router) GenerateOpenAPI(info Info) (*OpenAPI, error) {
// Use router info if set, otherwise use parameter.
if r.info != nil {
@@ -516,6 +622,13 @@ func (r *Router) GenerateOpenAPI(info Info) (*OpenAPI, error) {
Required: []string{fieldType, fieldTitle, fieldStatus},
}
+ // Schema registry collects named component schemas ($ref targets).
+ reg := newSchemaRegistry()
+
+ // usedOperationIDs tracks operationIds already assigned so generated ones
+ // stay globally unique within the document.
+ usedOperationIDs := make(map[string]bool)
+
// Process all registered routes.
for _, route := range r.routes {
// Convert FURSY path format to OpenAPI format.
@@ -538,24 +651,48 @@ func (r *Router) GenerateOpenAPI(info Info) (*OpenAPI, error) {
Responses: make(map[string]Response),
}
- // Add parameters.
- if len(route.Parameters) > 0 {
- for _, param := range route.Parameters {
- operation.Parameters = append(operation.Parameters, Parameter{
- Name: param.Name,
- In: param.In,
- Description: param.Description,
- Required: param.Required,
- Schema: generateSchema(param.Type),
- })
+ // Assign an operationId: use the explicit value if provided, otherwise
+ // derive a unique one from the method and path.
+ if operation.OperationID == "" {
+ operation.OperationID = uniqueOperationID(usedOperationIDs, route.Method, route.Path)
+ } else {
+ usedOperationIDs[operation.OperationID] = true
+ }
+
+ // Add parameters declared via RouteOptions.
+ declaredPathParams := make(map[string]bool)
+ for _, param := range route.Parameters {
+ operation.Parameters = append(operation.Parameters, Parameter{
+ Name: param.Name,
+ In: param.In,
+ Description: param.Description,
+ Required: param.Required,
+ Schema: reg.schemaFor(param.Type),
+ })
+ if param.In == "path" {
+ declaredPathParams[param.Name] = true
}
}
+ // Auto-declare path template parameters (e.g. /users/{id}) that were
+ // not explicitly provided, so the document is valid OpenAPI.
+ for _, name := range extractPathParams(openAPIPath) {
+ if declaredPathParams[name] {
+ continue
+ }
+ operation.Parameters = append(operation.Parameters, Parameter{
+ Name: name,
+ In: "path",
+ Required: true,
+ Schema: &Schema{Type: schemaTypeString},
+ })
+ }
+
// Add request body if RequestType is set.
if route.RequestType != nil {
- schema := generateSchema(route.RequestType)
+ schema := reg.schemaFor(route.RequestType)
operation.RequestBody = &RequestBody{
- Required: true,
+ Required: !route.OptionalRequestBody,
Content: map[string]MediaType{
MIMEApplicationJSON: {
Schema: schema,
@@ -564,7 +701,8 @@ func (r *Router) GenerateOpenAPI(info Info) (*OpenAPI, error) {
}
}
- // Add responses.
+ // Add responses. Explicit RouteOptions.Responses take precedence over
+ // the inferred success response.
if len(route.Responses) > 0 {
for status, resp := range route.Responses {
statusStr := fmt.Sprintf("%d", status)
@@ -572,45 +710,50 @@ func (r *Router) GenerateOpenAPI(info Info) (*OpenAPI, error) {
Description: resp.Description,
Content: map[string]MediaType{
resp.ContentType: {
- Schema: generateSchema(resp.Type),
+ Schema: reg.schemaFor(resp.Type),
},
},
}
}
} else {
- // Default responses.
- if route.ResponseType != nil {
- operation.Responses["200"] = Response{
- Description: descSuccess,
- Content: map[string]MediaType{
- MIMEApplicationJSON: {
- Schema: generateSchema(route.ResponseType),
- },
+ // Default success response, using SuccessStatus (0 means 200).
+ status := route.SuccessStatus
+ if status == 0 {
+ status = http.StatusOK
+ }
+ statusStr := fmt.Sprintf("%d", status)
+
+ response := Response{Description: successDescription(status)}
+ if status != http.StatusNoContent && route.ResponseType != nil {
+ response.Content = map[string]MediaType{
+ MIMEApplicationJSON: {
+ Schema: reg.schemaFor(route.ResponseType),
},
}
- } else {
- operation.Responses["200"] = Response{
- Description: descSuccess,
- }
}
+ operation.Responses[statusStr] = response
}
- // Add default error responses.
- operation.Responses["400"] = Response{
- Description: descBadRequest,
- Content: map[string]MediaType{
- mimeApplicationProblemJSON: {
- Schema: &Schema{Ref: refProblemSchema},
+ // Add default error responses, unless the user already supplied them.
+ if _, exists := operation.Responses["400"]; !exists {
+ operation.Responses["400"] = Response{
+ Description: descBadRequest,
+ Content: map[string]MediaType{
+ mimeApplicationProblemJSON: {
+ Schema: &Schema{Ref: refProblemSchema},
+ },
},
- },
+ }
}
- operation.Responses["500"] = Response{
- Description: descInternalServerError,
- Content: map[string]MediaType{
- mimeApplicationProblemJSON: {
- Schema: &Schema{Ref: refProblemSchema},
+ if _, exists := operation.Responses["500"]; !exists {
+ operation.Responses["500"] = Response{
+ Description: descInternalServerError,
+ Content: map[string]MediaType{
+ mimeApplicationProblemJSON: {
+ Schema: &Schema{Ref: refProblemSchema},
+ },
},
- },
+ }
}
// Assign operation to correct HTTP method.
@@ -634,9 +777,113 @@ func (r *Router) GenerateOpenAPI(info Info) (*OpenAPI, error) {
doc.Paths[openAPIPath] = pathItem
}
+ // Register inferred component schemas collected while processing routes.
+ for name, schema := range reg.defs {
+ doc.Components.Schemas[name] = schema
+ }
+
return doc, nil
}
+// successDescription returns the standard OpenAPI response description for a
+// success status code.
+func successDescription(status int) string {
+ switch status {
+ case http.StatusCreated:
+ return descCreated
+ case http.StatusAccepted:
+ return descAccepted
+ case http.StatusNoContent:
+ return descNoContent
+ default:
+ return descSuccess
+ }
+}
+
+// buildOperationID derives a deterministic operationId from the HTTP method
+// and route path, e.g. GET /users/:id -> getUsersById.
+func buildOperationID(method, path string) string {
+ var b strings.Builder
+ b.WriteString(strings.ToLower(method))
+
+ for _, seg := range strings.Split(path, "/") {
+ if seg == "" {
+ continue
+ }
+ switch seg[0] {
+ case ':', '*':
+ b.WriteString("By")
+ b.WriteString(exportableName(seg[1:]))
+ default:
+ b.WriteString(exportableName(seg))
+ }
+ }
+
+ if b.Len() == 0 {
+ return strings.ToLower(method)
+ }
+ return b.String()
+}
+
+// uniqueOperationID returns a unique operationId for the route, appending a
+// numeric suffix on collision and recording the result in used.
+func uniqueOperationID(used map[string]bool, method, path string) string {
+ base := buildOperationID(method, path)
+ id := base
+ for i := 2; used[id]; i++ {
+ id = fmt.Sprintf("%s_%d", base, i)
+ }
+ used[id] = true
+ return id
+}
+
+// exportableName converts a path segment into an exported-style identifier,
+// upper-casing the first letter and any letter following a separator.
+//
+// Examples: "users" -> "Users"; "user-names" -> "UserNames"; "id" -> "Id".
+func exportableName(s string) string {
+ var b strings.Builder
+ upperNext := true
+ for _, r := range s {
+ switch {
+ case unicode.IsLetter(r) || unicode.IsDigit(r):
+ if upperNext {
+ b.WriteRune(unicode.ToUpper(r))
+ upperNext = false
+ } else {
+ b.WriteRune(r)
+ }
+ default:
+ upperNext = true
+ }
+ }
+ return b.String()
+}
+
+// extractPathParams returns the names of path template parameters in an
+// OpenAPI path, in order of appearance.
+//
+// Example: "/users/{id}/posts/{postID}" -> ["id", "postID"].
+func extractPathParams(path string) []string {
+ var params []string
+ for {
+ start := strings.IndexByte(path, '{')
+ if start == -1 {
+ break
+ }
+ end := strings.IndexByte(path[start+1:], '}')
+ if end == -1 {
+ break
+ }
+ name := path[start+1 : start+1+end]
+ if name != "" {
+ params = append(params, name)
+ }
+ path = path[start+1+end+1:]
+ }
+ return params
+}
+
// convertPathToOpenAPI converts FURSY path format to OpenAPI format.
// /users/:id -> /users/{id}
// /files/*path -> /files/{path}.
diff --git a/openapi_test.go b/openapi_test.go
index a0db26e..491282c 100644
--- a/openapi_test.go
+++ b/openapi_test.go
@@ -19,6 +19,24 @@ type testUser struct {
Email string `json:"email,omitempty"`
}
+// recursiveNode is a self-referential type used to test schema cycle detection.
+type recursiveNode struct {
+ Value string `json:"value"`
+ Children []recursiveNode `json:"children,omitempty"`
+ Parent *recursiveNode `json:"parent,omitempty"`
+}
+
+// mutualA and mutualB form a mutually-recursive type cycle.
+type mutualA struct {
+ Name string `json:"name"`
+ B *mutualB `json:"b,omitempty"`
+}
+
+type mutualB struct {
+ ID int `json:"id"`
+ A *mutualA `json:"a,omitempty"`
+}
+
func TestOpenAPI_GenerateBasic(t *testing.T) {
router := New()
@@ -711,3 +729,518 @@ func TestRouter_ServeOpenAPI_DefaultInfo(t *testing.T) {
t.Errorf("Expected default version '1.0.0', got %s", doc.Info.Version)
}
}
+
+// TestOpenAPI_CycleDetection verifies that recursive types terminate during
+// schema generation instead of recursing infinitely.
+func TestOpenAPI_CycleDetection(t *testing.T) {
+ // Self-referential via slice and pointer.
+ schema := generateSchema(reflect.TypeOf(recursiveNode{}))
+ if schema.Type != "object" {
+ t.Fatalf("expected object schema, got %q", schema.Type)
+ }
+ if _, ok := schema.Properties["value"]; !ok {
+ t.Error("expected 'value' property")
+ }
+
+ children := schema.Properties["children"]
+ if children == nil || children.Type != "array" || children.Items == nil {
+ t.Fatalf("expected 'children' array with items, got %+v", children)
+ }
+ if children.Items.Type != "object" {
+ t.Errorf("expected cycle placeholder object for children items, got %q", children.Items.Type)
+ }
+
+ // Mutually-recursive types must also terminate.
+ mutual := generateSchema(reflect.TypeOf(mutualA{}))
+ if mutual.Type != "object" {
+ t.Fatalf("expected object schema, got %q", mutual.Type)
+ }
+ if _, ok := mutual.Properties["b"]; !ok {
+ t.Error("expected 'b' property")
+ }
+}
+
+// TestOpenAPI_AutoPathParameters verifies that :name path templates are
+// declared as required path parameters.
+func TestOpenAPI_AutoPathParameters(t *testing.T) {
+ router := New()
+ router.Handle("GET", "/users/:id", func(_ *Context) error { return nil })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ op := doc.Paths["/users/{id}"].Get
+ if op == nil {
+ t.Fatal("GET /users/{id} not found")
+ }
+
+ id := findOpenAPIParameter(op.Parameters, "id", "path")
+ if id == nil {
+ t.Fatal("expected auto-declared 'id' path parameter")
+ }
+ if !id.Required {
+ t.Error("expected 'id' path parameter to be required")
+ }
+ if id.Schema == nil || id.Schema.Type != "string" {
+ t.Errorf("expected 'id' schema type 'string', got %+v", id.Schema)
+ }
+}
+
+// TestOpenAPI_PathParameterOverride verifies that explicitly declared path
+// parameters take precedence over auto-declaration.
+func TestOpenAPI_PathParameterOverride(t *testing.T) {
+ router := New()
+ router.HandleWithOptions("GET", "/users/:id", func(_ *Context) error { return nil }, &RouteOptions{
+ Parameters: []RouteParameter{
+ {Name: "id", In: "path", Required: true, Description: "User ID", Type: reflect.TypeOf(int64(0))},
+ },
+ })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ op := doc.Paths["/users/{id}"].Get
+ count := 0
+ for _, p := range op.Parameters {
+ if p.Name == "id" && p.In == "path" {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Fatalf("expected exactly one 'id' path parameter, got %d", count)
+ }
+
+ id := findOpenAPIParameter(op.Parameters, "id", "path")
+ if id.Description != "User ID" {
+ t.Errorf("expected user-supplied description, got %q", id.Description)
+ }
+ if id.Schema == nil || id.Schema.Type != "integer" {
+ t.Errorf("expected user-supplied integer schema, got %+v", id.Schema)
+ }
+}
+
+// TestOpenAPI_UserResponsesNotClobbered verifies that user-supplied error
+// responses survive generation and defaults are only added when absent.
+func TestOpenAPI_UserResponsesNotClobbered(t *testing.T) {
+ router := New()
+ router.HandleWithOptions("GET", "/users", func(_ *Context) error { return nil }, &RouteOptions{
+ Responses: map[int]RouteResponse{
+ 400: {
+ Description: "Custom Bad Request",
+ ContentType: MIMEApplicationJSON,
+ Type: reflect.TypeOf(testUser{}),
+ },
+ },
+ })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ op := doc.Paths["/users"].Get
+ resp400, ok := op.Responses["400"]
+ if !ok {
+ t.Fatal("expected 400 response")
+ }
+ if resp400.Description != "Custom Bad Request" {
+ t.Errorf("user-supplied 400 was clobbered: got %q", resp400.Description)
+ }
+
+ if _, ok := op.Responses["500"]; !ok {
+ t.Error("expected default 500 response to still be added")
+ }
+}
+
+// findOpenAPIParameter returns the first parameter matching name and location.
+func findOpenAPIParameter(params []Parameter, name, in string) *Parameter {
+ for i := range params {
+ if params[i].Name == name && params[i].In == in {
+ return ¶ms[i]
+ }
+ }
+ return nil
+}
+
+// TestGenericBodyType verifies Empty maps to nil and other types are preserved.
+func TestGenericBodyType(t *testing.T) {
+ if got := genericBodyType[Empty](); got != nil {
+ t.Errorf("genericBodyType[Empty]() = %v, want nil", got)
+ }
+ if got := genericBodyType[testUser](); got != reflect.TypeOf(testUser{}) {
+ t.Errorf("genericBodyType[testUser]() = %v, want %v", got, reflect.TypeOf(testUser{}))
+ }
+ if got := genericBodyType[*testUser](); got != reflect.TypeOf((*testUser)(nil)) {
+ t.Errorf("genericBodyType[*testUser]() = %v, want %v", got, reflect.TypeOf((*testUser)(nil)))
+ }
+ if got := genericBodyType[string](); got != reflect.TypeOf("") {
+ t.Errorf("genericBodyType[string]() = %v, want %v", got, reflect.TypeOf(""))
+ }
+ if got := genericBodyType[[]testUser](); got == nil {
+ t.Error("genericBodyType[[]testUser]() = nil, want non-nil")
+ }
+}
+
+// TestRegisterGeneric_RecordsTypes verifies that type-safe handlers record
+// their Req/Res body types as RouteInfo metadata.
+func TestRegisterGeneric_RecordsTypes(t *testing.T) {
+ router := New()
+ router.POST[testUser, testUser]("/users", func(c *Box[testUser, testUser]) error {
+ return c.Created("/users/1", *c.ReqBody)
+ })
+ router.GET[Empty, testUser]("/users/:id", func(_ *Box[Empty, testUser]) error {
+ return nil
+ })
+ router.DELETE[Empty, Empty]("/users/:id", func(_ *Box[Empty, Empty]) error {
+ return nil
+ })
+
+ if len(router.routes) != 3 {
+ t.Fatalf("expected 3 recorded routes, got %d", len(router.routes))
+ }
+
+ post := router.routes[0]
+ if post.RequestType != reflect.TypeOf(testUser{}) {
+ t.Errorf("POST RequestType = %v, want %v", post.RequestType, reflect.TypeOf(testUser{}))
+ }
+ if post.ResponseType != reflect.TypeOf(testUser{}) {
+ t.Errorf("POST ResponseType = %v, want %v", post.ResponseType, reflect.TypeOf(testUser{}))
+ }
+
+ get := router.routes[1]
+ if get.RequestType != nil {
+ t.Errorf("GET RequestType = %v, want nil (Empty)", get.RequestType)
+ }
+ if get.ResponseType != reflect.TypeOf(testUser{}) {
+ t.Errorf("GET ResponseType = %v, want %v", get.ResponseType, reflect.TypeOf(testUser{}))
+ }
+
+ del := router.routes[2]
+ if del.RequestType != nil || del.ResponseType != nil {
+ t.Errorf("DELETE types = (%v, %v), want (nil, nil)", del.RequestType, del.ResponseType)
+ }
+}
+
+// TestRegisterGeneric_WithOptions verifies that variadic RouteOptions are
+// recorded by type-safe handlers.
+func TestRegisterGeneric_WithOptions(t *testing.T) {
+ router := New()
+ router.GET[Empty, testUser]("/users/:id", func(_ *Box[Empty, testUser]) error { return nil },
+ &RouteOptions{Summary: "Get user", Tags: []string{"users"}})
+
+ if len(router.routes) != 1 {
+ t.Fatalf("expected 1 route, got %d", len(router.routes))
+ }
+ if router.routes[0].Summary != "Get user" {
+ t.Errorf("Summary = %q, want %q", router.routes[0].Summary, "Get user")
+ }
+ if len(router.routes[0].Tags) != 1 || router.routes[0].Tags[0] != "users" {
+ t.Errorf("Tags = %v, want [users]", router.routes[0].Tags)
+ }
+}
+
+// TestOpenAPI_GenericHandlerSchemas verifies that type-safe handlers produce
+// request and response schemas in the generated document.
+func TestOpenAPI_GenericHandlerSchemas(t *testing.T) {
+ router := New()
+ router.POST[testUser, testUser]("/users", func(c *Box[testUser, testUser]) error {
+ return c.Created("/users/1", *c.ReqBody)
+ })
+ router.GET[Empty, []testUser]("/users", func(_ *Box[Empty, []testUser]) error {
+ return nil
+ })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ post := doc.Paths["/users"].Post
+ if post == nil {
+ t.Fatal("POST /users not found")
+ }
+ if post.RequestBody == nil {
+ t.Fatal("expected POST request body schema")
+ }
+ reqMedia, ok := post.RequestBody.Content[MIMEApplicationJSON]
+ if !ok || reqMedia.Schema == nil {
+ t.Fatal("expected application/json request schema")
+ }
+ if reqMedia.Schema.Ref != "#/components/schemas/testUser" {
+ t.Errorf("request schema ref = %q, want %q", reqMedia.Schema.Ref, "#/components/schemas/testUser")
+ }
+
+ respMedia, ok := post.Responses["200"].Content[MIMEApplicationJSON]
+ if !ok || respMedia.Schema == nil {
+ t.Fatal("expected application/json response schema")
+ }
+ if respMedia.Schema.Ref != "#/components/schemas/testUser" {
+ t.Errorf("response schema ref = %q, want %q", respMedia.Schema.Ref, "#/components/schemas/testUser")
+ }
+
+ // The shared type is registered once as a named component.
+ userSchema, ok := doc.Components.Schemas["testUser"]
+ if !ok {
+ t.Fatal("expected testUser component schema")
+ }
+ if _, ok := userSchema.Properties["id"]; !ok {
+ t.Error("expected testUser schema to include 'id' property")
+ }
+ if _, ok := userSchema.Properties["name"]; !ok {
+ t.Error("expected testUser schema to include 'name' property")
+ }
+ if len(userSchema.Required) != 2 {
+ t.Errorf("expected 2 required properties (id, name), got %v", userSchema.Required)
+ }
+
+ // GET uses Empty request type: no request body should be generated.
+ get := doc.Paths["/users"].Get
+ if get == nil {
+ t.Fatal("GET /users not found")
+ }
+ if get.RequestBody != nil {
+ t.Error("expected no request body for Empty request type")
+ }
+
+ // Array response type should produce an array schema whose items $ref the
+ // named component.
+ getResp, ok := get.Responses["200"].Content[MIMEApplicationJSON]
+ if !ok || getResp.Schema == nil {
+ t.Fatal("expected GET response schema")
+ }
+ if getResp.Schema.Type != "array" {
+ t.Errorf("expected GET response schema type 'array', got %q", getResp.Schema.Type)
+ }
+ if getResp.Schema.Items == nil || getResp.Schema.Items.Ref != "#/components/schemas/testUser" {
+ t.Errorf("expected array items $ref to testUser, got %+v", getResp.Schema.Items)
+ }
+}
+
+// TestOpenAPI_SuccessStatus verifies that SuccessStatus controls the inferred
+// success response, including 204 with no content.
+func TestOpenAPI_SuccessStatus(t *testing.T) {
+ router := New()
+ router.POST[testUser, testUser]("/users", func(c *Box[testUser, testUser]) error {
+ return c.Created("/users/1", *c.ReqBody)
+ }, &RouteOptions{SuccessStatus: http.StatusCreated})
+ router.DELETE[Empty, Empty]("/users/:id", func(c *Box[Empty, Empty]) error {
+ return c.NoContentSuccess()
+ }, &RouteOptions{SuccessStatus: http.StatusNoContent})
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ post := doc.Paths["/users"].Post
+ if _, ok := post.Responses["200"]; ok {
+ t.Error("did not expect a 200 response when SuccessStatus is 201")
+ }
+ created, ok := post.Responses["201"]
+ if !ok {
+ t.Fatal("expected 201 response")
+ }
+ if created.Description != descCreated {
+ t.Errorf("201 description = %q, want %q", created.Description, descCreated)
+ }
+ if media, ok := created.Content[MIMEApplicationJSON]; !ok || media.Schema == nil {
+ t.Error("expected 201 response content schema")
+ }
+
+ del := doc.Paths["/users/{id}"].Delete
+ noContent, ok := del.Responses["204"]
+ if !ok {
+ t.Fatal("expected 204 response")
+ }
+ if noContent.Description != descNoContent {
+ t.Errorf("204 description = %q, want %q", noContent.Description, descNoContent)
+ }
+ if len(noContent.Content) != 0 {
+ t.Errorf("204 response must not have content, got %d media types", len(noContent.Content))
+ }
+ if _, ok := del.Responses["400"]; !ok {
+ t.Error("expected default 400 response alongside 204")
+ }
+}
+
+// TestOpenAPI_OptionalRequestBody verifies OptionalRequestBody relaxes the
+// inferred request body's required flag.
+func TestOpenAPI_OptionalRequestBody(t *testing.T) {
+ router := New()
+ router.POST[testUser, testUser]("/users", func(_ *Box[testUser, testUser]) error {
+ return nil
+ }, &RouteOptions{OptionalRequestBody: true})
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ post := doc.Paths["/users"].Post
+ if post.RequestBody == nil {
+ t.Fatal("expected request body")
+ }
+ if post.RequestBody.Required {
+ t.Error("expected optional (Required=false) request body")
+ }
+}
+
+// TestOpenAPI_ExplicitResponsesOverrideInference verifies that explicit
+// RouteOptions.Responses suppress the inferred success response.
+func TestOpenAPI_ExplicitResponsesOverrideInference(t *testing.T) {
+ router := New()
+ router.POST[testUser, testUser]("/users", func(_ *Box[testUser, testUser]) error {
+ return nil
+ }, &RouteOptions{
+ Responses: map[int]RouteResponse{
+ http.StatusCreated: {
+ Description: "Custom Created",
+ ContentType: MIMEApplicationJSON,
+ Type: reflect.TypeOf(testUser{}),
+ },
+ },
+ })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ post := doc.Paths["/users"].Post
+ if _, ok := post.Responses["200"]; ok {
+ t.Error("inference should not add 200 when explicit Responses are provided")
+ }
+ if _, ok := post.Responses["201"]; !ok {
+ t.Error("expected user-provided 201 response")
+ }
+}
+
+// TestOpenAPI_GroupGenericRoute verifies that group generic routes carry the
+// prefixed path, options, and Req/Res types.
+func TestOpenAPI_GroupGenericRoute(t *testing.T) {
+ router := New()
+ api := router.Group("/api")
+ api.GET[Empty, testUser]("/users/:id", func(_ *Box[Empty, testUser]) error {
+ return nil
+ }, &RouteOptions{Summary: "Get user", Tags: []string{"users"}})
+
+ if len(router.routes) != 1 {
+ t.Fatalf("expected 1 recorded route, got %d", len(router.routes))
+ }
+ if router.routes[0].ResponseType != reflect.TypeOf(testUser{}) {
+ t.Errorf("group ResponseType = %v, want %v", router.routes[0].ResponseType, reflect.TypeOf(testUser{}))
+ }
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ op := doc.Paths["/api/users/{id}"].Get
+ if op == nil {
+ t.Fatal("expected GET /api/users/{id} from group registration")
+ }
+ if op.Summary != "Get user" {
+ t.Errorf("Summary = %q, want %q", op.Summary, "Get user")
+ }
+ if len(op.Tags) != 1 || op.Tags[0] != "users" {
+ t.Errorf("Tags = %v, want [users]", op.Tags)
+ }
+ if findOpenAPIParameter(op.Parameters, "id", "path") == nil {
+ t.Error("expected auto-declared 'id' path parameter on group route")
+ }
+}
+
+// TestOpenAPI_AutoOperationID verifies deterministic operationId generation
+// from method + path, with global uniqueness across the document.
+func TestOpenAPI_AutoOperationID(t *testing.T) {
+ router := New()
+ router.GET[Empty, testUser]("/users", func(_ *Box[Empty, testUser]) error { return nil })
+ router.POST[testUser, testUser]("/users", func(_ *Box[testUser, testUser]) error { return nil })
+ router.GET[Empty, testUser]("/users/:id", func(_ *Box[Empty, testUser]) error { return nil })
+ router.DELETE[Empty, Empty]("/files/*path", func(_ *Box[Empty, Empty]) error { return nil })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ got := map[string]string{
+ "get /users": doc.Paths["/users"].Get.OperationID,
+ "post /users": doc.Paths["/users"].Post.OperationID,
+ "get /users/{id}": doc.Paths["/users/{id}"].Get.OperationID,
+ "delete /files/{path}": doc.Paths["/files/{path}"].Delete.OperationID,
+ }
+ want := map[string]string{
+ "get /users": "getUsers",
+ "post /users": "postUsers",
+ "get /users/{id}": "getUsersById",
+ "delete /files/{path}": "deleteFilesByPath",
+ }
+ for route, wantID := range want {
+ if got[route] != wantID {
+ t.Errorf("%s operationId = %q, want %q", route, got[route], wantID)
+ }
+ }
+
+ // Every operation must have a unique, non-empty operationId.
+ seen := make(map[string]string)
+ for path, item := range doc.Paths {
+ for method, op := range operationsOf(item) {
+ if op.OperationID == "" {
+ t.Errorf("%s %s: empty operationId", method, path)
+ continue
+ }
+ if prev, dup := seen[op.OperationID]; dup {
+ t.Errorf("duplicate operationId %q (%s and %s %s)", op.OperationID, prev, method, path)
+ }
+ seen[op.OperationID] = method + " " + path
+ }
+ }
+}
+
+// TestOpenAPI_OperationIDUniqueness verifies explicit operationIds are
+// preserved and generated ones avoid collisions with them.
+func TestOpenAPI_OperationIDUniqueness(t *testing.T) {
+ router := New()
+ router.HandleWithOptions("GET", "/x", func(_ *Context) error { return nil }, &RouteOptions{
+ OperationID: "getUsersById",
+ })
+ router.GET[Empty, testUser]("/users/:id", func(_ *Box[Empty, testUser]) error { return nil })
+
+ doc, err := router.GenerateOpenAPI(Info{Title: "Test", Version: "1.0.0"})
+ if err != nil {
+ t.Fatalf("GenerateOpenAPI failed: %v", err)
+ }
+
+ if got := doc.Paths["/x"].Get.OperationID; got != "getUsersById" {
+ t.Errorf("explicit operationId = %q, want %q", got, "getUsersById")
+ }
+ if got := doc.Paths["/users/{id}"].Get.OperationID; got != "getUsersById_2" {
+ t.Errorf("colliding generated operationId = %q, want %q", got, "getUsersById_2")
+ }
+}
+
+// operationsOf returns the non-nil operations of a PathItem keyed by method.
+func operationsOf(item PathItem) map[string]*Operation {
+ all := map[string]*Operation{
+ "GET": item.Get,
+ "POST": item.Post,
+ "PUT": item.Put,
+ "DELETE": item.Delete,
+ "PATCH": item.Patch,
+ "HEAD": item.Head,
+ "OPTIONS": item.Options,
+ }
+ ops := make(map[string]*Operation, len(all))
+ for method, op := range all {
+ if op != nil {
+ ops[method] = op
+ }
+ }
+ return ops
+}
diff --git a/route_info.go b/route_info.go
index 5e40688..a05ba3d 100644
--- a/route_info.go
+++ b/route_info.go
@@ -41,6 +41,13 @@ type RouteInfo struct {
// Responses stores metadata about possible responses.
Responses map[int]RouteResponse
+
+ // SuccessStatus is the default success status code for the inferred
+ // response. Zero means 200. A value of 204 emits no response body.
+ SuccessStatus int
+
+ // OptionalRequestBody marks the inferred request body as not required.
+ OptionalRequestBody bool
}
// RouteParameter stores metadata about a route parameter.
@@ -95,4 +102,11 @@ type RouteOptions struct {
// Responses stores metadata about possible responses.
Responses map[int]RouteResponse
+
+ // SuccessStatus is the default success status code for the inferred
+ // response. Zero means 200. A value of 204 emits no response body.
+ SuccessStatus int
+
+ // OptionalRequestBody marks the inferred request body as not required.
+ OptionalRequestBody bool
}
diff --git a/router.go b/router.go
index 84b3b2f..b7277ad 100644
--- a/router.go
+++ b/router.go
@@ -61,6 +61,7 @@ import (
"errors"
"net/http"
"os/signal"
+ "reflect"
"sort"
"strings"
"sync"
@@ -437,51 +438,85 @@ func (r *Router) Group(prefix string, middleware ...HandlerFunc) *RouteGroup {
// GET registers a type-safe handler for GET requests.
// Type parameters are inferred from the handler signature.
//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+//
// Example:
//
// router.GET("/users/:id", func(c *fursy.Box[fursy.Empty, UserResponse]) error {
// return c.OK(UserResponse{ID: 1, Name: "Alice"})
// })
-func (r *Router) GET[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodGet, path, adaptGenericHandler(handler))
+func (r *Router) GET[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodGet, path, handler, firstRouteOptions(opts))
}
// POST registers a type-safe handler for POST requests.
// The request body is automatically bound and validated.
//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+//
// Example:
//
// router.POST("/users", func(c *fursy.Box[CreateUserReq, UserResponse]) error {
// req := c.ReqBody
// return c.Created("/users/1", UserResponse{ID: 1, Name: req.Name})
// })
-func (r *Router) POST[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodPost, path, adaptGenericHandler(handler))
+func (r *Router) POST[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodPost, path, handler, firstRouteOptions(opts))
}
// PUT registers a type-safe handler for PUT requests.
-func (r *Router) PUT[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodPut, path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (r *Router) PUT[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodPut, path, handler, firstRouteOptions(opts))
}
// DELETE registers a type-safe handler for DELETE requests.
-func (r *Router) DELETE[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodDelete, path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (r *Router) DELETE[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodDelete, path, handler, firstRouteOptions(opts))
}
// PATCH registers a type-safe handler for PATCH requests.
-func (r *Router) PATCH[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodPatch, path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (r *Router) PATCH[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodPatch, path, handler, firstRouteOptions(opts))
}
// HEAD registers a type-safe handler for HEAD requests.
-func (r *Router) HEAD[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodHead, path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (r *Router) HEAD[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodHead, path, handler, firstRouteOptions(opts))
}
// OPTIONS registers a type-safe handler for OPTIONS requests.
-func (r *Router) OPTIONS[Req, Res any](path string, handler Handler[Req, Res]) {
- r.Handle(http.MethodOptions, path, adaptGenericHandler(handler))
+//
+// An optional *RouteOptions may be supplied to document the route for OpenAPI.
+func (r *Router) OPTIONS[Req, Res any](path string, handler Handler[Req, Res], opts ...*RouteOptions) {
+ r.registerGeneric(http.MethodOptions, path, handler, firstRouteOptions(opts))
+}
+
+// registerGeneric registers a type-safe handler, recording the request and
+// response body types (from Req/Res) as route metadata for OpenAPI generation.
+//
+// The Empty sentinel maps to "no body" (nil type), mirroring Box.Bind.
+func (r *Router) registerGeneric[Req, Res any](method, path string, handler Handler[Req, Res], opts *RouteOptions) {
+ r.registerRoute(method, path, adaptGenericHandler(handler), opts,
+ genericBodyType[Req](), genericBodyType[Res]())
+}
+
+// firstRouteOptions returns the first *RouteOptions from opts, or nil.
+//
+// Generic route methods accept options variadically so existing two-argument
+// calls remain source-compatible.
+func firstRouteOptions(opts []*RouteOptions) *RouteOptions {
+ if len(opts) > 0 {
+ return opts[0]
+ }
+ return nil
}
// Handle registers a handler for the given HTTP method and path.
@@ -516,6 +551,14 @@ func (r *Router) Handle(method, path string, handler HandlerFunc) {
// Tags: []string{"users"},
// })
func (r *Router) HandleWithOptions(method, path string, handler HandlerFunc, opts *RouteOptions) {
+ r.registerRoute(method, path, handler, opts, nil, nil)
+}
+
+// registerRoute is the shared registration path for all routes. It inserts the
+// handler into the method's radix tree and records RouteInfo for OpenAPI
+// generation. reqType and resType are the optional request/response body types
+// of type-safe handlers; they are nil for plain handlers and for Empty.
+func (r *Router) registerRoute(method, path string, handler HandlerFunc, opts *RouteOptions, reqType, resType reflect.Type) {
if method == "" {
panic("fursy: HTTP method cannot be empty")
}
@@ -540,29 +583,43 @@ func (r *Router) HandleWithOptions(method, path string, handler HandlerFunc, opt
// Store route metadata for OpenAPI generation.
routeInfo := RouteInfo{
- Method: method,
- Path: path,
- }
-
- if opts != nil {
- routeInfo.Summary = opts.Summary
- routeInfo.Description = opts.Description
- routeInfo.Tags = opts.Tags
- routeInfo.OperationID = opts.OperationID
- routeInfo.Deprecated = opts.Deprecated
- routeInfo.Parameters = opts.Parameters
- routeInfo.Responses = opts.Responses
+ Method: method,
+ Path: path,
+ RequestType: reqType,
+ ResponseType: resType,
}
+ applyRouteOptions(&routeInfo, opts)
r.routes = append(r.routes, routeInfo)
}
+// applyRouteOptions copies documentation metadata from opts into routeInfo.
+// It is a no-op when opts is nil.
+func applyRouteOptions(routeInfo *RouteInfo, opts *RouteOptions) {
+ if opts == nil {
+ return
+ }
+ routeInfo.Summary = opts.Summary
+ routeInfo.Description = opts.Description
+ routeInfo.Tags = opts.Tags
+ routeInfo.OperationID = opts.OperationID
+ routeInfo.Deprecated = opts.Deprecated
+ routeInfo.Parameters = opts.Parameters
+ routeInfo.Responses = opts.Responses
+ routeInfo.SuccessStatus = opts.SuccessStatus
+ routeInfo.OptionalRequestBody = opts.OptionalRequestBody
+}
+
// handleWithGroupMiddleware registers a route with group middleware.
-// This is called by RouteGroup.Handle() to register routes with group-specific middleware.
+// This is called by the RouteGroup registration methods to register routes with
+// group-specific middleware.
//
-// The groupHandlers slice contains: group.middleware + handler
+// The groupHandlers slice contains: group.middleware + handler.
// These will be combined with router.middleware in ServeHTTP.
-func (r *Router) handleWithGroupMiddleware(method, path string, groupHandlers []HandlerFunc) {
+//
+// reqType and resType are the optional request/response body types of type-safe
+// handlers; they are nil for plain handlers and for Empty.
+func (r *Router) handleWithGroupMiddleware(method, path string, groupHandlers []HandlerFunc, opts *RouteOptions, reqType, resType reflect.Type) {
if method == "" {
panic("fursy: HTTP method cannot be empty")
}
@@ -589,10 +646,15 @@ func (r *Router) handleWithGroupMiddleware(method, path string, groupHandlers []
}
// Store route metadata for OpenAPI generation.
- r.routes = append(r.routes, RouteInfo{
- Method: method,
- Path: path,
- })
+ routeInfo := RouteInfo{
+ Method: method,
+ Path: path,
+ RequestType: reqType,
+ ResponseType: resType,
+ }
+ applyRouteOptions(&routeInfo, opts)
+
+ r.routes = append(r.routes, routeInfo)
}
// createGroupHandlerWrapper creates a handler that executes group middleware + handler.
diff --git a/router_generic.go b/router_generic.go
index 43babbd..b3d1bdd 100644
--- a/router_generic.go
+++ b/router_generic.go
@@ -6,37 +6,51 @@ package fursy
import "net/http"
-// GET registers a type-safe GET handler. Deprecated: use router.GET() instead.
+// GET registers a type-safe GET handler.
+//
+// Deprecated: use router.GET() instead.
func GET[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodGet, path, adaptGenericHandler(handler))
}
-// POST registers a type-safe POST handler. Deprecated: use router.POST() instead.
+// POST registers a type-safe POST handler.
+//
+// Deprecated: use router.POST() instead.
func POST[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodPost, path, adaptGenericHandler(handler))
}
-// PUT registers a type-safe PUT handler. Deprecated: use router.PUT() instead.
+// PUT registers a type-safe PUT handler.
+//
+// Deprecated: use router.PUT() instead.
func PUT[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodPut, path, adaptGenericHandler(handler))
}
-// DELETE registers a type-safe DELETE handler. Deprecated: use router.DELETE() instead.
+// DELETE registers a type-safe DELETE handler.
+//
+// Deprecated: use router.DELETE() instead.
func DELETE[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodDelete, path, adaptGenericHandler(handler))
}
-// PATCH registers a type-safe PATCH handler. Deprecated: use router.PATCH() instead.
+// PATCH registers a type-safe PATCH handler.
+//
+// Deprecated: use router.PATCH() instead.
func PATCH[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodPatch, path, adaptGenericHandler(handler))
}
-// HEAD registers a type-safe HEAD handler. Deprecated: use router.HEAD() instead.
+// HEAD registers a type-safe HEAD handler.
+//
+// Deprecated: use router.HEAD() instead.
func HEAD[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodHead, path, adaptGenericHandler(handler))
}
-// OPTIONS registers a type-safe OPTIONS handler. Deprecated: use router.OPTIONS() instead.
+// OPTIONS registers a type-safe OPTIONS handler.
+//
+// Deprecated: use router.OPTIONS() instead.
func OPTIONS[Req, Res any](r *Router, path string, handler Handler[Req, Res]) {
r.Handle(http.MethodOptions, path, adaptGenericHandler(handler))
}