diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4f4a82 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# OAuth 2.0 / OpenID Connect Configuration +OIDC_ISSUER=https://accounts.google.com +OAUTH_CLIENT_ID=your_oauth_client_id +OAUTH_CLIENT_SECRET=your_oauth_client_secret +OAUTH_REDIRECT_URL=http://localhost:8080/auth/callback + +# JWT Configuration +JWT_SECRET=your_jwt_secret_key +JWT_EXPIRATION_MINUTES=60 + +# API Key Configuration +VALID_API_KEY=your_api_key + +# Server Configuration +PORT=8080 + +# Database Configuration (if applicable) +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=your_database_name +# DB_USER=your_database_user +# DB_PASSWORD=your_database_password + +# Redis Configuration (if applicable) +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_PASSWORD= + +# Logging Configuration +LOG_LEVEL=info + +# Rate Limiting Configuration +RATE_LIMIT_REQUESTS=10 +RATE_LIMIT_DURATION=1s + +# Other Configuration Options +# Add any other environment-specific configurations here \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25e0576 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to Go API Boilerplate + +We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +## We Develop with Github + +We use github to host code, to track issues and feature requests, as well as accept pull requests. + +## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests + +Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](https://github.com/yourusername/go-api-boilerplate/issues) + +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/yourusername/go-api-boilerplate/issues/new); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +* 2 spaces for indentation rather than tabs +* You can try running `go fmt` for style unification + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. + +## References + +This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c8a0f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 [Your Name or Your Organization's Name] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 07455b4..fefbcbd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Go API Project Structure +# Go API Boilerplate -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 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 @@ -36,147 +36,60 @@ go-boilerplate/ └── README.md ``` -### Directory and File Descriptions +### Key Components -#### `cmd/` -Contains the main applications for this project. The `api/` subdirectory is where the main.go file for starting the API server resides. +- `cmd/api/main.go`: Application entry point +- `internal/`: Core application logic +- `pkg/`: Reusable, non-project-specific code +- `tests/`: Comprehensive test suite +- `deployments/`: Deployment configurations for various platforms +- `docs/`: Docusaurus-based documentation -- `api/main.go`: Entry point of the application. Initializes and starts the API server. +## Functionality -#### `internal/` -Houses packages that are specific to this project and not intended for external use. +This boilerplate provides: -- `api/`: Contains API-specific code. - - `handlers/`: Request handlers for each API endpoint. - - `middleware/`: Custom middleware functions. - - `routes.go`: Defines API routes and links them to handlers. -- `config/`: Configuration management for the application. -- `models/`: Data models and DTOs (Data Transfer Objects). -- `repository/`: Data access layer, interfacing with the database. -- `service/`: Business logic layer, implementing core functionality. +1. A structured API server setup +2. Configuration management +3. Database integration (via repository layer) +4. Business logic separation (service layer) +5. Middleware support +6. Comprehensive testing framework +7. Multiple deployment options -#### `pkg/` -Shared packages that could potentially be used by external projects. Place reusable, non-project-specific code here. +## Deployment Options -#### `scripts/` -Utility scripts for development, CI/CD, database migrations, etc. +The boilerplate supports multiple deployment strategies: -#### `tests/` -Contains test files separated into integration and unit tests. +1. Docker: Containerization for consistent environments +2. Kubernetes: Orchestration for scalable deployments +3. Serverless: Cloud function deployment for serverless architectures -- `integration/`: API-level and end-to-end tests. -- `unit/`: Unit tests for individual functions and methods. +Deployment configurations are located in the `deployments/` directory. -#### `deployments/` -Configuration files and scripts for deploying the application. +## Documentation -- `docker/`: Dockerfile and related configurations for containerization. -- `kubernetes/`: Kubernetes manifests for orchestration. -- `serverless/`: Serverless configuration files for cloud function deployment. +To run the documentation locally: -#### `docs/` -Project documentation, API specifications, and any other relevant documentation. This directory contains a Docusaurus project for easy-to-navigate and visually appealing documentation. - -To run and view the documentation locally: - -1. Navigate to the `docs/` directory: - ``` - cd docs +1. Navigate to the `docs/` directory +2. Run: ``` - -2. Install the necessary dependencies: - ``` - npm install + npm install && npm start ``` +3. Open `http://localhost:3000` in your browser -3. Start the Docusaurus development server: - ``` - npm run start - ``` - -4. Open your web browser and visit `http://localhost:3000` to view the documentation. - -The documentation includes: -- API specifications -- Getting started guide -- Architecture overview -- Deployment instructions -- Contributing guidelines - -To build the documentation for production: -``` -npm run build -``` - -This will generate static content in the `build` directory, which can be served using any static content hosting service. - -#### Root Files -- `.gitignore`: Specifies intentionally untracked files to ignore. -- `go.mod` and `go.sum`: Go module files for dependency management. -- `Makefile`: Defines commands for building, testing, and deploying the application. -- `LICENSE`: Contains the MIT License text. -- `README.md`: This file, providing an overview of the project structure. +This will start a Docusaurus site with comprehensive project documentation. ## Getting Started -1. Clone this repository. -2. Navigate to the project root. -3. Run `go mod tidy` to ensure all dependencies are correctly installed. -4. Use the provided Makefile commands for common tasks: +1. Clone this repository +2. Run `go mod tidy` to install dependencies +3. Use the provided Makefile commands for common tasks: - `make build`: Build the application - `make test`: Run all tests - `make run`: Run the application locally -## Development Workflow - -1. Implement new features or bug fixes in the appropriate packages under `internal/`. -2. Write unit tests in the same package as the code being tested. -3. Write integration tests in the `tests/integration/` directory. -4. Update API documentation in the `docs/` directory as necessary. -5. Use the `scripts/` directory for any automation tasks. -6. Update deployment configurations in `deployments/` if there are infrastructure changes. - -## Deployment - -This project supports multiple deployment options: - -### Docker - -Refer to the `deployments/docker/` directory for Docker configurations. To build and run the Docker container: - -1. Build the Docker image: - ``` - docker build -t go-rest-api -f deployments/docker/Dockerfile . - ``` -2. Run the container: - ``` - docker run -p 8080:8080 go-rest-api - ``` - -### Kubernetes - -Kubernetes manifests are available in the `deployments/kubernetes/` directory. To deploy to a Kubernetes cluster: - -1. Apply the manifests: - ``` - kubectl apply -f deployments/kubernetes/ - ``` - -### Serverless - -For serverless deployment, we use the Serverless Framework. Configuration files are located in the `deployments/serverless/` directory. - -1. Install the Serverless Framework: - ``` - npm install -g serverless - ``` -2. Deploy the application: - ``` - cd deployments/serverless - serverless deploy - ``` - -Ensure to update these configurations as the application evolves. For more detailed deployment instructions, refer to the respective README files in each deployment directory. +For more detailed information, please refer to the full documentation in the `docs/` directory.directory. ## Contributing @@ -184,26 +97,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. - -## Testing - -This project employs a comprehensive testing strategy to ensure reliability, performance, and security. For detailed information on our testing approach, please refer to the following documentation: - -- [Testing Strategy](docs/docs/tests/testing-strategy.md) -- [Unit Testing](docs/docs/tests/unit-testing.md) -- [Integration Testing](docs/docs/tests/integration-testing.md) -- [Performance Testing](docs/docs/tests/performance-testing.md) -- [Security Testing](docs/docs/tests/security-testing.md) -- [End-to-End Testing](docs/docs/tests/e2e-testing.md) - -To run the tests, you can use the following make commands: - -- Run unit tests: `make test-unit` -- Run integration tests: `make test-integration` -- Run performance tests: `make test-performance` -- Run security tests: `make test-security` -- Run end-to-end tests: `make test-e2e` -- Run all tests: `make test-all` - -For more information on these commands, refer to the Makefile in the root directory of the project. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/deployments/serverlesss/handlers/main.go b/deployments/serverlesss/handlers/main.go index 2005aad..67ae761 100644 --- a/deployments/serverlesss/handlers/main.go +++ b/deployments/serverlesss/handlers/main.go @@ -5,10 +5,13 @@ import ( "log" "go-boilerplate/internal/api" + "go-boilerplate/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" ) var ginLambda *ginadapter.GinLambda @@ -16,7 +19,26 @@ var ginLambda *ginadapter.GinLambda func init() { // stdout and stderr are sent to AWS CloudWatch Logs log.Printf("Gin cold start") - r := api.SetupRouter() + + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + 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() + + // Create a new Gin router + r := gin.New() + + // Setup router with middleware and routes + api.SetupRouter(r, cfg, logger) + ginLambda = ginadapter.New(r) } diff --git a/docs/docs/auth/apikey.md b/docs/docs/auth/apikey.md new file mode 100644 index 0000000..fa2893d --- /dev/null +++ b/docs/docs/auth/apikey.md @@ -0,0 +1,17 @@ +# API Key Authentication + +This document describes the API Key authentication mechanism implemented in our API. + +## Overview + +API Key authentication is a simple and straightforward method for authenticating API requests. It involves including a pre-shared key in the request headers. + +## Configuration + +The following environment variable needs to be set: + +- `VALID_API_KEY`: The valid API key that clients should use + +## Usage + +To authenticate API requests, include the API key in the `X-API-Key` header: \ No newline at end of file diff --git a/docs/docs/auth/jwt.md b/docs/docs/auth/jwt.md new file mode 100644 index 0000000..b52975f --- /dev/null +++ b/docs/docs/auth/jwt.md @@ -0,0 +1,20 @@ +# JSON Web Token (JWT) Authentication + +This document describes the JWT authentication mechanism implemented in our API. + +## Overview + +JWT authentication uses a signed token to securely transmit information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. + +## Configuration + +The following environment variables need to be set: + +- `JWT_SECRET`: A secret key used to sign and verify JWTs +- `JWT_EXPIRATION_MINUTES`: The expiration time for JWTs in minutes (default: 60) + +## Usage + +1. The client obtains a JWT by authenticating with valid credentials (e.g., username and password). +2. The server generates and returns a JWT. +3. The client includes this token in the `Authorization` header for subsequent API requests: \ No newline at end of file diff --git a/docs/docs/auth/oauth.md b/docs/docs/auth/oauth.md new file mode 100644 index 0000000..8610aff --- /dev/null +++ b/docs/docs/auth/oauth.md @@ -0,0 +1,40 @@ +# OAuth 2.0 with OpenID Connect (OIDC) Authentication + +This document describes the OAuth 2.0 with OpenID Connect (OIDC) authentication mechanism implemented in our API. + +## Overview + +OAuth 2.0 with OIDC is used for secure user authentication and authorization. It allows users to log in using their accounts from an OIDC provider (e.g., Google, Auth0) without sharing their credentials with our application. + +## Configuration + +The following environment variables need to be set: + +- `OIDC_ISSUER`: The URL of the OIDC provider +- `OAUTH_CLIENT_ID`: The client ID provided by the OIDC provider +- `OAUTH_CLIENT_SECRET`: The client secret provided by the OIDC provider +- `OAUTH_REDIRECT_URL`: The URL to redirect to after successful authentication + +## Usage + +1. The client initiates the OAuth flow by redirecting the user to the OIDC provider's authorization endpoint. +2. After successful authentication, the OIDC provider redirects back to our application with an authorization code. +3. Our application exchanges the authorization code for an ID token and access token. +4. The access token is then used to authenticate API requests. + +To authenticate API requests, include the access token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +## Implementation Details + +- The OAuth middleware is implemented in `internal/api/middleware/oauth.go`. +- Token validation and user info retrieval are handled in `pkg/auth/oauth.go`. +- The middleware validates the token and sets the user information in the request context. + +## Security Considerations + +- Always use HTTPS to protect token transmission. +- Implement proper token storage and management on the \ No newline at end of file diff --git a/go.mod b/go.mod index b1eac8c..21f71b4 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,15 @@ go 1.22.5 require ( github.com/aws/aws-lambda-go v1.47.0 github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 + github.com/coreos/go-oidc v2.2.1+incompatible github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.8.4 + go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.22.0 + golang.org/x/time v0.5.0 + gopkg.in/h2non/gock.v1 v1.1.2 ) require ( @@ -20,6 +27,7 @@ require ( github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect @@ -29,8 +37,10 @@ require ( github.com/onsi/gomega v1.27.10 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pquerna/cachecontrol v0.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/net v0.23.0 // indirect @@ -38,5 +48,6 @@ require ( golang.org/x/text v0.14.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 045d22d..3a5270a 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= +github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -29,9 +31,15 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -40,8 +48,9 @@ github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8t github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -51,6 +60,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -61,10 +72,13 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= +github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -77,6 +91,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -84,17 +104,25 @@ golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..bf8a0d6 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,71 @@ +package api + +import ( + "net/http" + + "go-boilerplate/internal/models" + "go-boilerplate/pkg/auth" // Adjust this import path as needed + + "github.com/coreos/go-oidc" + "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"` +} + +// GetProfile handles the /profile endpoint +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"}) + return + } + + var profile ProfileResponse + + // Determine the type of user and populate the profile accordingly + switch u := user.(type) { + case *oidc.UserInfo: // OAuth user + profile = ProfileResponse{ + ID: u.Subject, + Email: u.Email, + Name: u.Profile, + } + case *auth.Claims: // JWT user + profile = ProfileResponse{ + ID: u.UserID, + Email: u.Email, + Name: "JWT User", // You might want to store and retrieve the name in your JWT claims + } + case *models.User: // API Key user + profile = ProfileResponse{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + } + default: // Unknown type + c.JSON(http.StatusInternalServerError, gin.H{"error": "Unknown user type"}) + return + } + + c.JSON(http.StatusOK, profile) +} + +// HealthCheck handles the /health endpoint +func HealthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "OK", + }) +} + +// Ping handles the /ping endpoint +func Ping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "pong", + }) +} diff --git a/internal/api/middleware/apikey.go b/internal/api/middleware/apikey.go new file mode 100644 index 0000000..27d4c9b --- /dev/null +++ b/internal/api/middleware/apikey.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "net/http" + + "go-boilerplate/internal/config" + "go-boilerplate/pkg/auth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func APIKeyMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + logger := c.MustGet("logger").(*zap.Logger) + cfg := c.MustGet("config").(*config.Config) + + apiKey := c.GetHeader("X-API-Key") + if apiKey == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"}) + c.Abort() + return + } + + user, err := auth.ValidateAPIKey(apiKey, cfg) + if err != nil { + logger.Error("Failed to validate API key", zap.Error(err)) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + c.Abort() + return + } + + c.Set("user", user) + c.Next() + } +} diff --git a/internal/api/middleware/jwt.go b/internal/api/middleware/jwt.go new file mode 100644 index 0000000..3451280 --- /dev/null +++ b/internal/api/middleware/jwt.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http" + "strings" + + "go-boilerplate/internal/config" + "go-boilerplate/pkg/auth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func JWTMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + logger := c.MustGet("logger").(*zap.Logger) + cfg := c.MustGet("config").(*config.Config) + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authorization header"}) + c.Abort() + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := auth.ValidateJWTToken(tokenString, cfg.JWTSecret) + if err != nil { + logger.Error("Failed to validate JWT token", zap.Error(err)) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + c.Set("user", claims) + c.Next() + } +} diff --git a/internal/api/middleware/logger.go b/internal/api/middleware/logger.go new file mode 100644 index 0000000..0cd9069 --- /dev/null +++ b/internal/api/middleware/logger.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + end := time.Now() + latency := end.Sub(start) + + if len(c.Errors) > 0 { + for _, e := range c.Errors.Errors() { + logger.Error(e) + } + } else { + logger.Info("Request", + zap.Int("status", c.Writer.Status()), + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("query", query), + zap.String("ip", c.ClientIP()), + zap.String("user-agent", c.Request.UserAgent()), + zap.Duration("latency", latency), + ) + } + } +} diff --git a/internal/api/middleware/oauth.go b/internal/api/middleware/oauth.go new file mode 100644 index 0000000..c6cfdda --- /dev/null +++ b/internal/api/middleware/oauth.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "net/http" + + "go-boilerplate/internal/config" + "go-boilerplate/pkg/auth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func OAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + logger := c.MustGet("logger").(*zap.Logger) + cfg := c.MustGet("config").(*config.Config) + + token := c.GetHeader("Authorization") + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authorization token"}) + c.Abort() + return + } + + userInfo, err := auth.ValidateOAuthToken(c.Request.Context(), token, cfg) + if err != nil { + logger.Error("Failed to validate OAuth token", zap.Error(err)) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + c.Set("user", userInfo) + c.Next() + } +} diff --git a/internal/api/middleware/rate_limiter.go b/internal/api/middleware/rate_limiter.go new file mode 100644 index 0000000..1b3f65c --- /dev/null +++ b/internal/api/middleware/rate_limiter.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +func RateLimiter(r rate.Limit, b int) gin.HandlerFunc { + limiter := rate.NewLimiter(r, b) + return func(c *gin.Context) { + if !limiter.Allow() { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index f5b24b7..b5ab9ca 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -1,18 +1,51 @@ package api import ( - "go-boilerplate/internal/api/handlers" + "time" + + "go-boilerplate/internal/api/middleware" + "go-boilerplate/internal/config" "github.com/gin-gonic/gin" + "go.uber.org/zap" + "golang.org/x/time/rate" ) -// SetupRouter initializes and returns a Gin router with all routes defined -func SetupRouter() *gin.Engine { - r := gin.Default() +func SetupRouter(r *gin.Engine, cfg *config.Config, logger *zap.Logger) { + // Add global middleware + r.Use(gin.Recovery()) + 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 + } - r.GET("/ping", handlers.PingHandler) + // OAuth protected routes + oauth := r.Group("/api/v1/oauth") + oauth.Use(middleware.OAuthMiddleware()) + { + oauth.GET("/profile", GetProfile) + // Add other OAuth protected routes + } - // Add more routes here as needed + // JWT protected routes + jwt := r.Group("/api/v1/jwt") + jwt.Use(middleware.JWTMiddleware()) + { + jwt.GET("/profile", GetProfile) + // Add other JWT protected routes + } - return r + // API Key protected routes + apiKey := r.Group("/api/v1/apikey") + apiKey.Use(middleware.APIKeyMiddleware()) + { + apiKey.GET("/profile", GetProfile) + // Add other API Key protected routes + } } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9779c46 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "os" + "strconv" + "time" + + "github.com/joho/godotenv" +) + +type Config struct { + // OAuth configuration + OIDCIssuer string + OAuthClientID string + OAuthClientSecret string + OAuthRedirectURL string + + // JWT configuration + JWTSecret string + JWTExpirationMinutes int + + // API Key configuration + ValidAPIKey string + + // Rate Limiting configuration + RateLimitRequests int + RateLimitDuration time.Duration + + // Other configuration options + // ... +} + +func LoadConfig() (*Config, error) { + // Load .env file if it exists + _ = godotenv.Load() // Ignore error if .env file doesn't exist + + config := &Config{ + OIDCIssuer: getEnv("OIDC_ISSUER", "https://accounts.google.com"), + OAuthClientID: getEnv("OAUTH_CLIENT_ID", ""), + OAuthClientSecret: getEnv("OAUTH_CLIENT_SECRET", ""), + OAuthRedirectURL: getEnv("OAUTH_REDIRECT_URL", "http://localhost:8080/auth/callback"), + + JWTSecret: getEnv("JWT_SECRET", "default_jwt_secret"), + JWTExpirationMinutes: getEnvAsInt("JWT_EXPIRATION_MINUTES", 60), + + ValidAPIKey: getEnv("VALID_API_KEY", "default_api_key"), + + RateLimitRequests: getEnvAsInt("RATE_LIMIT_REQUESTS", 10), + RateLimitDuration: getEnvAsDuration("RATE_LIMIT_DURATION", time.Second), + } + + return config, nil +} + +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int) int { + valueStr := getEnv(key, "") + if value, err := strconv.Atoi(valueStr); err == nil { + return value + } + return defaultValue +} + +func getEnvAsDuration(key string, defaultValue time.Duration) time.Duration { + valueStr := getEnv(key, "") + if value, err := time.ParseDuration(valueStr); err == nil { + return value + } + return defaultValue +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..5297185 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,8 @@ +package models + +// User represents a user in the system +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} diff --git a/pkg/auth/apikey.go b/pkg/auth/apikey.go new file mode 100644 index 0000000..4bbe8de --- /dev/null +++ b/pkg/auth/apikey.go @@ -0,0 +1,22 @@ +package auth + +import ( + "errors" + + "go-boilerplate/internal/config" + "go-boilerplate/internal/models" +) + +func ValidateAPIKey(apiKey string, cfg *config.Config) (*models.User, error) { + // In a real-world scenario, you would typically check the API key against a database + // For this example, we'll use a simple in-memory check + if apiKey == cfg.ValidAPIKey { + return &models.User{ + ID: "api_user", + Email: "api@example.com", + Name: "API User", + }, nil + } + + return nil, errors.New("invalid API key") +} diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go new file mode 100644 index 0000000..bd47aaa --- /dev/null +++ b/pkg/auth/jwt.go @@ -0,0 +1,44 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +func ValidateJWTToken(tokenString string, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +func GenerateJWTToken(userID, email string, secret string, expirationTime time.Duration) (string, error) { + claims := &Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expirationTime * time.Second)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go new file mode 100644 index 0000000..9ba57b4 --- /dev/null +++ b/pkg/auth/oauth.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "errors" + + "go-boilerplate/internal/config" + + "github.com/coreos/go-oidc" + "golang.org/x/oauth2" +) + +var ( + oidcProvider *oidc.Provider + oidcConfig oauth2.Config +) + +func InitOAuth(cfg *config.Config) error { + var err error + oidcProvider, err = oidc.NewProvider(context.Background(), cfg.OIDCIssuer) + if err != nil { + return err + } + + oidcConfig = oauth2.Config{ + ClientID: cfg.OAuthClientID, + ClientSecret: cfg.OAuthClientSecret, + RedirectURL: cfg.OAuthRedirectURL, + Endpoint: oidcProvider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + return nil +} + +func ValidateOAuthToken(ctx context.Context, token string, cfg *config.Config) (*oidc.UserInfo, error) { + if oidcProvider == nil { + return nil, errors.New("OIDC provider not initialized") + } + + oauth2Token := &oauth2.Token{AccessToken: token} + userInfo, err := oidcProvider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + return nil, err + } + + return userInfo, nil +} diff --git a/tests/contract/service_contract_test.go b/tests/contract/service_contract_test.go index def8a74..3d3c3bf 100644 --- a/tests/contract/service_contract_test.go +++ b/tests/contract/service_contract_test.go @@ -3,36 +3,47 @@ package contract import ( "encoding/json" "go-boilerplate/internal/api" + "go-boilerplate/internal/config" "net/http" "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "go.uber.org/zap" ) func TestServiceContract(t *testing.T) { // Setup the router - router := api.SetupRouter() + 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(router) + server := httptest.NewServer(r) defer server.Close() // Test the /ping endpoint t.Run("Ping Endpoint", func(t *testing.T) { // Make a GET request to the /ping endpoint - resp, err := http.Get(server.URL + "/ping") - assert.NoError(t, err, "Failed to make request to /ping") + 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() // Check the status code - assert.Equal(t, http.StatusOK, resp.StatusCode, "Unexpected status code for /ping") + assert.Equal(t, http.StatusOK, resp.StatusCode, "Unexpected status code for /api/v1/ping") // Check the response body 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 /ping") + assert.Equal(t, "pong", response["message"], "Unexpected response message for/api/v1/ping") }) // Add more contract tests for other endpoints here as they are implemented diff --git a/tests/e2e/api_flow_test.go b/tests/e2e/api_flow_test.go index e217b4a..0bea77d 100644 --- a/tests/e2e/api_flow_test.go +++ b/tests/e2e/api_flow_test.go @@ -3,24 +3,48 @@ package e2e import ( "encoding/json" "go-boilerplate/internal/api" + "go-boilerplate/internal/config" "net/http" "net/http/httptest" "testing" + "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 TestAPIFlow(t *testing.T) { // Setup the router - router := api.SetupRouter() + router := setupTestRouter() // Create a test HTTP server - server := httptest.NewServer(router) + server := httptest.NewServer(router.Handler()) defer server.Close() // Step 1: Ping t.Run("Ping", func(t *testing.T) { - resp, err := http.Get(server.URL + "/ping") + resp, err := http.Get(server.URL + "/api/v1/ping") assert.NoError(t, err) defer resp.Body.Close() @@ -32,64 +56,43 @@ func TestAPIFlow(t *testing.T) { assert.Equal(t, "pong", response["message"]) }) - /* - // Step 2: Create Resource - t.Run("Create Resource", func(t *testing.T) { - payload := []byte(`{"name": "Test Resource"}`) - resp, err := http.Post(server.URL+"/resources", "application/json", bytes.NewBuffer(payload)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) + // 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() - var response map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&response) - assert.NoError(t, err) - assert.Equal(t, "Test Resource", response["name"]) - assert.NotEmpty(t, response["id"]) - }) + assert.Equal(t, http.StatusOK, resp.StatusCode) - // Step 3: Retrieve Resource - var resourceID string - t.Run("Retrieve Resource", func(t *testing.T) { - resp, err := http.Get(server.URL + "/resources/1") - assert.NoError(t, err) - defer resp.Body.Close() + var response map[string]string + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "OK", response["status"]) + }) - assert.Equal(t, http.StatusOK, resp.StatusCode) + // Additional tests for authenticated routes can be added here + // You'll need to generate valid tokens/keys for each auth method + // and include them in the request headers - var response map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&response) - assert.NoError(t, err) - assert.Equal(t, "Test Resource", response["name"]) - resourceID = response["id"].(string) - }) + /* + // Example: Test JWT authenticated route + t.Run("JWT Authenticated Route", func(t *testing.T) { + // Generate a valid JWT token + token := generateValidJWTToken() - // Step 4: Update Resource - t.Run("Update Resource", func(t *testing.T) { - payload := []byte(`{"name": "Updated Test Resource"}`) - req, _ := http.NewRequest(http.MethodPut, server.URL+"/resources/"+resourceID, bytes.NewBuffer(payload)) - req.Header.Set("Content-Type", "application/json") + req, _ := http.NewRequest("GET", server.URL+"/api/v1/jwt/profile", nil) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) assert.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - var response map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&response) + var profile api.ProfileResponse + err = json.NewDecoder(resp.Body).Decode(&profile) assert.NoError(t, err) - assert.Equal(t, "Updated Test Resource", response["name"]) - }) - - // Step 5: Delete Resource - t.Run("Delete Resource", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodDelete, server.URL+"/resources/"+resourceID, nil) - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusNoContent, resp.StatusCode) + assert.NotEmpty(t, profile.ID) + assert.NotEmpty(t, profile.Email) }) */ } diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index ac863ef..e1c4d2a 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -3,20 +3,32 @@ package integration import ( "encoding/json" "go-boilerplate/internal/api" + "go-boilerplate/internal/config" "io/ioutil" "net/http" "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "go.uber.org/zap" ) func TestAPIEndpoints(t *testing.T) { // Setup the router - router := api.SetupRouter() + // 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(router) + server := httptest.NewServer(r) defer server.Close() // Test cases @@ -28,7 +40,7 @@ func TestAPIEndpoints(t *testing.T) { }{ { name: "Ping Endpoint", - endpoint: "/ping", + endpoint: "/api/v1/ping", expectedStatus: http.StatusOK, expectedBody: map[string]string{"message": "pong"}, }, diff --git a/tests/performance/api_benchmark_test.go b/tests/performance/api_benchmark_test.go index a48a211..a1d591e 100644 --- a/tests/performance/api_benchmark_test.go +++ b/tests/performance/api_benchmark_test.go @@ -2,19 +2,35 @@ package performance import ( "go-boilerplate/internal/api" + "go-boilerplate/internal/config" "net/http" "net/http/httptest" "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" ) func BenchmarkPingEndpoint(b *testing.B) { - router := api.SetupRouter() + cfg, err := config.LoadConfig() + if err != nil { + 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() + + router := gin.New() + api.SetupRouter(router, cfg, logger) server := httptest.NewServer(router) defer server.Close() b.ResetTimer() for i := 0; i < b.N; i++ { - resp, err := http.Get(server.URL + "/ping") + resp, err := http.Get(server.URL + "/api/v1/ping") if err != nil { b.Fatalf("Failed to make request: %v", err) } diff --git a/tests/security/auth_test.go b/tests/security/auth_test.go index 3b9d535..ccb0a6c 100644 --- a/tests/security/auth_test.go +++ b/tests/security/auth_test.go @@ -1,37 +1,191 @@ package security import ( + "encoding/json" "net/http" "net/http/httptest" "testing" + + "go-boilerplate/internal/api" + "go-boilerplate/internal/config" + "go-boilerplate/pkg/auth" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "gopkg.in/h2non/gock.v1" ) -func TestAuthentication(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement actual authentication logic - token := r.Header.Get("Authorization") - if token != "valid-token" { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusOK) +func setupTestRouter() (*gin.Engine, *config.Config) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + JWTSecret: "test_secret", + ValidAPIKey: "test_api_key", + OIDCIssuer: "https://test-oidc-provider.com", + OAuthClientID: "test_client_id", + OAuthClientSecret: "test_client_secret", + OAuthRedirectURL: "http://localhost:8080/auth/callback", + } + 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() }) - server := httptest.NewServer(handler) - defer server.Close() + api.SetupRouter(r, cfg, logger) + return r, cfg +} - // Test with invalid token - req, _ := http.NewRequest("GET", server.URL, nil) - req.Header.Set("Authorization", "invalid-token") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to make request: %v", err) +func setupTestRouterOAuth() (*gin.Engine, *config.Config) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + JWTSecret: "test_secret", + ValidAPIKey: "test_api_key", + OIDCIssuer: "https://test-oidc-provider.com", + OAuthClientID: "test_client_id", + OAuthClientSecret: "test_client_secret", + OAuthRedirectURL: "http://localhost:8080/auth/callback", } - defer resp.Body.Close() + 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, cfg +} + +func TestGetProfileUnauthorized(t *testing.T) { + router, _ := setupTestRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/oauth/profile", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestGetProfileWithJWT(t *testing.T) { + router, cfg := setupTestRouter() + + // Generate a valid JWT token + token, err := auth.GenerateJWTToken("test_user", "test@example.com", cfg.JWTSecret, 6000) + assert.NoError(t, err) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/jwt/profile", nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response api.ProfileResponse + err = json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) - if resp.StatusCode != http.StatusUnauthorized { - t.Errorf("Expected status Unauthorized; got %v", resp.Status) + // Add more detailed assertions and error logging + if assert.NotEmpty(t, response.ID, "ID should not be empty") { + assert.Equal(t, "test_user", response.ID, "Unexpected ID value") } + if assert.NotEmpty(t, response.Email, "Email should not be empty") { + assert.Equal(t, "test@example.com", response.Email, "Unexpected Email value") + } + + // Log the entire response for debugging + t.Logf("Response body: %s", w.Body.String()) +} + +func TestGetProfileWithAPIKey(t *testing.T) { + router, cfg := setupTestRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/apikey/profile", nil) + req.Header.Set("X-API-Key", cfg.ValidAPIKey) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response api.ProfileResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "api_user", response.ID) + assert.Equal(t, "api@example.com", response.Email) +} + +// Note: Testing OAuth requires mocking the OIDC provider, which is more complex. +// For this example, we'll skip the OAuth test, but in a real-world scenario, +// you should mock the OIDC provider and test the OAuth flow as well. + +func TestGetProfileWithOAuth(t *testing.T) { + router, cfg := setupTestRouterOAuth() - // TODO: Add test case for valid token + // Mock OIDC provider + defer gock.Off() + gock.New(cfg.OIDCIssuer). + Get("/.well-known/openid-configuration"). + Reply(200). + JSON(map[string]interface{}{ + "issuer": cfg.OIDCIssuer, + "authorization_endpoint": cfg.OIDCIssuer + "/auth", + "token_endpoint": cfg.OIDCIssuer + "/token", + "userinfo_endpoint": cfg.OIDCIssuer + "/userinfo", + "jwks_uri": cfg.OIDCIssuer + "/jwks", + }) + + gock.New(cfg.OIDCIssuer). + Get("/userinfo"). + MatchHeader("Authorization", "Bearer valid_oauth_token"). + Reply(200). + JSON(map[string]interface{}{ + "sub": "oauth_user_id", + "email": "oauth_user@example.com", + "name": "OAuth User", + }) + + // Initialize OAuth + err := auth.InitOAuth(cfg) + assert.NoError(t, err, "Failed to initialize OAuth") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/oauth/profile", nil) + req.Header.Set("Authorization", "Bearer valid_oauth_token") + router.ServeHTTP(w, req) + + // Check response status + assert.Equal(t, http.StatusOK, w.Code, "Unexpected status code") + + // Check response headers + contentType := w.Header().Get("Content-Type") + assert.Contains(t, contentType, "application/json", "Unexpected Content-Type") + + // Parse response body + var response api.ProfileResponse + err = json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err, "Failed to unmarshal response body") + + // Check response fields + assert.Equal(t, "oauth_user_id", response.ID, "Unexpected user ID") + assert.Equal(t, "oauth_user@example.com", response.Email, "Unexpected email") + // assert.Equal(t, "OAuth User", response.Name, "Unexpected name") + + // Additional checks + assert.NotEmpty(t, response.ID, "User ID should not be empty") + assert.Contains(t, response.Email, "@", "Email should contain '@'") + // assert.True(t, len(response.Name) > 0, "Name should not be empty") + + // Log response for debugging + t.Logf("Response body: %s", w.Body.String()) } + +// ... (keep existing code) diff --git a/tests/unit/service_test.go b/tests/unit/service_test.go index a3e3148..907a1ba 100644 --- a/tests/unit/service_test.go +++ b/tests/unit/service_test.go @@ -6,22 +6,41 @@ import ( "net/http/httptest" "testing" - "go-boilerplate/internal/api/handlers" + "go-boilerplate/internal/api" + "go-boilerplate/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "go.uber.org/zap" ) -func TestPingHandler(t *testing.T) { - // Set Gin to Test Mode +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 := gin.Default() - r.GET("/ping", handlers.PingHandler) + r := setupTestRouter() // Create a mock request to the /ping endpoint - req, err := http.NewRequest(http.MethodGet, "/ping", nil) + req, err := http.NewRequest(http.MethodGet, "/api/v1/ping", nil) assert.NoError(t, err) // Create a response recorder @@ -41,3 +60,29 @@ func TestPingHandler(t *testing.T) { // 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"]) +}