Melkeydev / go-blueprint

Go-blueprint allows users to spin up a quick Go project using a popular framework
MIT License
2.07k stars 141 forks source link

[Feature Request] [Fiber Framework] A Better Versioning REST APIs/Web Front End #225

Closed H0llyW00dzZ closed 1 month ago

H0llyW00dzZ commented 1 month ago

Tell us about your feature request

As there is no implementation for better versioning of REST APIs/Web Front End for the Fiber framework, if anyone agrees, I can submit a pull request. This will also make it easier for maintainability, reusability, and scalability, following the idiomatic Go style.

Example:

// APIRoute represents a single API route, containing the path, HTTP method,
// handler function, and an optional rate limiter.
type APIRoute struct {
    Path        string
    Method      string
    Handler     fiber.Handler
    RateLimiter fiber.Handler
}

// APIGroup represents a group of API routes under a common prefix.
// It also allows for a group-wide rate limiter.
type APIGroup struct {
    Prefix      string
    Routes      []APIRoute
    RateLimiter fiber.Handler
}

// registerAPIRoutes sets up the API routing for a given Fiber router.
// It configures API versioning and registers health checks, as well as
// custom application routes.
//
// Parameters:
//
//  api: The Fiber router on which to register the routes.
//  appName: A string representing the name of the application, used for WebSocket subprotocols.
//  monitorPath: The path for the server monitoring route.
//  db: The database service interface used for data-related operations.
func registerAPIRoutes(api fiber.Router, appName, monitorPath string, db database.Service) {
    v1 := api.Group("/v1", func(c *fiber.Ctx) error { // "/v1/" prefix
        c.Set("Version", "v1")
        return c.Next()
    })
    v2 := api.Group("/v2", func(c *fiber.Ctx) error { // "/v2/" prefix
        c.Set("Version", "v2")
        return c.Next()
    })
    // ... function implementation ...
}

// theAPIs registers custom application routes for different API versions.
//
// Parameters:
//
//  v1: Fiber router for version 1 of the API.
//  v2: Fiber router for version 2 of the API.
//  appName: A string representing the name of the application, used for WebSocket subprotocols.
//  monitorPath: The path for the server monitoring route.
//  db: The database service interface used for data-related operations.
//  siteRateLimiter: A rate limiter middleware applied to certain routes.
func theAPIs(v1, v2 fiber.Router, appName, monitorPath string, db database.Service, siteRateLimiter fiber.Handler) {
    // Define the API groups and routes
    apiGroups := []APIGroup{
        {
            Prefix: "/my-project/server",
            RateLimiter: siteRateLimiter,
            Routes: []APIRoute{
                {Path: monitorPath, Method: fiber.MethodGet, Handler: monitor.Server},
            },
        },
    }
    // Register the API routes for each version
    for _, group := range apiGroups {
        registerGroup(v1, group)
    }
    // ... function implementation ...
}

// registerGroup adds all routes from an APIGroup to a specific Fiber router.
//
// Parameters:
//
//  router: The Fiber router on which to register the group's routes.
//  group: The APIGroup containing the routes to be registered.
func registerGroup(router fiber.Router, group APIGroup) {
    g := router.Group(group.Prefix)

    if group.RateLimiter != nil {
        g.Use(group.RateLimiter)
    }

    for _, route := range group.Routes {
        if route.RateLimiter != nil {
            g.Add(route.Method, route.Path, route.RateLimiter, route.Handler)
        } else {
            g.Add(route.Method, route.Path, route.Handler)
        }
    }
    // ... function implementation ...
}

[!NOTE] This method currently works only for the Fiber framework, as I haven't tested it with other frameworks.

Disclaimer

H0llyW00dzZ commented 1 month ago

[!NOTE] Additionally, it should be noted that these methods are compatible with the latest version of Go (I'm using) and provide a more straightforward integration with databases.

Example:


          Prefix: "/gopher",
          Routes: []APIRoute{
              {Path: "/reload", Method: fiber.MethodPost, Handler: func(c *fiber.Ctx) error { return users.ReloadConfigHandler(c, db) }},
          },

then it will be https://go.dev/v1/gopher/reload (example)

H0llyW00dzZ commented 1 month ago

Updated:

The helper functions for these methods that can be used for custom error handling independently to avoid conflicts:

// isVersionedAPIRoute checks if the given path matches a versioned API route.
func isVersionedAPIRoute(path string) bool {
    // Define the versioned API route prefixes
    versionedAPIPrefixes := []string{"/v1", "/v2"}

    // Check if the path starts with any of the versioned API prefixes
    for _, prefix := range versionedAPIPrefixes {
        if len(path) >= len(prefix) && path[:len(prefix)] == prefix {
            return true
        }
    }

    return false
}

// apiInternalServerErrorHandler is a custom error handler for internal server errors in versioned APIs.
func apiInternalServerErrorHandler(c *fiber.Ctx, err error) error {
    // Log the error
    Logger.LogErrorf("Internal Server Error occurred in versioned API: %v", err)
    // Return a JSON response with the 500 Internal Server Error status code
    return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
        "error": fiber.ErrInternalServerError.Message,
    })
}

// apiErrorHandler is a custom error handler for versioned APIs.
// It returns JSON responses for 404 (Not Found) and 403 (Forbidden) status codes.
func apiErrorHandler(c *fiber.Ctx) error {
    // Check if the response status code is already set
    if c.Response().StatusCode() != fiber.StatusOK {
        // Get the current status code
        statusCode := c.Response().StatusCode()

        // Check if the status code is 404 or 403
        if statusCode == fiber.StatusNotFound {
            // Return a JSON response for 404 (Not Found) error
            return c.Status(statusCode).JSON(fiber.Map{
                "error": fiber.ErrNotFound.Message,
            })
        } else if statusCode == fiber.StatusForbidden {
            // Return a JSON response for 403 (Forbidden) error
            return c.Status(statusCode).JSON(fiber.Map{
                "error": fiber.ErrForbidden.Message,
            })
        }
    }

    // If the status code is not 404 or 403, continue with the original request
    return c.Next()
}

Then, you can simplify and call them like this:

    // Set custom error handler for the application
    app.Use(func(c *fiber.Ctx) error {
        // Call the next route handler and catch any errors
        err := c.Next()

        // If there was an error, use the custom error handler
        if err != nil {
            // Check if the error is a 404 or 403 error for versioned APIs
            if e, ok := err.(*fiber.Error); ok {
                if e.Code == fiber.StatusNotFound || e.Code == fiber.StatusForbidden {
                    // Check if the request path matches a versioned API route
                    if isVersionedAPIRoute(c.Path()) {
                        // Return a JSON response for 404 or 403 errors in versioned APIs
                        return c.Status(e.Code).JSON(fiber.Map{
                            "error": e.Message,
                        })
                    }
                }
            }

            // Check if the error is a 404 or 403 error for frontend routes
            if e, ok := err.(*fiber.Error); ok {
                if e.Code == fiber.StatusNotFound {
                    // Check if the request path matches a frontend route
                    if isFrontendRoute(c.Path()) {
                        // Render the 404 error page for frontend routes
                        return webPageHandler.PageNotFoundHandler(c)
                    }
                } else if e.Code == fiber.StatusForbidden {
                    // Check if the request path matches a frontend route
                    if isFrontendRoute(c.Path()) {
                        // Render the 403 error page for frontend routes
                        return webPageHandler.PageForbidden403Handler(c)
                    }
                }
            }

            // If the error is not a 404 or 403 error for versioned APIs or frontend routes, check if it's an internal server error
            if e, ok := err.(*fiber.Error); ok && e.Code == fiber.StatusInternalServerError {
                // Return a JSON response for internal server errors in versioned APIs
                return apiInternalServerErrorHandler(c, err)
            }

            // If the error is not a versioned API error, frontend error, or internal server error, fallback to the general error page
            return webPageHandler.Page500InternalServerHandler(c, err)
        }

        // ... rest of the code ...
    })

When a versioned API route is not found, it will use the original message provided by the Fiber framework, as it is reusable. Example:

image

[!NOTE] Also note that Page500InternalServerHandler and apiInternalServerErrorHandler are for handling manipulate panics.

briancbarrow commented 1 month ago

Like we mentioned in the other issues/PRs, versioning is something that can be handled by each user as they see fit.