Repository navigation
Expand file tree
/
Copy pathapi_note.go
More file actions
398 lines (358 loc) · 13.3 KB
/
Copy pathapi_note.go
File metadata and controls
398 lines (358 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
// Package notelink provides a framework for generating API documentation
// and integrating it with a Fiber web server. It allows developers to define
// endpoints with parameters, schemas, and authentication, and automatically
// generates an interactive HTML documentation page.
//
// The package supports:
// - Endpoint documentation with methods, paths, descriptions, and responses
// - Parameter definitions (query, path, header)
// - Automatic TypeScript schema generation from Go structs
// - JWT authentication middleware
// - Interactive API testing via a generated HTML interface
//
// To use this package, create an ApiNote instance with a configuration and
// JWT secret, then define routes using the DocumentedRoute method. The
// documentation is served at "/api-docs" by default.
//
// Example:
//
// config := notelink.Config{Title: "My API", Host: "localhost:8080"}
// api := notelink.NewApiNote(&config, "secret-key")
// api.DocumentedRoute("GET", "/api/v1/hello", "Say hello", map[string]string{"200": "Success"}, handler, nil)
// api.Listen()
package notelink
import (
"fmt"
"net/http"
"os"
"strings"
"github.com/goccy/go-json"
"github.com/gofiber/contrib/v3/monitor"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
// ApiNote is the main structure for API documentation and routing.
// It integrates with a Fiber application to serve both the API endpoints
// and their documentation.
type ApiNote struct {
endpoints map[string]Endpoint
config *Config
app *fiber.App
jwtSecret string
middlewares []fiber.Handler
jwtMiddlewares []fiber.Handler
customAuthMiddleware []fiber.Handler
}
// NewApiNote creates a new ApiNote instance with the provided configuration and JWT secret.
// It initializes a Fiber application and sets up the "/api-docs" route for documentation.
//
// The config parameter defines the API's title, host, and other metadata.
// The jwtSecret is used for JWT authentication middleware.
//
// Returns a pointer to the initialized ApiNote.
func NewApiNote(config *Config, jwtSecret string) *ApiNote {
app := fiber.New(fiber.Config{
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
})
apiNote := &ApiNote{
config: config,
endpoints: make(map[string]Endpoint),
app: app,
middlewares: []fiber.Handler{},
jwtMiddlewares: []fiber.Handler{},
customAuthMiddleware: []fiber.Handler{},
jwtSecret: jwtSecret,
}
app.Get("/api-docs", func(c fiber.Ctx) error {
// Default to Scalar if DocsUI is empty or explicitly set to "scalar"
if apiNote.config.DocsUI == "" || apiNote.config.DocsUI == "scalar" {
return apiNote.ScalarUIHandler()(c)
} else if apiNote.config.DocsUI == "swagger" {
return apiNote.SwaggerUIHandler()(c)
}
// Fallback to original HTML handler for any other value
return apiNote.Handler()(c)
})
app.Get("/api-docs/metrics", monitor.New(monitor.Config{Title: "Service Metrics Page"}))
app.Get("/api-docs/indent", func(c fiber.Ctx) error {
data, err := json.MarshalIndent(app.GetRoutes(true), "", " ")
if err != nil {
return c.Status(http.StatusInternalServerError).SendString("Error marshaling routes")
}
return c.Status(http.StatusOK).SendString(string(data))
})
// Serve OpenAPI JSON spec at /api-docs/openapi.json
app.Get("/api-docs/openapi.json", func(c fiber.Ctx) error {
spec := apiNote.GenerateOpenAPISpec()
c.Set("Content-Type", "application/json")
return c.JSON(spec)
})
// Serve favicon - try multiple possible locations
app.Get("/icon.png", func(c fiber.Ctx) error {
// Try different possible locations for the icon
iconPaths := []string{
"./icon.png", // Current directory
"../icon.png", // Parent directory (for examples folder)
"../../icon.png", // Grandparent directory
}
for _, iconPath := range iconPaths {
if _, err := os.Stat(iconPath); err == nil {
c.Set("Content-Type", "image/png")
return c.SendFile(iconPath)
}
}
// If no icon found, return 404
return c.Status(fiber.StatusNotFound).SendString("Icon not found")
})
// Also serve favicon.ico for browsers that look for it by default
app.Get("/favicon.ico", func(c fiber.Ctx) error {
// Try different possible locations for the icon
iconPaths := []string{
"./icon.png", // Current directory
"../icon.png", // Parent directory (for examples folder)
"../../icon.png", // Grandparent directory
}
for _, iconPath := range iconPaths {
if _, err := os.Stat(iconPath); err == nil {
c.Set("Content-Type", "image/x-icon")
return c.SendFile(iconPath)
}
}
// If no icon found, return 404
return c.Status(fiber.StatusNotFound).SendString("Icon not found")
})
return apiNote
}
// Use adds one or more middleware handlers to be applied to all subsequent routes.
// Middleware is executed in the order it is added.
// These middlewares are treated as custom (non-authentication) middleware and will
// not set the AuthRequired flag in endpoint documentation.
//
// Example:
//
// api.Use(RequestLoggerMiddleware()) // Apply custom logging to all following routes
func (an *ApiNote) Use(middleware ...fiber.Handler) {
an.middlewares = append(an.middlewares, middleware...)
}
// UseJWT adds JWT authentication middleware to all subsequent routes.
// Routes defined after calling this method will have AuthRequired set to true
// in their documentation, indicating they require authentication.
//
// This is a convenience method that calls JWTMiddleware() and tracks it separately
// from custom middleware, allowing proper documentation of authentication requirements.
//
// Example:
//
// api.UseJWT() // All routes defined after this will require JWT authentication
func (an *ApiNote) UseJWT() {
an.jwtMiddlewares = append(an.jwtMiddlewares, an.JWTMiddleware())
}
// UseCustomAuth adds custom authentication middleware to all subsequent routes.
// Routes defined after calling this method will have AuthRequired set to true
// in their documentation, indicating they require authentication.
//
// This method allows you to use your own authentication logic instead of the
// built-in JWT middleware while still properly documenting that routes require authentication.
//
// Example:
//
// api.UseCustomAuth(MyCustomAuthMiddleware()) // All routes defined after this will require custom authentication
func (an *ApiNote) UseCustomAuth(middleware ...fiber.Handler) {
an.customAuthMiddleware = append(an.customAuthMiddleware, middleware...)
}
// DocumentedRoute registers an API endpoint with its documentation and handler.
// It accepts a DocumentedRouteInput object containing the route details and
// processes it to add the route to the Fiber app and store endpoint details for documentation.
//
// Parameters:
// - input: DocumentedRouteInput object containing method, path, description, responses,
// handler, params, schemasRequest, and schemasResponse
//
// Example usage:
//
// api.DocumentedRoute(notelink.DocumentedRouteInput{
// Method: "POST",
// Path: "/v3/users",
// Description: "Create a new user (Authenticated)",
// Responses: map[string]string{
// "201": "User created",
// "400": "Invalid input",
// "401": "Unauthorized",
// },
// Handler: handlerFunc,
// Params: []notelink.Parameter{},
// SchemasRequest: CreateUserRequest{},
// SchemasResponse: UserResponse{},
// })
func (an *ApiNote) DocumentedRoute(input *DocumentedRouteInput) error {
// Validate required fields
if input.Method == "" || input.Path == "" {
return fmt.Errorf("method and path are required")
}
if input.Handler == nil {
return fmt.Errorf("handler is required")
}
key := input.Method + " " + input.Path
endpoint := Endpoint{
Method: input.Method,
Path: an.config.BasePath + input.Path,
Description: input.Description,
Responses: input.Responses,
Parameters: input.Params,
}
// Set AuthRequired based on explicit input or JWT middleware presence
if input.AuthRequired != nil {
endpoint.AuthRequired = *input.AuthRequired
} else {
// Auto-detect: true if JWT middleware or custom auth middleware is active
endpoint.AuthRequired = len(an.jwtMiddlewares) > 0 || len(an.customAuthMiddleware) > 0
}
if input.SchemasRequest != nil {
endpoint.RequestSchema = input.SchemasRequest
}
if input.SchemasResponse != nil {
endpoint.ResponseSchema = input.SchemasResponse
}
an.endpoints[key] = endpoint
// Combine authentication middlewares (JWT or custom), custom middlewares, then add the handler
handlers := []any{}
if endpoint.AuthRequired {
// Add JWT middlewares if present
for _, h := range an.jwtMiddlewares {
handlers = append(handlers, h)
}
// Add custom auth middlewares if present
for _, h := range an.customAuthMiddleware {
handlers = append(handlers, h)
}
}
// Add validation middleware if validation is needed
// Validation is enabled by default when parameters or request schema are defined
if len(endpoint.Parameters) > 0 || endpoint.RequestSchema != nil {
validationHandler := func(c fiber.Ctx) error {
// Validate parameters
if len(endpoint.Parameters) > 0 {
if err := ValidateParameters(c, endpoint.Parameters); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(err)
}
}
// Validate request body for POST/PUT/PATCH
if endpoint.RequestSchema != nil &&
(endpoint.Method == "POST" || endpoint.Method == "PUT" || endpoint.Method == "PATCH") {
if err := ValidateRequestBody(c, endpoint.RequestSchema); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(err)
}
}
return c.Next()
}
handlers = append(handlers, validationHandler)
}
// Add custom non-auth middlewares
for _, h := range an.middlewares {
handlers = append(handlers, h)
}
// Add the route handler
handlers = append(handlers, input.Handler)
// Ensure we have at least one handler
if len(handlers) == 0 {
return fmt.Errorf("at least one handler is required")
}
path := an.config.BasePath + input.Path
// Get first handler and rest as varargs for v3 API
firstHandler := handlers[0]
restHandlers := []any{}
if len(handlers) > 1 {
restHandlers = handlers[1:]
}
switch strings.ToUpper(input.Method) {
case "GET":
an.app.Get(path, firstHandler, restHandlers...)
case "POST":
an.app.Post(path, firstHandler, restHandlers...)
case "PUT":
an.app.Put(path, firstHandler, restHandlers...)
case "DELETE":
an.app.Delete(path, firstHandler, restHandlers...)
case "PATCH":
an.app.Patch(path, firstHandler, restHandlers...)
case "HEAD":
an.app.Head(path, firstHandler, restHandlers...)
case "CONNECT":
an.app.Connect(path, firstHandler, restHandlers...)
case "OPTIONS":
an.app.Options(path, firstHandler, restHandlers...)
case "TRACE":
an.app.Trace(path, firstHandler, restHandlers...)
default:
return fmt.Errorf("unsupported HTTP method: %s", input.Method)
}
return nil
}
// Fiber returns the underlying *fiber.App instance used by the ApiNote.
//
// This allows external packages or components to directly access and
// configure the Fiber application (e.g., for adding routes, middleware, etc.).
func (an *ApiNote) Fiber() *fiber.App {
return an.app
}
// Handler returns a Fiber handler that serves the API documentation as HTML.
// The documentation is generated dynamically based on registered endpoints.
//
// The returned handler sets the Content-Type to "text/html" and responds with status 200.
func (an *ApiNote) Handler() fiber.Handler {
return func(c fiber.Ctx) error {
html := an.generateHTML()
c.Set("Content-Type", "text/html")
return c.Status(http.StatusOK).SendString(html)
}
}
// JWTMiddleware returns a Fiber middleware handler that validates JWT tokens.
// It checks the "Authorization" header for a "Bearer" token and verifies it
// using the configured jwtSecret.
//
// If the token is valid, it sets the "user_id" in the context from the token's "sub" claim.
// If invalid or missing, it returns a 401 Unauthorized response.
//
// Example usage:
//
// api.Use(api.JWTMiddleware())
func (an *ApiNote) JWTMiddleware() fiber.Handler {
return func(c fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Authorization header required"})
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Authorization header format"})
}
tokenStr := parts[1]
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(an.jwtSecret), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
}
if claims, ok := token.Claims.(jwt.MapClaims); ok {
c.Locals("user_id", claims["sub"])
}
return c.Next()
}
}
// Listen starts the Fiber server on the port specified in Config.Host.
// The Host field should be in the format "host:port" (e.g., "localhost:8080").
// If no port is specified, it defaults to ":8080".
//
// Returns an error if the server fails to start.
func (an *ApiNote) Listen() error {
hostParts := strings.Split(an.config.Host, ":")
port := ":8080" // Default port
if len(hostParts) > 1 {
port = ":" + hostParts[1]
}
return an.app.Listen(port)
}