From ed61350d94c981778f091497c3e4c4dfa51adc07 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:48:21 -0400 Subject: [PATCH 01/13] test: assert raw openapi spec matches fixture --- app.go | 24 ++ docs/middleware/openapi.md | 103 +++++++ docs/whats_new.md | 4 + group.go | 18 ++ middleware/openapi/config.go | 98 ++++++ middleware/openapi/openapi.go | 168 ++++++++++ middleware/openapi/openapi_test.go | 372 +++++++++++++++++++++++ middleware/openapi/testdata/openapi.json | 1 + router.go | 33 +- 9 files changed, 813 insertions(+), 8 deletions(-) create mode 100644 docs/middleware/openapi.md create mode 100644 middleware/openapi/config.go create mode 100644 middleware/openapi/openapi.go create mode 100644 middleware/openapi/openapi_test.go create mode 100644 middleware/openapi/testdata/openapi.json diff --git a/app.go b/app.go index dfadcac1272..a591fca81cb 100644 --- a/app.go +++ b/app.go @@ -706,6 +706,30 @@ func (app *App) Name(name string) Router { return app } +// Summary assigns a short summary to the most recently added route. +func (app *App) Summary(sum string) Router { + app.mutex.Lock() + app.latestRoute.Summary = sum + app.mutex.Unlock() + return app +} + +// Description assigns a description to the most recently added route. +func (app *App) Description(desc string) Router { + app.mutex.Lock() + app.latestRoute.Description = desc + app.mutex.Unlock() + return app +} + +// MediaType assigns a response media type to the most recently added route. +func (app *App) MediaType(typ string) Router { + app.mutex.Lock() + app.latestRoute.MediaType = typ + app.mutex.Unlock() + return app +} + // GetRoute Get route by name func (app *App) GetRoute(name string) Route { for _, routes := range app.stack { diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md new file mode 100644 index 00000000000..0c597b9403b --- /dev/null +++ b/docs/middleware/openapi.md @@ -0,0 +1,103 @@ +--- +id: openapi +--- + +# OpenAPI + +OpenAPI middleware for [Fiber](https://github.com/gofiber/fiber) that generates an OpenAPI specification based on the routes registered in your application. + +## Signatures + +```go +func New(config ...Config) fiber.Handler +``` + +## Examples + +Import the middleware package that is part of the Fiber web framework + +```go +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/openapi" +) +``` + +After you initiate your Fiber app, you can use the following possibilities: + +```go +// Initialize default config. Register the middleware *after* all routes +// so that the spec includes every handler. +app.Use(openapi.New()) + +// Or extend your config for customization +app.Use(openapi.New(openapi.Config{ + Title: "My API", + Version: "1.0.0", + ServerURL: "https://example.com", +})) + +// Customize metadata for specific operations +app.Use(openapi.New(openapi.Config{ + Operations: map[string]openapi.Operation{ + "GET /users": { + Summary: "List users", + Description: "Returns all users", + MediaType: fiber.MIMEApplicationJSON, + }, + }, +})) + +// Routes may optionally document themselves using Summary, Description and MediaType +app.Get("/users", listUsers). + Summary("List users"). + Description("List all users"). + MediaType(fiber.MIMEApplicationJSON) + +// If not specified, routes default to an empty summary and description and a +// "text/plain" response media type. +``` + +Each documented route automatically includes a `200` response with the description `OK` to satisfy the minimum OpenAPI requirements. + +`CONNECT` routes are ignored because the OpenAPI specification does not define a `connect` operation. + +## Config + +| Property | Type | Description | Default | +|:------------|:------------------------|:----------------------------------------------------------------|:------------------:| +| Next | `func(fiber.Ctx) bool` | Next defines a function to skip this middleware when returned true. | `nil` | +| Title | `string` | Title is the title for the generated OpenAPI specification. | `"Fiber API"` | +| Version | `string` | Version is the version for the generated OpenAPI specification. | `"1.0.0"` | +| Description | `string` | Description is the description for the generated specification. | `""` | +| ServerURL | `string` | ServerURL is the server URL used in the generated specification.| `""` | +| Path | `string` | Path is the route where the specification will be served. | `"/openapi.json"` | +| Operations | `map[string]Operation` | Per-route metadata keyed by `METHOD /path`. | `nil` | + +## Default Config + +```go +var ConfigDefault = Config{ + Next: nil, + Title: "Fiber API", + Version: "1.0.0", + Description: "", + ServerURL: "", + Path: "/openapi.json", + Operations: nil, +} +``` + +### Operation + +```go +type Operation struct { + OperationID string + Summary string + Description string + Tags []string + Deprecated bool + MediaType string +} +``` + diff --git a/docs/whats_new.md b/docs/whats_new.md index 581f1a59234..369800bbbcf 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -1288,6 +1288,10 @@ Deprecated fields `Duration`, `Store`, and `Key` have been removed in v3. Use `E Monitor middleware is migrated to the [Contrib package](https://github.com/gofiber/contrib/tree/main/monitor) with [PR #1172](https://github.com/gofiber/contrib/pull/1172). +### OpenAPI + +Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions and return media types directly or configure them globally. + ### Proxy The proxy middleware has been updated to improve consistency with Go naming conventions. The `TlsConfig` field in the configuration struct has been renamed to `TLSConfig`. Additionally, the `WithTlsConfig` method has been removed; you should now configure TLS directly via the `TLSConfig` property within the `Config` struct. diff --git a/group.go b/group.go index f85674bfb8c..1f6942ee420 100644 --- a/group.go +++ b/group.go @@ -45,6 +45,24 @@ func (grp *Group) Name(name string) Router { return grp } +// Summary assigns a short summary to the most recently added route in the group. +func (grp *Group) Summary(sum string) Router { + grp.app.Summary(sum) + return grp +} + +// Description assigns a description to the most recently added route in the group. +func (grp *Group) Description(desc string) Router { + grp.app.Description(desc) + return grp +} + +// MediaType assigns a response media type to the most recently added route in the group. +func (grp *Group) MediaType(typ string) Router { + grp.app.MediaType(typ) + return grp +} + // Use registers a middleware route that will match requests // with the provided prefix (which is optional and defaults to "/"). // Also, you can pass another app instance as a sub-router along a routing path. diff --git a/middleware/openapi/config.go b/middleware/openapi/config.go new file mode 100644 index 00000000000..8fefe8ad3b1 --- /dev/null +++ b/middleware/openapi/config.go @@ -0,0 +1,98 @@ +package openapi + +import ( + "github.com/gofiber/fiber/v3" +) + +// Config defines the config for middleware. +type Config struct { + // Next defines a function to skip this middleware when returned true. + // + // Optional. Default: nil + Next func(c fiber.Ctx) bool + + // Title is the title for the generated OpenAPI specification. + // + // Optional. Default: "Fiber API" + Title string + + // Version is the version for the generated OpenAPI specification. + // + // Optional. Default: "1.0.0" + Version string + + // Description is the description for the generated OpenAPI specification. + // + // Optional. Default: "" + Description string + + // ServerURL is the server URL used in the generated specification. + // + // Optional. Default: "" + ServerURL string + + // Path is the route where the specification will be served. + // + // Optional. Default: "/openapi.json" + Path string + + // Operations allows providing per-route metadata keyed by + // "METHOD /path" (e.g. "GET /users"). + // + // Optional. Default: nil + Operations map[string]Operation +} + +// ConfigDefault is the default config. +var ConfigDefault = Config{ + Next: nil, + Title: "Fiber API", + Version: "1.0.0", + Description: "", + ServerURL: "", + Path: "/openapi.json", + Operations: nil, +} + +func configDefault(config ...Config) Config { + if len(config) < 1 { + return ConfigDefault + } + + cfg := config[0] + + if cfg.Next == nil { + cfg.Next = ConfigDefault.Next + } + if cfg.Title == "" { + cfg.Title = ConfigDefault.Title + } + if cfg.Version == "" { + cfg.Version = ConfigDefault.Version + } + if cfg.Description == "" { + cfg.Description = ConfigDefault.Description + } + if cfg.ServerURL == "" { + cfg.ServerURL = ConfigDefault.ServerURL + } + if cfg.Path == "" { + cfg.Path = ConfigDefault.Path + } + if cfg.Operations == nil { + cfg.Operations = ConfigDefault.Operations + } + + return cfg +} + +// Operation configures metadata for a single route in the generated spec. +type Operation struct { + OperationID string + Summary string + Description string + Tags []string + Deprecated bool + // MediaType defines the media type for the 200 response. + MediaType string +} diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go new file mode 100644 index 00000000000..da4d9bbf77c --- /dev/null +++ b/middleware/openapi/openapi.go @@ -0,0 +1,168 @@ +package openapi + +import ( + "encoding/json" + "strings" + "sync" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/utils/v2" +) + +// New creates a new middleware handler that serves the generated OpenAPI specification. +func New(config ...Config) fiber.Handler { + cfg := configDefault(config...) + + var ( + data []byte + once sync.Once + genErr error + ) + + return func(c fiber.Ctx) error { + if cfg.Next != nil && cfg.Next(c) { + return c.Next() + } + + if !strings.HasSuffix(c.Path(), cfg.Path) { + return c.Next() + } + + once.Do(func() { + spec := generateSpec(c.App(), cfg) + data, genErr = json.Marshal(spec) + }) + if genErr != nil { + return genErr + } + c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8) + return c.Status(fiber.StatusOK).Send(data) + } +} + +type openAPISpec struct { + OpenAPI string `json:"openapi"` + Info openAPIInfo `json:"info"` + Servers []openAPIServer `json:"servers,omitempty"` + Paths map[string]map[string]operation `json:"paths"` +} + +type openAPIInfo struct { + Title string `json:"title"` + Version string `json:"version"` + Description string `json:"description,omitempty"` +} + +type openAPIServer struct { + URL string `json:"url"` +} + +type operation struct { + OperationID string `json:"operationId,omitempty"` + Summary string `json:"summary"` + Description string `json:"description"` + Tags []string `json:"tags,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Parameters []parameter `json:"parameters,omitempty"` + Responses map[string]response `json:"responses"` +} + +type response struct { + Description string `json:"description"` + Content map[string]map[string]any `json:"content,omitempty"` +} + +type parameter struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Schema map[string]string `json:"schema"` +} + +func generateSpec(app *fiber.App, cfg Config) openAPISpec { + paths := make(map[string]map[string]operation) + stack := app.Stack() + + for _, routes := range stack { + for _, r := range routes { + if r.Method == fiber.MethodConnect { + continue + } + + path := r.Path + var params []parameter + if len(r.Params) > 0 { + for _, p := range r.Params { + path = strings.Replace(path, ":"+p, "{"+p+"}", 1) + params = append(params, parameter{ + Name: p, + In: "path", + Required: true, + Schema: map[string]string{"type": "string"}, + }) + } + } + + method := utils.ToLower(r.Method) + if paths[path] == nil { + paths[path] = make(map[string]operation) + } + + key := r.Method + " " + r.Path + meta := cfg.Operations[key] + + summary := meta.Summary + if summary == "" { + summary = r.Summary + } + if summary == "" { + summary = r.Method + " " + r.Path + } + description := meta.Description + if description == "" { + description = r.Description + } + + respType := meta.MediaType + if respType == "" { + respType = r.MediaType + } + resp := response{Description: "OK"} + if respType != "" { + resp.Content = map[string]map[string]any{ + respType: {}, + } + } + + opID := meta.OperationID + if opID == "" { + opID = r.Name + } + paths[path][method] = operation{ + OperationID: opID, + Summary: summary, + Description: description, + Tags: meta.Tags, + Deprecated: meta.Deprecated, + Parameters: params, + Responses: map[string]response{ + "200": resp, + }, + } + } + } + + spec := openAPISpec{ + OpenAPI: "3.0.0", + Info: openAPIInfo{ + Title: cfg.Title, + Version: cfg.Version, + Description: cfg.Description, + }, + Paths: paths, + } + if cfg.ServerURL != "" { + spec.Servers = []openAPIServer{{URL: cfg.ServerURL}} + } + return spec +} diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go new file mode 100644 index 00000000000..a0d79826819 --- /dev/null +++ b/middleware/openapi/openapi_test.go @@ -0,0 +1,372 @@ +package openapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/require" +) + +func Test_OpenAPI_Generate(t *testing.T) { + app := fiber.New() + + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + app.Post("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusCreated) }) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec struct { + Paths map[string]map[string]any `json:"paths"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.Contains(t, spec.Paths, "/users") + operations := spec.Paths["/users"] + require.Contains(t, operations, "get") + require.Contains(t, operations, "post") + getOp := operations["get"].(map[string]any) + require.Contains(t, getOp, "responses") + responses := getOp["responses"].(map[string]any) + require.Contains(t, responses, "200") +} + +func Test_OpenAPI_JSONEquality(t *testing.T) { + app := fiber.New() + + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). + Name("listUsers").MediaType(fiber.MIMEApplicationJSON) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + rootOps := map[string]operation{} + for _, m := range app.Config().RequestMethods { + if m == fiber.MethodConnect { + continue + } + lower := strings.ToLower(m) + upper := strings.ToUpper(m) + rootOps[lower] = operation{ + Summary: upper + " /", + Description: "", + Responses: map[string]response{ + "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMETextPlain: {}}}, + }, + } + } + expected := openAPISpec{ + OpenAPI: "3.0.0", + Info: openAPIInfo{Title: "Fiber API", Version: "1.0.0"}, + Paths: map[string]map[string]operation{ + "/": rootOps, + "/users": { + "get": { + OperationID: "listUsers", + Summary: "GET /users", + Description: "", + Responses: map[string]response{ + "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMEApplicationJSON: {}}}, + }, + }, + }, + }, + } + exp, err := json.Marshal(expected) + require.NoError(t, err) + require.JSONEq(t, string(exp), string(body)) +} + +func Test_OpenAPI_RawJSON(t *testing.T) { + app := fiber.New() + + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). + Name("listUsers").MediaType(fiber.MIMEApplicationJSON) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + rootOps := map[string]operation{} + for _, m := range app.Config().RequestMethods { + if m == fiber.MethodConnect { + continue + } + lower := strings.ToLower(m) + upper := strings.ToUpper(m) + rootOps[lower] = operation{ + Summary: upper + " /", + Description: "", + Responses: map[string]response{ + "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMETextPlain: {}}}, + }, + } + } + expected := openAPISpec{ + OpenAPI: "3.0.0", + Info: openAPIInfo{Title: "Fiber API", Version: "1.0.0"}, + Paths: map[string]map[string]operation{ + "/": rootOps, + "/users": { + "get": { + OperationID: "listUsers", + Summary: "GET /users", + Description: "", + Responses: map[string]response{ + "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMEApplicationJSON: {}}}, + }, + }, + }, + }, + } + exp, err := json.Marshal(expected) + require.NoError(t, err) + require.Equal(t, string(exp), string(body)) +} + +func Test_OpenAPI_RawJSONFile(t *testing.T) { + app := fiber.New() + + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). + Name("listUsers").MediaType(fiber.MIMEApplicationJSON) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expected, err := os.ReadFile("testdata/openapi.json") + require.NoError(t, err) + + require.Equal(t, string(expected), string(body)) +} + +func Test_OpenAPI_OperationConfig(t *testing.T) { + app := fiber.New() + app.Get("/users", func(c fiber.Ctx) error { return c.JSON(fiber.Map{"hello": "world"}) }) + + app.Use(New(Config{ + Operations: map[string]Operation{ + "GET /users": { + OperationID: "listUsersCustom", + Summary: "List users", + Description: "Returns all users", + Tags: []string{"users"}, + Deprecated: true, + MediaType: fiber.MIMEApplicationJSON, + }, + }, + })) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + + op := spec.Paths["/users"]["get"] + require.Equal(t, "listUsersCustom", op.OperationID) + require.Equal(t, "List users", op.Summary) + require.Equal(t, "Returns all users", op.Description) + require.ElementsMatch(t, []string{"users"}, op.Tags) + require.True(t, op.Deprecated) + require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) +} + +func Test_OpenAPI_RouteMetadata(t *testing.T) { + app := fiber.New() + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). + Summary("List users").Description("User list").MediaType(fiber.MIMEApplicationJSON) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + + op := spec.Paths["/users"]["get"] + require.Equal(t, "List users", op.Summary) + require.Equal(t, "User list", op.Description) + require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) +} + +// getPaths is a helper that mounts the middleware, performs the request and +// decodes the resulting OpenAPI specification paths. +func getPaths(t *testing.T, app *fiber.App) map[string]map[string]any { + t.Helper() + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec struct { + Paths map[string]map[string]any `json:"paths"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + return spec.Paths +} + +func Test_OpenAPI_Methods(t *testing.T) { + handler := func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) } + + tests := []struct { + method string + register func(*fiber.App) + }{ + {fiber.MethodGet, func(a *fiber.App) { a.Get("/method", handler) }}, + {fiber.MethodPost, func(a *fiber.App) { a.Post("/method", handler) }}, + {fiber.MethodPut, func(a *fiber.App) { a.Put("/method", handler) }}, + {fiber.MethodPatch, func(a *fiber.App) { a.Patch("/method", handler) }}, + {fiber.MethodDelete, func(a *fiber.App) { a.Delete("/method", handler) }}, + {fiber.MethodHead, func(a *fiber.App) { a.Head("/method", handler) }}, + {fiber.MethodOptions, func(a *fiber.App) { a.Options("/method", handler) }}, + {fiber.MethodTrace, func(a *fiber.App) { a.Trace("/method", handler) }}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + app := fiber.New() + tt.register(app) + + paths := getPaths(t, app) + require.Contains(t, paths, "/method") + ops := paths["/method"] + require.Contains(t, ops, strings.ToLower(tt.method)) + }) + } +} + +func Test_OpenAPI_DifferentHandlers(t *testing.T) { + app := fiber.New() + + app.Get("/string", func(c fiber.Ctx) error { return c.SendString("a") }) + app.Get("/json", func(c fiber.Ctx) error { return c.JSON(fiber.Map{"hello": "world"}) }) + + paths := getPaths(t, app) + + require.Contains(t, paths, "/string") + require.Contains(t, paths["/string"], "get") + require.Contains(t, paths, "/json") + require.Contains(t, paths["/json"], "get") +} + +func Test_OpenAPI_Params(t *testing.T) { + app := fiber.New() + + app.Get("/users/:id", func(c fiber.Ctx) error { return c.SendString(c.Params("id")) }) + + paths := getPaths(t, app) + require.Contains(t, paths, "/users/{id}") + require.Contains(t, paths["/users/{id}"], "get") + op := paths["/users/{id}"]["get"].(map[string]any) + params := op["parameters"].([]any) + require.Len(t, params, 1) + p0 := params[0].(map[string]any) + require.Equal(t, "id", p0["name"]) + require.Equal(t, "path", p0["in"]) +} + +func Test_OpenAPI_Groups(t *testing.T) { + app := fiber.New() + + api := app.Group("/api") + api.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + api.Post("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusCreated) }) + + paths := getPaths(t, app) + + require.Contains(t, paths, "/api/users") + ops := paths["/api/users"] + require.Contains(t, ops, "get") + require.Contains(t, ops, "post") +} + +func Test_OpenAPI_Groups_Metadata(t *testing.T) { + app := fiber.New() + + api := app.Group("/api") + api.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). + Summary("List users").Description("Group users").MediaType(fiber.MIMEApplicationJSON) + + paths := getPaths(t, app) + + require.Contains(t, paths, "/api/users") + op := paths["/api/users"]["get"].(map[string]any) + require.Equal(t, "List users", op["summary"]) + require.Equal(t, "Group users", op["description"]) + resp := op["responses"].(map[string]any) + cont := resp["200"].(map[string]any)["content"].(map[string]any) + require.Contains(t, cont, fiber.MIMEApplicationJSON) +} + +func Test_OpenAPI_NoRoutes(t *testing.T) { + app := fiber.New() + + paths := getPaths(t, app) + + require.Len(t, paths, 1) + require.Contains(t, paths, "/") +} + +func Test_OpenAPI_RootOnly(t *testing.T) { + app := fiber.New() + + app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + paths := getPaths(t, app) + + require.Contains(t, paths, "/") + require.Contains(t, paths["/"], "get") +} + +func Test_OpenAPI_GroupMiddleware(t *testing.T) { + app := fiber.New() + + api := app.Group("/api/v2") + api.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + api.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/api/v2/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.Contains(t, spec.Paths, "/api/v2/users") +} diff --git a/middleware/openapi/testdata/openapi.json b/middleware/openapi/testdata/openapi.json new file mode 100644 index 00000000000..6299f22121b --- /dev/null +++ b/middleware/openapi/testdata/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.0.0","info":{"title":"Fiber API","version":"1.0.0"},"paths":{"/":{"delete":{"summary":"DELETE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"get":{"summary":"GET /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"head":{"summary":"HEAD /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"options":{"summary":"OPTIONS /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"patch":{"summary":"PATCH /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"post":{"summary":"POST /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"put":{"summary":"PUT /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"trace":{"summary":"TRACE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}}},"/users":{"get":{"operationId":"listUsers","summary":"GET /users","description":"","responses":{"200":{"description":"OK","content":{"application/json":{}}}}}}}} \ No newline at end of file diff --git a/router.go b/router.go index 6ef36ef093e..ace4157c51c 100644 --- a/router.go +++ b/router.go @@ -36,6 +36,14 @@ type Router interface { Route(path string) Register Name(name string) Router + // Summary sets a short summary for the most recently registered route. + Summary(sum string) Router + // Description sets a human-readable description for the most recently + // registered route. + Description(desc string) Router + // MediaType sets the media type returned by the most recently + // registered route. + MediaType(typ string) Router } // Route is a struct that holds all metadata for each registered handler. @@ -52,6 +60,9 @@ type Route struct { Path string `json:"path"` // Original registered route path Params []string `json:"params"` // Case-sensitive param keys Handlers []Handler `json:"-"` // Ctx handlers + Summary string `json:"summary"` + Description string `json:"description"` + MediaType string `json:"media_type"` routeParser routeParser // Parameter parser // Data for routing use bool // USE matches path prefixes @@ -373,11 +384,14 @@ func (*App) copyRoute(route *Route) *Route { routeParser: route.routeParser, // Public data - Path: route.Path, - Params: route.Params, - Name: route.Name, - Method: route.Method, - Handlers: route.Handlers, + Path: route.Path, + Params: route.Params, + Name: route.Name, + Method: route.Method, + Handlers: route.Handlers, + Summary: route.Summary, + Description: route.Description, + MediaType: route.MediaType, } } @@ -521,9 +535,12 @@ func (app *App) register(methods []string, pathRaw string, group *Group, handler Params: parsedRaw.params, group: group, - Path: pathRaw, - Method: method, - Handlers: handlers, + Path: pathRaw, + Method: method, + Handlers: handlers, + Summary: "", + Description: "", + MediaType: MIMETextPlain, } // Increment global handler count From 0cded2bd4c1c718cbbe9a6caebcfb957804e1514 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Fri, 22 Aug 2025 05:54:01 -0400 Subject: [PATCH 02/13] refactor: split route media types --- app.go | 14 +++++++--- docs/middleware/openapi.md | 13 +++++----- docs/whats_new.md | 2 +- group.go | 12 ++++++--- middleware/openapi/config.go | 8 +++--- middleware/openapi/openapi.go | 31 ++++++++++++++++++---- middleware/openapi/openapi_test.go | 33 +++++++++++++++++------- middleware/openapi/testdata/openapi.json | 2 +- router.go | 16 ++++++++---- 9 files changed, 95 insertions(+), 36 deletions(-) diff --git a/app.go b/app.go index a591fca81cb..fd0729bd467 100644 --- a/app.go +++ b/app.go @@ -722,10 +722,18 @@ func (app *App) Description(desc string) Router { return app } -// MediaType assigns a response media type to the most recently added route. -func (app *App) MediaType(typ string) Router { +// Consumes assigns a request media type to the most recently added route. +func (app *App) Consumes(typ string) Router { app.mutex.Lock() - app.latestRoute.MediaType = typ + app.latestRoute.Consumes = typ + app.mutex.Unlock() + return app +} + +// Produces assigns a response media type to the most recently added route. +func (app *App) Produces(typ string) Router { + app.mutex.Lock() + app.latestRoute.Produces = typ app.mutex.Unlock() return app } diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index 0c597b9403b..99bf8819076 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -43,19 +43,19 @@ app.Use(openapi.New(openapi.Config{ "GET /users": { Summary: "List users", Description: "Returns all users", - MediaType: fiber.MIMEApplicationJSON, + Produces: fiber.MIMEApplicationJSON, }, }, })) -// Routes may optionally document themselves using Summary, Description and MediaType +// Routes may optionally document themselves using Summary, Description, Produces and Consumes app.Get("/users", listUsers). Summary("List users"). Description("List all users"). - MediaType(fiber.MIMEApplicationJSON) + Produces(fiber.MIMEApplicationJSON) // If not specified, routes default to an empty summary and description and a -// "text/plain" response media type. +// "text/plain" request and response media type. ``` Each documented route automatically includes a `200` response with the description `OK` to satisfy the minimum OpenAPI requirements. @@ -92,12 +92,13 @@ var ConfigDefault = Config{ ```go type Operation struct { - OperationID string + Id string Summary string Description string Tags []string Deprecated bool - MediaType string + Consumes string + Produces string } ``` diff --git a/docs/whats_new.md b/docs/whats_new.md index 369800bbbcf..a59b0f51be7 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -1290,7 +1290,7 @@ Monitor middleware is migrated to the [Contrib package](https://github.com/gofib ### OpenAPI -Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions and return media types directly or configure them globally. +Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions and request/response media types directly or configure them globally. ### Proxy diff --git a/group.go b/group.go index 1f6942ee420..78d2978de53 100644 --- a/group.go +++ b/group.go @@ -57,9 +57,15 @@ func (grp *Group) Description(desc string) Router { return grp } -// MediaType assigns a response media type to the most recently added route in the group. -func (grp *Group) MediaType(typ string) Router { - grp.app.MediaType(typ) +// Consumes assigns a request media type to the most recently added route in the group. +func (grp *Group) Consumes(typ string) Router { + grp.app.Consumes(typ) + return grp +} + +// Produces assigns a response media type to the most recently added route in the group. +func (grp *Group) Produces(typ string) Router { + grp.app.Produces(typ) return grp } diff --git a/middleware/openapi/config.go b/middleware/openapi/config.go index 8fefe8ad3b1..2b137d1aa78 100644 --- a/middleware/openapi/config.go +++ b/middleware/openapi/config.go @@ -88,11 +88,13 @@ func configDefault(config ...Config) Config { // Operation configures metadata for a single route in the generated spec. type Operation struct { - OperationID string + Id string Summary string Description string Tags []string Deprecated bool - // MediaType defines the media type for the 200 response. - MediaType string + // Consumes defines the request media type. + Consumes string + // Produces defines the response media type. + Produces string } diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index da4d9bbf77c..e8fb4835110 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -64,6 +64,7 @@ type operation struct { Tags []string `json:"tags,omitempty"` Deprecated bool `json:"deprecated,omitempty"` Parameters []parameter `json:"parameters,omitempty"` + RequestBody *requestBody `json:"requestBody,omitempty"` Responses map[string]response `json:"responses"` } @@ -79,6 +80,10 @@ type parameter struct { Schema map[string]string `json:"schema"` } +type requestBody struct { + Content map[string]map[string]any `json:"content"` +} + func generateSpec(app *fiber.App, cfg Config) openAPISpec { paths := make(map[string]map[string]operation) stack := app.Stack() @@ -103,7 +108,7 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { } } - method := utils.ToLower(r.Method) + methodLower := utils.ToLower(r.Method) if paths[path] == nil { paths[path] = make(map[string]operation) } @@ -123,9 +128,9 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { description = r.Description } - respType := meta.MediaType + respType := meta.Produces if respType == "" { - respType = r.MediaType + respType = r.Produces } resp := response{Description: "OK"} if respType != "" { @@ -134,17 +139,33 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { } } - opID := meta.OperationID + reqType := meta.Consumes + if reqType == "" { + reqType = r.Consumes + } + var reqBody *requestBody + includeBody := false + if reqType != "" { + if meta.Consumes != "" || r.Consumes != fiber.MIMETextPlain || (r.Method != fiber.MethodGet && r.Method != fiber.MethodHead && r.Method != fiber.MethodOptions && r.Method != fiber.MethodTrace) { + includeBody = true + } + } + if includeBody { + reqBody = &requestBody{Content: map[string]map[string]any{reqType: {}}} + } + + opID := meta.Id if opID == "" { opID = r.Name } - paths[path][method] = operation{ + paths[path][methodLower] = operation{ OperationID: opID, Summary: summary, Description: description, Tags: meta.Tags, Deprecated: meta.Deprecated, Parameters: params, + RequestBody: reqBody, Responses: map[string]response{ "200": resp, }, diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index a0d79826819..9a31dbdac91 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -43,7 +43,7 @@ func Test_OpenAPI_JSONEquality(t *testing.T) { app := fiber.New() app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Name("listUsers").MediaType(fiber.MIMEApplicationJSON) + Name("listUsers").Produces(fiber.MIMEApplicationJSON) app.Use(New()) @@ -62,13 +62,19 @@ func Test_OpenAPI_JSONEquality(t *testing.T) { } lower := strings.ToLower(m) upper := strings.ToUpper(m) - rootOps[lower] = operation{ + op := operation{ Summary: upper + " /", Description: "", Responses: map[string]response{ "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMETextPlain: {}}}, }, } + switch m { + case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace: + default: + op.RequestBody = &requestBody{Content: map[string]map[string]any{fiber.MIMETextPlain: {}}} + } + rootOps[lower] = op } expected := openAPISpec{ OpenAPI: "3.0.0", @@ -96,7 +102,7 @@ func Test_OpenAPI_RawJSON(t *testing.T) { app := fiber.New() app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Name("listUsers").MediaType(fiber.MIMEApplicationJSON) + Name("listUsers").Produces(fiber.MIMEApplicationJSON) app.Use(New()) @@ -115,13 +121,19 @@ func Test_OpenAPI_RawJSON(t *testing.T) { } lower := strings.ToLower(m) upper := strings.ToUpper(m) - rootOps[lower] = operation{ + op := operation{ Summary: upper + " /", Description: "", Responses: map[string]response{ "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMETextPlain: {}}}, }, } + switch m { + case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace: + default: + op.RequestBody = &requestBody{Content: map[string]map[string]any{fiber.MIMETextPlain: {}}} + } + rootOps[lower] = op } expected := openAPISpec{ OpenAPI: "3.0.0", @@ -149,7 +161,7 @@ func Test_OpenAPI_RawJSONFile(t *testing.T) { app := fiber.New() app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Name("listUsers").MediaType(fiber.MIMEApplicationJSON) + Name("listUsers").Produces(fiber.MIMEApplicationJSON) app.Use(New()) @@ -174,12 +186,13 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { app.Use(New(Config{ Operations: map[string]Operation{ "GET /users": { - OperationID: "listUsersCustom", + Id: "listUsersCustom", Summary: "List users", Description: "Returns all users", Tags: []string{"users"}, Deprecated: true, - MediaType: fiber.MIMEApplicationJSON, + Consumes: fiber.MIMEApplicationJSON, + Produces: fiber.MIMEApplicationJSON, }, }, })) @@ -199,12 +212,14 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { require.ElementsMatch(t, []string{"users"}, op.Tags) require.True(t, op.Deprecated) require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) + require.NotNil(t, op.RequestBody) + require.Contains(t, op.RequestBody.Content, fiber.MIMEApplicationJSON) } func Test_OpenAPI_RouteMetadata(t *testing.T) { app := fiber.New() app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Summary("List users").Description("User list").MediaType(fiber.MIMEApplicationJSON) + Summary("List users").Description("User list").Produces(fiber.MIMEApplicationJSON) app.Use(New()) @@ -321,7 +336,7 @@ func Test_OpenAPI_Groups_Metadata(t *testing.T) { api := app.Group("/api") api.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Summary("List users").Description("Group users").MediaType(fiber.MIMEApplicationJSON) + Summary("List users").Description("Group users").Produces(fiber.MIMEApplicationJSON) paths := getPaths(t, app) diff --git a/middleware/openapi/testdata/openapi.json b/middleware/openapi/testdata/openapi.json index 6299f22121b..6a549b76ea5 100644 --- a/middleware/openapi/testdata/openapi.json +++ b/middleware/openapi/testdata/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.0","info":{"title":"Fiber API","version":"1.0.0"},"paths":{"/":{"delete":{"summary":"DELETE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"get":{"summary":"GET /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"head":{"summary":"HEAD /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"options":{"summary":"OPTIONS /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"patch":{"summary":"PATCH /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"post":{"summary":"POST /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"put":{"summary":"PUT /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"trace":{"summary":"TRACE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}}},"/users":{"get":{"operationId":"listUsers","summary":"GET /users","description":"","responses":{"200":{"description":"OK","content":{"application/json":{}}}}}}}} \ No newline at end of file +{"openapi":"3.0.0","info":{"title":"Fiber API","version":"1.0.0"},"paths":{"/":{"delete":{"summary":"DELETE /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"get":{"summary":"GET /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"head":{"summary":"HEAD /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"options":{"summary":"OPTIONS /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"patch":{"summary":"PATCH /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"post":{"summary":"POST /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"put":{"summary":"PUT /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"trace":{"summary":"TRACE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}}},"/users":{"get":{"operationId":"listUsers","summary":"GET /users","description":"","responses":{"200":{"description":"OK","content":{"application/json":{}}}}}}}} \ No newline at end of file diff --git a/router.go b/router.go index ace4157c51c..962ddbd0e3a 100644 --- a/router.go +++ b/router.go @@ -41,9 +41,12 @@ type Router interface { // Description sets a human-readable description for the most recently // registered route. Description(desc string) Router - // MediaType sets the media type returned by the most recently + // Consumes sets the request media type for the most recently // registered route. - MediaType(typ string) Router + Consumes(typ string) Router + // Produces sets the response media type for the most recently + // registered route. + Produces(typ string) Router } // Route is a struct that holds all metadata for each registered handler. @@ -62,7 +65,8 @@ type Route struct { Handlers []Handler `json:"-"` // Ctx handlers Summary string `json:"summary"` Description string `json:"description"` - MediaType string `json:"media_type"` + Consumes string `json:"consumes"` + Produces string `json:"produces"` routeParser routeParser // Parameter parser // Data for routing use bool // USE matches path prefixes @@ -391,7 +395,8 @@ func (*App) copyRoute(route *Route) *Route { Handlers: route.Handlers, Summary: route.Summary, Description: route.Description, - MediaType: route.MediaType, + Consumes: route.Consumes, + Produces: route.Produces, } } @@ -540,7 +545,8 @@ func (app *App) register(methods []string, pathRaw string, group *Group, handler Handlers: handlers, Summary: "", Description: "", - MediaType: MIMETextPlain, + Consumes: MIMETextPlain, + Produces: MIMETextPlain, } // Increment global handler count From 886ed13657d1ac3b726c10b0c720144acb3fe665 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Fri, 22 Aug 2025 13:07:24 -0400 Subject: [PATCH 03/13] feat: add route tags and deprecation --- app.go | 27 +++++++++++++++++++++++++++ docs/middleware/openapi.md | 10 +++++++--- group.go | 12 ++++++++++++ middleware/openapi/openapi.go | 12 ++++++++++-- middleware/openapi/openapi_test.go | 10 ++++++++-- router.go | 8 ++++++++ router_test.go | 15 +++++++++++++++ 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/app.go b/app.go index fd0729bd467..8e1a636f6b4 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" "io" + "mime" "net" "net/http" "net/http/httputil" @@ -724,6 +725,11 @@ func (app *App) Description(desc string) Router { // Consumes assigns a request media type to the most recently added route. func (app *App) Consumes(typ string) Router { + if typ != "" { + if _, _, err := mime.ParseMediaType(typ); err != nil || !strings.Contains(typ, "/") { + panic("invalid media type: " + typ) + } + } app.mutex.Lock() app.latestRoute.Consumes = typ app.mutex.Unlock() @@ -732,12 +738,33 @@ func (app *App) Consumes(typ string) Router { // Produces assigns a response media type to the most recently added route. func (app *App) Produces(typ string) Router { + if typ != "" { + if _, _, err := mime.ParseMediaType(typ); err != nil || !strings.Contains(typ, "/") { + panic("invalid media type: " + typ) + } + } app.mutex.Lock() app.latestRoute.Produces = typ app.mutex.Unlock() return app } +// Tags assigns tags to the most recently added route. +func (app *App) Tags(tags ...string) Router { + app.mutex.Lock() + app.latestRoute.Tags = tags + app.mutex.Unlock() + return app +} + +// Deprecated marks the most recently added route as deprecated. +func (app *App) Deprecated() Router { + app.mutex.Lock() + app.latestRoute.Deprecated = true + app.mutex.Unlock() + return app +} + // GetRoute Get route by name func (app *App) GetRoute(name string) Route { for _, routes := range app.stack { diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index 99bf8819076..87fe1dd1c11 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -48,14 +48,18 @@ app.Use(openapi.New(openapi.Config{ }, })) -// Routes may optionally document themselves using Summary, Description, Produces and Consumes +// Routes may optionally document themselves using Summary, Description, +// Tags, Deprecated, Produces and Consumes app.Get("/users", listUsers). Summary("List users"). Description("List all users"). + Tags("users", "admin"). + Deprecated(). Produces(fiber.MIMEApplicationJSON) -// If not specified, routes default to an empty summary and description and a -// "text/plain" request and response media type. +// If not specified, routes default to an empty summary and description, no tags, +// not deprecated, and a "text/plain" request and response media type. +// Consumes and Produces will panic if provided an invalid media type. ``` Each documented route automatically includes a `200` response with the description `OK` to satisfy the minimum OpenAPI requirements. diff --git a/group.go b/group.go index 78d2978de53..7cf7c2e59c9 100644 --- a/group.go +++ b/group.go @@ -69,6 +69,18 @@ func (grp *Group) Produces(typ string) Router { return grp } +// Tags assigns tags to the most recently added route in the group. +func (grp *Group) Tags(tags ...string) Router { + grp.app.Tags(tags...) + return grp +} + +// Deprecated marks the most recently added route in the group as deprecated. +func (grp *Group) Deprecated() Router { + grp.app.Deprecated() + return grp +} + // Use registers a middleware route that will match requests // with the provided prefix (which is optional and defaults to "/"). // Also, you can pass another app instance as a sub-router along a routing path. diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index e8fb4835110..59ab8509f9d 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -158,12 +158,20 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { if opID == "" { opID = r.Name } + + tags := meta.Tags + if len(tags) == 0 { + tags = r.Tags + } + + deprecated := meta.Deprecated || r.Deprecated + paths[path][methodLower] = operation{ OperationID: opID, Summary: summary, Description: description, - Tags: meta.Tags, - Deprecated: meta.Deprecated, + Tags: tags, + Deprecated: deprecated, Parameters: params, RequestBody: reqBody, Responses: map[string]response{ diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index 9a31dbdac91..b616a717b84 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -219,7 +219,8 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { func Test_OpenAPI_RouteMetadata(t *testing.T) { app := fiber.New() app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Summary("List users").Description("User list").Produces(fiber.MIMEApplicationJSON) + Summary("List users").Description("User list").Produces(fiber.MIMEApplicationJSON). + Tags("users", "read").Deprecated() app.Use(New()) @@ -235,6 +236,8 @@ func Test_OpenAPI_RouteMetadata(t *testing.T) { require.Equal(t, "List users", op.Summary) require.Equal(t, "User list", op.Description) require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) + require.ElementsMatch(t, []string{"users", "read"}, op.Tags) + require.True(t, op.Deprecated) } // getPaths is a helper that mounts the middleware, performs the request and @@ -336,7 +339,8 @@ func Test_OpenAPI_Groups_Metadata(t *testing.T) { api := app.Group("/api") api.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). - Summary("List users").Description("Group users").Produces(fiber.MIMEApplicationJSON) + Summary("List users").Description("Group users").Produces(fiber.MIMEApplicationJSON). + Tags("users").Deprecated() paths := getPaths(t, app) @@ -344,6 +348,8 @@ func Test_OpenAPI_Groups_Metadata(t *testing.T) { op := paths["/api/users"]["get"].(map[string]any) require.Equal(t, "List users", op["summary"]) require.Equal(t, "Group users", op["description"]) + require.ElementsMatch(t, []any{"users"}, op["tags"].([]any)) + require.Equal(t, true, op["deprecated"]) resp := op["responses"].(map[string]any) cont := resp["200"].(map[string]any)["content"].(map[string]any) require.Contains(t, cont, fiber.MIMEApplicationJSON) diff --git a/router.go b/router.go index 962ddbd0e3a..36c53d951f4 100644 --- a/router.go +++ b/router.go @@ -47,6 +47,10 @@ type Router interface { // Produces sets the response media type for the most recently // registered route. Produces(typ string) Router + // Tags sets the tags for the most recently registered route. + Tags(tags ...string) Router + // Deprecated marks the most recently registered route as deprecated. + Deprecated() Router } // Route is a struct that holds all metadata for each registered handler. @@ -67,6 +71,8 @@ type Route struct { Description string `json:"description"` Consumes string `json:"consumes"` Produces string `json:"produces"` + Tags []string `json:"tags"` + Deprecated bool `json:"deprecated"` routeParser routeParser // Parameter parser // Data for routing use bool // USE matches path prefixes @@ -397,6 +403,8 @@ func (*App) copyRoute(route *Route) *Route { Description: route.Description, Consumes: route.Consumes, Produces: route.Produces, + Tags: route.Tags, + Deprecated: route.Deprecated, } } diff --git a/router_test.go b/router_test.go index 74591563ddd..8c5d8ef01be 100644 --- a/router_test.go +++ b/router_test.go @@ -1421,3 +1421,18 @@ func Test_AddRoute_MergeHandlers(t *testing.T) { require.Len(t, app.stack[app.methodInt(MethodGet)], 1) require.Len(t, app.stack[app.methodInt(MethodGet)][0].Handlers, 2) } + +func Test_Route_InvalidMediaType(t *testing.T) { + t.Run("produces", func(t *testing.T) { + app := New() + require.Panics(t, func() { + app.Get("/", testEmptyHandler).Produces("invalid") + }) + }) + t.Run("consumes", func(t *testing.T) { + app := New() + require.Panics(t, func() { + app.Get("/", testEmptyHandler).Consumes("invalid") + }) + }) +} From 60d459670df2d0bb7d929cb9b4e6cb49d116c576 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:56:34 +0200 Subject: [PATCH 04/13] test: expand openapi middleware coverage --- middleware/openapi/openapi_test.go | 122 +++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index b616a717b84..11e04f20f20 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -391,3 +391,125 @@ func Test_OpenAPI_GroupMiddleware(t *testing.T) { require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) require.Contains(t, spec.Paths, "/api/v2/users") } + +func Test_OpenAPI_ConfigValues(t *testing.T) { + app := fiber.New() + + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + cfg := Config{ + Title: "Custom API", + Version: "2.1.0", + Description: "My description", + ServerURL: "https://example.com", + Path: "/spec.json", + } + app.Use(New(cfg)) + + req := httptest.NewRequest(fiber.MethodGet, "/spec.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.Equal(t, cfg.Title, spec.Info.Title) + require.Equal(t, cfg.Version, spec.Info.Version) + require.Equal(t, cfg.Description, spec.Info.Description) + require.Len(t, spec.Servers, 1) + require.Equal(t, cfg.ServerURL, spec.Servers[0].URL) +} + +func Test_OpenAPI_Next(t *testing.T) { + app := fiber.New() + + app.Use(New(Config{Next: func(fiber.Ctx) bool { return true }})) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusNotFound, resp.StatusCode) +} + +func Test_OpenAPI_ConnectIgnored(t *testing.T) { + app := fiber.New() + + app.Connect("/conn", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + paths := getPaths(t, app) + require.NotContains(t, paths, "/conn") +} + +func Test_OpenAPI_MultipleParams(t *testing.T) { + app := fiber.New() + + app.Get("/users/:uid/books/:bid", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + paths := getPaths(t, app) + require.Contains(t, paths, "/users/{uid}/books/{bid}") + op := paths["/users/{uid}/books/{bid}"]["get"].(map[string]any) + params := op["parameters"].([]any) + require.Len(t, params, 2) + p0 := params[0].(map[string]any) + p1 := params[1].(map[string]any) + require.Equal(t, "uid", p0["name"]) + require.Equal(t, "path", p0["in"]) + require.Equal(t, "bid", p1["name"]) + require.Equal(t, "path", p1["in"]) +} + +func Test_OpenAPI_ConsumesProduces(t *testing.T) { + app := fiber.New() + + app.Post("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusCreated) }). + Consumes(fiber.MIMEApplicationJSON). + Produces(fiber.MIMEApplicationXML) + + paths := getPaths(t, app) + + op := paths["/users"]["post"].(map[string]any) + rb := op["requestBody"].(map[string]any) + reqContent := rb["content"].(map[string]any) + require.Contains(t, reqContent, fiber.MIMEApplicationJSON) + + resp := op["responses"].(map[string]any)["200"].(map[string]any) + cont := resp["content"].(map[string]any) + require.Contains(t, cont, fiber.MIMEApplicationXML) +} + +func Test_OpenAPI_NoRequestBodyForGET(t *testing.T) { + app := fiber.New() + + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + paths := getPaths(t, app) + op := paths["/users"]["get"].(map[string]any) + require.NotContains(t, op, "requestBody") +} + +func Test_OpenAPI_Cache(t *testing.T) { + app := fiber.New() + + app.Get("/first", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.Contains(t, spec.Paths, "/first") + + app.Get("/second", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) + + req = httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err = app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.NotContains(t, spec.Paths, "/second") +} From 149c053b36b1026996b5cb7da6a043f08a4fe210 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:04:23 +0200 Subject: [PATCH 05/13] test: cover openapi helpers --- group_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ router_test.go | 16 +++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 group_test.go diff --git a/group_test.go b/group_test.go new file mode 100644 index 00000000000..5a49aefd639 --- /dev/null +++ b/group_test.go @@ -0,0 +1,65 @@ +package fiber + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Group_OpenAPI_Helpers(t *testing.T) { + t.Parallel() + + t.Run("Summary", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Summary("sum") + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, "sum", route.Summary) + }) + + t.Run("Description", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Description("desc") + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, "desc", route.Description) + }) + + t.Run("Consumes", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Consumes(MIMEApplicationJSON) + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, MIMEApplicationJSON, route.Consumes) + }) + + t.Run("Produces", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Produces(MIMEApplicationXML) + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, MIMEApplicationXML, route.Produces) + }) + + t.Run("Tags", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Tags("foo", "bar") + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, []string{"foo", "bar"}, route.Tags) + }) + + t.Run("Deprecated", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Deprecated() + route := app.stack[app.methodInt(MethodGet)][0] + require.True(t, route.Deprecated) + }) +} diff --git a/router_test.go b/router_test.go index 8c5d8ef01be..490eca9d565 100644 --- a/router_test.go +++ b/router_test.go @@ -1436,3 +1436,19 @@ func Test_Route_InvalidMediaType(t *testing.T) { }) }) } + +func Test_App_Produces(t *testing.T) { + t.Parallel() + app := New() + app.Get("/", testEmptyHandler).Produces(MIMEApplicationJSON) + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, MIMEApplicationJSON, route.Produces) +} + +func Test_App_Deprecated(t *testing.T) { + t.Parallel() + app := New() + app.Get("/", testEmptyHandler).Deprecated() + route := app.stack[app.methodInt(MethodGet)][0] + require.True(t, route.Deprecated) +} From ddbaa0f276f0e4fe58ce609d7ff0ebe71e3e7ec3 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Thu, 18 Sep 2025 21:30:28 -0400 Subject: [PATCH 06/13] feat(openapi): add operation helpers --- app.go | 136 +++++++++++++++ docs/middleware/openapi.md | 40 ++++- docs/whats_new.md | 2 +- group.go | 18 ++ group_test.go | 32 ++++ middleware/openapi/config.go | 33 ++++ middleware/openapi/openapi.go | 261 ++++++++++++++++++++++++++--- middleware/openapi/openapi_test.go | 66 +++++++- router.go | 109 ++++++++++-- router_test.go | 97 +++++++++++ 10 files changed, 749 insertions(+), 45 deletions(-) diff --git a/app.go b/app.go index 8e1a636f6b4..60bb514ce65 100644 --- a/app.go +++ b/app.go @@ -749,6 +749,142 @@ func (app *App) Produces(typ string) Router { return app } +// RequestBody documents the request payload for the most recently added route. +func (app *App) RequestBody(description string, required bool, mediaTypes ...string) Router { + sanitized := sanitizeMediaTypes(mediaTypes, true) + + app.mutex.Lock() + app.latestRoute.RequestBody = &RouteRequestBody{ + Description: description, + Required: required, + MediaTypes: append([]string(nil), sanitized...), + } + if len(sanitized) > 0 { + app.latestRoute.Consumes = sanitized[0] + } + app.mutex.Unlock() + + return app +} + +// Parameter documents an input parameter for the most recently added route. +func (app *App) Parameter(name, in string, required bool, schema map[string]any, description string) Router { + if strings.TrimSpace(name) == "" { + panic("parameter name is required") + } + + location := strings.ToLower(strings.TrimSpace(in)) + switch location { + case "path", "query", "header", "cookie": + default: + panic("invalid parameter location: " + in) + } + + if schema == nil { + schema = map[string]any{"type": "string"} + } + + schemaCopy := make(map[string]any, len(schema)) + for k, v := range schema { + schemaCopy[k] = v + } + if _, ok := schemaCopy["type"]; !ok { + schemaCopy["type"] = "string" + } + + if location == "path" { + required = true + } + + param := RouteParameter{ + Name: name, + In: location, + Required: required, + Description: description, + Schema: schemaCopy, + } + + app.mutex.Lock() + app.latestRoute.Parameters = append(app.latestRoute.Parameters, param) + app.mutex.Unlock() + + return app +} + +// Response documents an HTTP response for the most recently added route. +func (app *App) Response(status int, description string, mediaTypes ...string) Router { + if status != 0 && (status < 100 || status > 599) { + panic("invalid status code") + } + + sanitized := sanitizeMediaTypes(mediaTypes, false) + + if description == "" { + if status == 0 { + description = "Default response" + } else if text := http.StatusText(status); text != "" { + description = text + } else { + description = "Status " + strconv.Itoa(status) + } + } + + key := "default" + if status > 0 { + key = strconv.Itoa(status) + } + + resp := RouteResponse{Description: description} + if len(sanitized) > 0 { + resp.MediaTypes = append([]string(nil), sanitized...) + } + + app.mutex.Lock() + if app.latestRoute.Responses == nil { + app.latestRoute.Responses = make(map[string]RouteResponse) + } + app.latestRoute.Responses[key] = resp + if status == StatusOK && len(resp.MediaTypes) > 0 { + app.latestRoute.Produces = resp.MediaTypes[0] + } + app.mutex.Unlock() + + return app +} + +func sanitizeMediaTypes(mediaTypes []string, require bool) []string { + if len(mediaTypes) == 0 { + if require { + panic("at least one media type must be provided") + } + return nil + } + + seen := make(map[string]struct{}, len(mediaTypes)) + sanitized := make([]string, 0, len(mediaTypes)) + for _, typ := range mediaTypes { + trimmed := strings.TrimSpace(typ) + if trimmed == "" { + continue + } + if _, _, err := mime.ParseMediaType(trimmed); err != nil || !strings.Contains(trimmed, "/") { + panic("invalid media type: " + typ) + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + sanitized = append(sanitized, trimmed) + } + if require && len(sanitized) == 0 { + panic("at least one media type must be provided") + } + if len(sanitized) == 0 { + return nil + } + return sanitized +} + // Tags assigns tags to the most recently added route. func (app *App) Tags(tags ...string) Router { app.mutex.Lock() diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index 87fe1dd1c11..ea5152c232c 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -49,12 +49,14 @@ app.Use(openapi.New(openapi.Config{ })) // Routes may optionally document themselves using Summary, Description, -// Tags, Deprecated, Produces and Consumes -app.Get("/users", listUsers). - Summary("List users"). - Description("List all users"). +// RequestBody, Parameter, Response, Tags, Deprecated, Produces and Consumes. +app.Post("/users", createUser). + Summary("Create user"). + Description("Creates a new user"). + RequestBody("User payload", true, fiber.MIMEApplicationJSON). + Parameter("trace-id", "header", true, nil, "Tracing identifier"). + Response(fiber.StatusCreated, "Created", fiber.MIMEApplicationJSON). Tags("users", "admin"). - Deprecated(). Produces(fiber.MIMEApplicationJSON) // If not specified, routes default to an empty summary and description, no tags, @@ -62,7 +64,7 @@ app.Get("/users", listUsers). // Consumes and Produces will panic if provided an invalid media type. ``` -Each documented route automatically includes a `200` response with the description `OK` to satisfy the minimum OpenAPI requirements. +Each documented route automatically includes a `200` response with the description `OK` to satisfy the minimum OpenAPI requirements. Additional responses can be declared via the `Response` helper or the middleware configuration. `CONNECT` routes are ignored because the OpenAPI specification does not define a `connect` operation. @@ -103,6 +105,32 @@ type Operation struct { Deprecated bool Consumes string Produces string + Parameters []Parameter + RequestBody *RequestBody + Responses map[string]Response +} + +type Parameter struct { + Name string + In string + Description string + Required bool + Schema map[string]any +} + +type Media struct { + Schema map[string]any +} + +type Response struct { + Description string + Content map[string]Media +} + +type RequestBody struct { + Description string + Required bool + Content map[string]Media } ``` diff --git a/docs/whats_new.md b/docs/whats_new.md index a59b0f51be7..c9251ff52ac 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -1290,7 +1290,7 @@ Monitor middleware is migrated to the [Contrib package](https://github.com/gofib ### OpenAPI -Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions and request/response media types directly or configure them globally. +Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions, parameters, request bodies, and custom responses—alongside request/response media types—directly or configure them globally. ### Proxy diff --git a/group.go b/group.go index 7cf7c2e59c9..6bb1f172e47 100644 --- a/group.go +++ b/group.go @@ -69,6 +69,24 @@ func (grp *Group) Produces(typ string) Router { return grp } +// RequestBody documents the request payload for the most recently added route in the group. +func (grp *Group) RequestBody(description string, required bool, mediaTypes ...string) Router { + grp.app.RequestBody(description, required, mediaTypes...) + return grp +} + +// Parameter documents an input parameter for the most recently added route in the group. +func (grp *Group) Parameter(name, in string, required bool, schema map[string]any, description string) Router { + grp.app.Parameter(name, in, required, schema, description) + return grp +} + +// Response documents an HTTP response for the most recently added route in the group. +func (grp *Group) Response(status int, description string, mediaTypes ...string) Router { + grp.app.Response(status, description, mediaTypes...) + return grp +} + // Tags assigns tags to the most recently added route in the group. func (grp *Group) Tags(tags ...string) Router { grp.app.Tags(tags...) diff --git a/group_test.go b/group_test.go index 5a49aefd639..4b58c7bcf87 100644 --- a/group_test.go +++ b/group_test.go @@ -45,6 +45,38 @@ func Test_Group_OpenAPI_Helpers(t *testing.T) { require.Equal(t, MIMEApplicationXML, route.Produces) }) + t.Run("RequestBody", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Post("/users", testEmptyHandler).RequestBody("User", true, MIMEApplicationJSON) + route := app.stack[app.methodInt(MethodPost)][0] + require.NotNil(t, route.RequestBody) + require.Equal(t, []string{MIMEApplicationJSON}, route.RequestBody.MediaTypes) + }) + + t.Run("Parameter", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users/:id", testEmptyHandler).Parameter("id", "path", false, map[string]any{"type": "integer"}, "identifier") + route := app.stack[app.methodInt(MethodGet)][0] + require.Len(t, route.Parameters, 1) + require.Equal(t, "id", route.Parameters[0].Name) + require.True(t, route.Parameters[0].Required) + require.Equal(t, "integer", route.Parameters[0].Schema["type"]) + }) + + t.Run("Response", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler).Response(StatusCreated, "Created", MIMEApplicationJSON) + route := app.stack[app.methodInt(MethodGet)][0] + require.Contains(t, route.Responses, "201") + require.Equal(t, []string{MIMEApplicationJSON}, route.Responses["201"].MediaTypes) + }) + t.Run("Tags", func(t *testing.T) { t.Parallel() app := New() diff --git a/middleware/openapi/config.go b/middleware/openapi/config.go index 2b137d1aa78..ecf5b813ef6 100644 --- a/middleware/openapi/config.go +++ b/middleware/openapi/config.go @@ -97,4 +97,37 @@ type Operation struct { Consumes string // Produces defines the response media type. Produces string + // Parameters augments the generated parameter list. + Parameters []Parameter + // RequestBody overrides or augments the generated request body. + RequestBody *RequestBody + // Responses augments the generated responses by status code (e.g. "201"). + Responses map[string]Response +} + +// Parameter describes a single OpenAPI parameter. +type Parameter struct { + Name string + In string + Description string + Required bool + Schema map[string]any +} + +// Media describes the schema payload for a request or response media type. +type Media struct { + Schema map[string]any +} + +// Response describes an OpenAPI response object. +type Response struct { + Description string + Content map[string]Media +} + +// RequestBody describes the request body configuration for an operation. +type RequestBody struct { + Description string + Required bool + Content map[string]Media } diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index 59ab8509f9d..a9e1851bb43 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -74,14 +74,17 @@ type response struct { } type parameter struct { - Name string `json:"name"` - In string `json:"in"` - Required bool `json:"required"` - Schema map[string]string `json:"schema"` + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Description string `json:"description,omitempty"` + Schema map[string]any `json:"schema,omitempty"` } type requestBody struct { - Content map[string]map[string]any `json:"content"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Content map[string]map[string]any `json:"content"` } func generateSpec(app *fiber.App, cfg Config) openAPISpec { @@ -95,16 +98,19 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { } path := r.Path - var params []parameter + params := make([]parameter, 0, len(r.Params)) + paramIndex := make(map[string]int, len(r.Params)) if len(r.Params) > 0 { for _, p := range r.Params { path = strings.Replace(path, ":"+p, "{"+p+"}", 1) - params = append(params, parameter{ + param := parameter{ Name: p, In: "path", Required: true, - Schema: map[string]string{"type": "string"}, - }) + Schema: map[string]any{"type": "string"}, + } + params = append(params, param) + paramIndex[param.In+":"+param.Name] = len(params) - 1 } } @@ -116,6 +122,9 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { key := r.Method + " " + r.Path meta := cfg.Operations[key] + params = mergeRouteParameters(params, paramIndex, r.Parameters) + params = mergeConfigParameters(params, paramIndex, meta.Parameters) + summary := meta.Summary if summary == "" { summary = r.Summary @@ -132,26 +141,31 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { if respType == "" { respType = r.Produces } - resp := response{Description: "OK"} - if respType != "" { - resp.Content = map[string]map[string]any{ + + responses := mergeResponses(r.Responses, meta.Responses) + if responses == nil { + responses = make(map[string]response) + } + defaultResp, exists := responses["200"] + if defaultResp.Description == "" { + defaultResp.Description = "OK" + } + if !exists && respType != "" { + defaultResp.Content = map[string]map[string]any{ respType: {}, } } + responses["200"] = defaultResp - reqType := meta.Consumes - if reqType == "" { - reqType = r.Consumes - } - var reqBody *requestBody - includeBody := false - if reqType != "" { - if meta.Consumes != "" || r.Consumes != fiber.MIMETextPlain || (r.Method != fiber.MethodGet && r.Method != fiber.MethodHead && r.Method != fiber.MethodOptions && r.Method != fiber.MethodTrace) { - includeBody = true + reqBody := buildRequestBody(r.RequestBody, meta.RequestBody) + if reqBody == nil { + reqType := meta.Consumes + if reqType == "" { + reqType = r.Consumes + } + if shouldIncludeRequestBody(reqType, meta, r) { + reqBody = &requestBody{Content: map[string]map[string]any{reqType: {}}} } - } - if includeBody { - reqBody = &requestBody{Content: map[string]map[string]any{reqType: {}}} } opID := meta.Id @@ -174,9 +188,7 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { Deprecated: deprecated, Parameters: params, RequestBody: reqBody, - Responses: map[string]response{ - "200": resp, - }, + Responses: responses, } } } @@ -195,3 +207,198 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { } return spec } + +func mergeRouteParameters(params []parameter, index map[string]int, extras []fiber.RouteParameter) []parameter { + if len(extras) == 0 { + return params + } + for _, extra := range extras { + if strings.TrimSpace(extra.Name) == "" { + continue + } + location := strings.ToLower(strings.TrimSpace(extra.In)) + if location == "" { + location = "query" + } + param := parameter{ + Name: extra.Name, + In: location, + Description: extra.Description, + Required: extra.Required, + Schema: copyAnyMap(extra.Schema), + } + if param.Schema == nil { + param.Schema = map[string]any{"type": "string"} + } + if param.In == "path" { + param.Required = true + } + params = appendOrReplaceParameter(params, index, param) + } + return params +} + +func mergeConfigParameters(params []parameter, index map[string]int, extras []Parameter) []parameter { + if len(extras) == 0 { + return params + } + for _, extra := range extras { + if strings.TrimSpace(extra.Name) == "" { + continue + } + location := strings.ToLower(strings.TrimSpace(extra.In)) + if location == "" { + location = "query" + } + param := parameter{ + Name: extra.Name, + In: location, + Description: extra.Description, + Required: extra.Required, + Schema: copyAnyMap(extra.Schema), + } + if param.Schema == nil { + param.Schema = map[string]any{"type": "string"} + } + if param.In == "path" { + param.Required = true + } + params = appendOrReplaceParameter(params, index, param) + } + return params +} + +func appendOrReplaceParameter(params []parameter, index map[string]int, p parameter) []parameter { + if p.Name == "" || p.In == "" { + return params + } + key := p.In + ":" + p.Name + if idx, ok := index[key]; ok { + params[idx] = p + return params + } + index[key] = len(params) + return append(params, p) +} + +func copyAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func mergeResponses(routeResponses map[string]fiber.RouteResponse, cfgResponses map[string]Response) map[string]response { + var merged map[string]response + if len(routeResponses) > 0 { + merged = make(map[string]response, len(routeResponses)) + for code, resp := range routeResponses { + merged[code] = response{ + Description: resp.Description, + Content: mediaTypesToContent(resp.MediaTypes), + } + } + } + if len(cfgResponses) > 0 { + if merged == nil { + merged = make(map[string]response, len(cfgResponses)) + } + for code, resp := range cfgResponses { + merged[code] = response{ + Description: resp.Description, + Content: convertMediaContent(resp.Content), + } + } + } + return merged +} + +func convertMediaContent(content map[string]Media) map[string]map[string]any { + if len(content) == 0 { + return nil + } + converted := make(map[string]map[string]any, len(content)) + for mediaType, media := range content { + entry := map[string]any{} + if schema := copyAnyMap(media.Schema); len(schema) > 0 { + entry["schema"] = schema + } + converted[mediaType] = entry + } + return converted +} + +func mediaTypesToContent(mediaTypes []string) map[string]map[string]any { + if len(mediaTypes) == 0 { + return nil + } + content := make(map[string]map[string]any, len(mediaTypes)) + for _, mediaType := range mediaTypes { + if mediaType == "" { + continue + } + content[mediaType] = map[string]any{} + } + if len(content) == 0 { + return nil + } + return content +} + +func buildRequestBody(routeBody *fiber.RouteRequestBody, cfgBody *RequestBody) *requestBody { + var merged *requestBody + if routeBody != nil { + merged = &requestBody{ + Description: routeBody.Description, + Required: routeBody.Required, + Content: mediaTypesToContent(routeBody.MediaTypes), + } + } + if cfgBody != nil { + cfgReq := &requestBody{ + Description: cfgBody.Description, + Required: cfgBody.Required, + Content: convertMediaContent(cfgBody.Content), + } + if merged == nil { + merged = cfgReq + } else { + if cfgReq.Description != "" { + merged.Description = cfgReq.Description + } + merged.Required = cfgReq.Required + if len(cfgReq.Content) > 0 { + if merged.Content == nil { + merged.Content = cfgReq.Content + } else { + for mediaType, entry := range cfgReq.Content { + merged.Content[mediaType] = entry + } + } + } + } + } + return merged +} + +func shouldIncludeRequestBody(reqType string, meta Operation, route *fiber.Route) bool { + if reqType == "" || route == nil { + return false + } + if meta.Consumes != "" { + return true + } + if route.Consumes != fiber.MIMETextPlain { + return true + } + switch route.Method { + case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace: + return false + default: + return true + } +} diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index 11e04f20f20..35c80705f43 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -193,6 +193,25 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { Deprecated: true, Consumes: fiber.MIMEApplicationJSON, Produces: fiber.MIMEApplicationJSON, + Parameters: []Parameter{{ + Name: "limit", + In: "query", + Required: true, + Description: "Maximum items", + Schema: map[string]any{"type": "integer"}, + }}, + RequestBody: &RequestBody{ + Description: "Custom payload", + Required: true, + Content: map[string]Media{ + fiber.MIMEApplicationJSON: {Schema: map[string]any{"type": "object"}}, + }, + }, + Responses: map[string]Response{ + "201": {Description: "Created", Content: map[string]Media{ + fiber.MIMEApplicationJSON: {Schema: map[string]any{"type": "object"}}, + }}, + }, }, }, })) @@ -212,14 +231,22 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { require.ElementsMatch(t, []string{"users"}, op.Tags) require.True(t, op.Deprecated) require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) + require.Contains(t, op.Responses, "201") + require.Contains(t, op.Responses["201"].Content, fiber.MIMEApplicationJSON) require.NotNil(t, op.RequestBody) + require.Equal(t, "Custom payload", op.RequestBody.Description) require.Contains(t, op.RequestBody.Content, fiber.MIMEApplicationJSON) + require.True(t, op.RequestBody.Required) + require.Len(t, op.Parameters, 1) + require.Equal(t, "limit", op.Parameters[0].Name) + require.Equal(t, "integer", op.Parameters[0].Schema["type"]) } func Test_OpenAPI_RouteMetadata(t *testing.T) { app := fiber.New() app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }). Summary("List users").Description("User list").Produces(fiber.MIMEApplicationJSON). + Parameter("trace-id", "header", true, nil, "Tracing identifier"). Tags("users", "read").Deprecated() app.Use(New()) @@ -238,6 +265,39 @@ func Test_OpenAPI_RouteMetadata(t *testing.T) { require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) require.ElementsMatch(t, []string{"users", "read"}, op.Tags) require.True(t, op.Deprecated) + require.Len(t, op.Parameters, 1) + require.Equal(t, "trace-id", op.Parameters[0].Name) + require.Equal(t, "header", op.Parameters[0].In) + require.Equal(t, "Tracing identifier", op.Parameters[0].Description) +} + +func Test_OpenAPI_RouteRequestBodyAndResponses(t *testing.T) { + app := fiber.New() + + app.Post("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusCreated) }). + RequestBody("Create user", true, fiber.MIMEApplicationJSON). + Response(fiber.StatusCreated, "Created", fiber.MIMEApplicationJSON) + + app.Use(New()) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + + op := spec.Paths["/users"]["post"] + require.NotNil(t, op.RequestBody) + require.Equal(t, "Create user", op.RequestBody.Description) + require.True(t, op.RequestBody.Required) + require.Contains(t, op.RequestBody.Content, fiber.MIMEApplicationJSON) + require.Contains(t, op.Responses, "201") + require.Equal(t, "Created", op.Responses["201"].Description) + require.Contains(t, op.Responses["201"].Content, fiber.MIMEApplicationJSON) + require.Contains(t, op.Responses, "200") + require.Equal(t, "OK", op.Responses["200"].Description) } // getPaths is a helper that mounts the middleware, performs the request and @@ -306,7 +366,8 @@ func Test_OpenAPI_DifferentHandlers(t *testing.T) { func Test_OpenAPI_Params(t *testing.T) { app := fiber.New() - app.Get("/users/:id", func(c fiber.Ctx) error { return c.SendString(c.Params("id")) }) + app.Get("/users/:id", func(c fiber.Ctx) error { return c.SendString(c.Params("id")) }). + Parameter("id", "path", true, map[string]any{"type": "integer"}, "identifier") paths := getPaths(t, app) require.Contains(t, paths, "/users/{id}") @@ -317,6 +378,9 @@ func Test_OpenAPI_Params(t *testing.T) { p0 := params[0].(map[string]any) require.Equal(t, "id", p0["name"]) require.Equal(t, "path", p0["in"]) + require.Equal(t, "identifier", p0["description"]) + schema := p0["schema"].(map[string]any) + require.Equal(t, "integer", schema["type"]) } func Test_OpenAPI_Groups(t *testing.T) { diff --git a/router.go b/router.go index 36c53d951f4..3df94a2dea5 100644 --- a/router.go +++ b/router.go @@ -47,6 +47,15 @@ type Router interface { // Produces sets the response media type for the most recently // registered route. Produces(typ string) Router + // RequestBody documents the request body for the most recently + // registered route. + RequestBody(description string, required bool, mediaTypes ...string) Router + // Parameter documents an input parameter for the most recently + // registered route. + Parameter(name, in string, required bool, schema map[string]any, description string) Router + // Response documents an HTTP response for the most recently + // registered route. + Response(status int, description string, mediaTypes ...string) Router // Tags sets the tags for the most recently registered route. Tags(tags ...string) Router // Deprecated marks the most recently registered route as deprecated. @@ -64,16 +73,19 @@ type Route struct { Method string `json:"method"` // HTTP method Name string `json:"name"` // Route's name //nolint:revive // Having both a Path (uppercase) and a path (lowercase) is fine - Path string `json:"path"` // Original registered route path - Params []string `json:"params"` // Case-sensitive param keys - Handlers []Handler `json:"-"` // Ctx handlers - Summary string `json:"summary"` - Description string `json:"description"` - Consumes string `json:"consumes"` - Produces string `json:"produces"` - Tags []string `json:"tags"` - Deprecated bool `json:"deprecated"` - routeParser routeParser // Parameter parser + Path string `json:"path"` // Original registered route path + Params []string `json:"params"` // Case-sensitive param keys + Handlers []Handler `json:"-"` // Ctx handlers + Summary string `json:"summary"` + Description string `json:"description"` + Consumes string `json:"consumes"` + Produces string `json:"produces"` + RequestBody *RouteRequestBody `json:"requestBody"` + Parameters []RouteParameter `json:"parameters"` + Responses map[string]RouteResponse `json:"responses"` + Tags []string `json:"tags"` + Deprecated bool `json:"deprecated"` + routeParser routeParser // Parameter parser // Data for routing use bool // USE matches path prefixes mount bool // Indicated a mounted app on a specific route @@ -81,6 +93,28 @@ type Route struct { root bool // Path equals '/' } +// RouteParameter describes an input captured by a route. +type RouteParameter struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Description string `json:"description"` + Schema map[string]any `json:"schema"` +} + +// RouteResponse describes a response emitted by a route. +type RouteResponse struct { + Description string `json:"description"` + MediaTypes []string `json:"mediaTypes"` +} + +// RouteRequestBody describes the request payload accepted by a route. +type RouteRequestBody struct { + Description string `json:"description"` + Required bool `json:"required"` + MediaTypes []string `json:"mediaTypes"` +} + func (r *Route) match(detectionPath, path string, params *[maxParams]string) bool { // root detectionPath check if r.root && len(detectionPath) == 1 && detectionPath[0] == '/' { @@ -403,11 +437,66 @@ func (*App) copyRoute(route *Route) *Route { Description: route.Description, Consumes: route.Consumes, Produces: route.Produces, + RequestBody: cloneRouteRequestBody(route.RequestBody), + Parameters: cloneRouteParameters(route.Parameters), + Responses: cloneRouteResponses(route.Responses), Tags: route.Tags, Deprecated: route.Deprecated, } } +func cloneRouteRequestBody(body *RouteRequestBody) *RouteRequestBody { + if body == nil { + return nil + } + clone := &RouteRequestBody{ + Description: body.Description, + Required: body.Required, + } + if len(body.MediaTypes) > 0 { + clone.MediaTypes = append([]string(nil), body.MediaTypes...) + } + return clone +} + +func cloneRouteParameters(params []RouteParameter) []RouteParameter { + if len(params) == 0 { + return nil + } + cloned := make([]RouteParameter, len(params)) + for i, p := range params { + cloned[i] = RouteParameter{ + Name: p.Name, + In: p.In, + Required: p.Required, + Description: p.Description, + } + if len(p.Schema) > 0 { + schemaCopy := make(map[string]any, len(p.Schema)) + for k, v := range p.Schema { + schemaCopy[k] = v + } + cloned[i].Schema = schemaCopy + } + } + return cloned +} + +func cloneRouteResponses(responses map[string]RouteResponse) map[string]RouteResponse { + if len(responses) == 0 { + return nil + } + cloned := make(map[string]RouteResponse, len(responses)) + for code, resp := range responses { + copyResp := RouteResponse{Description: resp.Description} + if len(resp.MediaTypes) > 0 { + copyResp.MediaTypes = append([]string(nil), resp.MediaTypes...) + } + cloned[code] = copyResp + } + return cloned +} + func (app *App) normalizePath(path string) string { if path == "" { path = "/" diff --git a/router_test.go b/router_test.go index 490eca9d565..4005664fe64 100644 --- a/router_test.go +++ b/router_test.go @@ -1435,6 +1435,30 @@ func Test_Route_InvalidMediaType(t *testing.T) { app.Get("/", testEmptyHandler).Consumes("invalid") }) }) + t.Run("request body", func(t *testing.T) { + app := New() + require.Panics(t, func() { + app.Post("/", testEmptyHandler).RequestBody("payload", true, "invalid") + }) + }) + t.Run("request body missing type", func(t *testing.T) { + app := New() + require.Panics(t, func() { + app.Post("/", testEmptyHandler).RequestBody("payload", true) + }) + }) + t.Run("response", func(t *testing.T) { + app := New() + require.Panics(t, func() { + app.Get("/", testEmptyHandler).Response(StatusOK, "", "invalid") + }) + }) + t.Run("parameter", func(t *testing.T) { + app := New() + require.Panics(t, func() { + app.Get("/", testEmptyHandler).Parameter("foo", "body", true, nil, "") + }) + }) } func Test_App_Produces(t *testing.T) { @@ -1445,6 +1469,79 @@ func Test_App_Produces(t *testing.T) { require.Equal(t, MIMEApplicationJSON, route.Produces) } +func Test_App_RequestBody(t *testing.T) { + t.Parallel() + app := New() + app.Post("/users", testEmptyHandler). + RequestBody("User payload", true, MIMEApplicationJSON, MIMEApplicationXML) + + route := app.stack[app.methodInt(MethodPost)][0] + require.NotNil(t, route.RequestBody) + require.Equal(t, "User payload", route.RequestBody.Description) + require.True(t, route.RequestBody.Required) + require.Equal(t, []string{MIMEApplicationJSON, MIMEApplicationXML}, route.RequestBody.MediaTypes) + require.Equal(t, MIMEApplicationJSON, route.Consumes) +} + +func Test_App_Parameter(t *testing.T) { + t.Parallel() + app := New() + app.Get("/:id", testEmptyHandler). + Parameter("id", "path", false, map[string]any{"type": "integer"}, "identifier"). + Parameter("filter", "query", true, nil, "Filter results") + + route := app.stack[app.methodInt(MethodGet)][0] + require.Len(t, route.Parameters, 2) + + pathParam := route.Parameters[0] + require.Equal(t, "id", pathParam.Name) + require.Equal(t, "path", pathParam.In) + require.True(t, pathParam.Required) + require.Equal(t, "integer", pathParam.Schema["type"]) + require.Equal(t, "identifier", pathParam.Description) + + queryParam := route.Parameters[1] + require.Equal(t, "filter", queryParam.Name) + require.Equal(t, "query", queryParam.In) + require.True(t, queryParam.Required) + require.Equal(t, "string", queryParam.Schema["type"]) + require.Equal(t, "Filter results", queryParam.Description) +} + +func Test_App_Response(t *testing.T) { + t.Parallel() + app := New() + app.Get("/", testEmptyHandler). + Response(StatusOK, "OK", MIMEApplicationJSON). + Response(StatusCreated, "Created", MIMEApplicationJSON). + Response(0, "Default fallback") + + route := app.stack[app.methodInt(MethodGet)][0] + require.Equal(t, MIMEApplicationJSON, route.Produces) + require.Len(t, route.Responses, 3) + + okResp, ok := route.Responses["200"] + require.True(t, ok) + require.Equal(t, "OK", okResp.Description) + require.Equal(t, []string{MIMEApplicationJSON}, okResp.MediaTypes) + + created, ok := route.Responses["201"] + require.True(t, ok) + require.Equal(t, "Created", created.Description) + + defResp, ok := route.Responses["default"] + require.True(t, ok) + require.Equal(t, "Default fallback", defResp.Description) +} + +func Test_App_Response_InvalidStatus(t *testing.T) { + t.Parallel() + app := New() + require.Panics(t, func() { + app.Get("/", testEmptyHandler).Response(42, "invalid") + }) +} + func Test_App_Deprecated(t *testing.T) { t.Parallel() app := New() From 4ab58fd1a4bb34cf690f71a46e97f88200c8466a Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sun, 21 Sep 2025 08:59:21 -0400 Subject: [PATCH 07/13] refactor: use maps.Copy for metadata cloning --- app.go | 5 ++--- middleware/openapi/openapi.go | 9 +++------ router.go | 5 ++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app.go b/app.go index 60bb514ce65..0b79016cfbb 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" "io" + "maps" "mime" "net" "net/http" @@ -785,9 +786,7 @@ func (app *App) Parameter(name, in string, required bool, schema map[string]any, } schemaCopy := make(map[string]any, len(schema)) - for k, v := range schema { - schemaCopy[k] = v - } + maps.Copy(schemaCopy, schema) if _, ok := schemaCopy["type"]; !ok { schemaCopy["type"] = "string" } diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index a9e1851bb43..8a367049b9c 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -2,6 +2,7 @@ package openapi import ( "encoding/json" + "maps" "strings" "sync" @@ -286,9 +287,7 @@ func copyAnyMap(src map[string]any) map[string]any { return nil } dst := make(map[string]any, len(src)) - for k, v := range src { - dst[k] = v - } + maps.Copy(dst, src) return dst } @@ -375,9 +374,7 @@ func buildRequestBody(routeBody *fiber.RouteRequestBody, cfgBody *RequestBody) * if merged.Content == nil { merged.Content = cfgReq.Content } else { - for mediaType, entry := range cfgReq.Content { - merged.Content[mediaType] = entry - } + maps.Copy(merged.Content, cfgReq.Content) } } } diff --git a/router.go b/router.go index 3df94a2dea5..052d5d82d20 100644 --- a/router.go +++ b/router.go @@ -7,6 +7,7 @@ package fiber import ( "bytes" "fmt" + "maps" "slices" "sync/atomic" @@ -473,9 +474,7 @@ func cloneRouteParameters(params []RouteParameter) []RouteParameter { } if len(p.Schema) > 0 { schemaCopy := make(map[string]any, len(p.Schema)) - for k, v := range p.Schema { - schemaCopy[k] = v - } + maps.Copy(schemaCopy, p.Schema) cloned[i].Schema = schemaCopy } } From 9b1a022312b225949c0affacd1e5c9e5e790865a Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sun, 21 Sep 2025 09:24:42 -0400 Subject: [PATCH 08/13] docs: clarify openapi type reference --- docs/middleware/openapi.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index ea5152c232c..cf35509a9d5 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -134,3 +134,4 @@ type RequestBody struct { } ``` +Refer to the type definitions above when customizing OpenAPI operations in your configuration. From 2b4fa7583d899cc71d9d8a20b047d58598b486c0 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Fri, 24 Oct 2025 03:11:23 -0400 Subject: [PATCH 09/13] test: normalize openapi json assertions --- middleware/openapi/openapi_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index 35c80705f43..f9906e9a981 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -154,7 +154,7 @@ func Test_OpenAPI_RawJSON(t *testing.T) { } exp, err := json.Marshal(expected) require.NoError(t, err) - require.Equal(t, string(exp), string(body)) + require.JSONEq(t, string(exp), string(body)) } func Test_OpenAPI_RawJSONFile(t *testing.T) { @@ -176,7 +176,7 @@ func Test_OpenAPI_RawJSONFile(t *testing.T) { expected, err := os.ReadFile("testdata/openapi.json") require.NoError(t, err) - require.Equal(t, string(expected), string(body)) + require.JSONEq(t, string(expected), string(body)) } func Test_OpenAPI_OperationConfig(t *testing.T) { From 2bab831ed52d24508e561fd22cbb47a876b03663 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sat, 25 Oct 2025 12:37:59 -0400 Subject: [PATCH 10/13] chore: address openapi review feedback --- app.go | 20 ++++---- docs/middleware/openapi.md | 21 +++++---- group_test.go | 2 + middleware/openapi/config.go | 41 ++++++++--------- middleware/openapi/openapi.go | 35 ++++++++------ middleware/openapi/openapi_test.go | 74 ++++++++++++++++++------------ router.go | 36 ++++++++------- router_test.go | 3 ++ 8 files changed, 129 insertions(+), 103 deletions(-) diff --git a/app.go b/app.go index 0b79016cfbb..46ecabb2775 100644 --- a/app.go +++ b/app.go @@ -752,7 +752,7 @@ func (app *App) Produces(typ string) Router { // RequestBody documents the request payload for the most recently added route. func (app *App) RequestBody(description string, required bool, mediaTypes ...string) Router { - sanitized := sanitizeMediaTypes(mediaTypes, true) + sanitized := sanitizeRequiredMediaTypes(mediaTypes) app.mutex.Lock() app.latestRoute.RequestBody = &RouteRequestBody{ @@ -816,7 +816,7 @@ func (app *App) Response(status int, description string, mediaTypes ...string) R panic("invalid status code") } - sanitized := sanitizeMediaTypes(mediaTypes, false) + sanitized := sanitizeMediaTypes(mediaTypes) if description == "" { if status == 0 { @@ -851,11 +851,8 @@ func (app *App) Response(status int, description string, mediaTypes ...string) R return app } -func sanitizeMediaTypes(mediaTypes []string, require bool) []string { +func sanitizeMediaTypes(mediaTypes []string) []string { if len(mediaTypes) == 0 { - if require { - panic("at least one media type must be provided") - } return nil } @@ -875,15 +872,20 @@ func sanitizeMediaTypes(mediaTypes []string, require bool) []string { seen[trimmed] = struct{}{} sanitized = append(sanitized, trimmed) } - if require && len(sanitized) == 0 { - panic("at least one media type must be provided") - } if len(sanitized) == 0 { return nil } return sanitized } +func sanitizeRequiredMediaTypes(mediaTypes []string) []string { + sanitized := sanitizeMediaTypes(mediaTypes) + if len(sanitized) == 0 { + panic("at least one media type must be provided") + } + return sanitized +} + // Tags assigns tags to the most recently added route. func (app *App) Tags(tags ...string) Router { app.mutex.Lock() diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index cf35509a9d5..3cd00950ab9 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -85,12 +85,12 @@ Each documented route automatically includes a `200` response with the descripti ```go var ConfigDefault = Config{ Next: nil, + Operations: nil, Title: "Fiber API", Version: "1.0.0", Description: "", ServerURL: "", Path: "/openapi.json", - Operations: nil, } ``` @@ -98,24 +98,25 @@ var ConfigDefault = Config{ ```go type Operation struct { - Id string + RequestBody *RequestBody + Responses map[string]Response + Parameters []Parameter + Tags []string + + ID string Summary string Description string - Tags []string - Deprecated bool Consumes string Produces string - Parameters []Parameter - RequestBody *RequestBody - Responses map[string]Response + Deprecated bool } type Parameter struct { + Schema map[string]any Name string In string Description string Required bool - Schema map[string]any } type Media struct { @@ -123,14 +124,14 @@ type Media struct { } type Response struct { - Description string Content map[string]Media + Description string } type RequestBody struct { + Content map[string]Media Description string Required bool - Content map[string]Media } ``` diff --git a/group_test.go b/group_test.go index 4b58c7bcf87..fd6a63bf286 100644 --- a/group_test.go +++ b/group_test.go @@ -33,6 +33,7 @@ func Test_Group_OpenAPI_Helpers(t *testing.T) { grp := app.Group("/api") grp.Get("/users", testEmptyHandler).Consumes(MIMEApplicationJSON) route := app.stack[app.methodInt(MethodGet)][0] + //nolint:testifylint // MIMEApplicationJSON is a plain string, JSONEq not required require.Equal(t, MIMEApplicationJSON, route.Consumes) }) @@ -42,6 +43,7 @@ func Test_Group_OpenAPI_Helpers(t *testing.T) { grp := app.Group("/api") grp.Get("/users", testEmptyHandler).Produces(MIMEApplicationXML) route := app.stack[app.methodInt(MethodGet)][0] + //nolint:testifylint // MIMEApplicationXML is a plain string, JSONEq not required require.Equal(t, MIMEApplicationXML, route.Produces) }) diff --git a/middleware/openapi/config.go b/middleware/openapi/config.go index ecf5b813ef6..e790c8396df 100644 --- a/middleware/openapi/config.go +++ b/middleware/openapi/config.go @@ -11,6 +11,12 @@ type Config struct { // Optional. Default: nil Next func(c fiber.Ctx) bool + // Operations allows providing per-route metadata keyed by + // "METHOD /path" (e.g. "GET /users"). + // + // Optional. Default: nil + Operations map[string]Operation + // Title is the title for the generated OpenAPI specification. // // Optional. Default: "Fiber API" @@ -35,23 +41,17 @@ type Config struct { // // Optional. Default: "/openapi.json" Path string - - // Operations allows providing per-route metadata keyed by - // "METHOD /path" (e.g. "GET /users"). - // - // Optional. Default: nil - Operations map[string]Operation } // ConfigDefault is the default config. var ConfigDefault = Config{ Next: nil, + Operations: nil, Title: "Fiber API", Version: "1.0.0", Description: "", ServerURL: "", Path: "/openapi.json", - Operations: nil, } func configDefault(config ...Config) Config { @@ -88,30 +88,27 @@ func configDefault(config ...Config) Config { // Operation configures metadata for a single route in the generated spec. type Operation struct { - Id string + RequestBody *RequestBody + Responses map[string]Response + Parameters []Parameter + Tags []string + + ID string Summary string Description string - Tags []string + Consumes string + Produces string Deprecated bool - // Consumes defines the request media type. - Consumes string - // Produces defines the response media type. - Produces string - // Parameters augments the generated parameter list. - Parameters []Parameter - // RequestBody overrides or augments the generated request body. - RequestBody *RequestBody - // Responses augments the generated responses by status code (e.g. "201"). - Responses map[string]Response } // Parameter describes a single OpenAPI parameter. type Parameter struct { + Schema map[string]any + Name string In string Description string Required bool - Schema map[string]any } // Media describes the schema payload for a request or response media type. @@ -121,13 +118,13 @@ type Media struct { // Response describes an OpenAPI response object. type Response struct { - Description string Content map[string]Media + Description string } // RequestBody describes the request body configuration for an operation. type RequestBody struct { + Content map[string]Media Description string Required bool - Content map[string]Media } diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index 8a367049b9c..33dde151189 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -2,6 +2,7 @@ package openapi import ( "encoding/json" + "fmt" "maps" "strings" "sync" @@ -32,6 +33,9 @@ func New(config ...Config) fiber.Handler { once.Do(func() { spec := generateSpec(c.App(), cfg) data, genErr = json.Marshal(spec) + if genErr != nil { + genErr = fmt.Errorf("openapi: marshal spec: %w", genErr) + } }) if genErr != nil { return genErr @@ -42,10 +46,10 @@ func New(config ...Config) fiber.Handler { } type openAPISpec struct { - OpenAPI string `json:"openapi"` - Info openAPIInfo `json:"info"` - Servers []openAPIServer `json:"servers,omitempty"` Paths map[string]map[string]operation `json:"paths"` + Servers []openAPIServer `json:"servers,omitempty"` + Info openAPIInfo `json:"info"` + OpenAPI string `json:"openapi"` } type openAPIInfo struct { @@ -59,33 +63,34 @@ type openAPIServer struct { } type operation struct { - OperationID string `json:"operationId,omitempty"` - Summary string `json:"summary"` - Description string `json:"description"` - Tags []string `json:"tags,omitempty"` - Deprecated bool `json:"deprecated,omitempty"` - Parameters []parameter `json:"parameters,omitempty"` - RequestBody *requestBody `json:"requestBody,omitempty"` Responses map[string]response `json:"responses"` + RequestBody *requestBody `json:"requestBody,omitempty"` //nolint:tagliatelle + Parameters []parameter `json:"parameters,omitempty"` + Tags []string `json:"tags,omitempty"` + + OperationID string `json:"operationId,omitempty"` //nolint:tagliatelle + Summary string `json:"summary"` + Description string `json:"description"` + Deprecated bool `json:"deprecated,omitempty"` } type response struct { - Description string `json:"description"` Content map[string]map[string]any `json:"content,omitempty"` + Description string `json:"description"` } type parameter struct { + Schema map[string]any `json:"schema,omitempty"` + Description string `json:"description,omitempty"` Name string `json:"name"` In string `json:"in"` Required bool `json:"required"` - Description string `json:"description,omitempty"` - Schema map[string]any `json:"schema,omitempty"` } type requestBody struct { + Content map[string]map[string]any `json:"content"` Description string `json:"description,omitempty"` Required bool `json:"required,omitempty"` - Content map[string]map[string]any `json:"content"` } func generateSpec(app *fiber.App, cfg Config) openAPISpec { @@ -169,7 +174,7 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { } } - opID := meta.Id + opID := meta.ID if opID == "" { opID = r.Name } diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index f9906e9a981..fc87c2275ff 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -33,9 +33,9 @@ func Test_OpenAPI_Generate(t *testing.T) { operations := spec.Paths["/users"] require.Contains(t, operations, "get") require.Contains(t, operations, "post") - getOp := operations["get"].(map[string]any) + getOp := requireMap(t, operations["get"]) require.Contains(t, getOp, "responses") - responses := getOp["responses"].(map[string]any) + responses := requireMap(t, getOp["responses"]) require.Contains(t, responses, "200") } @@ -186,7 +186,7 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { app.Use(New(Config{ Operations: map[string]Operation{ "GET /users": { - Id: "listUsersCustom", + ID: "listUsersCustom", Summary: "List users", Description: "Returns all users", Tags: []string{"users"}, @@ -323,17 +323,17 @@ func Test_OpenAPI_Methods(t *testing.T) { handler := func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) } tests := []struct { - method string register func(*fiber.App) + method string }{ - {fiber.MethodGet, func(a *fiber.App) { a.Get("/method", handler) }}, - {fiber.MethodPost, func(a *fiber.App) { a.Post("/method", handler) }}, - {fiber.MethodPut, func(a *fiber.App) { a.Put("/method", handler) }}, - {fiber.MethodPatch, func(a *fiber.App) { a.Patch("/method", handler) }}, - {fiber.MethodDelete, func(a *fiber.App) { a.Delete("/method", handler) }}, - {fiber.MethodHead, func(a *fiber.App) { a.Head("/method", handler) }}, - {fiber.MethodOptions, func(a *fiber.App) { a.Options("/method", handler) }}, - {fiber.MethodTrace, func(a *fiber.App) { a.Trace("/method", handler) }}, + {func(a *fiber.App) { a.Get("/method", handler) }, fiber.MethodGet}, + {func(a *fiber.App) { a.Post("/method", handler) }, fiber.MethodPost}, + {func(a *fiber.App) { a.Put("/method", handler) }, fiber.MethodPut}, + {func(a *fiber.App) { a.Patch("/method", handler) }, fiber.MethodPatch}, + {func(a *fiber.App) { a.Delete("/method", handler) }, fiber.MethodDelete}, + {func(a *fiber.App) { a.Head("/method", handler) }, fiber.MethodHead}, + {func(a *fiber.App) { a.Options("/method", handler) }, fiber.MethodOptions}, + {func(a *fiber.App) { a.Trace("/method", handler) }, fiber.MethodTrace}, } for _, tt := range tests { @@ -372,14 +372,14 @@ func Test_OpenAPI_Params(t *testing.T) { paths := getPaths(t, app) require.Contains(t, paths, "/users/{id}") require.Contains(t, paths["/users/{id}"], "get") - op := paths["/users/{id}"]["get"].(map[string]any) - params := op["parameters"].([]any) + op := requireMap(t, paths["/users/{id}"]["get"]) + params := requireSlice(t, op["parameters"]) require.Len(t, params, 1) - p0 := params[0].(map[string]any) + p0 := requireMap(t, params[0]) require.Equal(t, "id", p0["name"]) require.Equal(t, "path", p0["in"]) require.Equal(t, "identifier", p0["description"]) - schema := p0["schema"].(map[string]any) + schema := requireMap(t, p0["schema"]) require.Equal(t, "integer", schema["type"]) } @@ -409,13 +409,13 @@ func Test_OpenAPI_Groups_Metadata(t *testing.T) { paths := getPaths(t, app) require.Contains(t, paths, "/api/users") - op := paths["/api/users"]["get"].(map[string]any) + op := requireMap(t, paths["/api/users"]["get"]) require.Equal(t, "List users", op["summary"]) require.Equal(t, "Group users", op["description"]) - require.ElementsMatch(t, []any{"users"}, op["tags"].([]any)) + require.ElementsMatch(t, []any{"users"}, requireSlice(t, op["tags"])) require.Equal(t, true, op["deprecated"]) - resp := op["responses"].(map[string]any) - cont := resp["200"].(map[string]any)["content"].(map[string]any) + resp := requireMap(t, op["responses"]) + cont := requireMap(t, requireMap(t, resp["200"])["content"]) require.Contains(t, cont, fiber.MIMEApplicationJSON) } @@ -511,11 +511,11 @@ func Test_OpenAPI_MultipleParams(t *testing.T) { paths := getPaths(t, app) require.Contains(t, paths, "/users/{uid}/books/{bid}") - op := paths["/users/{uid}/books/{bid}"]["get"].(map[string]any) - params := op["parameters"].([]any) + op := requireMap(t, paths["/users/{uid}/books/{bid}"]["get"]) + params := requireSlice(t, op["parameters"]) require.Len(t, params, 2) - p0 := params[0].(map[string]any) - p1 := params[1].(map[string]any) + p0 := requireMap(t, params[0]) + p1 := requireMap(t, params[1]) require.Equal(t, "uid", p0["name"]) require.Equal(t, "path", p0["in"]) require.Equal(t, "bid", p1["name"]) @@ -531,13 +531,13 @@ func Test_OpenAPI_ConsumesProduces(t *testing.T) { paths := getPaths(t, app) - op := paths["/users"]["post"].(map[string]any) - rb := op["requestBody"].(map[string]any) - reqContent := rb["content"].(map[string]any) + op := requireMap(t, paths["/users"]["post"]) + rb := requireMap(t, op["requestBody"]) + reqContent := requireMap(t, rb["content"]) require.Contains(t, reqContent, fiber.MIMEApplicationJSON) - resp := op["responses"].(map[string]any)["200"].(map[string]any) - cont := resp["content"].(map[string]any) + resp := requireMap(t, requireMap(t, op["responses"])["200"]) + cont := requireMap(t, resp["content"]) require.Contains(t, cont, fiber.MIMEApplicationXML) } @@ -547,7 +547,7 @@ func Test_OpenAPI_NoRequestBodyForGET(t *testing.T) { app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) paths := getPaths(t, app) - op := paths["/users"]["get"].(map[string]any) + op := requireMap(t, paths["/users"]["get"]) require.NotContains(t, op, "requestBody") } @@ -577,3 +577,17 @@ func Test_OpenAPI_Cache(t *testing.T) { require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) require.NotContains(t, spec.Paths, "/second") } + +func requireMap(t *testing.T, value any) map[string]any { + t.Helper() + m, ok := value.(map[string]any) + require.True(t, ok) + return m +} + +func requireSlice(t *testing.T, value any) []any { + t.Helper() + s, ok := value.([]any) + require.True(t, ok) + return s +} diff --git a/router.go b/router.go index 052d5d82d20..4776e7ae6e8 100644 --- a/router.go +++ b/router.go @@ -68,25 +68,27 @@ type Route struct { // ### important: always keep in sync with the copy method "app.copyRoute" and all creations of Route struct ### group *Group // Group instance. used for routes in groups + routeParser routeParser // Parameter parser + + Handlers []Handler `json:"-"` // Ctx handlers + Parameters []RouteParameter `json:"parameters"` + Responses map[string]RouteResponse `json:"responses"` + RequestBody *RouteRequestBody `json:"requestBody"` //nolint:tagliatelle + Tags []string `json:"tags"` + Params []string `json:"params"` // Case-sensitive param keys + path string // Prettified path // Public fields Method string `json:"method"` // HTTP method Name string `json:"name"` // Route's name //nolint:revive // Having both a Path (uppercase) and a path (lowercase) is fine - Path string `json:"path"` // Original registered route path - Params []string `json:"params"` // Case-sensitive param keys - Handlers []Handler `json:"-"` // Ctx handlers - Summary string `json:"summary"` - Description string `json:"description"` - Consumes string `json:"consumes"` - Produces string `json:"produces"` - RequestBody *RouteRequestBody `json:"requestBody"` - Parameters []RouteParameter `json:"parameters"` - Responses map[string]RouteResponse `json:"responses"` - Tags []string `json:"tags"` - Deprecated bool `json:"deprecated"` - routeParser routeParser // Parameter parser + Path string `json:"path"` // Original registered route path + Summary string `json:"summary"` + Description string `json:"description"` + Consumes string `json:"consumes"` + Produces string `json:"produces"` + Deprecated bool `json:"deprecated"` // Data for routing use bool // USE matches path prefixes mount bool // Indicated a mounted app on a specific route @@ -96,24 +98,24 @@ type Route struct { // RouteParameter describes an input captured by a route. type RouteParameter struct { + Schema map[string]any `json:"schema"` + Description string `json:"description"` Name string `json:"name"` In string `json:"in"` Required bool `json:"required"` - Description string `json:"description"` - Schema map[string]any `json:"schema"` } // RouteResponse describes a response emitted by a route. type RouteResponse struct { + MediaTypes []string `json:"mediaTypes"` //nolint:tagliatelle Description string `json:"description"` - MediaTypes []string `json:"mediaTypes"` } // RouteRequestBody describes the request payload accepted by a route. type RouteRequestBody struct { + MediaTypes []string `json:"mediaTypes"` //nolint:tagliatelle Description string `json:"description"` Required bool `json:"required"` - MediaTypes []string `json:"mediaTypes"` } func (r *Route) match(detectionPath, path string, params *[maxParams]string) bool { diff --git a/router_test.go b/router_test.go index 4005664fe64..58a0a1b4b28 100644 --- a/router_test.go +++ b/router_test.go @@ -1466,6 +1466,7 @@ func Test_App_Produces(t *testing.T) { app := New() app.Get("/", testEmptyHandler).Produces(MIMEApplicationJSON) route := app.stack[app.methodInt(MethodGet)][0] + //nolint:testifylint // MIMEApplicationJSON is a plain string, JSONEq not required require.Equal(t, MIMEApplicationJSON, route.Produces) } @@ -1480,6 +1481,7 @@ func Test_App_RequestBody(t *testing.T) { require.Equal(t, "User payload", route.RequestBody.Description) require.True(t, route.RequestBody.Required) require.Equal(t, []string{MIMEApplicationJSON, MIMEApplicationXML}, route.RequestBody.MediaTypes) + //nolint:testifylint // MIMEApplicationJSON is a plain string, JSONEq not required require.Equal(t, MIMEApplicationJSON, route.Consumes) } @@ -1517,6 +1519,7 @@ func Test_App_Response(t *testing.T) { Response(0, "Default fallback") route := app.stack[app.methodInt(MethodGet)][0] + //nolint:testifylint // MIMEApplicationJSON is a plain string, JSONEq not required require.Equal(t, MIMEApplicationJSON, route.Produces) require.Len(t, route.Responses, 3) From 3c33f845f2b28544cc45a31661dd032bb4c84e0a Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:43:01 -0500 Subject: [PATCH 11/13] fix: scope openapi handler to its resolved path --- app.go | 2 +- docs/middleware/openapi.md | 4 ++++ middleware/openapi/openapi.go | 34 +++++++++++++++++++++++++- middleware/openapi/openapi_test.go | 38 ++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/app.go b/app.go index 46ecabb2775..fea370202e6 100644 --- a/app.go +++ b/app.go @@ -864,7 +864,7 @@ func sanitizeMediaTypes(mediaTypes []string) []string { continue } if _, _, err := mime.ParseMediaType(trimmed); err != nil || !strings.Contains(trimmed, "/") { - panic("invalid media type: " + typ) + panic("invalid media type: " + trimmed) } if _, ok := seen[trimmed]; ok { continue diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index 3cd00950ab9..f34771c3ca6 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -80,6 +80,10 @@ Each documented route automatically includes a `200` response with the descripti | Path | `string` | Path is the route where the specification will be served. | `"/openapi.json"` | | Operations | `map[string]Operation` | Per-route metadata keyed by `METHOD /path`. | `nil` | +When the middleware is attached to a group or mounted under a prefixed `Use`, the configured `Path` is resolved relative to that +prefix. For example, `app.Group("/v1").Use(openapi.New())` serves the specification at `/v1/openapi.json`, while a global `app.U +se(openapi.New())` only intercepts `/openapi.json` and will not affect other endpoints ending in `openapi.json`. + ## Default Config ```go diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index 33dde151189..388b53763d3 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -26,7 +26,8 @@ func New(config ...Config) fiber.Handler { return c.Next() } - if !strings.HasSuffix(c.Path(), cfg.Path) { + targetPath := resolvedSpecPath(c, cfg.Path) + if c.Path() != targetPath { return c.Next() } @@ -45,6 +46,37 @@ func New(config ...Config) fiber.Handler { } } +func resolvedSpecPath(c fiber.Ctx, cfgPath string) string { + path := cfgPath + if path == "" { + path = ConfigDefault.Path + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + route := c.Route() + if route == nil { + return path + } + + prefix := route.Path + if idx := strings.Index(prefix, "*"); idx >= 0 { + prefix = prefix[:idx] + } + if prefix == "/" || prefix == "" { + return path + } + if strings.HasSuffix(prefix, "/") { + prefix = strings.TrimSuffix(prefix, "/") + } + if prefix == "" { + return path + } + + return prefix + path +} + type openAPISpec struct { Paths map[string]map[string]operation `json:"paths"` Servers []openAPIServer `json:"servers,omitempty"` diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index fc87c2275ff..32eefc6cef8 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -456,6 +456,44 @@ func Test_OpenAPI_GroupMiddleware(t *testing.T) { require.Contains(t, spec.Paths, "/api/v2/users") } +func Test_OpenAPI_DoesNotInterceptSimilarPaths(t *testing.T) { + app := fiber.New() + + app.Use(New()) + app.Get("/reports/openapi.json", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusAccepted) }) + + req := httptest.NewRequest(fiber.MethodGet, "/reports/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusAccepted, resp.StatusCode) +} + +func Test_OpenAPI_RootAndGroupSpecs(t *testing.T) { + app := fiber.New() + + app.Use(New(Config{Title: "root"})) + + v1 := app.Group("/v1") + v1.Use(New(Config{Title: "group"})) + + req := httptest.NewRequest(fiber.MethodGet, "/openapi.json", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + var spec openAPISpec + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.Equal(t, "root", spec.Info.Title) + + req = httptest.NewRequest(fiber.MethodGet, "/v1/openapi.json", nil) + resp, err = app.Test(req) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + + require.NoError(t, json.NewDecoder(resp.Body).Decode(&spec)) + require.Equal(t, "group", spec.Info.Title) +} + func Test_OpenAPI_ConfigValues(t *testing.T) { app := fiber.New() From 7da513dc368dee84921a3138a68549d667ee7589 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:40:49 -0500 Subject: [PATCH 12/13] Adjust OpenAPI default responses --- docs/middleware/openapi.md | 2 +- middleware/openapi/openapi.go | 34 +++++++++----- middleware/openapi/openapi_test.go | 57 ++++++++++++++++++++---- middleware/openapi/testdata/openapi.json | 2 +- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index f34771c3ca6..35c76563ff1 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -64,7 +64,7 @@ app.Post("/users", createUser). // Consumes and Produces will panic if provided an invalid media type. ``` -Each documented route automatically includes a `200` response with the description `OK` to satisfy the minimum OpenAPI requirements. Additional responses can be declared via the `Response` helper or the middleware configuration. +If no responses are declared, the middleware adds a sensible default: `200 OK` for most methods and `204 No Content` for `DELETE` and `HEAD`. When any responses are provided (either via route helpers or middleware configuration), no automatic default is added. `CONNECT` routes are ignored because the OpenAPI specification does not define a `connect` operation. diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index 388b53763d3..430dad25099 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -181,19 +181,10 @@ func generateSpec(app *fiber.App, cfg Config) openAPISpec { } responses := mergeResponses(r.Responses, meta.Responses) - if responses == nil { - responses = make(map[string]response) + if len(responses) == 0 { + status, defaultResp := defaultResponseForMethod(r.Method, respType) + responses = map[string]response{status: defaultResp} } - defaultResp, exists := responses["200"] - if defaultResp.Description == "" { - defaultResp.Description = "OK" - } - if !exists && respType != "" { - defaultResp.Content = map[string]map[string]any{ - respType: {}, - } - } - responses["200"] = defaultResp reqBody := buildRequestBody(r.RequestBody, meta.RequestBody) if reqBody == nil { @@ -436,3 +427,22 @@ func shouldIncludeRequestBody(reqType string, meta Operation, route *fiber.Route return true } } + +func defaultResponseForMethod(method, mediaType string) (string, response) { + status := "200" + description := "OK" + + switch method { + case fiber.MethodDelete, fiber.MethodHead: + status = "204" + description = "No Content" + } + + resp := response{Description: description} + if mediaType != "" && status != "204" { + resp.Content = map[string]map[string]any{ + mediaType: {}, + } + } + return status, resp +} diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index 32eefc6cef8..4c5fce95aed 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -62,12 +62,11 @@ func Test_OpenAPI_JSONEquality(t *testing.T) { } lower := strings.ToLower(m) upper := strings.ToUpper(m) + status, resp := defaultResponseForMethod(m, fiber.MIMETextPlain) op := operation{ Summary: upper + " /", Description: "", - Responses: map[string]response{ - "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMETextPlain: {}}}, - }, + Responses: map[string]response{status: resp}, } switch m { case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace: @@ -121,12 +120,11 @@ func Test_OpenAPI_RawJSON(t *testing.T) { } lower := strings.ToLower(m) upper := strings.ToUpper(m) + status, resp := defaultResponseForMethod(m, fiber.MIMETextPlain) op := operation{ Summary: upper + " /", Description: "", - Responses: map[string]response{ - "200": {Description: "OK", Content: map[string]map[string]any{fiber.MIMETextPlain: {}}}, - }, + Responses: map[string]response{status: resp}, } switch m { case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace: @@ -230,7 +228,7 @@ func Test_OpenAPI_OperationConfig(t *testing.T) { require.Equal(t, "Returns all users", op.Description) require.ElementsMatch(t, []string{"users"}, op.Tags) require.True(t, op.Deprecated) - require.Contains(t, op.Responses["200"].Content, fiber.MIMEApplicationJSON) + require.Len(t, op.Responses, 1) require.Contains(t, op.Responses, "201") require.Contains(t, op.Responses["201"].Content, fiber.MIMEApplicationJSON) require.NotNil(t, op.RequestBody) @@ -296,8 +294,49 @@ func Test_OpenAPI_RouteRequestBodyAndResponses(t *testing.T) { require.Contains(t, op.Responses, "201") require.Equal(t, "Created", op.Responses["201"].Description) require.Contains(t, op.Responses["201"].Content, fiber.MIMEApplicationJSON) - require.Contains(t, op.Responses, "200") - require.Equal(t, "OK", op.Responses["200"].Description) +} + +func Test_OpenAPI_DefaultResponses(t *testing.T) { + t.Run("delete defaults to 204 with no content", func(t *testing.T) { + app := fiber.New() + app.Delete("/users/:id", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) }) + + paths := getPaths(t, app) + op := requireMap(t, paths["/users/{id}"]["delete"]) + responses := requireMap(t, op["responses"]) + require.Len(t, responses, 1) + r204 := requireMap(t, responses["204"]) + require.Equal(t, "No Content", r204["description"]) + require.NotContains(t, r204, "content") + }) + + t.Run("post with explicit 201 does not add default 200", func(t *testing.T) { + app := fiber.New() + app.Post("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusCreated) }). + Response(fiber.StatusCreated, "Created", fiber.MIMEApplicationJSON) + + paths := getPaths(t, app) + op := requireMap(t, paths["/users"]["post"]) + responses := requireMap(t, op["responses"]) + require.Len(t, responses, 1) + r201 := requireMap(t, responses["201"]) + require.Equal(t, "Created", r201["description"]) + require.Contains(t, requireMap(t, r201["content"]), fiber.MIMEApplicationJSON) + }) + + t.Run("non-200 responses remain untouched", func(t *testing.T) { + app := fiber.New() + app.Get("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNotFound) }). + Response(fiber.StatusNotFound, "Not Found", fiber.MIMETextPlain) + + paths := getPaths(t, app) + op := requireMap(t, paths["/users"]["get"]) + responses := requireMap(t, op["responses"]) + require.Len(t, responses, 1) + r404 := requireMap(t, responses["404"]) + require.Equal(t, "Not Found", r404["description"]) + require.Contains(t, requireMap(t, r404["content"]), fiber.MIMETextPlain) + }) } // getPaths is a helper that mounts the middleware, performs the request and diff --git a/middleware/openapi/testdata/openapi.json b/middleware/openapi/testdata/openapi.json index 6a549b76ea5..8d9ea1a1a09 100644 --- a/middleware/openapi/testdata/openapi.json +++ b/middleware/openapi/testdata/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.0","info":{"title":"Fiber API","version":"1.0.0"},"paths":{"/":{"delete":{"summary":"DELETE /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"get":{"summary":"GET /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"head":{"summary":"HEAD /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"options":{"summary":"OPTIONS /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"patch":{"summary":"PATCH /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"post":{"summary":"POST /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"put":{"summary":"PUT /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"trace":{"summary":"TRACE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}}},"/users":{"get":{"operationId":"listUsers","summary":"GET /users","description":"","responses":{"200":{"description":"OK","content":{"application/json":{}}}}}}}} \ No newline at end of file +{"openapi":"3.0.0","info":{"title":"Fiber API","version":"1.0.0"},"paths":{"/":{"delete":{"summary":"DELETE /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"204":{"description":"No Content"}}},"get":{"summary":"GET /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"head":{"summary":"HEAD /","description":"","responses":{"204":{"description":"No Content"}}},"options":{"summary":"OPTIONS /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"patch":{"summary":"PATCH /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"post":{"summary":"POST /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"put":{"summary":"PUT /","description":"","requestBody":{"content":{"text/plain":{}}},"responses":{"200":{"description":"OK","content":{"text/plain":{}}}}},"trace":{"summary":"TRACE /","description":"","responses":{"200":{"description":"OK","content":{"text/plain":{}}}}}},"/users":{"get":{"operationId":"listUsers","summary":"GET /users","description":"","responses":{"200":{"description":"OK","content":{"application/json":{}}}}}}}} From abe75b9b5fa99198a2d3e27370d8952c81a3b5e4 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:23:43 -0500 Subject: [PATCH 13/13] Extend OpenAPI helpers with schema refs and examples --- adapter.go | 36 ++++++++++ app.go | 95 ++++++++++++++++++++------- docs/middleware/openapi.md | 35 +++++++++- docs/whats_new.md | 2 +- group.go | 48 +++++++++----- group_test.go | 40 +++++++++++ middleware/openapi/config.go | 16 ++++- middleware/openapi/openapi.go | 90 ++++++++++++++++++++----- middleware/openapi/openapi_test.go | 37 +++++++++++ register.go | 50 +++++++------- router.go | 102 ++++++++++++++++++++--------- router_test.go | 57 +++++++++++++++- 12 files changed, 490 insertions(+), 118 deletions(-) create mode 100644 adapter.go diff --git a/adapter.go b/adapter.go new file mode 100644 index 00000000000..27c63083831 --- /dev/null +++ b/adapter.go @@ -0,0 +1,36 @@ +package fiber + +import ( + "fmt" +) + +func adaptHandlers(path string, raw ...any) []Handler { + if len(raw) == 0 { + return nil + } + + handlers := make([]Handler, 0, len(raw)) + for _, h := range raw { + if h == nil { + panic(fmt.Sprintf("nil handler in route: %s\n", path)) + } + + switch v := h.(type) { + case Handler: + handlers = append(handlers, v) + case []Handler: + for _, inner := range v { + if inner == nil { + panic(fmt.Sprintf("nil handler in route: %s\n", path)) + } + handlers = append(handlers, inner) + } + case []any: + handlers = append(handlers, adaptHandlers(path, v...)...) + default: + panic(fmt.Sprintf("invalid handler type: %T", h)) + } + } + + return handlers +} diff --git a/app.go b/app.go index fea370202e6..98b32fac7e5 100644 --- a/app.go +++ b/app.go @@ -15,7 +15,6 @@ import ( "errors" "fmt" "io" - "maps" "mime" "net" "net/http" @@ -752,14 +751,29 @@ func (app *App) Produces(typ string) Router { // RequestBody documents the request payload for the most recently added route. func (app *App) RequestBody(description string, required bool, mediaTypes ...string) Router { + return app.RequestBodyWithExample(description, required, nil, "", nil, nil, mediaTypes...) +} + +// RequestBodyWithExample documents the request payload with schema references and examples. +func (app *App) RequestBodyWithExample(description string, required bool, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router { sanitized := sanitizeRequiredMediaTypes(mediaTypes) - app.mutex.Lock() - app.latestRoute.RequestBody = &RouteRequestBody{ + body := &RouteRequestBody{ Description: description, Required: required, MediaTypes: append([]string(nil), sanitized...), + SchemaRef: schemaRef, + Example: example, + Examples: copyAnyMap(examples), } + if schemaRef != "" { + body.Schema = map[string]any{"$ref": schemaRef} + } else if len(schema) > 0 { + body.Schema = copyAnyMap(schema) + } + + app.mutex.Lock() + app.latestRoute.RequestBody = body if len(sanitized) > 0 { app.latestRoute.Consumes = sanitized[0] } @@ -770,6 +784,15 @@ func (app *App) RequestBody(description string, required bool, mediaTypes ...str // Parameter documents an input parameter for the most recently added route. func (app *App) Parameter(name, in string, required bool, schema map[string]any, description string) Router { + return app.addParameter(name, in, required, schema, "", description, nil, nil) +} + +// ParameterWithExample documents an input parameter, including schema references and examples. +func (app *App) ParameterWithExample(name, in string, required bool, schema map[string]any, schemaRef string, description string, example any, examples map[string]any) Router { + return app.addParameter(name, in, required, schema, schemaRef, description, example, examples) +} + +func (app *App) addParameter(name, in string, required bool, schema map[string]any, schemaRef string, description string, example any, examples map[string]any) Router { if strings.TrimSpace(name) == "" { panic("parameter name is required") } @@ -781,14 +804,20 @@ func (app *App) Parameter(name, in string, required bool, schema map[string]any, panic("invalid parameter location: " + in) } - if schema == nil { + if schemaRef != "" { + schema = map[string]any{"$ref": schemaRef} + } else if schema == nil { schema = map[string]any{"type": "string"} } - schemaCopy := make(map[string]any, len(schema)) - maps.Copy(schemaCopy, schema) - if _, ok := schemaCopy["type"]; !ok { - schemaCopy["type"] = "string" + schemaCopy := copyAnyMap(schema) + if schemaCopy == nil { + schemaCopy = map[string]any{"type": "string"} + } + if schemaRef == "" { + if _, ok := schemaCopy["type"]; !ok { + schemaCopy["type"] = "string" + } } if location == "path" { @@ -801,6 +830,9 @@ func (app *App) Parameter(name, in string, required bool, schema map[string]any, Required: required, Description: description, Schema: schemaCopy, + SchemaRef: schemaRef, + Example: example, + Examples: copyAnyMap(examples), } app.mutex.Lock() @@ -812,6 +844,15 @@ func (app *App) Parameter(name, in string, required bool, schema map[string]any, // Response documents an HTTP response for the most recently added route. func (app *App) Response(status int, description string, mediaTypes ...string) Router { + return app.addResponse(status, description, nil, "", nil, nil, mediaTypes...) +} + +// ResponseWithExample documents an HTTP response with schema references and examples. +func (app *App) ResponseWithExample(status int, description string, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router { + return app.addResponse(status, description, schema, schemaRef, example, examples, mediaTypes...) +} + +func (app *App) addResponse(status int, description string, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router { if status != 0 && (status < 100 || status > 599) { panic("invalid status code") } @@ -837,6 +878,14 @@ func (app *App) Response(status int, description string, mediaTypes ...string) R if len(sanitized) > 0 { resp.MediaTypes = append([]string(nil), sanitized...) } + if schemaRef != "" { + resp.SchemaRef = schemaRef + resp.Schema = map[string]any{"$ref": schemaRef} + } else if len(schema) > 0 { + resp.Schema = copyAnyMap(schema) + } + resp.Example = example + resp.Examples = copyAnyMap(examples) app.mutex.Lock() if app.latestRoute.Responses == nil { @@ -958,7 +1007,7 @@ func (app *App) Use(args ...any) Router { var prefix string var subApp *App var prefixes []string - var handlers []Handler + var handlers []any for i := range args { switch arg := args[i].(type) { @@ -968,7 +1017,7 @@ func (app *App) Use(args ...any) Router { subApp = arg case []string: prefixes = arg - case Handler: + case Handler, []Handler, []any: handlers = append(handlers, arg) default: panic(fmt.Sprintf("use: invalid handler %v\n", reflect.TypeOf(arg))) @@ -993,66 +1042,66 @@ func (app *App) Use(args ...any) Router { // Get registers a route for GET methods that requests a representation // of the specified resource. Requests using GET should only retrieve data. -func (app *App) Get(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Get(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodGet}, path, handler, handlers...) } // Head registers a route for HEAD methods that asks for a response identical // to that of a GET request, but without the response body. -func (app *App) Head(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Head(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodHead}, path, handler, handlers...) } // Post registers a route for POST methods that is used to submit an entity to the // specified resource, often causing a change in state or side effects on the server. -func (app *App) Post(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Post(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodPost}, path, handler, handlers...) } // Put registers a route for PUT methods that replaces all current representations // of the target resource with the request payload. -func (app *App) Put(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Put(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodPut}, path, handler, handlers...) } // Delete registers a route for DELETE methods that deletes the specified resource. -func (app *App) Delete(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Delete(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodDelete}, path, handler, handlers...) } // Connect registers a route for CONNECT methods that establishes a tunnel to the // server identified by the target resource. -func (app *App) Connect(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Connect(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodConnect}, path, handler, handlers...) } // Options registers a route for OPTIONS methods that is used to describe the // communication options for the target resource. -func (app *App) Options(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Options(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodOptions}, path, handler, handlers...) } // Trace registers a route for TRACE methods that performs a message loop-back // test along the path to the target resource. -func (app *App) Trace(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Trace(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodTrace}, path, handler, handlers...) } // Patch registers a route for PATCH methods that is used to apply partial // modifications to a resource. -func (app *App) Patch(path string, handler Handler, handlers ...Handler) Router { +func (app *App) Patch(path string, handler any, handlers ...any) Router { return app.Add([]string{MethodPatch}, path, handler, handlers...) } // Add allows you to specify multiple HTTP methods to register a route. -func (app *App) Add(methods []string, path string, handler Handler, handlers ...Handler) Router { - app.register(methods, path, nil, append([]Handler{handler}, handlers...)...) +func (app *App) Add(methods []string, path string, handler any, handlers ...any) Router { + app.register(methods, path, nil, append([]any{handler}, handlers...)...) return app } // All will register the handler on all HTTP methods -func (app *App) All(path string, handler Handler, handlers ...Handler) Router { +func (app *App) All(path string, handler any, handlers ...any) Router { return app.Add(app.config.RequestMethods, path, handler, handlers...) } @@ -1060,7 +1109,7 @@ func (app *App) All(path string, handler Handler, handlers ...Handler) Router { // // api := app.Group("/api") // api.Get("/users", handler) -func (app *App) Group(prefix string, handlers ...Handler) Router { +func (app *App) Group(prefix string, handlers ...any) Router { grp := &Group{Prefix: prefix, app: app} if len(handlers) > 0 { app.register([]string{methodUse}, prefix, grp, handlers...) diff --git a/docs/middleware/openapi.md b/docs/middleware/openapi.md index 35c76563ff1..d4a19e29563 100644 --- a/docs/middleware/openapi.md +++ b/docs/middleware/openapi.md @@ -54,8 +54,27 @@ app.Post("/users", createUser). Summary("Create user"). Description("Creates a new user"). RequestBody("User payload", true, fiber.MIMEApplicationJSON). + // Use *WithExample helpers to attach schemas and examples (including $ref). + RequestBodyWithExample( + "User payload", true, + nil, "#/components/schemas/User", + map[string]any{"name": "alice"}, + map[string]any{"sample": map[string]any{"name": "bob"}}, + fiber.MIMEApplicationJSON, + ). Parameter("trace-id", "header", true, nil, "Tracing identifier"). + ParameterWithExample( + "trace-id", "header", true, nil, "", + "Tracing identifier", "abc-123", map[string]any{"sample": "xyz-789"}, + ). Response(fiber.StatusCreated, "Created", fiber.MIMEApplicationJSON). + ResponseWithExample( + fiber.StatusCreated, "Created", + nil, "#/components/schemas/UserResponse", + map[string]any{"id": 1}, + map[string]any{"sample": map[string]any{"id": 2}}, + fiber.MIMEApplicationJSON, + ). Tags("users", "admin"). Produces(fiber.MIMEApplicationJSON) @@ -117,6 +136,9 @@ type Operation struct { type Parameter struct { Schema map[string]any + SchemaRef string + Examples map[string]any + Example any Name string In string Description string @@ -124,19 +146,30 @@ type Parameter struct { } type Media struct { - Schema map[string]any + Schema map[string]any + SchemaRef string + Examples map[string]any + Example any } type Response struct { Content map[string]Media + Examples map[string]any + Example any + SchemaRef string Description string } type RequestBody struct { Content map[string]Media + Examples map[string]any + Example any + SchemaRef string Description string Required bool } ``` Refer to the type definitions above when customizing OpenAPI operations in your configuration. + +Schema references (`SchemaRef`) are emitted as `$ref` entries in the generated JSON and can point to components such as `#/components/schemas/User`. `Example` and `Examples` are forwarded verbatim into operation parameters, request bodies, and responses so that client generators can surface realistic payloads. diff --git a/docs/whats_new.md b/docs/whats_new.md index c9251ff52ac..2968724b919 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -1290,7 +1290,7 @@ Monitor middleware is migrated to the [Contrib package](https://github.com/gofib ### OpenAPI -Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions, parameters, request bodies, and custom responses—alongside request/response media types—directly or configure them globally. +Introduces an `openapi` middleware that inspects registered routes and serves a generated OpenAPI 3.0 specification. Each operation includes a summary and default `200` response. Routes may attach descriptions, parameters, request bodies, and custom responses—alongside request/response media types—directly or configure them globally. New helpers allow parameters, request bodies, and responses to include schema references and examples (including `$ref` targets under `components/schemas`), enabling richer generated documentation. ### Proxy diff --git a/group.go b/group.go index 6bb1f172e47..99ff7d2f14d 100644 --- a/group.go +++ b/group.go @@ -75,18 +75,36 @@ func (grp *Group) RequestBody(description string, required bool, mediaTypes ...s return grp } +// RequestBodyWithExample documents the request payload for the most recently added route in the group with schema references and examples. +func (grp *Group) RequestBodyWithExample(description string, required bool, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router { + grp.app.RequestBodyWithExample(description, required, schema, schemaRef, example, examples, mediaTypes...) + return grp +} + // Parameter documents an input parameter for the most recently added route in the group. func (grp *Group) Parameter(name, in string, required bool, schema map[string]any, description string) Router { grp.app.Parameter(name, in, required, schema, description) return grp } +// ParameterWithExample documents an input parameter for the most recently added route in the group with schema references and examples. +func (grp *Group) ParameterWithExample(name, in string, required bool, schema map[string]any, schemaRef string, description string, example any, examples map[string]any) Router { + grp.app.ParameterWithExample(name, in, required, schema, schemaRef, description, example, examples) + return grp +} + // Response documents an HTTP response for the most recently added route in the group. func (grp *Group) Response(status int, description string, mediaTypes ...string) Router { grp.app.Response(status, description, mediaTypes...) return grp } +// ResponseWithExample documents an HTTP response for the most recently added route in the group with schema references and examples. +func (grp *Group) ResponseWithExample(status int, description string, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router { + grp.app.ResponseWithExample(status, description, schema, schemaRef, example, examples, mediaTypes...) + return grp +} + // Tags assigns tags to the most recently added route in the group. func (grp *Group) Tags(tags ...string) Router { grp.app.Tags(tags...) @@ -124,7 +142,7 @@ func (grp *Group) Use(args ...any) Router { var subApp *App var prefix string var prefixes []string - var handlers []Handler + var handlers []any for i := range args { switch arg := args[i].(type) { @@ -134,7 +152,7 @@ func (grp *Group) Use(args ...any) Router { subApp = arg case []string: prefixes = arg - case Handler: + case Handler, []Handler, []any: handlers = append(handlers, arg) default: panic(fmt.Sprintf("use: invalid handler %v\n", reflect.TypeOf(arg))) @@ -163,60 +181,60 @@ func (grp *Group) Use(args ...any) Router { // Get registers a route for GET methods that requests a representation // of the specified resource. Requests using GET should only retrieve data. -func (grp *Group) Get(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Get(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodGet}, path, handler, handlers...) } // Head registers a route for HEAD methods that asks for a response identical // to that of a GET request, but without the response body. -func (grp *Group) Head(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Head(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodHead}, path, handler, handlers...) } // Post registers a route for POST methods that is used to submit an entity to the // specified resource, often causing a change in state or side effects on the server. -func (grp *Group) Post(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Post(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodPost}, path, handler, handlers...) } // Put registers a route for PUT methods that replaces all current representations // of the target resource with the request payload. -func (grp *Group) Put(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Put(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodPut}, path, handler, handlers...) } // Delete registers a route for DELETE methods that deletes the specified resource. -func (grp *Group) Delete(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Delete(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodDelete}, path, handler, handlers...) } // Connect registers a route for CONNECT methods that establishes a tunnel to the // server identified by the target resource. -func (grp *Group) Connect(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Connect(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodConnect}, path, handler, handlers...) } // Options registers a route for OPTIONS methods that is used to describe the // communication options for the target resource. -func (grp *Group) Options(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Options(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodOptions}, path, handler, handlers...) } // Trace registers a route for TRACE methods that performs a message loop-back // test along the path to the target resource. -func (grp *Group) Trace(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Trace(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodTrace}, path, handler, handlers...) } // Patch registers a route for PATCH methods that is used to apply partial // modifications to a resource. -func (grp *Group) Patch(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) Patch(path string, handler any, handlers ...any) Router { return grp.Add([]string{MethodPatch}, path, handler, handlers...) } // Add allows you to specify multiple HTTP methods to register a route. -func (grp *Group) Add(methods []string, path string, handler Handler, handlers ...Handler) Router { - grp.app.register(methods, getGroupPath(grp.Prefix, path), grp, append([]Handler{handler}, handlers...)...) +func (grp *Group) Add(methods []string, path string, handler any, handlers ...any) Router { + grp.app.register(methods, getGroupPath(grp.Prefix, path), grp, append([]any{handler}, handlers...)...) if !grp.anyRouteDefined { grp.anyRouteDefined = true } @@ -225,7 +243,7 @@ func (grp *Group) Add(methods []string, path string, handler Handler, handlers . } // All will register the handler on all HTTP methods -func (grp *Group) All(path string, handler Handler, handlers ...Handler) Router { +func (grp *Group) All(path string, handler any, handlers ...any) Router { _ = grp.Add(grp.app.config.RequestMethods, path, handler, handlers...) return grp } @@ -234,7 +252,7 @@ func (grp *Group) All(path string, handler Handler, handlers ...Handler) Router // // api := app.Group("/api") // api.Get("/users", handler) -func (grp *Group) Group(prefix string, handlers ...Handler) Router { +func (grp *Group) Group(prefix string, handlers ...any) Router { prefix = getGroupPath(grp.Prefix, prefix) if len(handlers) > 0 { grp.app.register([]string{methodUse}, prefix, grp, handlers...) diff --git a/group_test.go b/group_test.go index fd6a63bf286..0549b7fe200 100644 --- a/group_test.go +++ b/group_test.go @@ -57,6 +57,19 @@ func Test_Group_OpenAPI_Helpers(t *testing.T) { require.Equal(t, []string{MIMEApplicationJSON}, route.RequestBody.MediaTypes) }) + t.Run("RequestBodyWithExample", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Post("/users", testEmptyHandler). + RequestBodyWithExample("User", true, map[string]any{"type": "object"}, "#/components/schemas/User", map[string]any{"name": "doe"}, map[string]any{"sample": map[string]any{"name": "john"}}, MIMEApplicationJSON) + route := app.stack[app.methodInt(MethodPost)][0] + require.NotNil(t, route.RequestBody) + require.Equal(t, "#/components/schemas/User", route.RequestBody.SchemaRef) + require.Equal(t, map[string]any{"$ref": "#/components/schemas/User"}, route.RequestBody.Schema) + require.Equal(t, map[string]any{"name": "doe"}, route.RequestBody.Example) + }) + t.Run("Parameter", func(t *testing.T) { t.Parallel() app := New() @@ -69,6 +82,19 @@ func Test_Group_OpenAPI_Helpers(t *testing.T) { require.Equal(t, "integer", route.Parameters[0].Schema["type"]) }) + t.Run("ParameterWithExample", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users/:id", testEmptyHandler). + ParameterWithExample("id", "path", false, nil, "#/components/schemas/ID", "identifier", "123", map[string]any{"sample": "value"}) + route := app.stack[app.methodInt(MethodGet)][0] + require.Len(t, route.Parameters, 1) + require.Equal(t, "#/components/schemas/ID", route.Parameters[0].SchemaRef) + require.Equal(t, "123", route.Parameters[0].Example) + require.Equal(t, map[string]any{"sample": "value"}, route.Parameters[0].Examples) + }) + t.Run("Response", func(t *testing.T) { t.Parallel() app := New() @@ -79,6 +105,20 @@ func Test_Group_OpenAPI_Helpers(t *testing.T) { require.Equal(t, []string{MIMEApplicationJSON}, route.Responses["201"].MediaTypes) }) + t.Run("ResponseWithExample", func(t *testing.T) { + t.Parallel() + app := New() + grp := app.Group("/api") + grp.Get("/users", testEmptyHandler). + ResponseWithExample(StatusCreated, "Created", nil, "#/components/schemas/User", map[string]any{"id": 1}, map[string]any{"sample": map[string]any{"id": 2}}, MIMEApplicationJSON) + route := app.stack[app.methodInt(MethodGet)][0] + resp := route.Responses["201"] + require.Equal(t, "#/components/schemas/User", resp.SchemaRef) + require.Equal(t, map[string]any{"$ref": "#/components/schemas/User"}, resp.Schema) + require.Equal(t, map[string]any{"id": 1}, resp.Example) + require.Equal(t, map[string]any{"sample": map[string]any{"id": 2}}, resp.Examples) + }) + t.Run("Tags", func(t *testing.T) { t.Parallel() app := New() diff --git a/middleware/openapi/config.go b/middleware/openapi/config.go index e790c8396df..404018cece5 100644 --- a/middleware/openapi/config.go +++ b/middleware/openapi/config.go @@ -103,7 +103,10 @@ type Operation struct { // Parameter describes a single OpenAPI parameter. type Parameter struct { - Schema map[string]any + Schema map[string]any + SchemaRef string + Examples map[string]any + Example any Name string In string @@ -113,18 +116,27 @@ type Parameter struct { // Media describes the schema payload for a request or response media type. type Media struct { - Schema map[string]any + Schema map[string]any + SchemaRef string + Examples map[string]any + Example any } // Response describes an OpenAPI response object. type Response struct { Content map[string]Media + Examples map[string]any + Example any + SchemaRef string Description string } // RequestBody describes the request body configuration for an operation. type RequestBody struct { Content map[string]Media + Examples map[string]any + Example any + SchemaRef string Description string Required bool } diff --git a/middleware/openapi/openapi.go b/middleware/openapi/openapi.go index 430dad25099..2e1c8775aec 100644 --- a/middleware/openapi/openapi.go +++ b/middleware/openapi/openapi.go @@ -113,6 +113,8 @@ type response struct { type parameter struct { Schema map[string]any `json:"schema,omitempty"` + Example any `json:"example,omitempty"` + Examples map[string]any `json:"examples,omitempty"` Description string `json:"description,omitempty"` Name string `json:"name"` In string `json:"in"` @@ -254,10 +256,9 @@ func mergeRouteParameters(params []parameter, index map[string]int, extras []fib In: location, Description: extra.Description, Required: extra.Required, - Schema: copyAnyMap(extra.Schema), - } - if param.Schema == nil { - param.Schema = map[string]any{"type": "string"} + Schema: schemaFrom(extra.Schema, extra.SchemaRef, "string"), + Example: extra.Example, + Examples: copyAnyMap(extra.Examples), } if param.In == "path" { param.Required = true @@ -284,10 +285,9 @@ func mergeConfigParameters(params []parameter, index map[string]int, extras []Pa In: location, Description: extra.Description, Required: extra.Required, - Schema: copyAnyMap(extra.Schema), - } - if param.Schema == nil { - param.Schema = map[string]any{"type": "string"} + Schema: schemaFrom(extra.Schema, extra.SchemaRef, "string"), + Example: extra.Example, + Examples: copyAnyMap(extra.Examples), } if param.In == "path" { param.Required = true @@ -319,6 +319,40 @@ func copyAnyMap(src map[string]any) map[string]any { return dst } +func schemaFrom(schema map[string]any, schemaRef string, defaultType string) map[string]any { + if schemaRef != "" { + return map[string]any{"$ref": schemaRef} + } + + copied := copyAnyMap(schema) + if copied == nil { + copied = map[string]any{} + } + if _, ok := copied["type"]; !ok && defaultType != "" { + copied["type"] = defaultType + } + if len(copied) == 0 { + return nil + } + return copied +} + +func contentEntry(schema map[string]any, schemaRef string, example any, examples map[string]any) map[string]any { + entry := map[string]any{} + if schemaRef != "" { + entry["schema"] = map[string]any{"$ref": schemaRef} + } else if copy := copyAnyMap(schema); len(copy) > 0 { + entry["schema"] = copy + } + if example != nil { + entry["example"] = example + } + if ex := copyAnyMap(examples); len(ex) > 0 { + entry["examples"] = ex + } + return entry +} + func mergeResponses(routeResponses map[string]fiber.RouteResponse, cfgResponses map[string]Response) map[string]response { var merged map[string]response if len(routeResponses) > 0 { @@ -326,7 +360,7 @@ func mergeResponses(routeResponses map[string]fiber.RouteResponse, cfgResponses for code, resp := range routeResponses { merged[code] = response{ Description: resp.Description, - Content: mediaTypesToContent(resp.MediaTypes), + Content: mediaTypesToContent(resp.MediaTypes, resp.Schema, resp.SchemaRef, resp.Example, resp.Examples), } } } @@ -337,29 +371,45 @@ func mergeResponses(routeResponses map[string]fiber.RouteResponse, cfgResponses for code, resp := range cfgResponses { merged[code] = response{ Description: resp.Description, - Content: convertMediaContent(resp.Content), + Content: convertMediaContent(resp.Content, nil, resp.SchemaRef, resp.Example, resp.Examples), } } } return merged } -func convertMediaContent(content map[string]Media) map[string]map[string]any { +func convertMediaContent(content map[string]Media, defaultSchema map[string]any, defaultSchemaRef string, defaultExample any, defaultExamples map[string]any) map[string]map[string]any { if len(content) == 0 { return nil } converted := make(map[string]map[string]any, len(content)) for mediaType, media := range content { - entry := map[string]any{} - if schema := copyAnyMap(media.Schema); len(schema) > 0 { - entry["schema"] = schema + entry := contentEntry(media.Schema, media.SchemaRef, media.Example, media.Examples) + if len(entry) == 0 && (len(defaultSchema) > 0 || defaultSchemaRef != "" || defaultExample != nil || len(defaultExamples) > 0) { + entry = contentEntry(defaultSchema, defaultSchemaRef, defaultExample, defaultExamples) + } else { + if _, ok := entry["schema"]; !ok { + if defaultSchemaRef != "" { + entry["schema"] = map[string]any{"$ref": defaultSchemaRef} + } else if schema := copyAnyMap(defaultSchema); len(schema) > 0 { + entry["schema"] = schema + } + } + if _, ok := entry["example"]; !ok && defaultExample != nil { + entry["example"] = defaultExample + } + if _, ok := entry["examples"]; !ok { + if ex := copyAnyMap(defaultExamples); len(ex) > 0 { + entry["examples"] = ex + } + } } converted[mediaType] = entry } return converted } -func mediaTypesToContent(mediaTypes []string) map[string]map[string]any { +func mediaTypesToContent(mediaTypes []string, schema map[string]any, schemaRef string, example any, examples map[string]any) map[string]map[string]any { if len(mediaTypes) == 0 { return nil } @@ -368,7 +418,11 @@ func mediaTypesToContent(mediaTypes []string) map[string]map[string]any { if mediaType == "" { continue } - content[mediaType] = map[string]any{} + entry := contentEntry(schema, schemaRef, example, examples) + if len(entry) == 0 { + entry = map[string]any{} + } + content[mediaType] = entry } if len(content) == 0 { return nil @@ -382,14 +436,14 @@ func buildRequestBody(routeBody *fiber.RouteRequestBody, cfgBody *RequestBody) * merged = &requestBody{ Description: routeBody.Description, Required: routeBody.Required, - Content: mediaTypesToContent(routeBody.MediaTypes), + Content: mediaTypesToContent(routeBody.MediaTypes, routeBody.Schema, routeBody.SchemaRef, routeBody.Example, routeBody.Examples), } } if cfgBody != nil { cfgReq := &requestBody{ Description: cfgBody.Description, Required: cfgBody.Required, - Content: convertMediaContent(cfgBody.Content), + Content: convertMediaContent(cfgBody.Content, nil, cfgBody.SchemaRef, cfgBody.Example, cfgBody.Examples), } if merged == nil { merged = cfgReq diff --git a/middleware/openapi/openapi_test.go b/middleware/openapi/openapi_test.go index 4c5fce95aed..9a56ae9189b 100644 --- a/middleware/openapi/openapi_test.go +++ b/middleware/openapi/openapi_test.go @@ -339,6 +339,43 @@ func Test_OpenAPI_DefaultResponses(t *testing.T) { }) } +func Test_OpenAPI_SchemaRefsAndExamples(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Post("/users", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusCreated) }). + ParameterWithExample("q", "query", false, nil, "#/components/schemas/Query", "search query", "abc", map[string]any{"sample": "abc"}). + RequestBodyWithExample("user body", true, nil, "#/components/schemas/User", map[string]any{"name": "john"}, map[string]any{"sample": map[string]any{"name": "doe"}}, fiber.MIMEApplicationJSON). + ResponseWithExample(fiber.StatusCreated, "Created", nil, "#/components/schemas/UserResponse", map[string]any{"id": 1}, map[string]any{"sample": map[string]any{"id": 2}}, fiber.MIMEApplicationJSON) + + paths := getPaths(t, app) + op := requireMap(t, paths["/users"]["post"]) + + params := requireSlice(t, op["parameters"]) + require.Len(t, params, 1) + param := requireMap(t, params[0]) + require.Equal(t, "search query", param["description"]) + require.Equal(t, "abc", param["example"]) + require.Equal(t, map[string]any{"sample": "abc"}, requireMap(t, param["examples"])) + paramSchema := requireMap(t, param["schema"]) + require.Equal(t, "#/components/schemas/Query", paramSchema["$ref"]) + + body := requireMap(t, op["requestBody"]) + bodyContent := requireMap(t, body["content"]) + jsonContent := requireMap(t, bodyContent[fiber.MIMEApplicationJSON]) + bodySchema := requireMap(t, jsonContent["schema"]) + require.Equal(t, "#/components/schemas/User", bodySchema["$ref"]) + require.Equal(t, map[string]any{"name": "john"}, jsonContent["example"]) + require.Equal(t, map[string]any{"sample": map[string]any{"name": "doe"}}, requireMap(t, jsonContent["examples"])) + + resp := requireMap(t, requireMap(t, op["responses"])["201"]) + respContent := requireMap(t, resp["content"]) + respJSON := requireMap(t, respContent[fiber.MIMEApplicationJSON]) + respSchema := requireMap(t, respJSON["schema"]) + require.Equal(t, "#/components/schemas/UserResponse", respSchema["$ref"]) + require.Equal(t, map[string]any{"id": float64(1)}, respJSON["example"]) + require.Equal(t, map[string]any{"sample": map[string]any{"id": float64(2)}}, requireMap(t, respJSON["examples"])) +} + // getPaths is a helper that mounts the middleware, performs the request and // decodes the resulting OpenAPI specification paths. func getPaths(t *testing.T, app *fiber.App) map[string]map[string]any { diff --git a/register.go b/register.go index c7e8a12a8b6..6786d8ecaff 100644 --- a/register.go +++ b/register.go @@ -6,18 +6,18 @@ package fiber // Register defines all router handle interface generate by Route(). type Register interface { - All(handler Handler, handlers ...Handler) Register - Get(handler Handler, handlers ...Handler) Register - Head(handler Handler, handlers ...Handler) Register - Post(handler Handler, handlers ...Handler) Register - Put(handler Handler, handlers ...Handler) Register - Delete(handler Handler, handlers ...Handler) Register - Connect(handler Handler, handlers ...Handler) Register - Options(handler Handler, handlers ...Handler) Register - Trace(handler Handler, handlers ...Handler) Register - Patch(handler Handler, handlers ...Handler) Register - - Add(methods []string, handler Handler, handlers ...Handler) Register + All(handler any, handlers ...any) Register + Get(handler any, handlers ...any) Register + Head(handler any, handlers ...any) Register + Post(handler any, handlers ...any) Register + Put(handler any, handlers ...any) Register + Delete(handler any, handlers ...any) Register + Connect(handler any, handlers ...any) Register + Options(handler any, handlers ...any) Register + Trace(handler any, handlers ...any) Register + Patch(handler any, handlers ...any) Register + + Add(methods []string, handler any, handlers ...any) Register Route(path string) Register } @@ -45,68 +45,68 @@ type Registering struct { // }) // // This method will match all HTTP verbs: GET, POST, PUT, HEAD etc... -func (r *Registering) All(handler Handler, handlers ...Handler) Register { - r.app.register([]string{methodUse}, r.path, nil, append([]Handler{handler}, handlers...)...) +func (r *Registering) All(handler any, handlers ...any) Register { + r.app.register([]string{methodUse}, r.path, nil, append([]any{handler}, handlers...)...) return r } // Get registers a route for GET methods that requests a representation // of the specified resource. Requests using GET should only retrieve data. -func (r *Registering) Get(handler Handler, handlers ...Handler) Register { +func (r *Registering) Get(handler any, handlers ...any) Register { r.app.Add([]string{MethodGet}, r.path, handler, handlers...) return r } // Head registers a route for HEAD methods that asks for a response identical // to that of a GET request, but without the response body. -func (r *Registering) Head(handler Handler, handlers ...Handler) Register { +func (r *Registering) Head(handler any, handlers ...any) Register { return r.Add([]string{MethodHead}, handler, handlers...) } // Post registers a route for POST methods that is used to submit an entity to the // specified resource, often causing a change in state or side effects on the server. -func (r *Registering) Post(handler Handler, handlers ...Handler) Register { +func (r *Registering) Post(handler any, handlers ...any) Register { return r.Add([]string{MethodPost}, handler, handlers...) } // Put registers a route for PUT methods that replaces all current representations // of the target resource with the request payload. -func (r *Registering) Put(handler Handler, handlers ...Handler) Register { +func (r *Registering) Put(handler any, handlers ...any) Register { return r.Add([]string{MethodPut}, handler, handlers...) } // Delete registers a route for DELETE methods that deletes the specified resource. -func (r *Registering) Delete(handler Handler, handlers ...Handler) Register { +func (r *Registering) Delete(handler any, handlers ...any) Register { return r.Add([]string{MethodDelete}, handler, handlers...) } // Connect registers a route for CONNECT methods that establishes a tunnel to the // server identified by the target resource. -func (r *Registering) Connect(handler Handler, handlers ...Handler) Register { +func (r *Registering) Connect(handler any, handlers ...any) Register { return r.Add([]string{MethodConnect}, handler, handlers...) } // Options registers a route for OPTIONS methods that is used to describe the // communication options for the target resource. -func (r *Registering) Options(handler Handler, handlers ...Handler) Register { +func (r *Registering) Options(handler any, handlers ...any) Register { return r.Add([]string{MethodOptions}, handler, handlers...) } // Trace registers a route for TRACE methods that performs a message loop-back // test along the r.Path to the target resource. -func (r *Registering) Trace(handler Handler, handlers ...Handler) Register { +func (r *Registering) Trace(handler any, handlers ...any) Register { return r.Add([]string{MethodTrace}, handler, handlers...) } // Patch registers a route for PATCH methods that is used to apply partial // modifications to a resource. -func (r *Registering) Patch(handler Handler, handlers ...Handler) Register { +func (r *Registering) Patch(handler any, handlers ...any) Register { return r.Add([]string{MethodPatch}, handler, handlers...) } // Add allows you to specify multiple HTTP methods to register a route. -func (r *Registering) Add(methods []string, handler Handler, handlers ...Handler) Register { - r.app.register(methods, r.path, nil, append([]Handler{handler}, handlers...)...) +func (r *Registering) Add(methods []string, handler any, handlers ...any) Register { + r.app.register(methods, r.path, nil, append([]any{handler}, handlers...)...) return r } diff --git a/router.go b/router.go index 4776e7ae6e8..708d00fbcbc 100644 --- a/router.go +++ b/router.go @@ -19,20 +19,20 @@ import ( type Router interface { Use(args ...any) Router - Get(path string, handler Handler, handlers ...Handler) Router - Head(path string, handler Handler, handlers ...Handler) Router - Post(path string, handler Handler, handlers ...Handler) Router - Put(path string, handler Handler, handlers ...Handler) Router - Delete(path string, handler Handler, handlers ...Handler) Router - Connect(path string, handler Handler, handlers ...Handler) Router - Options(path string, handler Handler, handlers ...Handler) Router - Trace(path string, handler Handler, handlers ...Handler) Router - Patch(path string, handler Handler, handlers ...Handler) Router + Get(path string, handler any, handlers ...any) Router + Head(path string, handler any, handlers ...any) Router + Post(path string, handler any, handlers ...any) Router + Put(path string, handler any, handlers ...any) Router + Delete(path string, handler any, handlers ...any) Router + Connect(path string, handler any, handlers ...any) Router + Options(path string, handler any, handlers ...any) Router + Trace(path string, handler any, handlers ...any) Router + Patch(path string, handler any, handlers ...any) Router - Add(methods []string, path string, handler Handler, handlers ...Handler) Router - All(path string, handler Handler, handlers ...Handler) Router + Add(methods []string, path string, handler any, handlers ...any) Router + All(path string, handler any, handlers ...any) Router - Group(prefix string, handlers ...Handler) Router + Group(prefix string, handlers ...any) Router Route(path string) Register @@ -51,12 +51,21 @@ type Router interface { // RequestBody documents the request body for the most recently // registered route. RequestBody(description string, required bool, mediaTypes ...string) Router + // RequestBodyWithExample documents the request body for the most recently + // registered route with schema references and examples. + RequestBodyWithExample(description string, required bool, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router // Parameter documents an input parameter for the most recently // registered route. Parameter(name, in string, required bool, schema map[string]any, description string) Router + // ParameterWithExample documents an input parameter for the most recently + // registered route, including schema references and examples. + ParameterWithExample(name, in string, required bool, schema map[string]any, schemaRef string, description string, example any, examples map[string]any) Router // Response documents an HTTP response for the most recently // registered route. Response(status int, description string, mediaTypes ...string) Router + // ResponseWithExample documents an HTTP response for the most recently + // registered route, including schema references and examples. + ResponseWithExample(status int, description string, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router // Tags sets the tags for the most recently registered route. Tags(tags ...string) Router // Deprecated marks the most recently registered route as deprecated. @@ -99,6 +108,9 @@ type Route struct { // RouteParameter describes an input captured by a route. type RouteParameter struct { Schema map[string]any `json:"schema"` + SchemaRef string `json:"schemaRef,omitempty"` + Example any `json:"example,omitempty"` + Examples map[string]any `json:"examples,omitempty"` Description string `json:"description"` Name string `json:"name"` In string `json:"in"` @@ -107,15 +119,23 @@ type RouteParameter struct { // RouteResponse describes a response emitted by a route. type RouteResponse struct { - MediaTypes []string `json:"mediaTypes"` //nolint:tagliatelle - Description string `json:"description"` + MediaTypes []string `json:"mediaTypes"` //nolint:tagliatelle + Schema map[string]any `json:"schema,omitempty"` + SchemaRef string `json:"schemaRef,omitempty"` + Example any `json:"example,omitempty"` + Examples map[string]any `json:"examples,omitempty"` + Description string `json:"description"` } // RouteRequestBody describes the request payload accepted by a route. type RouteRequestBody struct { - MediaTypes []string `json:"mediaTypes"` //nolint:tagliatelle - Description string `json:"description"` - Required bool `json:"required"` + MediaTypes []string `json:"mediaTypes"` //nolint:tagliatelle + Schema map[string]any `json:"schema,omitempty"` + SchemaRef string `json:"schemaRef,omitempty"` + Example any `json:"example,omitempty"` + Examples map[string]any `json:"examples,omitempty"` + Description string `json:"description"` + Required bool `json:"required"` } func (r *Route) match(detectionPath, path string, params *[maxParams]string) bool { @@ -456,6 +476,14 @@ func cloneRouteRequestBody(body *RouteRequestBody) *RouteRequestBody { Description: body.Description, Required: body.Required, } + if len(body.Schema) > 0 { + clone.Schema = copyAnyMap(body.Schema) + } + clone.SchemaRef = body.SchemaRef + if len(body.Examples) > 0 { + clone.Examples = copyAnyMap(body.Examples) + } + clone.Example = body.Example if len(body.MediaTypes) > 0 { clone.MediaTypes = append([]string(nil), body.MediaTypes...) } @@ -474,11 +502,10 @@ func cloneRouteParameters(params []RouteParameter) []RouteParameter { Required: p.Required, Description: p.Description, } - if len(p.Schema) > 0 { - schemaCopy := make(map[string]any, len(p.Schema)) - maps.Copy(schemaCopy, p.Schema) - cloned[i].Schema = schemaCopy - } + cloned[i].Schema = copyAnyMap(p.Schema) + cloned[i].SchemaRef = p.SchemaRef + cloned[i].Examples = copyAnyMap(p.Examples) + cloned[i].Example = p.Example } return cloned } @@ -489,7 +516,13 @@ func cloneRouteResponses(responses map[string]RouteResponse) map[string]RouteRes } cloned := make(map[string]RouteResponse, len(responses)) for code, resp := range responses { - copyResp := RouteResponse{Description: resp.Description} + copyResp := RouteResponse{ + Description: resp.Description, + Schema: copyAnyMap(resp.Schema), + SchemaRef: resp.SchemaRef, + Examples: copyAnyMap(resp.Examples), + Example: resp.Example, + } if len(resp.MediaTypes) > 0 { copyResp.MediaTypes = append([]string(nil), resp.MediaTypes...) } @@ -498,6 +531,15 @@ func cloneRouteResponses(responses map[string]RouteResponse) map[string]RouteRes return cloned } +func copyAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + maps.Copy(dst, src) + return dst +} + func (app *App) normalizePath(path string) string { if path == "" { path = "/" @@ -584,17 +626,13 @@ func (app *App) deleteRoute(methods []string, matchFunc func(r *Route) bool) { } } -func (app *App) register(methods []string, pathRaw string, group *Group, handlers ...Handler) { +func (app *App) register(methods []string, pathRaw string, group *Group, handlers ...any) { // A regular route requires at least one ctx handler if len(handlers) == 0 && group == nil { panic(fmt.Sprintf("missing handler/middleware in route: %s\n", pathRaw)) } - // No nil handlers allowed - for _, h := range handlers { - if nil == h { - panic(fmt.Sprintf("nil handler in route: %s\n", pathRaw)) - } - } + + ctxHandlers := adaptHandlers(pathRaw, handlers...) // Precompute path normalization ONCE if pathRaw == "" { @@ -640,7 +678,7 @@ func (app *App) register(methods []string, pathRaw string, group *Group, handler Path: pathRaw, Method: method, - Handlers: handlers, + Handlers: ctxHandlers, Summary: "", Description: "", Consumes: MIMETextPlain, @@ -648,7 +686,7 @@ func (app *App) register(methods []string, pathRaw string, group *Group, handler } // Increment global handler count - atomic.AddUint32(&app.handlersCount, uint32(len(handlers))) //nolint:gosec // Not a concern + atomic.AddUint32(&app.handlersCount, uint32(len(ctxHandlers))) //nolint:gosec // Not a concern // Middleware route matches all HTTP methods if isUse { diff --git a/router_test.go b/router_test.go index 58a0a1b4b28..109c456a602 100644 --- a/router_test.go +++ b/router_test.go @@ -416,7 +416,7 @@ func Test_Router_NotFound_HTML_Inject(t *testing.T) { require.Equal(t, "Not Found", string(c.Response.Body())) } -func registerTreeManipulationRoutes(app *App, middleware ...func(Ctx) error) { +func registerTreeManipulationRoutes(app *App, middleware ...any) { app.Get("/test", func(c Ctx) error { app.Get("/dynamically-defined", func(c Ctx) error { return c.SendStatus(StatusOK) @@ -1485,6 +1485,23 @@ func Test_App_RequestBody(t *testing.T) { require.Equal(t, MIMEApplicationJSON, route.Consumes) } +func Test_App_RequestBodyWithExample(t *testing.T) { + t.Parallel() + examples := map[string]any{ + "sample": map[string]any{"name": "john"}, + } + app := New() + app.Post("/users", testEmptyHandler). + RequestBodyWithExample("payload", true, map[string]any{"type": "object"}, "#/components/schemas/User", map[string]any{"name": "doe"}, examples, MIMEApplicationJSON) + + route := app.stack[app.methodInt(MethodPost)][0] + require.NotNil(t, route.RequestBody) + require.Equal(t, "#/components/schemas/User", route.RequestBody.SchemaRef) + require.Equal(t, map[string]any{"$ref": "#/components/schemas/User"}, route.RequestBody.Schema) + require.Equal(t, map[string]any{"name": "doe"}, route.RequestBody.Example) + require.Equal(t, examples, route.RequestBody.Examples) +} + func Test_App_Parameter(t *testing.T) { t.Parallel() app := New() @@ -1510,6 +1527,26 @@ func Test_App_Parameter(t *testing.T) { require.Equal(t, "Filter results", queryParam.Description) } +func Test_App_ParameterWithExample(t *testing.T) { + t.Parallel() + app := New() + app.Get("/:id", testEmptyHandler). + ParameterWithExample("id", "path", false, nil, "#/components/schemas/ID", "identifier", "123", map[string]any{"sample": 123}) + + route := app.stack[app.methodInt(MethodGet)][0] + require.Len(t, route.Parameters, 1) + + param := route.Parameters[0] + require.Equal(t, "id", param.Name) + require.Equal(t, "path", param.In) + require.True(t, param.Required) + require.Equal(t, "#/components/schemas/ID", param.SchemaRef) + require.Equal(t, map[string]any{"$ref": "#/components/schemas/ID"}, param.Schema) + require.Equal(t, "identifier", param.Description) + require.Equal(t, "123", param.Example) + require.Equal(t, map[string]any{"sample": 123}, param.Examples) +} + func Test_App_Response(t *testing.T) { t.Parallel() app := New() @@ -1537,6 +1574,24 @@ func Test_App_Response(t *testing.T) { require.Equal(t, "Default fallback", defResp.Description) } +func Test_App_ResponseWithExample(t *testing.T) { + t.Parallel() + examples := map[string]any{"sample": map[string]any{"id": 2}} + app := New() + app.Get("/", testEmptyHandler). + ResponseWithExample(StatusOK, "user response", nil, "#/components/schemas/User", map[string]any{"id": 1}, examples, MIMEApplicationJSON) + + route := app.stack[app.methodInt(MethodGet)][0] + resp, ok := route.Responses["200"] + require.True(t, ok) + require.Equal(t, "#/components/schemas/User", resp.SchemaRef) + require.Equal(t, map[string]any{"$ref": "#/components/schemas/User"}, resp.Schema) + require.Equal(t, map[string]any{"id": 1}, resp.Example) + require.Equal(t, examples, resp.Examples) + //nolint:testifylint // MIMEApplicationJSON is a plain string, JSONEq not required + require.Equal(t, MIMEApplicationJSON, route.Produces) +} + func Test_App_Response_InvalidStatus(t *testing.T) { t.Parallel() app := New()