diff --git a/.env.example b/.env.example index 72196a9..38ac906 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # TOKEN CONFIGURATION TOKEN_URL=http://localhost:8080/api/v1/token +TOKEN_CACHE_EXPIRY=5m # JWT Configuration JWT_SECRET=your_jwt_secret_key diff --git a/Makefile b/Makefile index 3426e3f..afe1983 100644 --- a/Makefile +++ b/Makefile @@ -62,9 +62,10 @@ docker/run: help: @grep -h -E '^[a-zA-Z_/-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -## test-unit: Run unit tests +## test-unit: Run unit tests in cmd, internal, and pkg folders test-unit: - go test ./tests/unit + @echo " > Running unit tests..." + go test ./internal/... ./pkg/... ## test-integration: Run integration tests test-integration: @@ -89,4 +90,13 @@ test-contract: ## test-all: Run all tests test-all: test-unit test-integration test-performance test-security test-e2e -.PHONY: build run clean test test/coverage dep lint docker/build docker/run help test-unit test-integration test-performance test-security test-e2e test-all \ No newline at end of file +## docs: Run the documentation server locally +docs: + @echo " > Starting documentation server..." + @cd docs && npm i && npm start -- --port 3001 + +example-auth: + @echo " > Running example auth..." + @cd examples/auth && npm i && npm run dev + +.PHONY: build run clean test test/coverage dep lint docker/build docker/run help test-unit test-integration test-performance test-security test-e2e test-all docs example-auth diff --git a/README.md b/README.md index fefbcbd..9a891c4 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Go API Boilerplate +# Go REST API Boilerplate This repository provides a structured Go project for building scalable APIs. It emphasizes clean architecture, separation of concerns, and ease of testing and deployment. ## Project Structure ``` -go-boilerplate/ +go-rest-api/ ├── cmd/ │ └── api/ │ └── main.go diff --git a/deployments/serverlesss/handlers/main.go b/deployments/serverlesss/handlers/main.go index 67ae761..3f944eb 100644 --- a/deployments/serverlesss/handlers/main.go +++ b/deployments/serverlesss/handlers/main.go @@ -4,14 +4,15 @@ import ( "context" "log" - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" + "go-rest-api/internal/api" + "go-rest-api/internal/config" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ginadapter "github.com/awslabs/aws-lambda-go-api-proxy/gin" "github.com/gin-gonic/gin" - "go.uber.org/zap" + + logger "go-rest-api/pkg" ) var ginLambda *ginadapter.GinLambda @@ -26,18 +27,13 @@ func init() { log.Fatalf("Failed to load configuration: %v", err) } - // Initialize logger - logger, err := zap.NewProduction() - if err != nil { - log.Fatalf("Failed to initialize logger: %v", err) - } - defer logger.Sync() + logger.Init() // Create a new Gin router r := gin.New() // Setup router with middleware and routes - api.SetupRouter(r, cfg, logger) + api.SetupRouter(r, cfg, logger.Log) ginLambda = ginadapter.New(r) } diff --git a/deployments/serverlesss/serverless.yml b/deployments/serverlesss/serverless.yml index fae35e3..9c74aeb 100644 --- a/deployments/serverlesss/serverless.yml +++ b/deployments/serverlesss/serverless.yml @@ -1,4 +1,4 @@ -service: go-boilerplate +service: go-rest-api frameworkVersion: ^3.0.0 plugins: diff --git a/docs/docs/auth/_category_.yml b/docs/docs/auth/_category_.yml index f40bb4f..be8dacd 100644 --- a/docs/docs/auth/_category_.yml +++ b/docs/docs/auth/_category_.yml @@ -1 +1,2 @@ label: Authentication +position: 4 \ No newline at end of file diff --git a/docs/docs/auth/auth-middleware-md.md b/docs/docs/auth/auth-middleware-md.md new file mode 100644 index 0000000..07c3f31 --- /dev/null +++ b/docs/docs/auth/auth-middleware-md.md @@ -0,0 +1,24 @@ +# Auth Middleware + +The Auth Middleware, defined in `auth.go`, is responsible for extracting authentication tokens from incoming requests. It supports multiple authentication methods to provide flexibility in how clients can authenticate. + +## Key Features + +- Checks multiple sources for authentication tokens +- Supports different authentication methods +- Sets extracted token information in the request context + +## Token Extraction Process + +1. Check the `Authorization` header +2. If not found, check the `X-API-Key` header +3. If still not found, check the `access_token` query parameter + +## Usage in Routes + +```go +protected := r.Group("/api/v1") +protected.Use(middleware.AuthMiddleware()) +``` + +This middleware sets the stage for token validation, which is handled by the Token Middleware. diff --git a/docs/docs/auth/environment-variables-md.md b/docs/docs/auth/environment-variables-md.md new file mode 100644 index 0000000..b9e5f61 --- /dev/null +++ b/docs/docs/auth/environment-variables-md.md @@ -0,0 +1,35 @@ +# Environment Variables + +The authentication system uses environment variables for configuration. These variables allow for flexible deployment across different environments. + +## Key Environment Variables + +1. `TOKEN_URL` + - Purpose: Specifies the URL of the external authentication service used for token validation. + - Required: Yes + - Example: `https://auth.example.com/validate` + +2. `TOKEN_CACHE_EXPIRY` + - Purpose: Sets the expiration time for cached tokens. + - Usage: Parsed in the `init()` function of `token.go` + - Required: No (defaults to 5 minutes if not set) + - Example: `15m` for 15 minutes, `1h` for 1 hour + +## Setting Environment Variables + +### In Development + +Use a `.env` file in the project root: + +``` +TOKEN_URL=https://auth.example.com/validate +TOKEN_CACHE_EXPIRY=10m +``` + +### In Production + +Set environment variables according to your deployment platform: + +- Docker: Use the `-e` flag or `environment` section in docker-compose.yml +- Kubernetes: Use `ConfigMap` and `Secret` resources +- Cloud Platforms: Use the platform-specific method for setting environment variables \ No newline at end of file diff --git a/docs/docs/auth/index.md b/docs/docs/auth/index.md index 2011218..43ebf2b 100644 --- a/docs/docs/auth/index.md +++ b/docs/docs/auth/index.md @@ -11,289 +11,16 @@ Authentication is a crucial aspect of any API, serving several important purpose 4. **Data Protection**: By restricting access to authenticated users, sensitive data is protected from unauthorized access. 5. **Compliance**: Many regulatory standards require proper authentication mechanisms to be in place. -In this boilerplate, we've implemented a flexible authentication system that supports multiple authentication methods, including API keys, JWT tokens, and OAuth 2.0 with OpenID Connect (OIDC). +## Key Components -## Modifying routes.go to Add Authenticated Endpoints +1. [Auth Middleware](auth_middleware.md): Handles initial token extraction from various sources. +2. [Token Middleware](token_middleware.md): Validates tokens and retrieves user profiles. +3. [Token Caching](token_caching.md): Implements an in-memory cache for validated tokens. +4. [Environment Variables](environment_variables.md): Configures the authentication system. -To add new endpoints that require authentication, you'll need to modify the `routes.go` file. Here's how you can do it: +## How It Works -1. Open the `routes.go` file: - - -```1:38:internal/api/routes.go -package api - -import ( - "time" - - "go-boilerplate/internal/api/middleware" - "go-boilerplate/internal/config" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - "golang.org/x/time/rate" -) - -func SetupRouter(r *gin.Engine, cfg *config.Config, logger *zap.Logger) { - // Add global middleware - r.Use(gin.Recovery()) - r.Use(middleware.CORSMiddleware()) - r.Use(middleware.LoggerMiddleware(logger)) - r.Use(middleware.RateLimiter(rate.Every(time.Second), 10)) // 10 requests per second - - // Public routes - public := r.Group("/api/v1") - { - public.GET("/health", HealthCheck) - public.GET("/ping", Ping) - // Add other public routes - } - - // Protected routes - protected := r.Group("/api/v1") - protected.Use(middleware.AuthMiddleware()) - protected.Use(middleware.VerifyToken()) - { - protected.GET("/profile", GetProfile) - // Add other protected routes - } -} - -``` - - -2. To add a new authenticated endpoint, add it to the `protected` group. For example, to add a new `/api/v1/user-data` endpoint: - -```go -protected.GET("/user-data", GetUserData) -``` - -3. Implement the corresponding handler function (e.g., `GetUserData`) in the `handlers.go` file. - -4. If you need different authentication methods for different endpoints, you can create new route groups with specific middleware. For example: - -```go -jwtProtected := r.Group("/api/v1/jwt") -jwtProtected.Use(middleware.JWTAuthMiddleware()) -{ - jwtProtected.GET("/jwt-specific-data", GetJWTSpecificData) -} -``` - -## Setting the TOKEN_URL Environment Variable - -The `TOKEN_URL` environment variable is crucial for token validation in the OAuth 2.0 flow. It should be set to the URL of your OAuth provider's token introspection endpoint. Here's how to set it: - -1. In your `.env` file or deployment environment, add: - -``` -TOKEN_URL=https://your-oauth-provider.com/oauth/token/introspect -``` - -2. Ensure that your application loads this environment variable. In the `config/config.go` file, add: - -```go -TokenURL: os.Getenv("TOKEN_URL"), -``` - -3. Update your `Config` struct to include this new field. - -## How the Auth Middlewares Work - -The authentication system uses two main middleware components: `auth.go` and `token.go`. - -### auth.go Middleware - - -```1:43:internal/api/middleware/auth.go -package middleware - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -func AuthMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - var token string - var authHeader string - - // Check for Authorization header - token = c.GetHeader("Authorization") - authHeader = "Authorization" - - // If not found, check for X-API-Key header - if token == "" { - token = c.GetHeader("X-API-Key") - authHeader = "X-API-Key" - } - - // If still not found, check for access_token query parameter - if token == "" { - token = c.Query("access_token") - authHeader = "access_token" - } - - // If no token found in any of the above methods - if token == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication token is required"}) - c.Abort() - return - } - - // Add the token to the context - c.Set("auth_token", token) - c.Set("auth_header", authHeader) - - c.Next() - } -} -``` - - -This middleware: -1. Checks for an authentication token in the request headers or query parameters. -2. Supports multiple authentication methods (Authorization header, X-API-Key header, access_token query parameter). -3. If a token is found, it adds it to the Gin context for further processing. -4. If no token is found, it returns a 401 Unauthorized response. - -### token.go Middleware - - -```1:120:internal/api/middleware/token.go -package middleware - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "os" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -var logger *zap.Logger - -func init() { - logger, _ = zap.NewProduction() -} - -type Profile struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` -} - -func VerifyToken() gin.HandlerFunc { - return func(c *gin.Context) { - // Get the token from the context set by AuthMiddleware - token, exists := c.Get("auth_token") - authHeader, authHeaderExists := c.Get("auth_header") - - if !authHeaderExists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication header is missing"}) - c.Abort() - return - } - - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication token is missing"}) - c.Abort() - return - } - - tokenString, ok := token.(string) - if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid token format"}) - c.Abort() - return - } - - tokenURL := os.Getenv("TOKEN_URL") - - if tokenURL == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "TOKEN_URL not set"}) - c.Abort() - return - } - - // Validate token using the TOKEN_URL - req, err := http.NewRequest("GET", tokenURL, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) - c.Abort() - return - } - req.Header.Set(authHeader.(string), tokenString) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate token"}) - c.Abort() - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // use logger to log the error - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) - c.Abort() - return - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response"}) - c.Abort() - return - } - - var profile Profile - if err := json.Unmarshal(body, &profile); err != nil { - // Use a custom unmarshaling to map the fields correctly - var githubProfile struct { - ID uint `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Login string `json:"login"` - } - if err := json.Unmarshal(body, &githubProfile); err != nil { - logger.Error("Failed to parse GitHub profile", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse profile"}) - c.Abort() - return - } - profile = Profile{ - ID: fmt.Sprintf("%d", githubProfile.ID), - Email: githubProfile.Email, - Name: githubProfile.Name, - } - // If Email is empty, use Login as a fallback - if profile.Email == "" { - profile.Email = githubProfile.Login - } - } - - c.Set("user", profile) - c.Next() - } -} -``` - - -This middleware: -1. Retrieves the token from the Gin context (set by the auth middleware). -2. Validates the token by making a request to the `TOKEN_URL` endpoint. -3. If the token is valid, it extracts user information from the response. -4. Sets the user information in the Gin context for use in subsequent request handling. -5. If the token is invalid, it returns a 401 Unauthorized response. - -Together, these middlewares provide a robust authentication system that can be easily extended to support various authentication methods and token validation strategies. - -To use these middlewares, ensure they are properly added to your routes in the `routes.go` file, as shown in the earlier section on modifying routes. - -By understanding and properly configuring these authentication components, you can ensure that your API endpoints are secure and accessible only to authorized users. \ No newline at end of file +1. The Auth Middleware extracts the authentication token from the request. +2. The Token Middleware validates the token and retrieves the user profile. +3. Validated tokens are cached to reduce external API calls. +4. Environment variables control the behavior of the authentication system. \ No newline at end of file diff --git a/docs/docs/auth/token-caching-md.md b/docs/docs/auth/token-caching-md.md new file mode 100644 index 0000000..855b4cd --- /dev/null +++ b/docs/docs/auth/token-caching-md.md @@ -0,0 +1,43 @@ +# Token Caching + +The token caching mechanism is implemented within the Token Middleware to improve performance by reducing the number of external API calls for token validation. + +## Key Features + +- In-memory cache for validated tokens and user profiles +- Configurable cache expiry time +- Thread-safe cache operations using a read-write mutex + +## Cache Structure + +```go +type cacheEntry struct { + profile Profile + expiry time.Time +} + +var ( + tokenCache = make(map[string]cacheEntry) + cacheMutex sync.RWMutex + cacheExpiry time.Duration +) +``` + +## Cache Operations + +1. **Cache Check**: Before validating a token, the middleware checks if a valid cache entry exists. +2. **Cache Hit**: If a non-expired entry is found, it's used directly, skipping external validation. +3. **Cache Miss**: If no valid entry is found, the token is validated externally, and the result is cached. +4. **Cache Update**: After successful validation, the token and profile are cached with an expiry time. + +## Cache Expiry Configuration + +The cache expiry time can be configured using the `TOKEN_CACHE_EXPIRY` environment variable. If not set, it defaults to 5 minutes. + +## Cache Usage in Token Verification + +The caching mechanism significantly reduces the load on the external authentication service and improves response times for subsequent requests with the same token. If the cache is not hit, the token is validated externally and the result is cached. + +Header `X-Token-Cache` is set to `HIT` if the cache is hit, and `MISS` if the cache is not hit + + diff --git a/docs/docs/auth/token-middleware-md.md b/docs/docs/auth/token-middleware-md.md new file mode 100644 index 0000000..79b6a9f --- /dev/null +++ b/docs/docs/auth/token-middleware-md.md @@ -0,0 +1,32 @@ +# Token Middleware + +The Token Middleware, defined in `token.go`, is responsible for validating authentication tokens and retrieving user profiles. It works in conjunction with the Auth Middleware to provide a complete authentication solution. This middleware ensures that only valid tokens are accepted and provides user profile information for authenticated requests. + +:::warning + +This middleware is built as an example using GitHub API. It requires to be modified to work with other authentication services. + +::: + +## Key Features + +- Validates tokens using an external authentication service +- Retrieves and parses user profiles +- Implements token caching for improved performance +- Supports different profile structures (e.g., GitHub profiles) + +## Token Validation Process + +1. Retrieve the token from the request context (set by Auth Middleware) +2. Check the token cache for a valid, non-expired entry +3. If not in cache, validate the token using the external `TOKEN_URL` +4. Parse the user profile from the validation response +5. Cache the validated token and profile +6. Set the user profile in the request context + +## Usage in Routes + +```go +protected := r.Group("/api/v1") +protected.Use(middleware.AuthMiddleware(), middleware.VerifyToken()) +``` diff --git a/docs/docs/cors.md b/docs/docs/cors.md new file mode 100644 index 0000000..552dab5 --- /dev/null +++ b/docs/docs/cors.md @@ -0,0 +1,138 @@ +--- +title: "CORS" +sidebar_position: 5 +--- + +# CORS Middleware Configuration and Usage + +This document explains how to configure and use the CORS (Cross-Origin Resource Sharing) middleware in the Go REST API Boilerplate project. The middleware is implemented using the `gin-contrib/cors` package and is designed to be flexible and configurable. + +## Overview + +The CORS middleware allows you to control which origins can access your API. It's crucial for securing your API while still allowing legitimate cross-origin requests. + +## Configuration + +The middleware is configured in the `CORSMiddleware()` function, which returns a `gin.HandlerFunc`. Here's how it works: + +1. **Default Configuration**: It starts with the default CORS configuration. + +2. **Allowed Origins**: + - The middleware checks for an environment variable `ALLOWED_ORIGINS`. + - If set, it uses these origins as the allowed origins. + - If not set, it allows any origin that starts with `http://localhost` or `https://localhost`. + +3. **Allowed Methods**: The middleware allows the following HTTP methods: + - GET + - POST + - PUT + - PATCH + - DELETE + - OPTIONS + +4. **Allowed Headers**: The middleware allows the following headers: + - Origin + - Content-Type + - Accept + - Authorization + +## Usage + +To use this middleware in your Gin application, follow these steps: + +1. Import the middleware package: + +```go +import "go-rest-api/internal/api/middleware" +``` + +2. Add the middleware to your Gin router: + +```go +func main() { + router := gin.Default() + + // Apply the CORS middleware + router.Use(middleware.CORSMiddleware()) + + // Your routes go here + // ... + + router.Run() +} +``` + +## Configuration via Environment Variables + +To configure allowed origins using environment variables: + +1. Set the `ALLOWED_ORIGINS` environment variable before running your application. Multiple origins should be comma-separated. + + Example: + ```bash + export ALLOWED_ORIGINS="https://example.com,https://api.example.com" + ``` + +2. If `ALLOWED_ORIGINS` is not set, the middleware will default to allowing any origin that starts with `http://localhost` or `https://localhost`. + +## Customization + +If you need to customize the CORS settings further: + +1. Modify the `CORSMiddleware()` function in the middleware package. +2. You can adjust allowed methods, headers, or add more sophisticated origin checking logic. + +Example of adding a custom header: + +```go +config.AllowHeaders = append(config.AllowHeaders, "X-Custom-Header") +``` + +## Security Considerations + +1. **Restrict Origins**: In production, always set `ALLOWED_ORIGINS` to a specific list of trusted domains. +2. **Least Privilege**: Only expose the methods and headers that your API actually needs. +3. **Credentials**: If your API requires credentials (cookies, HTTP authentication), you may need to set `config.AllowCredentials = true`. Use this with caution and ensure `AllowOrigins` is not set to `*`. + +## Troubleshooting + +If you're experiencing CORS issues: + +1. Check that the `ALLOWED_ORIGINS` environment variable is set correctly. +2. Ensure that the origin making the request matches exactly with one of the allowed origins (including the protocol, `http://` or `https://`). +3. Verify that the request is using an allowed method and only includes allowed headers. + +## Example + +Here's a complete example of setting up a Gin router with the CORS middleware: + +```go +package main + +import ( + "github.com/gin-gonic/gin" + "go-rest-api/internal/api/middleware" +) + +func main() { + // Set up Gin + router := gin.Default() + + // Apply CORS middleware + router.Use(middleware.CORSMiddleware()) + + // Define a route + router.GET("/api/data", func(c *gin.Context) { + c.JSON(200, gin.H{ + "message": "This is CORS-enabled data", + }) + }) + + // Run the server + router.Run(":8080") +} +``` + +In this example, the CORS middleware will be applied to all routes. The allowed origins will be determined by the `ALLOWED_ORIGINS` environment variable, or default to localhost if not set. + +By following these guidelines, you can effectively implement and customize CORS in your Go REST API, ensuring that your API is accessible to the intended clients while maintaining security. \ No newline at end of file diff --git a/docs/docs/deployments/_category_.yml b/docs/docs/deployments/_category_.yml index 4ab8aa4..f0ca093 100644 --- a/docs/docs/deployments/_category_.yml +++ b/docs/docs/deployments/_category_.yml @@ -1 +1 @@ -label: Deployments +label: Deployments \ No newline at end of file diff --git a/docs/docs/deployments/docker.md b/docs/docs/deployments/docker.md index 8e552ec..99f3186 100644 --- a/docs/docs/deployments/docker.md +++ b/docs/docs/deployments/docker.md @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 1 --- # Docker Deployment @@ -21,7 +21,7 @@ The following file in the `/deployments/docker` directory is used for the Docker 1. Navigate to the project root directory: ```bash - cd go-boilerplate + cd go-rest-api ``` 2. Build the Docker image: diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 1ba54ce..875e535 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -1,22 +1,26 @@ --- sidebar_position: 1 +sidebar_label: Introduction --- -# Go API Project Structure +# Introduction -This repository contains a structured Go project for developing a robust and scalable API. The project is organized to promote clean architecture, separation of concerns, and ease of testing and deployment. +This Go REST API boilerplate provides a solid foundation for your API projects, emphasizing clean architecture, comprehensive testing, and flexible deployment options. By adhering to clean architecture principles, we ensure your API remains maintainable and scalable as it grows. The modular structure promotes a clear separation of concerns, making it easy to modify and extend your API in the future. + +Testing plays a crucial role in delivering a reliable and secure API. That's why the boilerplate includes a full suite of tests, covering unit tests, API security, service contracts, and performance benchmarks. With these tests in place, you can confidently deploy your API using your preferred method. Whether you choose Docker, Kubernetes, or serverless functions, you can find the guides on deploying on each of these options in the [Deployments](/docs/deployments) section. +OK, here's the modified document with a more direct and technical focus, and less sales-oriented language: ## Project Structure ``` -go-boilerplate/ +go-rest-api/ ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ ├── api/ -│ │ ├── handlers/ │ │ ├── middleware/ +│ │ ├── handlers.go │ │ └── routes.go │ ├── config/ │ ├── models/ @@ -51,8 +55,8 @@ Contains the main applications for this project. The `api/` subdirectory is wher Houses packages that are specific to this project and not intended for external use. - `api/`: Contains API-specific code. - - `handlers/`: Request handlers for each API endpoint. - `middleware/`: Custom middleware functions. + - `handlers.go`: Request handlers for each API endpoint. - `routes.go`: Defines API routes and links them to handlers. - `config/`: Configuration management for the application. - `models/`: Data models and DTOs (Data Transfer Objects). @@ -155,4 +159,4 @@ Please read CONTRIBUTING.md for details on our code of conduct and the process f ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/docs/docs/logging.md b/docs/docs/logging.md new file mode 100644 index 0000000..d0c5216 --- /dev/null +++ b/docs/docs/logging.md @@ -0,0 +1,152 @@ +--- +title: "Logging" +sidebar_position: 3 +--- + +# Logging + +This document explains how to use the custom logger implemented in the Go REST API Boilerplate project. The logger is a singleton wrapper around the `zap` logging library, providing a simple and consistent way to log messages across your application. + +## Overview + +The logger package provides a global `Log` variable of type `*Logger`, which is a wrapper around `zap.Logger`. It's designed as a singleton to ensure that only one instance of the logger is created and used throughout the application. + +## Initialization + +Before using the logger, you need to initialize it. This is typically done at the start of your application: + +```go +package main + +import ( + logger "go-rest-api/pkg" +) + +func main() { + logger.Init() + // Rest of your application code +} +``` + +The `Init()` function uses a `sync.Once` to ensure that the logger is only initialized once, even if `Init()` is called multiple times. + +## Basic Usage + +After initialization, you can use the logger throughout your application. The package provides several logging methods: + +### Logging at Different Levels + +```go +logger.Info("This is an info message") +logger.Error("This is an error message") +logger.Fatal("This is a fatal message") // This will also call os.Exit(1) +``` + +### Logging with Additional Fields + +You can add structured fields to your log messages: + +```go +logger.Info("User logged in", zap.String("username", "john_doe"), zap.Int("user_id", 12345)) +``` + + +### Creating a Logger with Preset Fields + +If you want to create a logger with some fields already set (useful for adding context to all logs from a particular component): + +```go +componentLogger := logger.With(zap.String("component", "auth_service")) +componentLogger.Info("Starting authentication") +``` + +## Advanced Usage + +### Direct Access to Underlying Zap Logger + +If you need access to the underlying `zap.Logger` for advanced use cases: + +```go +zapLogger := logger.Log.Logger +// Use zapLogger for zap-specific functionality +``` + +### Custom Log Fields + +You can create custom log fields using `zap.Field` constructors: + +```go +customField := zap.Any("custom_data", someComplexStruct) +logger.Info("Message with custom data", customField) +``` + +## Best Practices + +1. **Initialization**: Always call `logger.Init()` at the start of your application. + +2. **Log Levels**: Use appropriate log levels: + - `Info` for general information + - `Error` for error conditions + - `Fatal` for unrecoverable errors (use sparingly as it terminates the program) + +3. **Structured Logging**: Prefer using structured fields over string interpolation for better searchability and analysis of logs. + +4. **Context**: Use `logger.With()` to add context to logs from specific components or request handlers. + +5. **Performance**: The logger is designed to be performant, but avoid excessive logging in hot paths. + +## Customization + +The logger is initialized with a production configuration. If you need to customize the logger (e.g., for development environments), you can modify the `Init()` function in `logger.go`. + +## Examples + +### In HTTP Handlers + +```go +func UserHandler(w http.ResponseWriter, r *http.Request) { + userID := getUserIDFromRequest(r) + requestLogger := logger.With(zap.String("handler", "UserHandler"), zap.Int("user_id", userID)) + + requestLogger.Info("Processing user request") + + // Handler logic... + + if err != nil { + requestLogger.Error("Failed to process user request", zap.Error(err)) + // Handle error... + } + + requestLogger.Info("User request processed successfully") +} +``` + +### In Services + +```go +type AuthService struct { + logger *logger.Logger +} + +func NewAuthService() *AuthService { + return &AuthService{ + logger: logger.With(zap.String("service", "auth")), + } +} + +func (s *AuthService) Authenticate(username, password string) error { + s.logger.Info("Attempting authentication", zap.String("username", username)) + + // Authentication logic... + + if authFailed { + s.logger.Error("Authentication failed", zap.String("username", username)) + return ErrAuthFailed + } + + s.logger.Info("Authentication successful", zap.String("username", username)) + return nil +} +``` + +By following these guidelines and examples, you can effectively use the logger throughout your application to produce consistent, structured logs that will aid in monitoring, debugging, and maintaining your Go REST API. \ No newline at end of file diff --git a/docs/docs/make.md b/docs/docs/make.md new file mode 100644 index 0000000..1476f0b --- /dev/null +++ b/docs/docs/make.md @@ -0,0 +1,163 @@ +--- +sidebar_position: 2 +sidebar_label: Makefile +--- + +# Using the Makefile in Go REST API Boilerplate + +This document explains how to use the Makefile provided in the Go REST API Boilerplate project. The Makefile contains various commands to streamline development, testing, and deployment processes. + +## Prerequisites + +- Make sure you have `make` installed on your system. +- Ensure you have Go installed and properly configured. +- Docker should be installed for Docker-related commands. + +## Available Commands + +### Building and Running + +- `make build`: Compiles the binary. + ``` + make build + ``` + +- `make run`: Builds and runs the binary. + ``` + make run + ``` + +### Cleaning + +- `make clean`: Cleans build files and cache. + ``` + make clean + ``` + +### Testing + +- `make test`: Runs all unit tests. + ``` + make test + ``` + +- `make test/coverage`: Runs unit tests with coverage. + ``` + make test/coverage + ``` + +- `make test-unit`: Runs only unit tests. + ``` + make test-unit + ``` + +- `make test-integration`: Runs integration tests. + ``` + make test-integration + ``` + +- `make test-performance`: Runs performance tests. + ``` + make test-performance + ``` + +- `make test-security`: Runs security tests. + ``` + make test-security + ``` + +- `make test-e2e`: Runs end-to-end tests. + ``` + make test-e2e + ``` + +- `make test-contract`: Runs contract tests. + ``` + make test-contract + ``` + +- `make test-all`: Runs all types of tests. + ``` + make test-all + ``` + +### Dependencies + +- `make dep`: Ensures dependencies are up to date. + ``` + make dep + ``` + +### Code Quality + +- `make lint`: Lints the code using golangci-lint. + ``` + make lint + ``` + +### Docker + +- `make docker/build`: Builds the Docker image. + ``` + make docker/build + ``` + +- `make docker/run`: Runs the Docker image. + ``` + make docker/run + ``` + +### Documentation + +- `make docs`: Starts the documentation server locally. + ``` + make docs + ``` + +### Help + +- `make help`: Displays help information about available commands. + ``` + make help + ``` + +## Usage Examples + +1. To start development: + ``` + make dep + make build + make run + ``` + +2. To run tests before committing: + ``` + make test-all + ``` + +3. To build and run in Docker: + ``` + make docker/build + make docker/run + ``` + +4. To check code quality: + ``` + make lint + ``` + +5. To view documentation: + ``` + make docs + ``` + +## Customizing the Makefile + +You can customize the Makefile by modifying variables at the top: + +- `BINARY_NAME`: Change the name of the compiled binary. +- `BUILD_DIR`: Alter the build directory. +- `DOCKER_IMAGE`: Modify the Docker image name. +- `VERSION`: Update the version number. + +Remember to run `make help` to see all available commands and their descriptions. \ No newline at end of file diff --git a/docs/docs/tests/e2e-testing.md b/docs/docs/tests/e2e-testing.md deleted file mode 100644 index 4667f3d..0000000 --- a/docs/docs/tests/e2e-testing.md +++ /dev/null @@ -1,5 +0,0 @@ -# End-to-End (E2E) Testing - -E2E tests verify the entire application flow from start to finish. - -## Example \ No newline at end of file diff --git a/docs/docs/tests/index.md b/docs/docs/tests/index.md new file mode 100644 index 0000000..fad24af --- /dev/null +++ b/docs/docs/tests/index.md @@ -0,0 +1,408 @@ +# Go REST API Testing Documentation + +## Overview + +This document outlines the comprehensive testing strategy for our Go REST API. We employ various testing approaches to ensure the reliability, security, and performance of our API. The testing suite includes unit tests, integration tests, end-to-end tests, security tests, and performance benchmarks. + +## Table of Contents + +1. [Unit Tests](#unit-tests) +2. [API Security Tests](#api-security-tests) +3. [Service Contract Tests](#service-contract-tests) +4. [API Flow Tests](#api-flow-tests) +5. [API Integration Tests](#api-integration-tests) +6. [API Performance Benchmarks](#api-performance-benchmarks) + +## Unit Tests + +File: `service_test.go` + +### Testing Strategy + +Unit tests focus on testing individual components or functions of the API in isolation. These tests ensure that: + +1. Individual handlers work correctly +2. The API returns expected responses for specific inputs +3. Basic functionality is maintained as the codebase evolves + +### Key Components + +- Setup of a test router with mock configuration +- Individual test functions for each handler +- Assertions for status codes and response bodies + +### Code Example + +```go +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + JWTSecret: "test_secret", + ValidAPIKey: "test_api_key", + } + logger, _ := zap.NewDevelopment() + + r := gin.New() + + // Add the config and logger to the gin.Context + r.Use(func(c *gin.Context) { + c.Set("config", cfg) + c.Set("logger", logger) + c.Next() + }) + + api.SetupRouter(r, cfg, logger) + return r +} + +func TestPingHandler(t *testing.T) { + r := setupTestRouter() + + req, err := http.NewRequest(http.MethodGet, "/api/v1/ping", nil) + assert.NoError(t, err) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]string + err = json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.Equal(t, "pong", response["message"]) +} + +func TestHealthCheckHandler(t *testing.T) { + r := setupTestRouter() + + req, err := http.NewRequest(http.MethodGet, "/api/v1/health", nil) + assert.NoError(t, err) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]string + err = json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.Equal(t, "OK", response["status"]) +} +``` + +### Explanation + +1. `setupTestRouter()`: This function creates a test environment by setting up a Gin router with a mock configuration and logger. It's used to isolate the tests from the actual application environment. + +2. `TestPingHandler()`: This test verifies the `/api/v1/ping` endpoint: + - It creates a mock GET request to the endpoint. + - Uses `httptest.NewRecorder()` to capture the response. + - Asserts that the status code is 200 (OK). + - Checks that the response body contains the expected "pong" message. + +3. `TestHealthCheckHandler()`: This test verifies the `/api/v1/health` endpoint: + - Similar to the ping test, it creates a mock GET request. + - Asserts that the status code is 200 (OK). + - Checks that the response body contains the expected "OK" status. + +These unit tests ensure that the basic endpoints of the API are functioning correctly. They provide a quick way to verify that changes to the codebase haven't broken core functionality. + +## API Security Tests + +File: `api_security_test.go` + +### Testing Strategy + +The API security tests focus on verifying the authentication and authorization mechanisms of our API. These tests ensure that: + +1. Public endpoints are accessible without authentication +2. Protected endpoints require valid authentication +3. Invalid or missing authentication tokens are properly rejected + +### Key Components + +- Mock token server for simulating authentication responses +- Test cases for various scenarios (public endpoints, protected endpoints with valid/invalid tokens) + +### Code Example + +```go +func TestAPISecurityEndpoints(t *testing.T) { + // Setup mock token server + mockTokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "valid_token" { + json.NewEncoder(w).Encode(map[string]string{"id": "123", "email": "test@example.com", "name": "Test User"}) + } else { + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer mockTokenServer.Close() + + // Set TOKEN_URL environment variable + os.Setenv("TOKEN_URL", mockTokenServer.URL) + defer os.Unsetenv("TOKEN_URL") + + router := setupRouter() + + testCases := []struct { + name string + endpoint string + method string + token string + expectedStatus int + }{ + {"Public Endpoint - Health Check", "/api/v1/health", "GET", "", http.StatusOK}, + {"Protected Endpoint - No Token", "/api/v1/profile", "GET", "", http.StatusUnauthorized}, + {"Protected Endpoint - Valid Token", "/api/v1/profile", "GET", "valid_token", http.StatusOK}, + } + + // Test execution code... +} +``` + +## Service Contract Tests + +File: `service_contract_test.go` + +### Testing Strategy + +Service contract tests verify that our API adheres to its defined contract. These tests ensure that: + +1. Endpoints return the expected status codes +2. Response bodies match the expected structure and content +3. The API behaves consistently across different environments + +### Key Components + +- Setup of the router with test configuration +- Test cases for each endpoint, checking status codes and response bodies + +### Code Example + +```go +func TestServiceContract(t *testing.T) { + // Setup the router + cfg, err := config.LoadConfig() + assert.NoError(t, err, "Failed to load configuration") + + logger, err := zap.NewProduction() + assert.NoError(t, err, "Failed to initialize logger") + defer logger.Sync() + + r := gin.New() + api.SetupRouter(r, cfg, logger) + + // Create a test HTTP server + server := httptest.NewServer(r) + defer server.Close() + + // Test the /ping endpoint + t.Run("Ping Endpoint", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/ping") + assert.NoError(t, err, "Failed to make request to /api/v1/ping") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Unexpected status code for /api/v1/ping") + + var response map[string]string + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err, "Failed to decode response body") + assert.Equal(t, "pong", response["message"], "Unexpected response message for /api/v1/ping") + }) + + // Additional endpoint tests... +} +``` + +## API Flow Tests + +File: `api_flow_test.go` + +### Testing Strategy + +API flow tests simulate real-world usage scenarios by testing multiple endpoints in sequence. These tests ensure that: + +1. The API functions correctly in typical user flows +2. Different parts of the API integrate properly +3. State changes are correctly reflected across multiple requests + +### Key Components + +- Setup of a test router with mock configuration +- Sequential tests that mimic user interactions +- Verification of expected outcomes at each step + +### Code Example + +```go +func TestAPIFlow(t *testing.T) { + router := setupTestRouter() + server := httptest.NewServer(router.Handler()) + defer server.Close() + + // Step 1: Ping + t.Run("Ping", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/ping") + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]string + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "pong", response["message"]) + }) + + // Step 2: Health Check + t.Run("Health Check", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/health") + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]string + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "OK", response["status"]) + }) + + // Additional flow steps... +} +``` + +## API Integration Tests + +File: `api_test.go` + +### Testing Strategy + +API integration tests verify that all components of the API work together correctly. These tests ensure that: + +1. All endpoints are accessible and return expected results +2. The API handles various input scenarios correctly +3. Error handling and edge cases are properly managed + +### Key Components + +- Setup of the full API router with actual configuration +- Comprehensive test cases covering all endpoints +- Verification of status codes and response bodies + +### Code Example + +```go +func TestAPIEndpoints(t *testing.T) { + cfg, err := config.LoadConfig() + assert.NoError(t, err, "Failed to load configuration") + + logger, err := zap.NewProduction() + assert.NoError(t, err, "Failed to initialize logger") + defer logger.Sync() + + r := gin.New() + api.SetupRouter(r, cfg, logger) + + server := httptest.NewServer(r) + defer server.Close() + + testCases := []struct { + name string + endpoint string + expectedStatus int + expectedBody map[string]string + }{ + { + name: "Ping Endpoint", + endpoint: "/api/v1/ping", + expectedStatus: http.StatusOK, + expectedBody: map[string]string{"message": "pong"}, + }, + // Additional test cases... + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resp, err := http.Get(server.URL + tc.endpoint) + assert.NoError(t, err, "Failed to make request") + defer resp.Body.Close() + + assert.Equal(t, tc.expectedStatus, resp.StatusCode, "Unexpected status code") + + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err, "Failed to read response body") + + var responseBody map[string]string + err = json.Unmarshal(body, &responseBody) + assert.NoError(t, err, "Failed to parse response body") + + assert.Equal(t, tc.expectedBody, responseBody, "Unexpected response body") + }) + } +} +``` + +## API Performance Benchmarks + +File: `api_benchmark_test.go` + +### Testing Strategy + +API performance benchmarks measure the efficiency and scalability of our API. These tests ensure that: + +1. The API can handle a high volume of requests +2. Response times remain within acceptable limits under load +3. Rate limiting functionality works as expected + +### Key Components + +- Benchmarks for individual endpoints +- Tests for rate limiting effectiveness +- Measurement of response times and request handling capacity + +### Code Example + +```go +func BenchmarkPingEndpoint(b *testing.B) { + cfg, _ := config.LoadConfig() + logger, _ := zap.NewProduction() + defer logger.Sync() + + router := gin.New() + api.SetupRouter(router, cfg, logger, api.WithoutRateLimiting()) + server := httptest.NewServer(router) + defer server.Close() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + resp, err := http.Get(server.URL + "/api/v1/ping") + if err != nil { + b.Fatalf("Failed to make request: %v", err) + } + resp.Body.Close() + } +} + +func BenchmarkRateLimiting(b *testing.B) { + // Setup code... + + client := &http.Client{} + + b.ResetTimer() + for j := 0; j < 11; j++ { + resp, err := client.Get(server.URL + "/api/v1/ping") + if err != nil { + b.Fatalf("Failed to make request: %v", err) + } + + if j == 11 && resp.StatusCode != http.StatusTooManyRequests { + b.Errorf("Expected rate limiting to be triggered (status 429), got %d", resp.StatusCode) + } + + resp.Body.Close() + } +} +``` \ No newline at end of file diff --git a/docs/docs/tests/integration-testing.md b/docs/docs/tests/integration-testing.md deleted file mode 100644 index ebde365..0000000 --- a/docs/docs/tests/integration-testing.md +++ /dev/null @@ -1,5 +0,0 @@ -# Integration Testing - -Integration tests verify that different parts of the application work together correctly. - -## Example \ No newline at end of file diff --git a/docs/docs/tests/performance-testing.md b/docs/docs/tests/performance-testing.md deleted file mode 100644 index 3c34f3f..0000000 --- a/docs/docs/tests/performance-testing.md +++ /dev/null @@ -1,7 +0,0 @@ -# Performance Testing - -Performance tests ensure that our API endpoints meet performance requirements. - -## Running Performance Tests - -Use the `testing.B` type for benchmarking: diff --git a/docs/docs/tests/security-testing.md b/docs/docs/tests/security-testing.md deleted file mode 100644 index b39f8aa..0000000 --- a/docs/docs/tests/security-testing.md +++ /dev/null @@ -1,5 +0,0 @@ -# Security Testing - -Security tests focus on verifying authentication and authorization mechanisms. - -## Example \ No newline at end of file diff --git a/docs/docs/tests/testing-strategy.md b/docs/docs/tests/testing-strategy.md deleted file mode 100644 index 4f963c5..0000000 --- a/docs/docs/tests/testing-strategy.md +++ /dev/null @@ -1,13 +0,0 @@ -# Testing Strategy - -Our project employs a comprehensive testing strategy to ensure code quality, reliability, and performance. We use the following types of tests: - -1. Unit Tests -2. Integration Tests -3. Performance Tests -4. Security Tests -5. End-to-End (E2E) Tests - -## Running Tests - -To run all tests, use the following command: \ No newline at end of file diff --git a/docs/docs/tests/unit-testing.md b/docs/docs/tests/unit-testing.md deleted file mode 100644 index fab13c0..0000000 --- a/docs/docs/tests/unit-testing.md +++ /dev/null @@ -1,5 +0,0 @@ -# Unit Testing - -Unit tests focus on testing individual functions and components in isolation. - -## Example \ No newline at end of file diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 6c445a3..1c0869b 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -3,8 +3,8 @@ import type {Config} from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { - title: 'My Site', - tagline: 'Dinosaurs are cool', + title: 'Go REST API', + tagline: 'Boilerplate for Go REST API', favicon: 'img/favicon.ico', // Set the production url of your site here @@ -66,20 +66,20 @@ const config: Config = { // Replace with your project's social card image: 'img/docusaurus-social-card.jpg', navbar: { - title: 'My Site', + title: 'Go REST API', logo: { - alt: 'My Site Logo', - src: 'img/logo.svg', + alt: 'Go REST API Logo', + src: 'img/logo.png', }, items: [ { type: 'docSidebar', sidebarId: 'tutorialSidebar', position: 'left', - label: 'Tutorial', + label: 'Documentation', }, { - href: 'https://github.com/facebook/docusaurus', + href: 'https://github.com/nicobistolfi/go-rest-api?ref=go-rest-api', label: 'GitHub', position: 'right', }, @@ -92,25 +92,34 @@ const config: Config = { title: 'Docs', items: [ { - label: 'Tutorial', + label: 'Documentation', to: '/docs/intro', }, ], }, { - title: 'Community', + title: 'Go REST API', items: [ { - label: 'Stack Overflow', - href: 'https://stackoverflow.com/questions/tagged/docusaurus', + label: 'Github', + href: 'https://github.com/nicobistolfi/go-rest-api?ref=go-rest-api', + }, + ], + }, + { + title: 'Author', + items: [ + { + label: 'Linkedin', + href: 'https://www.linkedin.com/in/nicobistolfi/?ref=go-rest-api', }, { - label: 'Discord', - href: 'https://discordapp.com/invite/docusaurus', + label: 'Github', + href: 'https://github.com/nicobistolfi?ref=go-rest-api', }, { - label: 'Twitter', - href: 'https://twitter.com/docusaurus', + label: 'Website', + href: 'https://nico.bistol.fi/?ref=go-rest-api', }, ], }, @@ -128,7 +137,7 @@ const config: Config = { ], }, ], - copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, + copyright: `Built with Docusaurus.`, }, prism: { theme: prismThemes.github, diff --git a/docs/package.json b/docs/package.json index 0246119..46f3ba7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "start": "docusaurus start --port 3001", + "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", diff --git a/docs/src/components/HomepageFeatures/index.tsx b/docs/src/components/HomepageFeatures/index.tsx index 50a9e6f..430d0c2 100644 --- a/docs/src/components/HomepageFeatures/index.tsx +++ b/docs/src/components/HomepageFeatures/index.tsx @@ -4,38 +4,38 @@ import styles from './styles.module.css'; type FeatureItem = { title: string; - Svg: React.ComponentType>; + Svg?: React.ComponentType>; description: JSX.Element; }; const FeatureList: FeatureItem[] = [ { - title: 'Easy to Use', - Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, + title: 'Robust Architecture', description: ( <> - Docusaurus was designed from the ground up to be easily installed and - used to get your website up and running quickly. + Built with clean architecture principles, our Go REST API Boilerplate + ensures separation of concerns and maintainability. From handlers to + services, every component has its place. ), }, { - title: 'Focus on What Matters', - Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, + title: 'Comprehensive Testing', description: ( <> - Docusaurus lets you focus on your docs, and we'll do the chores. Go - ahead and move your docs into the docs directory. + Our boilerplate includes a full suite of tests, including unit tests, + API security tests, service contract tests, and performance benchmarks. + Ensure your API's reliability and security from day one. ), }, { - title: 'Powered by React', - Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, + title: 'Flexible Deployment Options', description: ( <> - Extend or customize your website layout by reusing React. Docusaurus can - be extended while reusing the same header and footer. + Deploy your API your way. Whether you prefer Docker containers, Kubernetes + orchestration, or serverless functions, we've got you covered with + detailed deployment guides and configurations. ), }, @@ -44,9 +44,11 @@ const FeatureList: FeatureItem[] = [ function Feature({title, Svg, description}: FeatureItem) { return (
-
- -
+ {Svg && styles.featureSvg && ( +
+ +
+ )}
{title}

{description}

diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 2bc6a4c..f65e18c 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -6,25 +6,25 @@ /* You can override the default Infima variables here. */ :root { - --ifm-color-primary: #2e8555; - --ifm-color-primary-dark: #29784c; - --ifm-color-primary-darker: #277148; - --ifm-color-primary-darkest: #205d3b; - --ifm-color-primary-light: #33925d; - --ifm-color-primary-lighter: #359962; - --ifm-color-primary-lightest: #3cad6e; + --ifm-color-primary: #218c96; + --ifm-color-primary-dark: #1e7e87; + --ifm-color-primary-darker: #1c777f; + --ifm-color-primary-darkest: #176269; + --ifm-color-primary-light: #249aa5; + --ifm-color-primary-lighter: #26a1ad; + --ifm-color-primary-lightest: #2cb7c4; --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } /* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme='dark'] { - --ifm-color-primary: #25c2a0; - --ifm-color-primary-dark: #21af90; - --ifm-color-primary-darker: #1fa588; - --ifm-color-primary-darkest: #1a8870; - --ifm-color-primary-light: #29d5b0; - --ifm-color-primary-lighter: #32d8b4; - --ifm-color-primary-lightest: #4fddbf; + --ifm-color-primary: #3ccad6; + --ifm-color-primary-dark: #2bc1ce; + --ifm-color-primary-darker: #29b6c2; + --ifm-color-primary-darkest: #2296a0; + --ifm-color-primary-light: #53d0db; + --ifm-color-primary-lighter: #5ed3dd; + --ifm-color-primary-lightest: #81dce4; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); -} +} \ No newline at end of file diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 400a3e1..e2e12b3 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -20,7 +20,7 @@ function HomepageHeader() { - Docusaurus Tutorial - 5min ⏱️ + Read the Documentation
diff --git a/docs/static/img/android-chrome-192x192.png b/docs/static/img/android-chrome-192x192.png new file mode 100644 index 0000000..2a5fc02 Binary files /dev/null and b/docs/static/img/android-chrome-192x192.png differ diff --git a/docs/static/img/android-chrome-512x512.png b/docs/static/img/android-chrome-512x512.png new file mode 100644 index 0000000..a52f50a Binary files /dev/null and b/docs/static/img/android-chrome-512x512.png differ diff --git a/docs/static/img/apple-touch-icon.png b/docs/static/img/apple-touch-icon.png new file mode 100644 index 0000000..94e5ecc Binary files /dev/null and b/docs/static/img/apple-touch-icon.png differ diff --git a/docs/static/img/docusaurus.png b/docs/static/img/docusaurus.png index f458149..14185db 100644 Binary files a/docs/static/img/docusaurus.png and b/docs/static/img/docusaurus.png differ diff --git a/docs/static/img/favicon-16x16.png b/docs/static/img/favicon-16x16.png new file mode 100644 index 0000000..770bcc8 Binary files /dev/null and b/docs/static/img/favicon-16x16.png differ diff --git a/docs/static/img/favicon-32x32.png b/docs/static/img/favicon-32x32.png new file mode 100644 index 0000000..0d419fc Binary files /dev/null and b/docs/static/img/favicon-32x32.png differ diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico index c01d54b..d059f0c 100644 Binary files a/docs/static/img/favicon.ico and b/docs/static/img/favicon.ico differ diff --git a/docs/static/img/logo.png b/docs/static/img/logo.png new file mode 100644 index 0000000..14185db Binary files /dev/null and b/docs/static/img/logo.png differ diff --git a/docs/static/img/logo.svg b/docs/static/img/logo.svg index 9db6d0d..afa08db 100644 --- a/docs/static/img/logo.svg +++ b/docs/static/img/logo.svg @@ -1 +1,9 @@ - \ No newline at end of file + + + + + + + + + \ No newline at end of file diff --git a/docs/static/img/site.webmanifest b/docs/static/img/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/docs/static/img/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/go.mod b/go.mod index 281118e..66cee81 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module go-boilerplate +module go-rest-api go 1.22.5 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index ccc2936..c6886e2 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,25 +1,25 @@ package api import ( + "fmt" "net/http" "os" - "go-boilerplate/pkg/auth" // Adjust this import path as needed + "go-rest-api/pkg/auth" // Adjust this import path as needed "github.com/gin-gonic/gin" ) -// ProfileResponse represents the structure of the profile data -type ProfileResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` -} - func GetToken(c *gin.Context) { - secretKey := []byte(os.Getenv("JWT_SECRET")) // Replace with a secure secret key - token, err := auth.GenerateJWT(secretKey) + secretKey := os.Getenv("JWT_SECRET") + if secretKey == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "JWT_SECRET is not set"}) + return + } + + token, err := auth.GenerateJWT([]byte(secretKey)) if err != nil { + fmt.Printf("Error generating JWT: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } @@ -34,7 +34,7 @@ func GetProfile(c *gin.Context) { // Retrieve the user from the context user, exists := c.Get("user") if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found in context"}) + c.JSON(http.StatusNotFound, gin.H{"error": "User not found in context"}) return } diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..19d871b --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,109 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "go-rest-api/internal/api/middleware" +) + +func TestGetToken(t *testing.T) { + r := gin.Default() + + r.GET("/token", GetToken) + + os.Setenv("JWT_SECRET", "test_secret") + os.Setenv("JWT_EXPIRATION_MINUTES", "1") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/token", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response, "token") +} + +func TestGetProfile(t *testing.T) { + r := gin.Default() + + r.Use(func(c *gin.Context) { + c.Set("user", middleware.Profile{ID: "1", Email: "test@example.com", Name: "Test User"}) + c.Next() + }) + r.GET("/profile", GetProfile) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/profile", nil) + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response middleware.Profile + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "1", response.ID) + assert.Equal(t, "test@example.com", response.Email) + assert.Equal(t, "Test User", response.Name) +} + +func TestHealthCheck(t *testing.T) { + r := gin.Default() + + r.GET("/health", HealthCheck) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "OK", response["status"]) +} + +func TestPing(t *testing.T) { + r := gin.Default() + + r.GET("/ping", Ping) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/ping", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "pong", response["message"]) +} + +func TestRegister(t *testing.T) { + r := gin.Default() + + r.POST("/register", Register) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/register", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "User registered successfully", response["message"]) +} diff --git a/internal/api/middleware/logger.go b/internal/api/middleware/logger.go index 0cd9069..e9c34c8 100644 --- a/internal/api/middleware/logger.go +++ b/internal/api/middleware/logger.go @@ -3,11 +3,13 @@ package middleware import ( "time" + customLogger "go-rest-api/pkg" + "github.com/gin-gonic/gin" "go.uber.org/zap" ) -func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc { +func LoggerMiddleware(logger *customLogger.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path diff --git a/internal/api/middleware/rate_limiter.go b/internal/api/middleware/rate_limiter.go index d69632a..1ac6494 100644 --- a/internal/api/middleware/rate_limiter.go +++ b/internal/api/middleware/rate_limiter.go @@ -5,14 +5,14 @@ import ( "sync" "github.com/gin-gonic/gin" - "go.uber.org/zap" + zap "go.uber.org/zap" "golang.org/x/time/rate" ) func RateLimiter(r rate.Limit, b int) gin.HandlerFunc { type client struct { limiter *rate.Limiter - lastSeen int64 + lastSeen int64 //lint:ignore U1000 This field is currently unused but may be used in future implementations } var ( diff --git a/internal/api/middleware/token.go b/internal/api/middleware/token.go index d3de691..44e6f94 100644 --- a/internal/api/middleware/token.go +++ b/internal/api/middleware/token.go @@ -3,9 +3,11 @@ package middleware import ( "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "os" + "sync" + "time" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -15,6 +17,20 @@ var logger *zap.Logger func init() { logger, _ = zap.NewProduction() + + // Set cache expiry from environment variable or use default + cacheExpiryStr := os.Getenv("TOKEN_CACHE_EXPIRY") + if cacheExpiryStr == "" { + cacheExpiry = 5 * time.Minute + } else { + duration, err := time.ParseDuration(cacheExpiryStr) + if err != nil { + logger.Warn("Invalid TOKEN_CACHE_EXPIRY, using default of 5 minutes", zap.Error(err)) + cacheExpiry = 5 * time.Minute + } else { + cacheExpiry = duration + } + } } type Profile struct { @@ -23,6 +39,17 @@ type Profile struct { Name string `json:"name"` } +type cacheEntry struct { + profile Profile + expiry time.Time +} + +var ( + tokenCache = make(map[string]cacheEntry) + cacheMutex sync.RWMutex + cacheExpiry time.Duration +) + func VerifyToken() gin.HandlerFunc { return func(c *gin.Context) { // Get the token from the context set by AuthMiddleware @@ -34,7 +61,6 @@ func VerifyToken() gin.HandlerFunc { c.Abort() return } - if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication token is missing"}) c.Abort() @@ -56,6 +82,22 @@ func VerifyToken() gin.HandlerFunc { return } + // Check cache first + cacheMutex.RLock() + entry, found := tokenCache[tokenString] + cacheMutex.RUnlock() + + if found && time.Now().Before(entry.expiry) { + // add a header to the response to indicate that the token is cached + c.Header("X-Token-Cache", "HIT") + c.Set("user", entry.profile) + c.Next() + return + } else { + // add a header to the response to indicate that the token is not cached + c.Header("X-Token-Cache", "MISS") + } + // Validate token using the TOKEN_URL req, err := http.NewRequest("GET", tokenURL, nil) if err != nil { @@ -81,7 +123,7 @@ func VerifyToken() gin.HandlerFunc { return } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response"}) c.Abort() @@ -114,6 +156,14 @@ func VerifyToken() gin.HandlerFunc { } } + // Cache the result + cacheMutex.Lock() + tokenCache[tokenString] = cacheEntry{ + profile: profile, + expiry: time.Now().Add(cacheExpiry), + } + cacheMutex.Unlock() + c.Set("user", profile) c.Next() } diff --git a/internal/api/routes.go b/internal/api/routes.go index 3cc1e09..5933cff 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -3,11 +3,12 @@ package api import ( "time" - "go-boilerplate/internal/api/middleware" - "go-boilerplate/internal/config" + "go-rest-api/internal/api/middleware" + "go-rest-api/internal/config" + + logger "go-rest-api/pkg" "github.com/gin-gonic/gin" - "go.uber.org/zap" "golang.org/x/time/rate" ) @@ -23,7 +24,7 @@ func WithoutRateLimiting() RouterOption { } } -func SetupRouter(router *gin.Engine, cfg *config.Config, logger *zap.Logger, opts ...RouterOption) { +func SetupRouter(router *gin.Engine, cfg *config.Config, logger *logger.Logger, opts ...RouterOption) { options := &routerOptions{} for _, opt := range opts { opt(options) @@ -35,7 +36,6 @@ func SetupRouter(router *gin.Engine, cfg *config.Config, logger *zap.Logger, opt router.Use(middleware.LoggerMiddleware(logger)) if options.skipRateLimiting { - // Apply rate limiting middleware logger.Info("Rate limiting is disabled") } else { // Apply rate limiting middleware diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2f67a87 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestLoadConfig(t *testing.T) { + // Set up test environment variables + os.Setenv("OIDC_ISSUER", "https://test.issuer.com") + os.Setenv("OAUTH_CLIENT_ID", "test_client_id") + os.Setenv("OAUTH_CLIENT_SECRET", "test_client_secret") + os.Setenv("OAUTH_REDIRECT_URL", "http://test.redirect.url") + os.Setenv("JWT_SECRET", "test_jwt_secret") + os.Setenv("JWT_EXPIRATION_MINUTES", "120") + os.Setenv("VALID_API_KEY", "test_api_key") + os.Setenv("RATE_LIMIT_REQUESTS", "20") + os.Setenv("RATE_LIMIT_DURATION", "1m") + + // Load the configuration + config, err := LoadConfig() + + // Check for errors + if err != nil { + t.Fatalf("LoadConfig() returned an error: %v", err) + } + + // Test each configuration value + tests := []struct { + name string + got interface{} + expected interface{} + }{ + {"OIDCIssuer", config.OIDCIssuer, "https://test.issuer.com"}, + {"OAuthClientID", config.OAuthClientID, "test_client_id"}, + {"OAuthClientSecret", config.OAuthClientSecret, "test_client_secret"}, + {"OAuthRedirectURL", config.OAuthRedirectURL, "http://test.redirect.url"}, + {"JWTSecret", config.JWTSecret, "test_jwt_secret"}, + {"JWTExpirationMinutes", config.JWTExpirationMinutes, 120}, + {"ValidAPIKey", config.ValidAPIKey, "test_api_key"}, + {"RateLimitRequests", config.RateLimitRequests, 20}, + {"RateLimitDuration", config.RateLimitDuration, time.Minute}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.expected { + t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected) + } + }) + } +} + +func TestGetEnv(t *testing.T) { + // Test with existing environment variable + os.Setenv("TEST_ENV_VAR", "test_value") + if got := getEnv("TEST_ENV_VAR", "default"); got != "test_value" { + t.Errorf("getEnv() = %v, want %v", got, "test_value") + } + + // Test with non-existing environment variable + if got := getEnv("NON_EXISTING_VAR", "default"); got != "default" { + t.Errorf("getEnv() = %v, want %v", got, "default") + } +} + +func TestGetEnvAsInt(t *testing.T) { + // Test with valid integer + os.Setenv("TEST_INT_VAR", "42") + if got := getEnvAsInt("TEST_INT_VAR", 0); got != 42 { + t.Errorf("getEnvAsInt() = %v, want %v", got, 42) + } + + // Test with invalid integer + os.Setenv("TEST_INVALID_INT", "not_an_int") + if got := getEnvAsInt("TEST_INVALID_INT", 10); got != 10 { + t.Errorf("getEnvAsInt() = %v, want %v", got, 10) + } +} + +func TestGetEnvAsDuration(t *testing.T) { + // Test with valid duration + os.Setenv("TEST_DURATION_VAR", "5m") + if got := getEnvAsDuration("TEST_DURATION_VAR", time.Second); got != 5*time.Minute { + t.Errorf("getEnvAsDuration() = %v, want %v", got, 5*time.Minute) + } + + // Test with invalid duration + os.Setenv("TEST_INVALID_DURATION", "not_a_duration") + if got := getEnvAsDuration("TEST_INVALID_DURATION", time.Hour); got != time.Hour { + t.Errorf("getEnvAsDuration() = %v, want %v", got, time.Hour) + } +} diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index 13ed39f..515acd3 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -6,6 +6,8 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + + logger "go-rest-api/pkg" ) // User represents a user for token generation @@ -29,7 +31,7 @@ func GenerateJWT(secretKey []byte) (string, error) { "user_id": user.ID, "username": user.Username, "email": user.Email, - "exp": time.Now().Add(time.Minute * time.Duration(mustAtoi(os.Getenv("JWT_EXPIRATION_MINUTES")))).Unix(), // Token expires in 24 hours + "exp": time.Now().Add(time.Minute * time.Duration(getJWTExpirationMinutes())).Unix(), // Token expires in 24 hours "iat": time.Now().Unix(), } @@ -53,3 +55,19 @@ func mustAtoi(s string) int { } return i } + +// Add this function at the end of the file +func getJWTExpirationMinutes() int { + logger.Init() + defaultExpiration := 1440 // 24 hours in minutes + envValue := os.Getenv("JWT_EXPIRATION_MINUTES") + if envValue == "" { + logger.Info("JWT_EXPIRATION_MINUTES is not set, using default value of 24 hours") + return defaultExpiration + } + minutes, err := strconv.Atoi(envValue) + if err != nil { + return defaultExpiration + } + return minutes +} diff --git a/pkg/auth/jwt_test.go b/pkg/auth/jwt_test.go new file mode 100644 index 0000000..4f2417f --- /dev/null +++ b/pkg/auth/jwt_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestGenerateJWT(t *testing.T) { + secretKey := []byte("test-secret-key") + + token, err := GenerateJWT(secretKey) + if err != nil { + t.Fatalf("Failed to generate JWT: %v", err) + } + + // Parse and validate the token + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return secretKey, nil + }) + + if err != nil { + t.Fatalf("Failed to parse JWT: %v", err) + } + + if !parsedToken.Valid { + t.Fatalf("Generated token is not valid") + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + t.Fatalf("Failed to get claims from token") + } + + // Check if the claims contain the expected user data + if claims["user_id"] != "123456" { + t.Errorf("Expected user_id '123456', got '%v'", claims["user_id"]) + } + if claims["username"] != "user" { + t.Errorf("Expected username 'user', got '%v'", claims["username"]) + } + if claims["email"] != "@example.com" { + t.Errorf("Expected email '@example.com', got '%v'", claims["email"]) + } + + // Check if the expiration time is set correctly + exp, ok := claims["exp"].(float64) + if !ok { + t.Fatalf("Failed to get expiration time from token") + } + expectedExp := time.Now().Add(time.Minute * time.Duration(getJWTExpirationMinutes())).Unix() + if int64(exp) != expectedExp { + t.Errorf("Expected expiration time %v, got %v", expectedExp, int64(exp)) + } +} + +func TestGetJWTExpirationMinutes(t *testing.T) { + // Test default value + os.Unsetenv("JWT_EXPIRATION_MINUTES") + if exp := getJWTExpirationMinutes(); exp != 1440 { + t.Errorf("Expected default expiration 1440, got %d", exp) + } + + // Test custom value + os.Setenv("JWT_EXPIRATION_MINUTES", "60") + if exp := getJWTExpirationMinutes(); exp != 60 { + t.Errorf("Expected expiration 60, got %d", exp) + } + + // Test invalid value + os.Setenv("JWT_EXPIRATION_MINUTES", "invalid") + if exp := getJWTExpirationMinutes(); exp != 1440 { + t.Errorf("Expected default expiration 1440 for invalid input, got %d", exp) + } + + // Clean up + os.Unsetenv("JWT_EXPIRATION_MINUTES") +} diff --git a/pkg/logger.go b/pkg/logger.go new file mode 100644 index 0000000..188e723 --- /dev/null +++ b/pkg/logger.go @@ -0,0 +1,66 @@ +package logger + +import ( + "os" + "sync" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var ( + Log *Logger + once sync.Once +) + +type Logger struct { + *zap.Logger +} + +func Init() { + once.Do(func() { + config := zap.NewProductionConfig() + config.EncoderConfig.TimeKey = "timestamp" + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + logger, err := config.Build() + if err != nil { + panic(err) + } + + Log = &Logger{logger} + }) +} + +func (l *Logger) Info(msg string, fields ...zap.Field) { + l.Logger.Info(msg, fields...) +} + +func (l *Logger) Error(msg string, fields ...zap.Field) { + l.Logger.Error(msg, fields...) +} + +func (l *Logger) Fatal(msg string, fields ...zap.Field) { + l.Logger.Fatal(msg, fields...) + os.Exit(1) +} + +func (l *Logger) With(fields ...zap.Field) *Logger { + return &Logger{l.Logger.With(fields...)} +} + +func Info(msg string, fields ...zap.Field) { + Log.Info(msg, fields...) +} + +func Error(msg string, fields ...zap.Field) { + Log.Error(msg, fields...) +} + +func Fatal(msg string, fields ...zap.Field) { + Log.Fatal(msg, fields...) +} + +func With(fields ...zap.Field) *Logger { + return Log.With(fields...) +} diff --git a/pkg/logger_test.go b/pkg/logger_test.go new file mode 100644 index 0000000..f625fd7 --- /dev/null +++ b/pkg/logger_test.go @@ -0,0 +1,141 @@ +package logger + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestInit(t *testing.T) { + Init() + if Log == nil { + t.Error("Log should not be nil after Init()") + } +} + +func TestLoggerMethods(t *testing.T) { + // Create a buffer to capture log output + var buf bytes.Buffer + + // Create a custom logger for testing + testEncoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) + testCore := zapcore.NewCore(testEncoder, zapcore.AddSync(&buf), zapcore.InfoLevel) + testLogger := &Logger{zap.New(testCore)} + + tests := []struct { + name string + logFunc func(string, ...zap.Field) + message string + fields []zap.Field + expected string + }{ + {"Info", testLogger.Info, "info message", []zap.Field{zap.String("key", "value")}, "info"}, + {"Error", testLogger.Error, "error message", []zap.Field{zap.Int("code", 500)}, "error"}, + {"With", func(msg string, fields ...zap.Field) { + testLogger.With(zap.String("with", "field")).Info(msg, fields...) + }, "with message", nil, "info"}, // Changed expected level to "info" + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + tt.logFunc(tt.message, tt.fields...) + + var logEntry map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &logEntry); err != nil { + t.Fatalf("Failed to unmarshal log entry: %v", err) + } + + if logEntry["level"] != tt.expected { + t.Errorf("Expected log level %s, got %s", tt.expected, logEntry["level"]) + } + if logEntry["msg"] != tt.message { + t.Errorf("Expected message %s, got %s", tt.message, logEntry["msg"]) + } + + for _, field := range tt.fields { + if value, ok := logEntry[field.Key]; !ok { + t.Errorf("Expected field %s not found in log entry", field.Key) + } else { + // Compare values based on the field type + switch field.Type { + case zapcore.StringType: + if value != field.String { + t.Errorf("Expected field %s with value %v, got %v", field.Key, field.String, value) + } + case zapcore.Int64Type, zapcore.Int32Type, zapcore.Int16Type, zapcore.Int8Type: + if int64(value.(float64)) != field.Integer { + t.Errorf("Expected field %s with value %v, got %v", field.Key, field.Integer, value) + } + default: + // For other types, use string comparison + if fmt.Sprintf("%v", value) != fmt.Sprintf("%v", field.Interface) { + t.Errorf("Expected field %s with value %v, got %v", field.Key, field.Interface, value) + } + } + } + } + + if tt.name == "With" { + if value, ok := logEntry["with"]; !ok || value != "field" { + t.Errorf("Expected 'with' field with value 'field', got %v", value) + } + } + }) + } +} + +func TestGlobalLoggerMethods(t *testing.T) { + // Initialize the global logger + Init() + + // Redirect global logger output to a buffer + var buf bytes.Buffer + Log.Logger = zap.New( + zapcore.NewCore( + zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), + zapcore.AddSync(&buf), + zapcore.InfoLevel, + ), + ) + + tests := []struct { + name string + logFunc func(string, ...zap.Field) + message string + fields []zap.Field + }{ + {"Info", Info, "global info", []zap.Field{zap.String("global", "value")}}, + {"Error", Error, "global error", []zap.Field{zap.Int("global_code", 500)}}, + {"With", func(msg string, fields ...zap.Field) { + With(zap.String("global_with", "field")).Info(msg, fields...) + }, "global with", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + tt.logFunc(tt.message, tt.fields...) + + logOutput := buf.String() + if !strings.Contains(logOutput, tt.message) { + t.Errorf("Log output doesn't contain expected message: %s", tt.message) + } + + for _, field := range tt.fields { + if !strings.Contains(logOutput, field.Key) || !strings.Contains(logOutput, field.String) { + t.Errorf("Log output doesn't contain expected field: %s", field.Key) + } + } + + if tt.name == "With" && !strings.Contains(logOutput, "global_with") { + t.Errorf("Log output doesn't contain 'global_with' field") + } + }) + } +} diff --git a/tests/contract/service_contract_test.go b/tests/contract/service_contract_test.go index 3d3c3bf..e11f95b 100644 --- a/tests/contract/service_contract_test.go +++ b/tests/contract/service_contract_test.go @@ -2,15 +2,16 @@ package contract import ( "encoding/json" - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" + "go-rest-api/internal/api" + "go-rest-api/internal/config" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" - "go.uber.org/zap" + + logger "go-rest-api/pkg" ) func TestServiceContract(t *testing.T) { @@ -18,12 +19,10 @@ func TestServiceContract(t *testing.T) { cfg, err := config.LoadConfig() assert.NoError(t, err, "Failed to load configuration") - logger, err := zap.NewProduction() - assert.NoError(t, err, "Failed to initialize logger") - defer logger.Sync() + logger.Init() r := gin.New() - api.SetupRouter(r, cfg, logger) + api.SetupRouter(r, cfg, logger.Log) // Create a test HTTP server server := httptest.NewServer(r) diff --git a/tests/e2e/api_flow_test.go b/tests/e2e/api_flow_test.go index 0bea77d..8c3aab8 100644 --- a/tests/e2e/api_flow_test.go +++ b/tests/e2e/api_flow_test.go @@ -2,15 +2,16 @@ package e2e import ( "encoding/json" - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" + "go-rest-api/internal/api" + "go-rest-api/internal/config" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" - "go.uber.org/zap" + + logger "go-rest-api/pkg" ) func setupTestRouter() *gin.Engine { @@ -19,18 +20,19 @@ func setupTestRouter() *gin.Engine { JWTSecret: "test_secret", ValidAPIKey: "test_api_key", } - logger, _ := zap.NewDevelopment() + + logger.Init() r := gin.New() // Add the config and logger to the gin.Context r.Use(func(c *gin.Context) { c.Set("config", cfg) - c.Set("logger", logger) + c.Set("logger", logger.Log) c.Next() }) - api.SetupRouter(r, cfg, logger) + api.SetupRouter(r, cfg, logger.Log) return r } diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index e1c4d2a..60bcb81 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -2,30 +2,27 @@ package integration import ( "encoding/json" - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" - "io/ioutil" + "go-rest-api/internal/api" + "go-rest-api/internal/config" + "io" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" - "go.uber.org/zap" + + logger "go-rest-api/pkg" ) func TestAPIEndpoints(t *testing.T) { - // Setup the router // Setup the router cfg, err := config.LoadConfig() assert.NoError(t, err, "Failed to load configuration") - logger, err := zap.NewProduction() - assert.NoError(t, err, "Failed to initialize logger") - defer logger.Sync() - + logger.Init() r := gin.New() - api.SetupRouter(r, cfg, logger) + api.SetupRouter(r, cfg, logger.Log) // Create a test HTTP server server := httptest.NewServer(r) @@ -58,7 +55,7 @@ func TestAPIEndpoints(t *testing.T) { assert.Equal(t, tc.expectedStatus, resp.StatusCode, "Unexpected status code") // Read and parse the response body - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) assert.NoError(t, err, "Failed to read response body") var responseBody map[string]string diff --git a/tests/performance/api_benchmark_test.go b/tests/performance/api_benchmark_test.go index 929d2ec..3f6bab1 100644 --- a/tests/performance/api_benchmark_test.go +++ b/tests/performance/api_benchmark_test.go @@ -1,14 +1,15 @@ package performance import ( - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" + "go-rest-api/internal/api" + "go-rest-api/internal/config" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" - "go.uber.org/zap" + + logger "go-rest-api/pkg" ) func BenchmarkPingEndpoint(b *testing.B) { @@ -17,14 +18,10 @@ func BenchmarkPingEndpoint(b *testing.B) { b.Fatalf("Failed to load configuration: %v", err) } - logger, err := zap.NewProduction() - if err != nil { - b.Fatalf("Failed to initialize logger: %v", err) - } - defer logger.Sync() + logger.Init() router := gin.New() - api.SetupRouter(router, cfg, logger, api.WithoutRateLimiting()) + api.SetupRouter(router, cfg, logger.Log, api.WithoutRateLimiting()) server := httptest.NewServer(router) defer server.Close() @@ -44,14 +41,10 @@ func BenchmarkRateLimiting(b *testing.B) { b.Fatalf("Failed to load configuration: %v", err) } - logger, err := zap.NewProduction() - if err != nil { - b.Fatalf("Failed to initialize logger: %v", err) - } - defer logger.Sync() + logger.Init() router := gin.New() - api.SetupRouter(router, cfg, logger) // Use default setup with rate limiting + api.SetupRouter(router, cfg, logger.Log) // Use default setup with rate limiting server := httptest.NewServer(router) defer server.Close() diff --git a/tests/security/api_security_test.go b/tests/security/api_security_test.go index 36d0ff4..00987c1 100644 --- a/tests/security/api_security_test.go +++ b/tests/security/api_security_test.go @@ -2,8 +2,8 @@ package security import ( "encoding/json" - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" + "go-rest-api/internal/api" + "go-rest-api/internal/config" "net/http" "net/http/httptest" "os" @@ -11,14 +11,15 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" - "go.uber.org/zap" + + logger "go-rest-api/pkg" ) func setupRouter() *gin.Engine { cfg, _ := config.LoadConfig() - logger, _ := zap.NewProduction() + logger.Init() r := gin.New() - api.SetupRouter(r, cfg, logger) + api.SetupRouter(r, cfg, logger.Log) return r } @@ -26,7 +27,12 @@ func TestAPISecurityEndpoints(t *testing.T) { // Setup mock token server mockTokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "valid_token" { - json.NewEncoder(w).Encode(map[string]string{"id": "123", "email": "test@example.com", "name": "Test User"}) + err := json.NewEncoder(w).Encode(map[string]string{"id": "123", "email": "test@example.com", "name": "Test User"}) + if err != nil { + t.Errorf("Failed to encode JSON response: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } } else { w.WriteHeader(http.StatusUnauthorized) } diff --git a/tests/unit/service_test.go b/tests/unit/service_test.go deleted file mode 100644 index 907a1ba..0000000 --- a/tests/unit/service_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package unit - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "go-boilerplate/internal/api" - "go-boilerplate/internal/config" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "go.uber.org/zap" -) - -func setupTestRouter() *gin.Engine { - gin.SetMode(gin.TestMode) - cfg := &config.Config{ - JWTSecret: "test_secret", - ValidAPIKey: "test_api_key", - } - logger, _ := zap.NewDevelopment() - - r := gin.New() - - // Add the config and logger to the gin.Context - r.Use(func(c *gin.Context) { - c.Set("config", cfg) - c.Set("logger", logger) - c.Next() - }) - - api.SetupRouter(r, cfg, logger) - return r -} - -func TestPingHandler(t *testing.T) { - // Setup the router - r := setupTestRouter() - - // Create a mock request to the /ping endpoint - req, err := http.NewRequest(http.MethodGet, "/api/v1/ping", nil) - assert.NoError(t, err) - - // Create a response recorder - w := httptest.NewRecorder() - - // Perform the request - r.ServeHTTP(w, req) - - // Assert the status code - assert.Equal(t, http.StatusOK, w.Code) - - // Parse the response body - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - - // Assert the response body - assert.Equal(t, "pong", response["message"]) -} - -func TestHealthCheckHandler(t *testing.T) { - // Setup the router - r := setupTestRouter() - - // Create a mock request to the /health endpoint - req, err := http.NewRequest(http.MethodGet, "/api/v1/health", nil) - assert.NoError(t, err) - - // Create a response recorder - w := httptest.NewRecorder() - - // Perform the request - r.ServeHTTP(w, req) - - // Assert the status code - assert.Equal(t, http.StatusOK, w.Code) - - // Parse the response body - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - - // Assert the response body - assert.Equal(t, "OK", response["status"]) -}