diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6906cee..8522d44 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,30 +1,36 @@ name: Go -on: [push] +on: + push: + branches: [ '**' ] + pull_request: + branches: [ main, master ] jobs: build: - name: Build + name: Build and Test runs-on: ubuntu-latest steps: - - name: Set up Go 1.13 - uses: actions/setup-go@v1 + - name: Set up Go + uses: actions/setup-go@v4 with: - go-version: 1.13 + go-version: '1.22.3' id: go - - name: Check out code into the Go module directory - uses: actions/checkout@v2 + + - name: Check out code + uses: actions/checkout@v4 + - name: Get dependencies - run: | - export GO111MODULE=on - go mod download - go get -u github.com/kyoh86/scopelint - # - name: Lint - # run: | - # scopelint ./... - - name: fmt - run: go fmt ./... - - name: vet + run: go mod download + + - name: Verify formatting + run: go fmt ./... + + - name: Vet run: go vet ./... + + - name: Test + run: go test -v ./... + - name: Build - run: go build -v . + run: go build -v ./... diff --git a/README.md b/README.md index 14236f6..4bb394f 100644 --- a/README.md +++ b/README.md @@ -9,52 +9,51 @@ ``` -simple and quick golang JSON mock server. -simulate an http server and return the specified json according to a custom route. +# Go JSON Server +A powerful, flexible and efficient mock server for testing and development environments. +Serve static JSON APIs and files with customizable routes and responses. -- static json api server -- static file server -- [ ] cache response and reload -- [ ] change api.json path -- [ ] url param -- [ ] server deamon -- [ ] jwt or session auth -- [ ] error response -- [ ] access log -- [ ] E2E test sample source in Github Actions +## Requirements -## Install +- Go 1.22.3 or later -```bash -$ go get -u github.com/tkc/go-json-server -``` +## Key Features -## Prepare api.json +- ✅ **Configuration-driven API** - Define your endpoints in a simple JSON file +- ✅ **Hot-reloading** - Changes to configuration are detected and applied without server restart +- ✅ **Response caching** - Improved performance with configurable TTL +- ✅ **Path parameters** - Support for dynamic route parameters like `/users/:id` +- ✅ **Static file server** - Serve files from specified directories +- ✅ **Middleware architecture** - Logging, CORS, timeout, and panic recovery included +- ✅ **Structured logging** - Configurable log levels with JSON or text formats +- ✅ **Request ID tracking** - Assign unique IDs to each request for better traceability +- ✅ **Error handling** - Detailed error responses and validation +- ✅ **Command-line flags** - Override configuration settings via command line arguments -```bash -- api.json // required -- response.json -``` +## Installation -See example -https://github.com/tkc/go-json-server/tree/master/example - -## Serve Mock Server ```bash -$ go-json-server +# Install the latest version +go install github.com/tkc/go-json-server@latest + +# Or using go get +go get -u github.com/tkc/go-json-server ``` -### Sample Json Setting +## Getting Started +### 1. Create your API configuration file -`api.json` +Create a file named `api.json` in your project directory: -```javascript +```json { "port": 3000, + "logLevel": "info", + "logFormat": "text", "endpoints": [ - { + { "method": "GET", "status": 200, "path": "/", @@ -69,46 +68,311 @@ $ go-json-server { "method": "GET", "status": 200, - "path": "/user/1", + "path": "/user/:id", "jsonPath": "./user.json" }, { - "path": "/file", + "path": "/files", "folder": "./static" } ] } ``` +### 2. Create your JSON response files -`health-check.json` -```javascript +Create the JSON files referenced in your configuration: + +**health-check.json** +```json { - "status": "ok", - "message": "go-json-server" + "status": "ok", + "message": "go-json-server running", + "version": "1.0.0" } ``` -`users.json` -```javascript +**users.json** +```json [ { - "id":1, - "name": "name" + "id": 1, + "name": "John Doe", + "email": "john@example.com" + }, + { + "id": 2, + "name": "Jane Smith", + "email": "jane@example.com" } ] ``` -`user.json` -```javascript +**user.json** +```json { - "id": 1, - "name": "name", - "address": "address" + "id": ":id", + "name": "John Doe", + "email": "john@example.com", + "address": "123 Main St" } ``` -## License -MIT ✨ +### 3. Start your server + +```bash +go-json-server +``` + +Or with custom configuration: + +```bash +go-json-server --config=./custom-config.json --port=8080 +``` + +## Running, Building and Testing + +### Running the Application + +You can run the application directly using Go: + +```bash +# Run from source code +go run go-json-server.go + +# Run with custom configuration +go run go-json-server.go --config=./example/api.json --port=8080 --log-level=debug +``` + +### Building the Application + +Build a binary for your current platform: + +```bash +# Simple build +go build -o go-json-server + +# Build with version information +go build -ldflags="-X 'main.Version=v1.0.0'" -o go-json-server +``` + +Build for multiple platforms: + +```bash +# Build for Linux +GOOS=linux GOARCH=amd64 go build -o go-json-server-linux-amd64 + +# Build for macOS +GOOS=darwin GOARCH=amd64 go build -o go-json-server-darwin-amd64 + +# Build for Windows +GOOS=windows GOARCH=amd64 go build -o go-json-server-windows-amd64.exe +``` + +### Testing + +Run all tests: + +```bash +# Run all tests +go test ./... + +# Run tests with coverage +go test -cover ./... + +# Run tests with verbose output +go test -v ./... + +# Run specific package tests +go test -v ./src/config +go test -v ./src/handler +go test -v ./src/logger +go test -v ./src/middleware +``` + +Generate and view test coverage: + +```bash +# Generate coverage profile +go test -coverprofile=coverage.out ./... + +# View coverage in browser +go tool cover -html=coverage.out + +# Get coverage percentage +go tool cover -func=coverage.out +``` +Run benchmark tests: +```bash +# Run benchmark tests +go test -bench=. ./... + +# Run benchmark with memory allocation stats +go test -bench=. -benchmem ./... +``` + +## Configuration Options + +| Option | Description | Default | +|--------|-------------|---------| +| `port` | Server port | 3000 | +| `host` | Server host | "" (all interfaces) | +| `logLevel` | Logging level (debug, info, warn, error, fatal) | "info" | +| `logFormat` | Log format (text, json) | "text" | +| `logPath` | Path to log file (stdout, stderr, or file path) | "stdout" | +| `endpoints` | Array of endpoint configurations | [] | + +### Endpoint Configuration + +| Option | Description | Required | +|--------|-------------|----------| +| `method` | HTTP method (GET, POST, PUT, DELETE, etc.) | Yes (for API endpoints) | +| `status` | HTTP response status code | Yes (for API endpoints) | +| `path` | URL path for the endpoint | Yes | +| `jsonPath` | Path to JSON response file | Yes (for API endpoints) | +| `folder` | Path to static files directory | Yes (for file server endpoints) | + +## Path Parameters + +You can use path parameters in your routes by prefixing a path segment with a colon: + +```json +{ + "method": "GET", + "status": 200, + "path": "/users/:id/posts/:postId", + "jsonPath": "./user-post.json" +} +``` + +The parameter values will be available in the JSON response by using the same parameter name with a colon: + +```json +{ + "userId": ":id", + "postId": ":postId", + "title": "Sample Post" +} +``` + +## Command Line Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--config` | Path to configuration file | "./api.json" | +| `--port` | Override server port from config | Config port value | +| `--log-level` | Override log level from config | Config log level | +| `--log-format` | Override log format from config | Config log format | +| `--log-path` | Override log path from config | Config log path | +| `--cache-ttl` | Cache TTL in seconds | 300 (5 minutes) | + +## Development Workflow + +1. **Clone the repository**: + ```bash + git clone https://github.com/tkc/go-json-server.git + cd go-json-server + ``` + +2. **Install dependencies**: + ```bash + go mod download + ``` + +3. **Make your changes**: + - Add features + - Fix bugs + - Update documentation + +4. **Run tests**: + ```bash + go test ./... + ``` + +5. **Build and run**: + ```bash + go build -o go-json-server + ./go-json-server --config=./example/api.json + ``` + +## Docker Support + +You can run go-json-server in a Docker container: + +```dockerfile +FROM golang:1.22.3-alpine AS builder +WORKDIR /app +COPY . . +RUN go build -o /go-json-server + +FROM alpine:latest +WORKDIR /app +COPY --from=builder /go-json-server /usr/local/bin/ +COPY api.json . +COPY *.json . +EXPOSE 3000 +CMD ["go-json-server"] +``` + +Build and run with Docker: + +```bash +# Build Docker image +docker build -t go-json-server . + +# Run Docker container +docker run -p 3000:3000 -v $(pwd)/example:/app/example go-json-server --config=/app/example/api.json +``` + +## Advanced Examples + +### Authentication Middleware Example + +To add basic authentication to your API: + +```go +// In your main.go custom implementation +auth := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "admin" || pass != "secret" { + w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "unauthorized"}`)) + return + } + next.ServeHTTP(w, r) + }) +} + +// Apply middleware +handler := middleware.Chain( + middleware.Logger(logger), + middleware.CORS(), + middleware.Recovery(logger), + middleware.RequestID(), + auth, +)(server.HandleRequest) +``` + +## Roadmap + +- [ ] GraphQL support +- [ ] WebSocket support +- [ ] JWT authentication +- [ ] Response delay simulation +- [ ] Integration with Swagger/OpenAPI +- [ ] Proxy mode +- [ ] Request validation +- [ ] Response templating +- [ ] Interactive web UI for API exploration + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT ✨ diff --git a/example/README.md b/example/README.md index 02f19a8..815f3d9 100644 --- a/example/README.md +++ b/example/README.md @@ -1,12 +1,84 @@ +# Go JSON Server Example +This directory contains example files for the Go JSON Server. These examples demonstrate various features of the server including: -## Install +- Basic API endpoints +- Path parameters (`:id`, `:userId`, `:postId`) +- Static file serving +- Different HTTP methods (GET, POST) +- Various response status codes + +## Files Overview + +- `api.json` - The main configuration file for the server +- `health-check.json` - Simple health check endpoint response +- `users.json` - List of users +- `user-detail.json` - Detailed user information with path parameter support +- `posts.json` - List of blog posts +- `post-detail.json` - Detailed post information with path parameter support +- `user-post.json` - Demonstrates multiple path parameters in one endpoint +- `user-created.json` - Example response for a POST request +- `static/` - Directory for static files + - `sample.jpg` - Example image file + - `index.html` - Example HTML documentation page + +## Running the Example + +From the root directory of the project: + +```bash +go run go-json-server.go --config=./example/api.json ``` -$ go get -u github.com/tkc/go-json-server + +Or if you've installed the binary: + +```bash +go-json-server --config=./example/api.json ``` -## Run +## Testing the Endpoints + +You can use curl, Postman, or any HTTP client to test the endpoints: + +```bash +# Get health check +curl http://localhost:3000/ + +# Get all users +curl http://localhost:3000/users + +# Get user with ID 1 +curl http://localhost:3000/user/1 +# Get all posts +curl http://localhost:3000/posts + +# Get post with ID 2 +curl http://localhost:3000/posts/2 + +# Get post 3 by user 2 +curl http://localhost:3000/users/2/posts/3 + +# Create a new user +curl -X POST http://localhost:3000/users + +# Access static HTML page +curl http://localhost:3000/static/index.html +# Or open in browser: http://localhost:3000/static/index.html ``` -$ go-json-server + +## Note on File Paths + +The paths in the `api.json` configuration file are relative to the directory from which you run the server, not the location of the configuration file itself. This means that when running from the project root, all paths include the `example/` prefix, like: + +```json +{ + "jsonPath": "./example/health-check.json" +} ``` + +If you want to run the server while in the example directory, you would need to modify the paths in `api.json` to remove the `example/` prefix. + +## Customizing + +Feel free to modify these example files or create your own to experiment with the Go JSON Server's capabilities. diff --git a/example/api.json b/example/api.json index 1326775..6173063 100644 --- a/example/api.json +++ b/example/api.json @@ -1,27 +1,53 @@ { "port": 3000, + "logLevel": "info", + "logFormat": "text", "endpoints": [ { "method": "GET", "status": 200, "path": "/", - "jsonPath": "./health-check.json" + "jsonPath": "./example/health-check.json" }, { "method": "GET", "status": 200, "path": "/users", - "jsonPath": "./users.json" + "jsonPath": "./example/users.json" }, { "method": "GET", "status": 200, - "path": "/user/1", - "jsonPath": "./user.json" + "path": "/user/:id", + "jsonPath": "./example/user-detail.json" }, { - "path": "/file", - "folder": "./static" + "method": "GET", + "status": 200, + "path": "/posts", + "jsonPath": "./example/posts.json" + }, + { + "method": "GET", + "status": 200, + "path": "/posts/:id", + "jsonPath": "./example/post-detail.json" + }, + { + "method": "GET", + "status": 200, + "path": "/users/:userId/posts/:postId", + "jsonPath": "./example/user-post.json" + }, + { + "method": "POST", + "status": 201, + "path": "/users", + "jsonPath": "./example/user-created.json" + }, + { + "path": "/static", + "folder": "./example/static" } ] -} \ No newline at end of file +} diff --git a/example/health-check.json b/example/health-check.json index d0e12b8..c1452db 100644 --- a/example/health-check.json +++ b/example/health-check.json @@ -1,4 +1,7 @@ { - "status": "ok", - "message": "go-json-server started" -} \ No newline at end of file + "status": "ok", + "message": "go-json-server is running", + "version": "1.0.0", + "timestamp": "2025-03-16T10:30:00Z", + "environment": "development" +} diff --git a/example/post-detail.json b/example/post-detail.json new file mode 100644 index 0000000..17b7c44 --- /dev/null +++ b/example/post-detail.json @@ -0,0 +1,40 @@ +{ + "id": ":id", + "user_id": 1, + "title": "Getting Started with Go", + "summary": "An introduction to the Go programming language", + "content": "Go is a statically typed, compiled programming language designed at Google by Robert Griesemer, Rob Pike, and Ken Thompson. It is syntactically similar to C, but with memory safety, garbage collection, structural typing, and CSP-style concurrency. The language is often referred to as Golang because of its domain name, golang.org, but the proper name is Go.\n\nGo was designed at Google in 2007 to improve programming productivity in an era of multicore, networked machines and large codebases. The designers wanted to address criticism of other languages in use at Google, but keep their useful characteristics:\n\n- Static typing and run-time efficiency (like C++)\n- Readability and usability (like Python or JavaScript)\n- High-performance networking and multiprocessing\n\nThe language was announced in November 2009 and is used in many of Google's production systems, as well as by other firms.\n\n## Key Features\n\n### Simplicity\nGo was designed to be simple and easy to understand. It has a concise syntax and a small number of language constructs.\n\n### Concurrency\nGo has built-in concurrency features through goroutines and channels, making it easy to write programs that take advantage of multicore processors.\n\n### Fast Compilation\nGo compiles directly to machine code, making compilation extremely fast.\n\n### Garbage Collection\nGo includes automatic memory management, which helps prevent memory leaks and other related issues.\n\n## Getting Started\n\nHere's a simple \"Hello, World!\" program in Go:\n\n```go\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Println(\"Hello, World!\")\n}\n```\n\nTo run this program, save it as `hello.go` and execute:\n\n```bash\ngo run hello.go\n```\n\nOr build it into an executable:\n\n```bash\ngo build hello.go\n./hello\n```\n\nThis is just the beginning of your Go journey. Happy coding!", + "published_at": "2025-02-10T14:30:00Z", + "updated_at": "2025-02-12T09:15:00Z", + "tags": ["go", "programming", "tutorial"], + "comments": [ + { + "id": 101, + "user_id": 2, + "content": "Great introduction! I've been meaning to learn Go.", + "created_at": "2025-02-10T16:45:00Z" + }, + { + "id": 102, + "user_id": 3, + "content": "Could you add more examples about goroutines?", + "created_at": "2025-02-11T08:30:00Z" + }, + { + "id": 103, + "user_id": 4, + "content": "Very helpful for beginners. Thanks!", + "created_at": "2025-02-12T14:20:00Z" + } + ], + "stats": { + "views": 1250, + "likes": 38, + "shares": 17 + }, + "author": { + "id": 1, + "name": "John Doe", + "avatar_url": "https://example.com/avatars/1.jpg" + } +} diff --git a/example/posts.json b/example/posts.json new file mode 100644 index 0000000..4fe7472 --- /dev/null +++ b/example/posts.json @@ -0,0 +1,47 @@ +[ + { + "id": 1, + "user_id": 1, + "title": "Getting Started with Go", + "summary": "An introduction to the Go programming language", + "content": "Go is a statically typed, compiled programming language designed at Google...", + "published_at": "2025-02-10T14:30:00Z", + "tags": ["go", "programming", "tutorial"] + }, + { + "id": 2, + "user_id": 1, + "title": "RESTful API Design", + "summary": "Best practices for designing RESTful APIs", + "content": "When designing a RESTful API, it's important to consider resource naming...", + "published_at": "2025-02-15T10:15:00Z", + "tags": ["api", "rest", "design"] + }, + { + "id": 3, + "user_id": 2, + "title": "JSON Server for Mock APIs", + "summary": "How to use JSON Server for rapid API prototyping", + "content": "JSON Server is a great tool for quickly creating mock APIs without writing any backend code...", + "published_at": "2025-02-20T08:45:00Z", + "tags": ["json-server", "mock", "api", "prototyping"] + }, + { + "id": 4, + "user_id": 4, + "title": "Testing Strategies for Frontend Applications", + "summary": "Comprehensive guide to testing frontend applications", + "content": "Testing is a crucial part of building reliable frontend applications...", + "published_at": "2025-03-01T16:20:00Z", + "tags": ["testing", "frontend", "javascript"] + }, + { + "id": 5, + "user_id": 3, + "title": "Containerization with Docker", + "summary": "Learn how to containerize your applications", + "content": "Docker allows you to package your application with all of its dependencies into a standardized unit...", + "published_at": "2025-03-05T11:10:00Z", + "tags": ["docker", "containers", "devops"] + } +] diff --git a/example/run.sh b/example/run.sh new file mode 100644 index 0000000..2dbbae7 --- /dev/null +++ b/example/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Change to the root directory of the project +cd $(dirname $0)/.. + +# Run the server with the example configuration +go run go-json-server.go --config=./example/api.json diff --git a/example/static/index.html b/example/static/index.html new file mode 100644 index 0000000..9f8de1d --- /dev/null +++ b/example/static/index.html @@ -0,0 +1,157 @@ + + +
+ + +This is a static page served by Go JSON Server. Below are the available API endpoints that you can test:
+ +Health check endpoint
+// Response
+{
+ "status": "ok",
+ "message": "go-json-server is running",
+ "version": "1.0.0",
+ "timestamp": "2025-03-16T10:30:00Z",
+ "environment": "development"
+}
+ Get list of all users
+// Response
+[
+ {
+ "id": 1,
+ "name": "John Doe",
+ "email": "john.doe@example.com",
+ "role": "admin",
+ ...
+ },
+ ...
+]
+ Get a specific user by ID
+// Response
+{
+ "id": ":id",
+ "name": "John Doe",
+ "email": "john.doe@example.com",
+ ...
+}
+ Get list of all posts
+// Response
+[
+ {
+ "id": 1,
+ "user_id": 1,
+ "title": "Getting Started with Go",
+ ...
+ },
+ ...
+]
+ Get a specific post by ID
+// Response
+{
+ "id": ":id",
+ "user_id": 1,
+ "title": "Getting Started with Go",
+ ...
+}
+ Get a specific post by a specific user
+// Response
+{
+ "user_id": ":userId",
+ "post_id": ":postId",
+ "relationship": "User :userId wrote post :postId",
+ ...
+}
+ Create a new user
+// Response
+{
+ "success": true,
+ "message": "User created successfully",
+ "user": {
+ "id": 5,
+ "name": "New User",
+ ...
+ },
+ ...
+}
+ Powered by Go JSON Server. Static files are served from the /static directory.
+ + diff --git a/example/user-created.json b/example/user-created.json new file mode 100644 index 0000000..aee2935 --- /dev/null +++ b/example/user-created.json @@ -0,0 +1,16 @@ +{ + "success": true, + "message": "User created successfully", + "user": { + "id": 5, + "name": "New User", + "email": "new.user@example.com", + "role": "user", + "active": true, + "created_at": "2025-03-16T12:30:00Z" + }, + "links": { + "self": "/users/5", + "collection": "/users" + } +} diff --git a/example/user-detail.json b/example/user-detail.json new file mode 100644 index 0000000..bd11a0d --- /dev/null +++ b/example/user-detail.json @@ -0,0 +1,26 @@ +{ + "id": ":id", + "name": "John Doe", + "email": "john.doe@example.com", + "role": "admin", + "active": true, + "created_at": "2025-01-15T08:30:00Z", + "profile": { + "avatar_url": "https://example.com/avatars/:id.jpg", + "bio": "Senior Developer with 10 years of experience", + "location": "San Francisco, CA", + "phone": "+1-555-123-4567" + }, + "preferences": { + "theme": "dark", + "notifications": true, + "language": "en-US", + "timezone": "America/Los_Angeles" + }, + "stats": { + "posts_count": 42, + "followers_count": 156, + "following_count": 89, + "last_active": "2025-03-15T18:30:00Z" + } +} diff --git a/example/user-post.json b/example/user-post.json new file mode 100644 index 0000000..6576cc6 --- /dev/null +++ b/example/user-post.json @@ -0,0 +1,21 @@ +{ + "user_id": ":userId", + "post_id": ":postId", + "relationship": "User :userId wrote post :postId", + "user": { + "id": ":userId", + "name": "Sample User :userId", + "profile_url": "/users/:userId" + }, + "post": { + "id": ":postId", + "title": "Post :postId by User :userId", + "content": "This is content for post :postId written by user :userId", + "permalink": "/posts/:postId" + }, + "metadata": { + "access_level": "public", + "created_at": "2025-03-16T12:00:00Z", + "last_modified": "2025-03-16T12:00:00Z" + } +} diff --git a/example/users.json b/example/users.json index 5ba7752..3c26799 100644 --- a/example/users.json +++ b/example/users.json @@ -1,6 +1,34 @@ [ { - "id":1, - "name": "name" + "id": 1, + "name": "John Doe", + "email": "john.doe@example.com", + "role": "admin", + "active": true, + "created_at": "2025-01-15T08:30:00Z" + }, + { + "id": 2, + "name": "Jane Smith", + "email": "jane.smith@example.com", + "role": "user", + "active": true, + "created_at": "2025-01-18T14:45:00Z" + }, + { + "id": 3, + "name": "Bob Johnson", + "email": "bob.johnson@example.com", + "role": "user", + "active": false, + "created_at": "2025-02-10T11:20:00Z" + }, + { + "id": 4, + "name": "Alice Williams", + "email": "alice.williams@example.com", + "role": "manager", + "active": true, + "created_at": "2025-02-28T09:15:00Z" } -] \ No newline at end of file +] diff --git a/go-json-server.go b/go-json-server.go index effbbc7..8238ff6 100644 --- a/go-json-server.go +++ b/go-json-server.go @@ -1,164 +1,145 @@ package main import ( - "bytes" - "encoding/json" + "context" + "flag" "fmt" - "io/ioutil" "log" "net/http" "os" + "os/signal" "strconv" + "syscall" + "time" + "github.com/tkc/go-json-server/src/config" + "github.com/tkc/go-json-server/src/handler" "github.com/tkc/go-json-server/src/logger" + "github.com/tkc/go-json-server/src/middleware" ) -const ( - charsetUTF8 = "charset=UTF-8" +var ( + // Version of the application + Version = "1.0.0" + + // Command line flags + configPath = flag.String("config", "./api.json", "Path to the configuration file") + port = flag.Int("port", 0, "Server port (overrides config)") + logLevel = flag.String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides config)") + logFormat = flag.String("log-format", "", "Log format: text, json (overrides config)") + logPath = flag.String("log-path", "", "Path to log file (overrides config)") + cacheTTL = flag.Int("cache-ttl", 300, "Cache TTL in seconds") ) -const ( - MIMEApplicationJSON = "application/json" - MIMEApplicationJSONCharsetUTF8 = MIMEApplicationJSON + "; " + charsetUTF8 - MIMEApplicationJavaScript = "application/javascript" - MIMEApplicationJavaScriptCharsetUTF8 = MIMEApplicationJavaScript + "; " + charsetUTF8 - MIMEApplicationXML = "application/xml" - MIMEApplicationXMLCharsetUTF8 = MIMEApplicationXML + "; " + charsetUTF8 - MIMETextXML = "text/xml" - MIMETextXMLCharsetUTF8 = MIMETextXML + "; " + charsetUTF8 - MIMEApplicationForm = "application/x-www-form-urlencoded" - MIMEApplicationProtobuf = "application/protobuf" - MIMEApplicationMsgpack = "application/msgpack" - MIMETextHTML = "text/html" - MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8 - MIMETextPlain = "text/plain" - MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8 - MIMEMultipartForm = "multipart/form-data" - MIMEOctetStream = "application/octet-stream" -) - -const ( - HeaderAccept = "Accept" - HeaderAcceptEncoding = "Accept-Encoding" - HeaderAllow = "Allow" - HeaderAuthorization = "Authorization" - HeaderContentDisposition = "Content-Disposition" - HeaderContentEncoding = "Content-Encoding" - HeaderContentLength = "Content-Length" - HeaderContentType = "Content-Type" - HeaderCookie = "Cookie" - HeaderSetCookie = "Set-Cookie" - HeaderIfModifiedSince = "If-Modified-Since" - HeaderLastModified = "Last-Modified" - HeaderLocation = "Location" - HeaderUpgrade = "Upgrade" - HeaderVary = "Vary" - HeaderWWWAuthenticate = "WWW-Authenticate" - HeaderXForwardedFor = "X-Forwarded-For" - HeaderXForwardedProto = "X-Forwarded-Proto" - HeaderXForwardedProtocol = "X-Forwarded-Protocol" - HeaderXForwardedSsl = "X-Forwarded-Ssl" - HeaderXUrlScheme = "X-Url-Scheme" - HeaderXHTTPMethodOverride = "X-HTTP-Method-Override" - HeaderXRealIP = "X-Real-IP" - HeaderXRequestID = "X-Request-ID" - HeaderServer = "Server" - HeaderOrigin = "Origin" - HeaderAccessControlRequestMethod = "Access-Control-Request-Method" - HeaderAccessControlRequestHeaders = "Access-Control-Request-Headers" - HeaderAccessControlAllowOrigin = "Access-Control-Allow-Origin" - HeaderAccessControlAllowMethods = "Access-Control-Allow-Methods" - HeaderAccessControlAllowHeaders = "Access-Control-Allow-Headers" - HeaderAccessControlAllowCredentials = "Access-Control-Allow-Credentials" - HeaderAccessControlExposeHeaders = "Access-Control-Expose-Headers" - HeaderAccessControlMaxAge = "Access-Control-Max-Age" - HeaderStrictTransportSecurity = "Strict-Transport-Security" - HeaderXContentTypeOptions = "X-Content-Type-Options" - HeaderXXSSProtection = "X-XSS-Protection" - HeaderXFrameOptions = "X-Frame-Options" - HeaderContentSecurityPolicy = "Content-Security-Policy" - HeaderXCSRFToken = "X-CSRF-Token" -) +func main() { + // Parse command line flags + flag.Parse() -type Endpoint struct { - Type string `json:"type"` - Method string `json:"method"` - Status int `json:"status"` - Path string `json:"path"` - JsonPath string `json:"jsonPath"` - Folder string `json:"folder"` -} + // Load configuration + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } -type API struct { - Host string `json:"host"` - Port int `json:"port"` - Endpoints []Endpoint `json:"endpoints"` -} + // Override configuration with command line flags + if *port > 0 { + cfg.Port = *port + } + if *logLevel != "" { + cfg.LogLevel = *logLevel + } + if *logFormat != "" { + cfg.LogFormat = *logFormat + } + if *logPath != "" { + cfg.LogPath = *logPath + } -var api API + // Initialize logger + logConfig := logger.LogConfig{ + Level: logger.ParseLogLevel(cfg.LogLevel), + Format: logger.LogFormat(cfg.LogFormat), + OutputPath: cfg.LogPath, + TimeFormat: time.RFC3339, + } -func main() { - raw, err := ioutil.ReadFile("./api.json") + log, err := logger.NewLogger(logConfig) if err != nil { - fmt.Println(err.Error()) + fmt.Printf("Failed to initialize logger: %v\n", err) os.Exit(1) } + defer log.Close() - err = json.Unmarshal(raw, &api) - if err != nil { - log.Fatal(" ", err) - } + // Log startup information + log.Info("Starting go-json-server", map[string]any{ + "version": Version, + "port": cfg.Port, + }) - for _, ep := range api.Endpoints { - log.Print(ep) - if len(ep.Folder) > 0 { - http.Handle(ep.Path+"/", http.StripPrefix(ep.Path+"/", http.FileServer(http.Dir(ep.Folder)))) - } else { - http.HandleFunc(ep.Path, response) - } - } + // Create server with response cache + server := handler.NewServer(cfg, log, time.Duration(*cacheTTL)*time.Second) - err = http.ListenAndServe(":"+strconv.Itoa(api.Port), nil) + // Setup configuration hot-reloading + reloadCh := make(chan bool) + if err := config.WatchConfig(*configPath, cfg, reloadCh); err != nil { + log.Error("Failed to watch config file", map[string]any{"error": err.Error()}) + } - if err != nil { - log.Fatal(" ", err) + // Create HTTP server with middlewares + srv := &http.Server{ + Addr: ":" + strconv.Itoa(cfg.Port), + Handler: middleware.Chain( + middleware.RequestID(), + middleware.Logger(log), + middleware.CORS(), + middleware.Timeout(30*time.Second), + middleware.Recovery(log), + )(http.HandlerFunc(server.HandleRequest)), } -} -func response(w http.ResponseWriter, r *http.Request) { + // Channel to listen for errors coming from the listener + serverErrors := make(chan error, 1) - appLogger := logger.CreateLogger() + // Start the server + go func() { + log.Info("Server listening", map[string]any{"address": srv.Addr}) + serverErrors <- srv.ListenAndServe() + }() - r.ParseForm() - appLogger.AccessLog(r) + // Channel to listen for config reload events + go func() { + for range reloadCh { + log.Info("Configuration reloaded") + + // Clear the response cache when config changes + server.ClearCache() + } + }() - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + // Channel to listen for interrupt signals + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) - for _, ep := range api.Endpoints { - if r.URL.Path == ep.Path && r.Method == ep.Method { - fmt.Println("method:", r.Method) - fmt.Println("path:", r.URL.Path) - w.Header().Set(HeaderContentType, MIMETextPlainCharsetUTF8) - w.WriteHeader(ep.Status) - s := path2Response(ep.JsonPath) - b := []byte(s) - w.Write(b) + // Blocking main and waiting for shutdown or server error + select { + case err := <-serverErrors: + if err != nil && err != http.ErrServerClosed { + log.Error("Server error", map[string]any{"error": err.Error()}) } - continue - } -} -func path2Response(path string) string { - file, err := os.Open(path) - if err != nil { - log.Print(err) - os.Exit(1) + case sig := <-shutdown: + log.Info("Shutdown signal received", map[string]any{ + "signal": sig.String(), + }) + + // Give outstanding requests a deadline for completion + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Gracefully shutdown the server + if err := srv.Shutdown(ctx); err != nil { + log.Error("Graceful shutdown failed", map[string]any{"error": err.Error()}) + srv.Close() + } } - defer file.Close() - buf := new(bytes.Buffer) - buf.ReadFrom(file) - return buf.String() } diff --git a/go-json-server_test.go b/go-json-server_test.go new file mode 100644 index 0000000..0b4db67 --- /dev/null +++ b/go-json-server_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tkc/go-json-server/src/config" + "github.com/tkc/go-json-server/src/logger" +) + +func TestMainComponents(t *testing.T) { + // This test doesn't run the main function itself but tests key components used by main + + // Create a temporary directory for test files + tempDir, err := os.MkdirTemp("", "go-json-server-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a test JSON file for the endpoint + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{"message":"test"}`), 0644) + assert.NoError(t, err) + + // Create a test config file + configPath := filepath.Join(tempDir, "config.json") + configContent := `{ + "port": 8080, + "logLevel": "info", + "logFormat": "text", + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(configContent), 0644) + assert.NoError(t, err) + + // Test loading config + cfg, err := config.LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, 8080, cfg.Port) + assert.Equal(t, "info", cfg.LogLevel) + + // Test creating logger + logConfig := logger.LogConfig{ + Level: logger.ParseLogLevel(cfg.LogLevel), + Format: logger.LogFormat(cfg.LogFormat), + OutputPath: "stdout", // Use stdout for testing + } + log, err := logger.NewLogger(logConfig) + assert.NoError(t, err) + assert.NotNil(t, log) + + // Test config validation + err = cfg.Validate() + assert.NoError(t, err) +} diff --git a/go.mod b/go.mod index bc8a07c..65f088a 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,16 @@ module github.com/tkc/go-json-server -go 1.13 +go 1.22 require ( - github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect - github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect - github.com/kyoh86/scopelint v0.2.0 // indirect - github.com/spf13/cast v1.3.1 - github.com/stretchr/testify v1.5.1 - gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect + github.com/fsnotify/fsnotify v1.7.0 + github.com/spf13/cast v1.6.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + golang.org/x/sys v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e2dabad..abaf70d 100644 --- a/go.sum +++ b/go.sum @@ -9,28 +9,64 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZq github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyoh86/scopelint v0.2.0 h1:suOCh1T05nIY8srcI266aqwf3RLtO8kniZOTaAnzRyg= github.com/kyoh86/scopelint v0.2.0/go.mod h1:veFgnmDG8sPR5nFaXGX2mEIOXKHjWpGo79v/NaiTcRE= github.com/mattn/go-isatty v0.0.6/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +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= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.4.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= gopkg.in/alecthomas/kingpin.v2 v2.2.5 h1:qskSCq465uEvC3oGocwvZNsO3RF3SpLVLumOAhL0bXo= gopkg.in/alecthomas/kingpin.v2 v2.2.5/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..78754a9 --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,213 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// Error definitions +var ( + ErrNoEndpoints = errors.New("no endpoints defined in configuration") + ErrEmptyPath = errors.New("endpoint with empty path found") + ErrDuplicateEndpoint = errors.New("duplicate endpoint found") + ErrJSONFileNotFound = errors.New("JSON file not found for endpoint") + ErrFolderNotFound = errors.New("folder not found for endpoint") +) + +// Endpoint represents a single API endpoint configuration +type Endpoint struct { + Type string `json:"type"` + Method string `json:"method"` + Status int `json:"status"` + Path string `json:"path"` + JsonPath string `json:"jsonPath"` + Folder string `json:"folder"` +} + +// Config represents the main configuration structure +type Config struct { + Host string `json:"host"` + Port int `json:"port"` + LogLevel string `json:"logLevel"` + LogFormat string `json:"logFormat"` + LogPath string `json:"logPath"` + Endpoints []Endpoint `json:"endpoints"` + mu sync.RWMutex +} + +// LoadConfig loads configuration from a file path +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + + // Default settings + if config.Port == 0 { + config.Port = 3000 + } + if config.LogLevel == "" { + config.LogLevel = "info" + } + if config.LogFormat == "" { + config.LogFormat = "text" + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, err + } + + return &config, nil +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if len(c.Endpoints) == 0 { + return ErrNoEndpoints + } + + // Check for duplicate paths and methods + pathMethods := make(map[string]bool) + for _, ep := range c.Endpoints { + if ep.Path == "" { + return fmt.Errorf("%w: empty path in endpoint", ErrEmptyPath) + } + + // Skip method duplication check for file servers + if ep.Folder != "" { + // Check folder existence + if _, err := os.Stat(ep.Folder); os.IsNotExist(err) { + return fmt.Errorf("%w: %s for path %s", ErrFolderNotFound, ep.Folder, ep.Path) + } + continue + } + + pathMethod := ep.Path + ":" + ep.Method + if pathMethods[pathMethod] { + return fmt.Errorf("%w: %s %s", ErrDuplicateEndpoint, ep.Method, ep.Path) + } + pathMethods[pathMethod] = true + + // Check JSON file existence + if ep.JsonPath != "" && ep.Folder == "" { + if _, err := os.Stat(ep.JsonPath); os.IsNotExist(err) { + return fmt.Errorf("%w: %s for %s %s", ErrJSONFileNotFound, ep.JsonPath, ep.Method, ep.Path) + } + } + } + + return nil +} + +// Reload reloads the configuration from disk +func (c *Config) Reload(path string) error { + newConfig, err := LoadConfig(path) + if err != nil { + return err + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.Host = newConfig.Host + c.Port = newConfig.Port + c.LogLevel = newConfig.LogLevel + c.LogFormat = newConfig.LogFormat + c.LogPath = newConfig.LogPath + c.Endpoints = newConfig.Endpoints + + return nil +} + +// GetEndpoints returns a thread-safe copy of endpoints +func (c *Config) GetEndpoints() []Endpoint { + c.mu.RLock() + defer c.mu.RUnlock() + + // Create a copy to avoid race conditions + endpoints := make([]Endpoint, len(c.Endpoints)) + copy(endpoints, c.Endpoints) + + return endpoints +} + +// GetPort returns the port in a thread-safe manner +func (c *Config) GetPort() int { + c.mu.RLock() + defer c.mu.RUnlock() + return c.Port +} + +// GetHost returns the host in a thread-safe manner +func (c *Config) GetHost() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.Host +} + +// GetLogConfig returns logging configuration +func (c *Config) GetLogConfig() (level, format, path string) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.LogLevel, c.LogFormat, c.LogPath +} + +// WatchConfig watches for changes in the config file and reloads when needed +func WatchConfig(configPath string, config *Config, reloadCh chan<- bool) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file watcher: %w", err) + } + + go func() { + defer watcher.Close() + + dir := filepath.Dir(configPath) + if err := watcher.Add(dir); err != nil { + fmt.Printf("Error watching directory %s: %v\n", dir, err) + return + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + if event.Name == configPath && (event.Op&fsnotify.Write == fsnotify.Write) { + // Add a small delay to wait for write completion + time.Sleep(100 * time.Millisecond) + + fmt.Println("Config file changed, reloading...") + if err := config.Reload(configPath); err != nil { + fmt.Printf("Error reloading config: %v\n", err) + } else if reloadCh != nil { + reloadCh <- true + } + } + + case err, ok := <-watcher.Errors: + if !ok { + return + } + fmt.Printf("Error watching config: %v\n", err) + } + } + }() + + return nil +} diff --git a/src/config/config_test.go b/src/config/config_test.go new file mode 100644 index 0000000..c77bfd4 --- /dev/null +++ b/src/config/config_test.go @@ -0,0 +1,263 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadConfig(t *testing.T) { + // Create a temporary config file + tempDir, err := os.MkdirTemp("", "config-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a test JSON file + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{"message":"test"}`), 0644) + assert.NoError(t, err) + + // Create a test config file + configPath := filepath.Join(tempDir, "config.json") + configContent := `{ + "port": 8080, + "logLevel": "debug", + "logFormat": "json", + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(configContent), 0644) + assert.NoError(t, err) + + // Test loading the config + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, 8080, cfg.Port) + assert.Equal(t, "debug", cfg.LogLevel) + assert.Equal(t, "json", cfg.LogFormat) + assert.Len(t, cfg.Endpoints, 1) + assert.Equal(t, "GET", cfg.Endpoints[0].Method) + assert.Equal(t, 200, cfg.Endpoints[0].Status) + assert.Equal(t, "/test", cfg.Endpoints[0].Path) + assert.Equal(t, jsonFile, cfg.Endpoints[0].JsonPath) +} + +func TestConfig_Validate(t *testing.T) { + tempDir, err := os.MkdirTemp("", "validate-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a test JSON file + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{"message":"test"}`), 0644) + assert.NoError(t, err) + + // Create a test folder + testFolder := filepath.Join(tempDir, "static") + err = os.Mkdir(testFolder, 0755) + assert.NoError(t, err) + + type testCase struct { + name string + setupFn func() Config + wantError bool + } + + tests := []testCase{ + { + name: "Valid config", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "/test", JsonPath: jsonFile, Status: 200}, + }, + } + }, + wantError: false, + }, + { + name: "Valid config with file server", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{ + {Path: "/static", Folder: testFolder}, + }, + } + }, + wantError: false, + }, + { + name: "No endpoints", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{}, + } + }, + wantError: true, + }, + { + name: "Empty path", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "", JsonPath: jsonFile, Status: 200}, + }, + } + }, + wantError: true, + }, + { + name: "Duplicate endpoint", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "/test", JsonPath: jsonFile, Status: 200}, + {Method: "GET", Path: "/test", JsonPath: jsonFile, Status: 200}, + }, + } + }, + wantError: true, + }, + { + name: "JSON file not found", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "/test", JsonPath: filepath.Join(tempDir, "notfound.json"), Status: 200}, + }, + } + }, + wantError: true, + }, + { + name: "Folder not found", + setupFn: func() Config { + return Config{ + Endpoints: []Endpoint{ + {Path: "/static", Folder: filepath.Join(tempDir, "notfound")}, + }, + } + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.setupFn() + err := config.Validate() + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestConfig_Reload(t *testing.T) { + // Create a temporary config file + tempDir, err := os.MkdirTemp("", "reload-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a test JSON file + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{"message":"test"}`), 0644) + assert.NoError(t, err) + + // Initial config + configPath := filepath.Join(tempDir, "config.json") + initialConfig := `{ + "port": 8080, + "logLevel": "debug", + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(initialConfig), 0644) + assert.NoError(t, err) + + // Load the initial config + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, 8080, cfg.Port) + assert.Equal(t, "debug", cfg.LogLevel) + + // Updated config + updatedConfig := `{ + "port": 9090, + "logLevel": "info", + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(updatedConfig), 0644) + assert.NoError(t, err) + + // Reload the config + err = cfg.Reload(configPath) + assert.NoError(t, err) + assert.Equal(t, 9090, cfg.Port) + assert.Equal(t, "info", cfg.LogLevel) +} + +func TestWatchConfig(t *testing.T) { + // This test is simplified as full testing would require more complex setup + tempDir, err := os.MkdirTemp("", "watch-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a test JSON file + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{"message":"test"}`), 0644) + assert.NoError(t, err) + + // Create a config file + configPath := filepath.Join(tempDir, "config.json") + configContent := `{ + "port": 8080, + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(configContent), 0644) + assert.NoError(t, err) + + // Load the config + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + + // Setup a channel to receive notifications + reloadCh := make(chan bool, 1) + + // Start watching + err = WatchConfig(configPath, cfg, reloadCh) + assert.NoError(t, err) + + // We can't easily test the file watching functionality in a unit test + // but we can at least verify the watcher is set up without errors +} diff --git a/src/handler/handler.go b/src/handler/handler.go new file mode 100644 index 0000000..118fa62 --- /dev/null +++ b/src/handler/handler.go @@ -0,0 +1,315 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/tkc/go-json-server/src/config" + "github.com/tkc/go-json-server/src/logger" +) + +// Error definitions +var ( + ErrNotFound = errors.New("endpoint not found") + ErrInternalServer = errors.New("internal server error") + ErrInvalidJSON = errors.New("invalid JSON format") + ErrJSONFileNotFound = errors.New("JSON file not found") + ErrMethodNotAllowed = errors.New("method not allowed") +) + +// Content type constants +const ( + MIMEApplicationJSON = "application/json" + MIMEApplicationJSONUTF8 = MIMEApplicationJSON + "; charset=UTF-8" + MIMETextPlainUTF8 = "text/plain; charset=UTF-8" +) + +// contextKey is a custom type used for context value keys +type contextKey string + +const ( + // PathParamsKey is the context key for path parameters + PathParamsKey contextKey = "pathParams" +) + +// ResponseCache caches JSON responses +type ResponseCache struct { + mu sync.RWMutex + cache map[string]cachedResponse +} + +// cachedResponse represents a cached response +type cachedResponse struct { + content []byte + expiration time.Time +} + +// NewResponseCache creates a new response cache +func NewResponseCache() *ResponseCache { + return &ResponseCache{ + cache: make(map[string]cachedResponse), + } +} + +// Get retrieves a cached response +func (c *ResponseCache) Get(key string) ([]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + cached, ok := c.cache[key] + if !ok { + return nil, false + } + + // Check if the cache has expired + if time.Now().After(cached.expiration) { + delete(c.cache, key) + return nil, false + } + + return cached.content, true +} + +// Set stores a response in the cache +func (c *ResponseCache) Set(key string, content []byte, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.cache[key] = cachedResponse{ + content: content, + expiration: time.Now().Add(ttl), + } +} + +// Clear clears the cache +func (c *ResponseCache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.cache = make(map[string]cachedResponse) +} + +// Server represents the JSON server +type Server struct { + Config *config.Config + Logger *logger.Logger + Cache *ResponseCache + CacheTTL time.Duration + PathParams map[string][]string + paramRegexp *regexp.Regexp +} + +// NewServer creates a new server instance +func NewServer(cfg *config.Config, log *logger.Logger, cacheTTL time.Duration) *Server { + s := &Server{ + Config: cfg, + Logger: log, + Cache: NewResponseCache(), + CacheTTL: cacheTTL, + PathParams: make(map[string][]string), + paramRegexp: regexp.MustCompile(`:([\w]+)`), + } + + // Pre-process endpoints to find path parameters + for _, ep := range cfg.GetEndpoints() { + if ep.Folder == "" { // Only for API endpoints, not static file servers + params := s.extractPathParams(ep.Path) + if len(params) > 0 { + s.PathParams[ep.Path] = params + } + } + } + + return s +} + +// extractPathParams extracts parameter names from a path pattern +// e.g., "/users/:id" returns ["id"] +func (s *Server) extractPathParams(path string) []string { + matches := s.paramRegexp.FindAllStringSubmatch(path, -1) + params := make([]string, 0, len(matches)) + + for _, match := range matches { + if len(match) > 1 { + params = append(params, match[1]) + } + } + + return params +} + +// matchPath checks if a request path matches a route pattern +// and extracts path parameters if present +func (s *Server) matchPath(pattern, path string) (bool, map[string]string) { + // Check for exact match (no parameters) + if pattern == path { + return true, nil + } + + // Check if this pattern has registered parameters + _, hasParams := s.PathParams[pattern] + if !hasParams { + return false, nil + } + + // Convert pattern to regexp + patternParts := strings.Split(pattern, "/") + pathParts := strings.Split(path, "/") + + if len(patternParts) != len(pathParts) { + return false, nil + } + + paramValues := make(map[string]string) + + for i, part := range patternParts { + if strings.HasPrefix(part, ":") { + // This is a parameter + paramName := part[1:] // Remove the colon + paramValues[paramName] = pathParts[i] + } else if part != pathParts[i] { + // Non-parameter parts must match exactly + return false, nil + } + } + + return true, paramValues +} + +// HandleRequest handles all HTTP requests +func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) { + // Set CORS headers (this is also done in middleware, but useful as a fallback) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + + // Handle OPTIONS requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // Check for file server endpoints first + for _, ep := range s.Config.GetEndpoints() { + if ep.Folder != "" && strings.HasPrefix(r.URL.Path, ep.Path) { + // This is a static file server endpoint + fileServer := http.StripPrefix(ep.Path, http.FileServer(http.Dir(ep.Folder))) + fileServer.ServeHTTP(w, r) + return + } + } + + // Handle API endpoints + for _, ep := range s.Config.GetEndpoints() { + if ep.Folder != "" { + continue // Skip file server endpoints + } + + // Check if path matches (with or without params) + match, pathParams := s.matchPath(ep.Path, r.URL.Path) + + if match && ep.Method == r.Method { + s.Logger.Debug("Matched endpoint", map[string]any{ + "path": r.URL.Path, + "method": r.Method, + "pattern": ep.Path, + "params": pathParams, + }) + + // Store path params in context + ctx := context.WithValue(r.Context(), PathParamsKey, pathParams) + r = r.WithContext(ctx) + + // Set headers + w.Header().Set("Content-Type", MIMEApplicationJSONUTF8) + + // Try to get response from cache + cacheKey := fmt.Sprintf("%s:%s", r.Method, r.URL.Path) + if cachedResponse, found := s.Cache.Get(cacheKey); found { + w.WriteHeader(ep.Status) + w.Write(cachedResponse) + return + } + + // Get JSON response + respBody, err := s.getJSONResponse(ep.JsonPath, pathParams) + if err != nil { + s.Logger.Error("Error getting JSON response", map[string]any{ + "error": err.Error(), + "path": ep.JsonPath, + }) + + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "Internal server error"}`)) + return + } + + // Write response + w.WriteHeader(ep.Status) + w.Write(respBody) + + // Cache the response for future requests + s.Cache.Set(cacheKey, respBody, s.CacheTTL) + return + } + } + + // If we got here, no endpoint matched + w.Header().Set("Content-Type", MIMEApplicationJSONUTF8) + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "Not found"}`)) +} + +// getJSONResponse gets the JSON response for an endpoint +func (s *Server) getJSONResponse(jsonPath string, pathParams map[string]string) ([]byte, error) { + file, err := os.Open(jsonPath) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrJSONFileNotFound, err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("error reading JSON file: %w", err) + } + + // If no path parameters, return the content as is + if len(pathParams) == 0 { + return content, nil + } + + // Replace path parameters in the JSON content if needed + contentStr := string(content) + for param, value := range pathParams { + placeholder := fmt.Sprintf(":%s", param) + contentStr = strings.ReplaceAll(contentStr, placeholder, value) + } + + // Validate that the result is still valid JSON + var jsonObj interface{} + if err := json.Unmarshal([]byte(contentStr), &jsonObj); err != nil { + // If parameter replacement made the JSON invalid, return the original + s.Logger.Warn("Parameter replacement resulted in invalid JSON", map[string]any{ + "error": err.Error(), + "path": jsonPath, + }) + return content, nil + } + + return []byte(contentStr), nil +} + +// ClearCache clears the response cache +func (s *Server) ClearCache() { + s.Cache.Clear() + s.Logger.Info("Response cache cleared") +} diff --git a/src/logger/logger.go b/src/logger/logger.go index ecdba63..e68f795 100644 --- a/src/logger/logger.go +++ b/src/logger/logger.go @@ -1,87 +1,341 @@ package logger import ( - "encoding/csv" + "bytes" "encoding/json" "fmt" "io" - "log" - stdlog "log" "net/http" "os" - "strconv" + "path/filepath" + "strings" + "time" ) -type stdLogger struct { - stderr *stdlog.Logger - stdout *stdlog.Logger -} +// LogLevel represents logging levels +type LogLevel int + +const ( + // Log level definitions + LevelDebug LogLevel = iota + LevelInfo + LevelWarn + LevelError + LevelFatal +) -func CreateLogger() *stdLogger { - return &stdLogger{ - stdout: stdlog.New(os.Stdout, "", 0), - stderr: stdlog.New(os.Stderr, "", 0), +// String returns the string representation of the log level +func (l LogLevel) String() string { + switch l { + case LevelDebug: + return "DEBUG" + case LevelInfo: + return "INFO" + case LevelWarn: + return "WARN" + case LevelError: + return "ERROR" + case LevelFatal: + return "FATAL" + default: + return "UNKNOWN" } } -func (l *stdLogger) AccessLog(r *http.Request) { - file, err := os.OpenFile("log.csv", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) - if err != nil { - log.Fatal(err) +// ParseLogLevel parses a string into a log level +func ParseLogLevel(level string) LogLevel { + switch strings.ToLower(level) { + case "debug": + return LevelDebug + case "info": + return LevelInfo + case "warn", "warning": + return LevelWarn + case "error": + return LevelError + case "fatal": + return LevelFatal + default: + return LevelInfo // Default is Info } +} - defer file.Close() +// LogFormat represents the format type for logs +type LogFormat string - jsonBody := dumpJsonBoddy(r) +const ( + // Log format definitions + FormatText LogFormat = "text" + FormatJSON LogFormat = "json" +) - s := []string{r.Method, r.Host, r.Proto, r.RequestURI, r.RemoteAddr, jsonBody} - writer := csv.NewWriter(file) - writer.Write(s) - writer.Flush() +// Logger represents a logger instance +type Logger struct { + level LogLevel + format LogFormat + writer io.Writer + timeFormat string } -func (l *stdLogger) Printf(format string, args ...interface{}) { - l.stdout.Printf(format, args...) +// LogConfig holds logger configuration +type LogConfig struct { + Level LogLevel + Format LogFormat + OutputPath string + TimeFormat string } -func (l *stdLogger) Errorf(format string, args ...interface{}) { - l.stderr.Printf(format, args...) +// LogEntry represents a structured log entry +type LogEntry struct { + Time string `json:"time"` + Level string `json:"level"` + Message string `json:"message"` + Data map[string]any `json:"data,omitempty"` } -func (l *stdLogger) Fatalf(format string, args ...interface{}) { - l.stderr.Fatalf(format, args...) +// AccessLogEntry represents an HTTP access log entry +type AccessLogEntry struct { + Time string `json:"time"` + RemoteAddr string `json:"remote_addr"` + Method string `json:"method"` + Path string `json:"path"` + Protocol string `json:"protocol"` + Status int `json:"status"` + UserAgent string `json:"user_agent"` + Latency float64 `json:"latency_ms"` + Body map[string]any `json:"body,omitempty"` } -func dumpJsonBoddy(req *http.Request) string { +// NewLogger creates a new logger instance +func NewLogger(config LogConfig) (*Logger, error) { + var writer io.Writer - if req.Method == "GET" { - return "" + // Configure output destination + switch config.OutputPath { + case "stdout", "": + writer = os.Stdout + case "stderr": + writer = os.Stderr + default: + // Ensure directory exists + dir := filepath.Dir(config.OutputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + + file, err := os.OpenFile(config.OutputPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + writer = file } - if req.Header.Get("Content-Type") != "application/json" { - return "" + // Default time format + timeFormat := config.TimeFormat + if timeFormat == "" { + timeFormat = time.RFC3339 } - length, err := strconv.Atoi(req.Header.Get("Content-Length")) + // Default format + format := config.Format + if format == "" { + format = FormatText + } + + return &Logger{ + level: config.Level, + format: format, + writer: writer, + timeFormat: timeFormat, + }, nil +} + +// SetWriter sets a new writer for the logger (useful for testing) +func (l *Logger) SetWriter(writer io.Writer) { + l.writer = writer +} + +// log records a message at the specified level +func (l *Logger) log(level LogLevel, message string, data map[string]any) { + if level < l.level { + return + } + + timestamp := time.Now().Format(l.timeFormat) + + entry := LogEntry{ + Time: timestamp, + Level: level.String(), + Message: message, + Data: data, + } + + var err error + var output []byte + + switch l.format { + case FormatJSON: + output, err = json.Marshal(entry) + if err == nil { + output = append(output, '\n') + } + default: // FormatText + var dataStr string + if len(data) > 0 { + dataJSON, jsonErr := json.Marshal(data) + if jsonErr == nil { + dataStr = string(dataJSON) + } else { + dataStr = fmt.Sprintf("%+v", data) + } + } + + if dataStr != "" { + output = []byte(fmt.Sprintf("[%s] %s - %s: %s\n", entry.Time, entry.Level, entry.Message, dataStr)) + } else { + output = []byte(fmt.Sprintf("[%s] %s - %s\n", entry.Time, entry.Level, entry.Message)) + } + } if err != nil { - return "" + fmt.Fprintf(os.Stderr, "Error marshaling log entry: %v\n", err) + return } - body := make([]byte, length) - length, err = req.Body.Read(body) + if _, err := l.writer.Write(output); err != nil { + fmt.Fprintf(os.Stderr, "Error writing log: %v\n", err) + } - if err != nil && err != io.EOF { - return "" + // Exit program on Fatal level logs + if level == LevelFatal { + os.Exit(1) } +} - var jsonBody map[string]interface{} - err = json.Unmarshal(body[:length], &jsonBody) +// Debug logs a debug message +func (l *Logger) Debug(message string, data ...map[string]any) { + var logData map[string]any + if len(data) > 0 { + logData = data[0] + } + l.log(LevelDebug, message, logData) +} + +// Info logs an info message +func (l *Logger) Info(message string, data ...map[string]any) { + var logData map[string]any + if len(data) > 0 { + logData = data[0] + } + l.log(LevelInfo, message, logData) +} + +// Warn logs a warning message +func (l *Logger) Warn(message string, data ...map[string]any) { + var logData map[string]any + if len(data) > 0 { + logData = data[0] + } + l.log(LevelWarn, message, logData) +} + +// Error logs an error message +func (l *Logger) Error(message string, data ...map[string]any) { + var logData map[string]any + if len(data) > 0 { + logData = data[0] + } + l.log(LevelError, message, logData) +} + +// Fatal logs a fatal error message and exits the program +func (l *Logger) Fatal(message string, data ...map[string]any) { + var logData map[string]any + if len(data) > 0 { + logData = data[0] + } + l.log(LevelFatal, message, logData) +} + +// AccessLog records an HTTP request in the log +func (l *Logger) AccessLog(r *http.Request, status int, latency time.Duration) { + var reqBody map[string]any + + // Read body for non-GET requests + if r.Method != http.MethodGet && r.Header.Get("Content-Type") == "application/json" { + if r.Body != nil { + var bodyBytes []byte + var err error + + // Read request body + bodyBytes, err = io.ReadAll(r.Body) + if err != nil { + // Log error + l.Error("Failed to read request body", map[string]any{"error": err.Error()}) + } else { + // Restore body so it can be read again + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Try to parse as JSON + if err := json.Unmarshal(bodyBytes, &reqBody); err != nil { + l.Debug("Failed to parse request body as JSON", map[string]any{"error": err.Error()}) + } + } + } + } + + // Calculate latency in milliseconds + latencyMs := float64(latency.Microseconds()) / 1000.0 + + entry := AccessLogEntry{ + Time: time.Now().Format(l.timeFormat), + RemoteAddr: r.RemoteAddr, + Method: r.Method, + Path: r.URL.Path, + Protocol: r.Proto, + Status: status, + UserAgent: r.UserAgent(), + Latency: latencyMs, + Body: reqBody, + } + + var err error + var output []byte + + switch l.format { + case FormatJSON: + output, err = json.Marshal(entry) + if err == nil { + output = append(output, '\n') + } + default: // FormatText + output = []byte(fmt.Sprintf( + "[%s] %s - %s %s %s %d %.2fms %s\n", + entry.Time, + entry.RemoteAddr, + entry.Method, + entry.Path, + entry.Protocol, + entry.Status, + entry.Latency, + entry.UserAgent, + )) + } if err != nil { - return "" + fmt.Fprintf(os.Stderr, "Error marshaling access log entry: %v\n", err) + return + } + + if _, err := l.writer.Write(output); err != nil { + fmt.Fprintf(os.Stderr, "Error writing access log: %v\n", err) } +} - s := fmt.Sprintf("%v", jsonBody) - return s +// Close closes the logger's file handle +func (l *Logger) Close() error { + if closer, ok := l.writer.(io.Closer); ok { + return closer.Close() + } + return nil } diff --git a/src/logger/logger_test.go b/src/logger/logger_test.go index a8d6060..2deda90 100644 --- a/src/logger/logger_test.go +++ b/src/logger/logger_test.go @@ -2,25 +2,214 @@ package logger import ( "bytes" - "net/http" + "encoding/json" + "net/http/httptest" + "strings" "testing" + "time" - "github.com/spf13/cast" "github.com/stretchr/testify/assert" ) -func TestAccessLog(t *testing.T) { +func TestLogLevel_String(t *testing.T) { + tests := []struct { + level LogLevel + expected string + }{ + {LevelDebug, "DEBUG"}, + {LevelInfo, "INFO"}, + {LevelWarn, "WARN"}, + {LevelError, "ERROR"}, + {LevelFatal, "FATAL"}, + {LogLevel(999), "UNKNOWN"}, + } - url := "example.com" - content := `{"integer":1,"string":"xyz", "object": { "element": 1 } , "array": [1, 2, 3]}` - byte := []byte(content) + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.level.String()) + }) + } +} + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + input string + expected LogLevel + }{ + {"debug", LevelDebug}, + {"DEBUG", LevelDebug}, + {"info", LevelInfo}, + {"INFO", LevelInfo}, + {"warn", LevelWarn}, + {"warning", LevelWarn}, + {"WARN", LevelWarn}, + {"error", LevelError}, + {"ERROR", LevelError}, + {"fatal", LevelFatal}, + {"FATAL", LevelFatal}, + {"unknown", LevelInfo}, + {"", LevelInfo}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, ParseLogLevel(tt.input)) + }) + } +} + +func TestNewLogger(t *testing.T) { + // Test with stdout + stdoutLogger, err := NewLogger(LogConfig{ + Level: LevelInfo, + Format: FormatText, + }) + assert.NoError(t, err) + assert.NotNil(t, stdoutLogger) + + // Test with stderr + stderrLogger, err := NewLogger(LogConfig{ + Level: LevelDebug, + Format: FormatJSON, + OutputPath: "stderr", + }) + assert.NoError(t, err) + assert.NotNil(t, stderrLogger) + + // We don't test file output here as it would require temp file handling + // That's covered implicitly in the log() test below +} + +func TestLogger_log(t *testing.T) { + // Create a buffer to capture output + var buf bytes.Buffer + + // Create a logger that writes to the buffer + log := &Logger{ + level: LevelDebug, + format: FormatText, + writer: &buf, + timeFormat: time.RFC3339, + } + + // Test text format logging + log.Debug("Debug message", map[string]any{"key": "value"}) + output := buf.String() + assert.Contains(t, output, "DEBUG") + assert.Contains(t, output, "Debug message") + assert.Contains(t, output, `"key":"value"`) + + // Reset buffer + buf.Reset() + + // Test JSON format logging + log.format = FormatJSON + log.Info("Info message", map[string]any{"number": 42}) + output = buf.String() + + var logEntry LogEntry + err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry) + assert.NoError(t, err) + assert.Equal(t, "INFO", logEntry.Level) + assert.Equal(t, "Info message", logEntry.Message) + assert.Equal(t, float64(42), logEntry.Data["number"]) +} + +func TestLogger_LogMethods(t *testing.T) { + // Create a buffer to capture output + var buf bytes.Buffer + + // Create a logger that writes to the buffer + log := &Logger{ + level: LevelDebug, + format: FormatText, + writer: &buf, + timeFormat: time.RFC3339, + } + + // Test each log method + tests := []struct { + method func(string, ...map[string]any) + level string + message string + data map[string]any + shouldExist bool // If the level is above the logger's level, it won't appear + }{ + {log.Debug, "DEBUG", "Debug test", map[string]any{"test": "debug"}, true}, + {log.Info, "INFO", "Info test", map[string]any{"test": "info"}, true}, + {log.Warn, "WARN", "Warn test", map[string]any{"test": "warn"}, true}, + {log.Error, "ERROR", "Error test", map[string]any{"test": "error"}, true}, + } + + for _, tt := range tests { + t.Run(tt.level, func(t *testing.T) { + buf.Reset() + tt.method(tt.message, tt.data) + output := buf.String() + + if tt.shouldExist { + assert.Contains(t, output, tt.level) + assert.Contains(t, output, tt.message) + for k, v := range tt.data { + assert.Contains(t, output, k) + assert.Contains(t, output, v) + } + } else { + assert.Empty(t, output) + } + }) + } + + // We don't test Fatal as it would call os.Exit(1) +} + +func TestLogger_AccessLog(t *testing.T) { + // Create a buffer to capture output + var buf bytes.Buffer + + // Create a logger that writes to the buffer + log := &Logger{ + level: LevelDebug, + format: FormatText, + writer: &buf, + timeFormat: time.RFC3339, + } + + // Create a test request + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("User-Agent", "test-agent") - body := bytes.NewReader(byte) - req, _ := http.NewRequest("POST", url, body) + // Log an access entry + log.AccessLog(req, 200, 100*time.Millisecond) + output := buf.String() + // Check text format + assert.Contains(t, output, "GET") + assert.Contains(t, output, "/test") + assert.Contains(t, output, "200") + assert.Contains(t, output, "100.00ms") + assert.Contains(t, output, "test-agent") + + // Reset buffer and test JSON format + buf.Reset() + log.format = FormatJSON + + // Create a request with JSON body + jsonBody := `{"test":"value"}` + req = httptest.NewRequest("POST", "/api", strings.NewReader(jsonBody)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("Content-Length", cast.ToString(len(content))) + req.Header.Set("User-Agent", "test-agent") + + // Log an access entry + log.AccessLog(req, 201, 150*time.Millisecond) + output = buf.String() - result := dumpJsonBoddy(req) - assert.Equal(t, result, "map[array:[1 2 3] integer:1 object:map[element:1] string:xyz]") + var accessEntry AccessLogEntry + err := json.Unmarshal([]byte(strings.TrimSpace(output)), &accessEntry) + assert.NoError(t, err) + assert.Equal(t, "POST", accessEntry.Method) + assert.Equal(t, "/api", accessEntry.Path) + assert.Equal(t, 201, accessEntry.Status) + assert.Equal(t, 150.0, accessEntry.Latency) + assert.Equal(t, "test-agent", accessEntry.UserAgent) } diff --git a/src/middleware/middleware.go b/src/middleware/middleware.go new file mode 100644 index 0000000..07113dc --- /dev/null +++ b/src/middleware/middleware.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "runtime/debug" + "time" + + "github.com/tkc/go-json-server/src/logger" +) + +// Middleware represents an HTTP middleware +type Middleware func(http.Handler) http.Handler + +// Logger is a middleware that logs HTTP requests +func Logger(log *logger.Logger) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap response writer to capture status code + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + // Call the next handler + next.ServeHTTP(rw, r) + + // Calculate request duration and log the request + duration := time.Since(start) + log.AccessLog(r, rw.statusCode, duration) + }) + } +} + +// CORS is a middleware that adds CORS headers to responses +func CORS() Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + + // Handle preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// Timeout is a middleware that adds a timeout to the request context +func Timeout(timeout time.Duration) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Create context with timeout + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + // Update request with new context + r = r.WithContext(ctx) + + // Create done channel + done := make(chan struct{}) + + // Execute handler in goroutine + go func() { + next.ServeHTTP(w, r) + close(done) + }() + + // Wait for timeout or completion + select { + case <-done: + return + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestTimeout) + json.NewEncoder(w).Encode(map[string]string{ + "error": "request timeout", + }) + } + } + }) + } +} + +// Recovery is a middleware that recovers from panics +func Recovery(log *logger.Logger) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + // Log panic details + stack := debug.Stack() + log.Error("Panic recovered", map[string]any{ + "error": err, + "stacktrace": string(stack), + "path": r.URL.Path, + "method": r.Method, + "remoteAddr": r.RemoteAddr, + }) + + // Return error response to client + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "internal server error", + }) + } + }() + + next.ServeHTTP(w, r) + }) + } +} + +// RequestID is a middleware that assigns a unique ID to requests +func RequestID() Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if request ID is already set + requestID := r.Header.Get("X-Request-ID") + if requestID == "" { + // Generate UUID (in practice, use a UUID library) + requestID = time.Now().Format("20060102150405") + "-" + randomString(8) + } + + // Set request ID in response header + w.Header().Set("X-Request-ID", requestID) + + // Add request ID to context + ctx := context.WithValue(r.Context(), "requestID", requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// Chain combines multiple middlewares into a single middleware +func Chain(middlewares ...Middleware) Middleware { + return func(next http.Handler) http.Handler { + for i := len(middlewares) - 1; i >= 0; i-- { + next = middlewares[i](next) + } + return next + } +} + +// responseWriter is a wrapper for http.ResponseWriter that captures the status code +type responseWriter struct { + http.ResponseWriter + statusCode int + written bool +} + +// WriteHeader captures the status code +func (rw *responseWriter) WriteHeader(code int) { + if !rw.written { + rw.statusCode = code + rw.written = true + rw.ResponseWriter.WriteHeader(code) + } +} + +// Write captures the default status code (200) if WriteHeader hasn't been called +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.statusCode = http.StatusOK + rw.written = true + } + return rw.ResponseWriter.Write(b) +} + +// randomString generates a random string +// Note: In production, use crypto/rand instead +func randomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + for i := range result { + result[i] = charset[time.Now().UnixNano()%int64(len(charset))] + time.Sleep(1 * time.Nanosecond) // Small delay to get different values + } + return string(result) +} diff --git a/src/middleware/middleware_test.go b/src/middleware/middleware_test.go new file mode 100644 index 0000000..3d960ef --- /dev/null +++ b/src/middleware/middleware_test.go @@ -0,0 +1,279 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tkc/go-json-server/src/logger" +) + +func TestLogger_Middleware(t *testing.T) { + // Create a buffer to capture logs + var buf bytes.Buffer + + // Create a test logger + log, err := logger.NewLogger(logger.LogConfig{ + Level: logger.LevelDebug, + Format: logger.FormatText, + TimeFormat: time.RFC3339, + }) + assert.NoError(t, err) + + // Replace the writer with our buffer + log.SetWriter(&buf) + + // Create a test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test response")) + }) + + // Apply the middleware + handler := Logger(log)(testHandler) + + // Create a test request + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + // Execute the handler + handler.ServeHTTP(w, req) + + // Check response + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "test response", w.Body.String()) + + // Check log output + logOutput := buf.String() + assert.Contains(t, logOutput, "GET") + assert.Contains(t, logOutput, "/test") + assert.Contains(t, logOutput, "200") +} + +func TestCORS_Middleware(t *testing.T) { + // Create a test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test response")) + }) + + // Apply the middleware + handler := CORS()(testHandler) + + // Test regular request + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Check CORS headers + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "GET") + + // Test preflight request + req = httptest.NewRequest("OPTIONS", "/test", nil) + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Check response for OPTIONS + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "", w.Body.String()) // Empty body for OPTIONS +} + +func TestTimeout_Middleware(t *testing.T) { + // Create a handler that delays + delayHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + // Context was canceled, don't write response + return + case <-time.After(100 * time.Millisecond): + w.WriteHeader(http.StatusOK) + w.Write([]byte("delayed response")) + } + }) + + // Test with timeout longer than delay + t.Run("No timeout", func(t *testing.T) { + handler := Timeout(200 * time.Millisecond)(delayHandler) + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "delayed response", w.Body.String()) + }) + + // Test with timeout shorter than delay + t.Run("With timeout", func(t *testing.T) { + handler := Timeout(50 * time.Millisecond)(delayHandler) + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusRequestTimeout, w.Code) + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "request timeout", response["error"]) + }) +} + +func TestRecovery_Middleware(t *testing.T) { + // Create a buffer to capture logs + var buf bytes.Buffer + + // Create a test logger + log, err := logger.NewLogger(logger.LogConfig{ + Level: logger.LevelDebug, + Format: logger.FormatText, + TimeFormat: time.RFC3339, + }) + assert.NoError(t, err) + + // Replace the writer with our buffer + log.SetWriter(&buf) + + // Create a handler that panics + panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("test panic") + }) + + // Apply the middleware + handler := Recovery(log)(panicHandler) + + // Create a test request + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + // Execute the handler (should recover from panic) + handler.ServeHTTP(w, req) + + // Check response + assert.Equal(t, http.StatusInternalServerError, w.Code) + var response map[string]string + err = json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "internal server error", response["error"]) + + // Check log output + logOutput := buf.String() + assert.Contains(t, logOutput, "ERROR") + assert.Contains(t, logOutput, "Panic recovered") + assert.Contains(t, logOutput, "test panic") +} + +func TestRequestID_Middleware(t *testing.T) { + // Create a handler that checks request ID + var capturedID string + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := r.Context().Value("requestID").(string) + if ok { + capturedID = id + } + w.WriteHeader(http.StatusOK) + }) + + // Apply the middleware + handler := RequestID()(testHandler) + + // Test without existing request ID + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Check that a request ID was generated and added to both context and response + assert.NotEmpty(t, capturedID) + assert.Equal(t, capturedID, w.Header().Get("X-Request-ID")) + + // Test with existing request ID + req = httptest.NewRequest("GET", "/test", nil) + req.Header.Set("X-Request-ID", "existing-id") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Check that the existing ID was preserved + assert.Equal(t, "existing-id", capturedID) + assert.Equal(t, "existing-id", w.Header().Get("X-Request-ID")) +} + +func TestChain_Middleware(t *testing.T) { + // Create middleware that add headers + middleware1 := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test-1", "value1") + next.ServeHTTP(w, r) + }) + } + + middleware2 := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test-2", "value2") + next.ServeHTTP(w, r) + }) + } + + // Create test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test")) + }) + + // Chain middleware + handler := Chain(middleware1, middleware2)(testHandler) + + // Execute + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Check headers were set in the correct order + assert.Equal(t, "value1", w.Header().Get("X-Test-1")) + assert.Equal(t, "value2", w.Header().Get("X-Test-2")) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestResponseWriter(t *testing.T) { + origWriter := httptest.NewRecorder() + rw := &responseWriter{ + ResponseWriter: origWriter, + } + + // Test WriteHeader + rw.WriteHeader(http.StatusCreated) + assert.Equal(t, http.StatusCreated, rw.statusCode) + assert.True(t, rw.written) + assert.Equal(t, http.StatusCreated, origWriter.Code) + + // Test calling WriteHeader again (should not change status) + rw.WriteHeader(http.StatusOK) + assert.Equal(t, http.StatusCreated, rw.statusCode) // Status should not change + + // Test Write + origWriter = httptest.NewRecorder() + rw = &responseWriter{ + ResponseWriter: origWriter, + } + rw.Write([]byte("test")) + assert.Equal(t, http.StatusOK, rw.statusCode) // Default status + assert.True(t, rw.written) + assert.Equal(t, "test", origWriter.Body.String()) +} + +func TestRandomString(t *testing.T) { + // Test length + for _, length := range []int{8, 16, 32} { + result := randomString(length) + assert.Len(t, result, length) + } + + // Test uniqueness + s1 := randomString(16) + s2 := randomString(16) + assert.NotEqual(t, s1, s2) +}