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