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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions examples/03-rest-api-with-openapi/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions examples/03-rest-api-with-openapi/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
4 changes: 4 additions & 0 deletions examples/03-rest-api-with-openapi/go.sum
Original file line number Diff line number Diff line change
@@ -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=
113 changes: 113 additions & 0 deletions examples/03-rest-api-with-openapi/handlers.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions examples/03-rest-api-with-openapi/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bookstore API — API Docs</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>

<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: "/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis],
layout: "BaseLayout",
});
};
</script>
</body>
</html>
Loading