From 73a832284296f58506013ca04085e9ea200cab6b Mon Sep 17 00:00:00 2001 From: Nico Bistolfi <4433590+nicobistolfi@users.noreply.github.com> Date: Tue, 3 Sep 2024 10:11:12 -0700 Subject: [PATCH] Cleanup v1 (#8) * renaming everything to go-rest-api * serve docs * adding favicons and docs * adding favicons and docs * improving docs * fixing logger * improving docs * adding unit tests * adding unit tests * adding more documentation and cache to the token middleware --------- Co-authored-by: Nico Bistolfi --- .env.example | 1 + Makefile | 16 +- README.md | 4 +- deployments/serverlesss/handlers/main.go | 16 +- deployments/serverlesss/serverless.yml | 2 +- docs/docs/auth/_category_.yml | 1 + docs/docs/auth/auth-middleware-md.md | 24 ++ docs/docs/auth/environment-variables-md.md | 35 ++ docs/docs/auth/index.md | 293 +------------ docs/docs/auth/token-caching-md.md | 43 ++ docs/docs/auth/token-middleware-md.md | 32 ++ docs/docs/cors.md | 138 ++++++ docs/docs/deployments/_category_.yml | 2 +- docs/docs/deployments/docker.md | 4 +- docs/docs/intro.md | 16 +- docs/docs/logging.md | 152 +++++++ docs/docs/make.md | 163 +++++++ docs/docs/tests/e2e-testing.md | 5 - docs/docs/tests/index.md | 408 ++++++++++++++++++ docs/docs/tests/integration-testing.md | 5 - docs/docs/tests/performance-testing.md | 7 - docs/docs/tests/security-testing.md | 5 - docs/docs/tests/testing-strategy.md | 13 - docs/docs/tests/unit-testing.md | 5 - docs/docusaurus.config.ts | 41 +- docs/package.json | 2 +- .../src/components/HomepageFeatures/index.tsx | 34 +- docs/src/css/custom.css | 30 +- docs/src/pages/index.tsx | 2 +- docs/static/img/android-chrome-192x192.png | Bin 0 -> 12351 bytes docs/static/img/android-chrome-512x512.png | Bin 0 -> 31389 bytes docs/static/img/apple-touch-icon.png | Bin 0 -> 11225 bytes docs/static/img/docusaurus.png | Bin 5142 -> 22428 bytes docs/static/img/favicon-16x16.png | Bin 0 -> 673 bytes docs/static/img/favicon-32x32.png | Bin 0 -> 1455 bytes docs/static/img/favicon.ico | Bin 3626 -> 15406 bytes docs/static/img/logo.png | Bin 0 -> 22428 bytes docs/static/img/logo.svg | 10 +- docs/static/img/site.webmanifest | 1 + go.mod | 2 +- internal/api/handlers.go | 22 +- internal/api/handlers_test.go | 109 +++++ internal/api/middleware/logger.go | 4 +- internal/api/middleware/rate_limiter.go | 4 +- internal/api/middleware/token.go | 56 ++- internal/api/routes.go | 10 +- internal/config/config_test.go | 94 ++++ pkg/auth/jwt.go | 20 +- pkg/auth/jwt_test.go | 80 ++++ pkg/logger.go | 66 +++ pkg/logger_test.go | 141 ++++++ tests/contract/service_contract_test.go | 13 +- tests/e2e/api_flow_test.go | 14 +- tests/integration/api_test.go | 19 +- tests/performance/api_benchmark_test.go | 23 +- tests/security/api_security_test.go | 18 +- tests/unit/service_test.go | 88 ---- 57 files changed, 1738 insertions(+), 555 deletions(-) create mode 100644 docs/docs/auth/auth-middleware-md.md create mode 100644 docs/docs/auth/environment-variables-md.md create mode 100644 docs/docs/auth/token-caching-md.md create mode 100644 docs/docs/auth/token-middleware-md.md create mode 100644 docs/docs/cors.md create mode 100644 docs/docs/logging.md create mode 100644 docs/docs/make.md delete mode 100644 docs/docs/tests/e2e-testing.md create mode 100644 docs/docs/tests/index.md delete mode 100644 docs/docs/tests/integration-testing.md delete mode 100644 docs/docs/tests/performance-testing.md delete mode 100644 docs/docs/tests/security-testing.md delete mode 100644 docs/docs/tests/testing-strategy.md delete mode 100644 docs/docs/tests/unit-testing.md create mode 100644 docs/static/img/android-chrome-192x192.png create mode 100644 docs/static/img/android-chrome-512x512.png create mode 100644 docs/static/img/apple-touch-icon.png create mode 100644 docs/static/img/favicon-16x16.png create mode 100644 docs/static/img/favicon-32x32.png create mode 100644 docs/static/img/logo.png create mode 100644 docs/static/img/site.webmanifest create mode 100644 internal/api/handlers_test.go create mode 100644 internal/config/config_test.go create mode 100644 pkg/auth/jwt_test.go create mode 100644 pkg/logger.go create mode 100644 pkg/logger_test.go delete mode 100644 tests/unit/service_test.go 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 0000000000000000000000000000000000000000..2a5fc023667765c81319893bb1788942057df9e8 GIT binary patch literal 12351 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

Z%{tb1Ffyd0JB`4vI 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/android-chrome-512x512.png b/docs/static/img/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..a52f50a26ca50bc424ed86c1fcaaeae046a3c11a GIT binary patch literal 31389 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Ü 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 D3hdqu literal 0 HcmV?d00001 diff --git a/docs/static/img/docusaurus.png b/docs/static/img/docusaurus.png index f458149e3c8f53335f28fbc162ae67f55575c881..14185dbcb9c6ca44c8899747b3f2aeb57261a119 100644 GIT binary patch literal 22428 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 F{0H{b<4^zq diff --git a/docs/static/img/favicon-16x16.png b/docs/static/img/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..770bcc86cd9e3ae08bef479ee6f8a35bd9062523 GIT binary patch literal 673 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 \ 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"]) -}