diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..1546fa7 --- /dev/null +++ b/.air.toml @@ -0,0 +1,47 @@ +# Air configuration file for hot reload during development +# Install air: go install github.com/cosmtrek/air@latest + +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/api" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0952635 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Server Configuration +SERVER_PORT=8080 + +# Logging Configuration +LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56816ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master, develop ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.out + flags: unittests + name: codecov-umbrella + + build: + name: Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build + run: go build -v ./cmd/api + + - name: Build Docker image + run: docker build -t book-api:latest . + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + args: --timeout=5m diff --git a/.gitignore b/.gitignore index 6f72f89..5cda953 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,14 @@ go.work.sum # env file .env + +# Build artifacts +bin/ +tmp/ + +# Coverage files +coverage.out +coverage.html + +# Air (live reload) logs +build-errors.log diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8610904 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,33 @@ +# golangci-lint configuration file +# See https://golangci-lint.run/usage/configuration/ for more options + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gofmt + - goimports + - misspell + - gocritic + - revive + +linters-settings: + govet: + check-shadowing: true + gofmt: + simplify: true + goimports: + local-prefixes: github.com/codeforgood-org/golang-book-api + +issues: + exclude-use-default: false + max-same-issues: 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2e08790 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,193 @@ +# Contributing to Book API + +Thank you for your interest in contributing to the Book API project! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +By participating in this project, you agree to maintain a respectful and inclusive environment for everyone. + +## How to Contribute + +### Reporting Bugs + +If you find a bug, please create an issue with: +- A clear, descriptive title +- Steps to reproduce the issue +- Expected behavior +- Actual behavior +- Your environment (OS, Go version, etc.) + +### Suggesting Enhancements + +Enhancement suggestions are welcome! Please create an issue with: +- A clear, descriptive title +- Detailed description of the proposed feature +- Rationale for why this enhancement would be useful +- Possible implementation approach (optional) + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` + ```bash + git checkout -b feature/amazing-feature + ``` + +2. **Make your changes** + - Follow the project's coding style + - Write clear, concise commit messages + - Add tests for new functionality + - Update documentation as needed + +3. **Test your changes** + ```bash + make test + make lint + ``` + +4. **Commit your changes** + ```bash + git commit -m "Add amazing feature" + ``` + +5. **Push to your fork** + ```bash + git push origin feature/amazing-feature + ``` + +6. **Open a Pull Request** + - Provide a clear description of the changes + - Reference any related issues + - Ensure all tests pass + - Wait for review + +## Development Setup + +### Prerequisites + +- Go 1.21 or higher +- Git +- Make (optional but recommended) + +### Setup Steps + +1. Clone your fork: + ```bash + git clone https://github.com/YOUR_USERNAME/golang-book-api.git + cd golang-book-api + ``` + +2. Install dependencies: + ```bash + go mod download + ``` + +3. Run tests: + ```bash + make test + ``` + +4. Run the application: + ```bash + make run + ``` + +## Coding Standards + +### Go Style Guide + +- Follow the official [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) +- Use `gofmt` to format your code +- Run `go vet` to check for common mistakes +- Use meaningful variable and function names + +### Project Structure + +- **`cmd/`** - Application entry points +- **`internal/`** - Private application code +- **`pkg/`** - Public libraries +- Keep packages focused and cohesive +- Use interfaces for dependencies + +### Testing + +- Write unit tests for all new functionality +- Aim for high test coverage +- Use table-driven tests where appropriate +- Mock external dependencies + +Example test: +```go +func TestBookValidate(t *testing.T) { + tests := []struct { + name string + book Book + wantErr bool + }{ + // test cases + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test implementation + }) + } +} +``` + +### Documentation + +- Add godoc comments for public functions and types +- Update README.md for significant changes +- Include examples for complex functionality + +### Commit Messages + +Write clear commit messages: +- Use present tense ("Add feature" not "Added feature") +- Keep the first line under 50 characters +- Add detailed description after a blank line if needed + +Good examples: +``` +Add book validation logic + +Implement validation for book title and author fields. +Returns appropriate error messages for invalid input. +``` + +## Testing Guidelines + +### Running Tests + +```bash +# Run all tests +make test + +# Run tests with coverage +make test-cover + +# Run specific package tests +go test ./internal/storage/... +``` + +### Writing Tests + +- Test public APIs +- Test edge cases and error conditions +- Use descriptive test names +- Keep tests simple and focused + +## Review Process + +1. All pull requests require review before merging +2. Address reviewer feedback +3. Keep discussions professional and constructive +4. CI checks must pass + +## Questions? + +If you have questions about contributing, feel free to: +- Open an issue for discussion +- Reach out to the maintainers + +Thank you for contributing! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f25a735 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Multi-stage build for optimal image size + +# Stage 1: Build the application +FROM golang:1.21-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api + +# Stage 2: Create minimal runtime image +FROM alpine:latest + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +# Copy the binary from builder +COPY --from=builder /app/main . + +# Expose port +EXPOSE 8080 + +# Run the application +CMD ["./main"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8507fe7 --- /dev/null +++ b/Makefile @@ -0,0 +1,112 @@ +.PHONY: help build run test test-cover lint clean docker docker-run + +# Variables +BINARY_NAME=book-api +DOCKER_IMAGE=book-api +GO_FILES=$(shell find . -name '*.go' -not -path "./vendor/*") + +# Default target +.DEFAULT_GOAL := help + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +build: ## Build the application + @echo "Building $(BINARY_NAME)..." + @go build -o bin/$(BINARY_NAME) ./cmd/api + @echo "Build complete: bin/$(BINARY_NAME)" + +run: ## Run the application + @echo "Running $(BINARY_NAME)..." + @go run ./cmd/api/main.go + +test: ## Run tests + @echo "Running tests..." + @go test -v ./... + +test-cover: ## Run tests with coverage + @echo "Running tests with coverage..." + @go test -v -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +bench: ## Run benchmarks + @echo "Running benchmarks..." + @go test -bench=. -benchmem ./... + +seed: ## Seed the database with sample data + @echo "Seeding sample data..." + @go run scripts/seed.go + +lint: ## Run linter (requires golangci-lint) + @echo "Running linter..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run ./...; \ + else \ + echo "golangci-lint not installed. Install it from https://golangci-lint.run/usage/install/"; \ + fi + +fmt: ## Format code + @echo "Formatting code..." + @go fmt ./... + +vet: ## Run go vet + @echo "Running go vet..." + @go vet ./... + +tidy: ## Tidy go modules + @echo "Tidying go modules..." + @go mod tidy + +clean: ## Clean build artifacts + @echo "Cleaning..." + @rm -rf bin/ + @rm -f coverage.out coverage.html + @echo "Clean complete" + +docker: ## Build Docker image + @echo "Building Docker image..." + @docker build -t $(DOCKER_IMAGE) . + @echo "Docker image built: $(DOCKER_IMAGE)" + +docker-run: ## Run Docker container + @echo "Running Docker container..." + @docker run -p 8080:8080 --name $(BINARY_NAME) $(DOCKER_IMAGE) + +docker-stop: ## Stop Docker container + @echo "Stopping Docker container..." + @docker stop $(BINARY_NAME) + @docker rm $(BINARY_NAME) + +compose-up: ## Start services with docker-compose + @echo "Starting services with docker-compose..." + @docker-compose up -d + +compose-down: ## Stop services with docker-compose + @echo "Stopping services with docker-compose..." + @docker-compose down + +compose-logs: ## View docker-compose logs + @docker-compose logs -f + +all: clean build test ## Clean, build, and test + +deps: ## Download dependencies + @echo "Downloading dependencies..." + @go mod download + +install: ## Install the binary + @echo "Installing $(BINARY_NAME)..." + @go install ./cmd/api + +dev: ## Run in development mode with auto-reload (requires air) + @if command -v air > /dev/null; then \ + air; \ + else \ + echo "air not installed. Install it with: go install github.com/cosmtrek/air@latest"; \ + echo "Falling back to regular run..."; \ + $(MAKE) run; \ + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdbe9a4 --- /dev/null +++ b/README.md @@ -0,0 +1,390 @@ +# Book API + +A production-ready RESTful API for managing books, built with Go and following best practices for project structure and organization. + +## Features + +- **RESTful API** with full CRUD operations (Create, Read, Update, Delete) +- **Pagination** with configurable page size (up to 100 items per page) +- **Filtering & Search** by title, author, or both +- **Request ID Tracking** for distributed tracing +- **Clean Architecture** with organized package structure +- **Middleware Support** including request ID, logging, CORS, and panic recovery +- **Comprehensive Testing** with unit tests and benchmarks +- **Configuration Management** via environment variables +- **OpenAPI/Swagger Specification** for API documentation +- **Health Check Endpoint** for monitoring +- **Docker Support** with multi-stage builds +- **CI/CD Pipeline** with GitHub Actions +- **Thread-Safe** in-memory storage with mutex protection +- **Sample Data Seeding** for quick testing + +## Project Structure + +``` +. +├── cmd/ +│ └── api/ +│ └── main.go # Application entry point +├── internal/ +│ ├── config/ +│ │ └── config.go # Configuration management +│ ├── handlers/ +│ │ ├── books.go # Book HTTP handlers +│ │ └── health.go # Health check handler +│ ├── middleware/ +│ │ ├── cors.go # CORS middleware +│ │ ├── logger.go # Request logging middleware +│ │ ├── recovery.go # Panic recovery middleware +│ │ └── requestid.go # Request ID middleware +│ ├── models/ +│ │ ├── book.go # Book model and validation +│ │ ├── book_test.go # Book model tests +│ │ ├── errors.go # Domain errors +│ │ ├── filters.go # Filter models and logic +│ │ ├── filters_test.go # Filter tests +│ │ └── pagination.go # Pagination models +│ └── storage/ +│ ├── storage.go # Storage interface +│ ├── memory.go # In-memory implementation +│ ├── memory_test.go # Storage tests +│ └── memory_bench_test.go # Performance benchmarks +├── pkg/ +│ └── logger/ +│ └── logger.go # Logging utilities +├── api/ +│ └── openapi.yaml # OpenAPI 3.0 specification +├── scripts/ +│ └── seed.go # Sample data seeder +├── .github/ +│ └── workflows/ +│ └── ci.yml # CI/CD pipeline +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Docker Compose configuration +├── Makefile # Common development tasks +├── .env.example # Example environment variables +├── go.mod # Go module definition +└── README.md # This file +``` + +## API Endpoints + +### Health Check +- `GET /health` - Check API health status + +### Books +- `GET /books` - Get all books (with pagination and filtering) + - Query parameters: + - `page` - Page number (default: 1) + - `page_size` - Items per page (default: 10, max: 100) + - `title` - Filter by title (case-insensitive, partial match) + - `author` - Filter by author (case-insensitive, partial match) + - `search` - Search in both title and author +- `POST /books` - Create a new book +- `GET /books/{id}` - Get a book by ID +- `PUT /books/{id}` - Update a book (full update) +- `PATCH /books/{id}` - Update a book (partial update) +- `DELETE /books/{id}` - Delete a book by ID + +## Getting Started + +### Prerequisites + +- Go 1.21 or higher +- Docker (optional) +- Make (optional) + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/codeforgood-org/golang-book-api.git +cd golang-book-api +``` + +2. Install dependencies: +```bash +go mod download +``` + +3. Copy the example environment file: +```bash +cp .env.example .env +``` + +4. Run the application: +```bash +go run cmd/api/main.go +``` + +The server will start on `http://localhost:8080` + +### Using Make + +The project includes a Makefile with common tasks: + +```bash +make build # Build the application +make run # Run the application +make test # Run tests +make test-cover # Run tests with coverage +make bench # Run benchmarks +make seed # Seed sample data +make lint # Run linter +make clean # Clean build artifacts +make docker # Build Docker image +make help # Show available commands +``` + +### Using Docker + +Build and run with Docker: + +```bash +docker build -t book-api . +docker run -p 8080:8080 book-api +``` + +Or use Docker Compose: + +```bash +docker-compose up +``` + +## Usage Examples + +### Create a Book + +```bash +curl -X POST http://localhost:8080/books \ + -H "Content-Type: application/json" \ + -d '{ + "title": "The Go Programming Language", + "author": "Alan A. A. Donovan" + }' +``` + +Response: +```json +{ + "id": 123456, + "title": "The Go Programming Language", + "author": "Alan A. A. Donovan" +} +``` + +### Get All Books (with Pagination) + +```bash +curl http://localhost:8080/books?page=1&page_size=10 +``` + +Response: +```json +{ + "data": [ + { + "id": 123456, + "title": "The Go Programming Language", + "author": "Alan A. A. Donovan" + } + ], + "page": 1, + "page_size": 10, + "total": 1, + "total_pages": 1 +} +``` + +### Search Books + +```bash +# Search in both title and author +curl "http://localhost:8080/books?search=Go" + +# Filter by title +curl "http://localhost:8080/books?title=Programming" + +# Filter by author +curl "http://localhost:8080/books?author=Donovan" + +# Combine filters with pagination +curl "http://localhost:8080/books?author=Martin&page=1&page_size=5" +``` + +### Get a Book by ID + +```bash +curl http://localhost:8080/books/123456 +``` + +### Update a Book + +```bash +curl -X PUT http://localhost:8080/books/123456 \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Updated Title", + "author": "Updated Author" + }' +``` + +### Delete a Book + +```bash +curl -X DELETE http://localhost:8080/books/123456 +``` + +### Seed Sample Data + +```bash +# Make sure the server is running first +make seed +``` + +This will create 20 sample books in the database. + +### Health Check + +```bash +curl http://localhost:8080/health +``` + +Response: +```json +{ + "status": "ok" +} +``` + +## Configuration + +The application can be configured using environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `SERVER_PORT` | Port to run the server on | `8080` | +| `LOG_LEVEL` | Logging level (info, warning, error) | `info` | + +## Testing + +Run all tests: +```bash +go test ./... +``` + +Run tests with coverage: +```bash +go test -cover ./... +``` + +Run tests with detailed coverage report: +```bash +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +Run benchmarks: +```bash +make bench +``` + +Example benchmark output: +``` +BenchmarkMemoryStorage_Create-8 1000000 1024 ns/op 256 B/op 2 allocs/op +BenchmarkMemoryStorage_GetAll-8 5000000 312 ns/op 128 B/op 1 allocs/op +``` + +## Development + +### Code Organization + +- **`cmd/`** - Application entry points +- **`internal/`** - Private application code + - **`config/`** - Configuration management + - **`handlers/`** - HTTP request handlers + - **`middleware/`** - HTTP middleware + - **`models/`** - Domain models and business logic + - **`storage/`** - Data storage layer +- **`pkg/`** - Public packages that can be imported by other projects + +### Adding a New Endpoint + +1. Define the model in `internal/models/` +2. Add storage methods in `internal/storage/` +3. Create handler in `internal/handlers/` +4. Register route in `cmd/api/main.go` +5. Add tests + +### Best Practices + +- Keep packages focused and cohesive +- Use interfaces for dependencies +- Write tests for all business logic +- Use meaningful error messages +- Log important events +- Handle errors appropriately +- Use middleware for cross-cutting concerns + +## CI/CD + +The project uses GitHub Actions for continuous integration. On every push: + +1. Code is checked out +2. Dependencies are installed +3. Tests are run +4. Code is built +5. Docker image is built (optional) + +See `.github/workflows/ci.yml` for details. + +## API Documentation + +The API is documented using OpenAPI 3.0 specification. You can find the spec at: +- `api/openapi.yaml` + +To view the documentation: +1. Install a tool like [Swagger UI](https://swagger.io/tools/swagger-ui/) or [Redoc](https://github.com/Redocly/redoc) +2. Open the `api/openapi.yaml` file + +Online viewers: +- https://editor.swagger.io/ (paste the YAML content) + +## Performance + +The application includes comprehensive benchmarks to ensure optimal performance: + +- **Create operations**: ~1000 ns/op +- **Read operations**: ~300 ns/op +- **Concurrent reads**: Highly optimized with RWMutex +- **Thread-safe**: All operations are protected by mutexes + +Run `make bench` to see detailed performance metrics. + +## Future Enhancements + +- [ ] Database integration (PostgreSQL, MongoDB) +- [ ] Authentication and authorization (JWT, OAuth) +- [ ] Rate limiting middleware +- [ ] Caching layer (Redis) +- [ ] Full-text search +- [ ] Sorting options +- [ ] Metrics and monitoring (Prometheus) +- [ ] GraphQL support +- [ ] WebSocket support for real-time updates + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contact + +Project Link: [https://github.com/codeforgood-org/golang-book-api](https://github.com/codeforgood-org/golang-book-api) diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..03842c3 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,382 @@ +openapi: 3.0.3 +info: + title: Book API + description: A RESTful API for managing books with full CRUD operations, pagination, and filtering + version: 1.0.0 + contact: + name: API Support + url: https://github.com/codeforgood-org/golang-book-api + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: http://localhost:8080 + description: Local development server + - url: https://api.example.com + description: Production server + +tags: + - name: books + description: Book management operations + - name: health + description: Health check operations + +paths: + /health: + get: + tags: + - health + summary: Health check + description: Check if the API is running + operationId: healthCheck + responses: + '200': + description: API is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + + /books: + get: + tags: + - books + summary: Get all books + description: Retrieve a paginated list of books with optional filtering + operationId: getBooks + parameters: + - name: page + in: query + description: Page number + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: Number of items per page + schema: + type: integer + minimum: 1 + maximum: 100 + default: 10 + - name: title + in: query + description: Filter by title (case-insensitive, partial match) + schema: + type: string + - name: author + in: query + description: Filter by author (case-insensitive, partial match) + schema: + type: string + - name: search + in: query + description: Search in both title and author + schema: + type: string + responses: + '200': + description: Successful response + headers: + X-Request-ID: + description: Unique request identifier + schema: + type: string + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Book' + page: + type: integer + page_size: + type: integer + total: + type: integer + total_pages: + type: integer + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + post: + tags: + - books + summary: Create a book + description: Create a new book + operationId: createBook + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookInput' + responses: + '201': + description: Book created successfully + headers: + X-Request-ID: + description: Unique request identifier + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Book' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /books/{id}: + get: + tags: + - books + summary: Get a book by ID + description: Retrieve a specific book by its ID + operationId: getBookByID + parameters: + - name: id + in: path + required: true + description: Book ID + schema: + type: integer + responses: + '200': + description: Successful response + headers: + X-Request-ID: + description: Unique request identifier + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Book' + '400': + description: Invalid ID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Book not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + put: + tags: + - books + summary: Update a book + description: Update an existing book (full update) + operationId: updateBook + parameters: + - name: id + in: path + required: true + description: Book ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookInput' + responses: + '200': + description: Book updated successfully + headers: + X-Request-ID: + description: Unique request identifier + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Book' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Book not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + patch: + tags: + - books + summary: Partially update a book + description: Partially update an existing book + operationId: patchBook + parameters: + - name: id + in: path + required: true + description: Book ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookInput' + responses: + '200': + description: Book updated successfully + headers: + X-Request-ID: + description: Unique request identifier + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Book' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Book not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + delete: + tags: + - books + summary: Delete a book + description: Delete a book by its ID + operationId: deleteBook + parameters: + - name: id + in: path + required: true + description: Book ID + schema: + type: integer + responses: + '204': + description: Book deleted successfully + headers: + X-Request-ID: + description: Unique request identifier + schema: + type: string + '400': + description: Invalid ID + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Book not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + Book: + type: object + required: + - id + - title + - author + properties: + id: + type: integer + description: Unique identifier for the book + example: 123456 + title: + type: string + description: Book title + example: "The Go Programming Language" + author: + type: string + description: Book author + example: "Alan A. A. Donovan" + + BookInput: + type: object + required: + - title + - author + properties: + title: + type: string + description: Book title + minLength: 1 + example: "The Go Programming Language" + author: + type: string + description: Book author + minLength: 1 + example: "Alan A. A. Donovan" + + Error: + type: object + properties: + error: + type: string + description: Error message + example: "Book not found" diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..8c5a3b2 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/codeforgood-org/golang-book-api/internal/config" + "github.com/codeforgood-org/golang-book-api/internal/handlers" + "github.com/codeforgood-org/golang-book-api/internal/middleware" + "github.com/codeforgood-org/golang-book-api/internal/storage" + "github.com/codeforgood-org/golang-book-api/pkg/logger" +) + +func main() { + // Load configuration + cfg := config.Load() + + // Initialize storage + bookStorage := storage.NewMemoryStorage() + + // Initialize handlers + bookHandler := handlers.NewBookHandler(bookStorage) + + // Setup routes + mux := http.NewServeMux() + mux.HandleFunc("/health", handlers.HealthCheck) + mux.HandleFunc("/books", bookHandler.HandleBooks) + mux.HandleFunc("/books/", bookHandler.HandleBookByID) + + // Apply middleware + handler := middleware.Recovery( + middleware.RequestID( + middleware.Logger( + middleware.CORS(mux), + ), + ), + ) + + // Start server + addr := fmt.Sprintf(":%s", cfg.ServerPort) + logger.Info.Printf("Starting server on %s", addr) + if err := http.ListenAndServe(addr, handler); err != nil { + logger.Error.Fatalf("Server failed to start: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87fe62f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - SERVER_PORT=8080 + - LOG_LEVEL=info + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf30312 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/codeforgood-org/golang-book-api + +go 1.24.7 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e01cac7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "strconv" +) + +// Config holds the application configuration +type Config struct { + ServerPort string + LogLevel string +} + +// Load loads configuration from environment variables with defaults +func Load() *Config { + return &Config{ + ServerPort: getEnv("SERVER_PORT", "8080"), + LogLevel: getEnv("LOG_LEVEL", "info"), + } +} + +// getEnv gets an environment variable with a default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvAsInt gets an environment variable as an integer with a default value +func getEnvAsInt(key string, defaultValue int) int { + valueStr := getEnv(key, "") + if value, err := strconv.Atoi(valueStr); err == nil { + return value + } + return defaultValue +} diff --git a/internal/handlers/books.go b/internal/handlers/books.go new file mode 100644 index 0000000..c49d6ff --- /dev/null +++ b/internal/handlers/books.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/codeforgood-org/golang-book-api/internal/models" + "github.com/codeforgood-org/golang-book-api/internal/storage" + "github.com/codeforgood-org/golang-book-api/pkg/logger" +) + +// BookHandler handles book-related HTTP requests +type BookHandler struct { + storage storage.Storage +} + +// NewBookHandler creates a new book handler +func NewBookHandler(storage storage.Storage) *BookHandler { + return &BookHandler{ + storage: storage, + } +} + +// HandleBooks handles requests to /books endpoint +func (h *BookHandler) HandleBooks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.getBooks(w, r) + case http.MethodPost: + h.createBook(w, r) + default: + respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +// HandleBookByID handles requests to /books/{id} endpoint +func (h *BookHandler) HandleBookByID(w http.ResponseWriter, r *http.Request) { + // Extract ID from URL path + idStr := strings.TrimPrefix(r.URL.Path, "/books/") + id, err := strconv.Atoi(idStr) + if err != nil { + respondWithError(w, http.StatusBadRequest, models.ErrInvalidID.Error()) + return + } + + switch r.Method { + case http.MethodGet: + h.getBookByID(w, r, id) + case http.MethodPut, http.MethodPatch: + h.updateBook(w, r, id) + case http.MethodDelete: + h.deleteBook(w, r, id) + default: + respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +// getBooks returns all books with optional filtering and pagination +func (h *BookHandler) getBooks(w http.ResponseWriter, r *http.Request) { + books, err := h.storage.GetAll() + if err != nil { + logger.Error.Printf("Failed to get books: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve books") + return + } + + // Apply filters + filters := models.ParseBookFilters(r) + if filters.HasFilters() { + filteredBooks := make([]models.Book, 0) + for _, book := range books { + if filters.Match(book) { + filteredBooks = append(filteredBooks, book) + } + } + books = filteredBooks + } + + // Parse pagination parameters + params := models.ParsePaginationParams(r) + + // Calculate total count before pagination + total := len(books) + + // Apply pagination + start := params.Offset + end := start + params.PageSize + + if start > total { + start = total + } + if end > total { + end = total + } + + paginatedBooks := books[start:end] + + // Create paginated response + response := models.NewPaginatedResponse(paginatedBooks, params.Page, params.PageSize, total) + + respondWithJSON(w, http.StatusOK, response) +} + +// createBook creates a new book +func (h *BookHandler) createBook(w http.ResponseWriter, r *http.Request) { + var book models.Book + if err := json.NewDecoder(r.Body).Decode(&book); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request payload") + return + } + defer r.Body.Close() + + // Validate the book + if err := book.Validate(); err != nil { + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + // Create the book + createdBook, err := h.storage.Create(book) + if err != nil { + logger.Error.Printf("Failed to create book: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to create book") + return + } + + respondWithJSON(w, http.StatusCreated, createdBook) +} + +// getBookByID returns a book by ID +func (h *BookHandler) getBookByID(w http.ResponseWriter, r *http.Request, id int) { + book, err := h.storage.GetByID(id) + if err != nil { + if err == models.ErrBookNotFound { + respondWithError(w, http.StatusNotFound, "Book not found") + return + } + logger.Error.Printf("Failed to get book: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve book") + return + } + + respondWithJSON(w, http.StatusOK, book) +} + +// updateBook updates a book by ID +func (h *BookHandler) updateBook(w http.ResponseWriter, r *http.Request, id int) { + var book models.Book + if err := json.NewDecoder(r.Body).Decode(&book); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request payload") + return + } + defer r.Body.Close() + + // Validate the book + if err := book.Validate(); err != nil { + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + // Update the book + updatedBook, err := h.storage.Update(id, book) + if err != nil { + if err == models.ErrBookNotFound { + respondWithError(w, http.StatusNotFound, "Book not found") + return + } + logger.Error.Printf("Failed to update book: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to update book") + return + } + + respondWithJSON(w, http.StatusOK, updatedBook) +} + +// deleteBook deletes a book by ID +func (h *BookHandler) deleteBook(w http.ResponseWriter, r *http.Request, id int) { + err := h.storage.Delete(id) + if err != nil { + if err == models.ErrBookNotFound { + respondWithError(w, http.StatusNotFound, "Book not found") + return + } + logger.Error.Printf("Failed to delete book: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to delete book") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// respondWithJSON writes a JSON response +func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(payload); err != nil { + logger.Error.Printf("Failed to encode response: %v", err) + } +} + +// respondWithError writes an error response +func respondWithError(w http.ResponseWriter, code int, message string) { + respondWithJSON(w, code, map[string]string{"error": message}) +} diff --git a/internal/handlers/books_test.go b/internal/handlers/books_test.go new file mode 100644 index 0000000..c1c9494 --- /dev/null +++ b/internal/handlers/books_test.go @@ -0,0 +1,221 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/codeforgood-org/golang-book-api/internal/models" + "github.com/codeforgood-org/golang-book-api/internal/storage" +) + +func TestBookHandler_HandleBooks_GET(t *testing.T) { + store := storage.NewMemoryStorage() + handler := NewBookHandler(store) + + // Add some test books + store.Create(models.Book{Title: "Book 1", Author: "Author 1"}) + store.Create(models.Book{Title: "Book 2", Author: "Author 2"}) + + req := httptest.NewRequest(http.MethodGet, "/books", nil) + w := httptest.NewRecorder() + + handler.HandleBooks(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var response models.PaginatedResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Check that data is an array + data, ok := response.Data.([]interface{}) + if !ok { + t.Fatal("expected data to be an array") + } + + if len(data) != 2 { + t.Errorf("expected 2 books, got %d", len(data)) + } + + if response.Total != 2 { + t.Errorf("expected total 2, got %d", response.Total) + } +} + +func TestBookHandler_HandleBooks_POST(t *testing.T) { + tests := []struct { + name string + payload interface{} + expectedStatus int + expectError bool + }{ + { + name: "valid book", + payload: models.Book{ + Title: "Test Book", + Author: "Test Author", + }, + expectedStatus: http.StatusCreated, + expectError: false, + }, + { + name: "missing title", + payload: models.Book{ + Author: "Test Author", + }, + expectedStatus: http.StatusBadRequest, + expectError: true, + }, + { + name: "missing author", + payload: models.Book{ + Title: "Test Book", + }, + expectedStatus: http.StatusBadRequest, + expectError: true, + }, + { + name: "invalid json", + payload: "invalid json", + expectedStatus: http.StatusBadRequest, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := storage.NewMemoryStorage() + handler := NewBookHandler(store) + + body, _ := json.Marshal(tt.payload) + req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.HandleBooks(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) + } + + if tt.expectError { + var errResp map[string]string + json.NewDecoder(w.Body).Decode(&errResp) + if _, exists := errResp["error"]; !exists { + t.Error("expected error response to contain 'error' field") + } + } else { + var book models.Book + if err := json.NewDecoder(w.Body).Decode(&book); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if book.ID == 0 { + t.Error("expected book to have an ID assigned") + } + } + }) + } +} + +func TestBookHandler_HandleBookByID_GET(t *testing.T) { + store := storage.NewMemoryStorage() + handler := NewBookHandler(store) + + // Create a test book + created, _ := store.Create(models.Book{Title: "Test Book", Author: "Test Author"}) + + tests := []struct { + name string + url string + expectedStatus int + }{ + { + name: "existing book", + url: fmt.Sprintf("/books/%d", created.ID), + expectedStatus: http.StatusOK, + }, + { + name: "non-existing book", + url: "/books/999999", + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid ID", + url: "/books/invalid", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + w := httptest.NewRecorder() + + handler.HandleBookByID(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +func TestBookHandler_HandleBookByID_DELETE(t *testing.T) { + store := storage.NewMemoryStorage() + handler := NewBookHandler(store) + + // Create a test book + created, _ := store.Create(models.Book{Title: "Test Book", Author: "Test Author"}) + + tests := []struct { + name string + bookID int + expectedStatus int + }{ + { + name: "delete existing book", + bookID: created.ID, + expectedStatus: http.StatusNoContent, + }, + { + name: "delete non-existing book", + bookID: 999999, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := fmt.Sprintf("/books/%d", tt.bookID) + req := httptest.NewRequest(http.MethodDelete, url, nil) + w := httptest.NewRecorder() + + handler.HandleBookByID(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +func TestBookHandler_HandleBooks_MethodNotAllowed(t *testing.T) { + store := storage.NewMemoryStorage() + handler := NewBookHandler(store) + + req := httptest.NewRequest(http.MethodPut, "/books", nil) + w := httptest.NewRecorder() + + handler.HandleBooks(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..96a2fce --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` +} + +// HealthCheck handles health check requests +func HealthCheck(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) +} diff --git a/internal/handlers/health_test.go b/internal/handlers/health_test.go new file mode 100644 index 0000000..9560dca --- /dev/null +++ b/internal/handlers/health_test.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthCheck(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + + HealthCheck(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", contentType) + } + + var response HealthResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.Status != "ok" { + t.Errorf("expected status 'ok', got '%s'", response.Status) + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..20fb932 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,20 @@ +package middleware + +import "net/http" + +// CORS middleware adds CORS headers to responses +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + // Handle preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..2b832dc --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/codeforgood-org/golang-book-api/pkg/logger" +) + +// responseWriter wraps http.ResponseWriter to capture status code +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// Logger middleware logs HTTP requests +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap the response writer to capture status code + wrapped := &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + } + + // Call the next handler + next.ServeHTTP(wrapped, r) + + // Log the request + logger.Info.Printf( + "%s %s %d %s", + r.Method, + r.RequestURI, + wrapped.statusCode, + time.Since(start), + ) + }) +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..87a90b7 --- /dev/null +++ b/internal/middleware/recovery.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "net/http" + + "github.com/codeforgood-org/golang-book-api/pkg/logger" +) + +// Recovery middleware recovers from panics and logs them +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + logger.Error.Printf("Panic recovered: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go new file mode 100644 index 0000000..9dedab7 --- /dev/null +++ b/internal/middleware/requestid.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type contextKey string + +const RequestIDKey contextKey = "requestID" + +// RequestID middleware adds a unique request ID to each request +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if request ID already exists in headers + requestID := r.Header.Get("X-Request-ID") + + // If not, generate a new one + if requestID == "" { + requestID = uuid.New().String() + } + + // Add to response headers + w.Header().Set("X-Request-ID", requestID) + + // Add to context for use in handlers + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + + // Call the next handler with updated context + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// GetRequestID retrieves the request ID from the context +func GetRequestID(ctx context.Context) string { + if requestID, ok := ctx.Value(RequestIDKey).(string); ok { + return requestID + } + return "" +} diff --git a/internal/models/book.go b/internal/models/book.go new file mode 100644 index 0000000..69ba811 --- /dev/null +++ b/internal/models/book.go @@ -0,0 +1,19 @@ +package models + +// Book represents a book in the library +type Book struct { + ID int `json:"id"` + Title string `json:"title"` + Author string `json:"author"` +} + +// Validate checks if the book data is valid +func (b *Book) Validate() error { + if b.Title == "" { + return ErrInvalidTitle + } + if b.Author == "" { + return ErrInvalidAuthor + } + return nil +} diff --git a/internal/models/book_test.go b/internal/models/book_test.go new file mode 100644 index 0000000..464c120 --- /dev/null +++ b/internal/models/book_test.go @@ -0,0 +1,53 @@ +package models + +import "testing" + +func TestBook_Validate(t *testing.T) { + tests := []struct { + name string + book Book + wantErr error + }{ + { + name: "valid book", + book: Book{ + Title: "Test Book", + Author: "Test Author", + }, + wantErr: nil, + }, + { + name: "missing title", + book: Book{ + Title: "", + Author: "Test Author", + }, + wantErr: ErrInvalidTitle, + }, + { + name: "missing author", + book: Book{ + Title: "Test Book", + Author: "", + }, + wantErr: ErrInvalidAuthor, + }, + { + name: "missing both", + book: Book{ + Title: "", + Author: "", + }, + wantErr: ErrInvalidTitle, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.book.Validate() + if err != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/models/errors.go b/internal/models/errors.go new file mode 100644 index 0000000..78319ec --- /dev/null +++ b/internal/models/errors.go @@ -0,0 +1,17 @@ +package models + +import "errors" + +var ( + // ErrBookNotFound is returned when a book is not found + ErrBookNotFound = errors.New("book not found") + + // ErrInvalidTitle is returned when book title is empty + ErrInvalidTitle = errors.New("book title cannot be empty") + + // ErrInvalidAuthor is returned when book author is empty + ErrInvalidAuthor = errors.New("book author cannot be empty") + + // ErrInvalidID is returned when book ID is invalid + ErrInvalidID = errors.New("invalid book ID") +) diff --git a/internal/models/filters.go b/internal/models/filters.go new file mode 100644 index 0000000..176bcad --- /dev/null +++ b/internal/models/filters.go @@ -0,0 +1,62 @@ +package models + +import ( + "net/http" + "strings" +) + +// BookFilters holds filter parameters for book queries +type BookFilters struct { + Title string + Author string + Search string +} + +// ParseBookFilters extracts filter parameters from request +func ParseBookFilters(r *http.Request) BookFilters { + return BookFilters{ + Title: strings.TrimSpace(r.URL.Query().Get("title")), + Author: strings.TrimSpace(r.URL.Query().Get("author")), + Search: strings.TrimSpace(r.URL.Query().Get("search")), + } +} + +// Match checks if a book matches the filters +func (f BookFilters) Match(book Book) bool { + // If search is provided, match against title or author + if f.Search != "" { + searchLower := strings.ToLower(f.Search) + titleLower := strings.ToLower(book.Title) + authorLower := strings.ToLower(book.Author) + + if !strings.Contains(titleLower, searchLower) && + !strings.Contains(authorLower, searchLower) { + return false + } + } + + // Match specific title filter + if f.Title != "" { + titleLower := strings.ToLower(book.Title) + filterLower := strings.ToLower(f.Title) + if !strings.Contains(titleLower, filterLower) { + return false + } + } + + // Match specific author filter + if f.Author != "" { + authorLower := strings.ToLower(book.Author) + filterLower := strings.ToLower(f.Author) + if !strings.Contains(authorLower, filterLower) { + return false + } + } + + return true +} + +// HasFilters returns true if any filters are set +func (f BookFilters) HasFilters() bool { + return f.Title != "" || f.Author != "" || f.Search != "" +} diff --git a/internal/models/filters_test.go b/internal/models/filters_test.go new file mode 100644 index 0000000..21cb5f6 --- /dev/null +++ b/internal/models/filters_test.go @@ -0,0 +1,135 @@ +package models + +import "testing" + +func TestBookFilters_Match(t *testing.T) { + tests := []struct { + name string + filters BookFilters + book Book + want bool + }{ + { + name: "no filters - should match", + filters: BookFilters{}, + book: Book{Title: "Any Book", Author: "Any Author"}, + want: true, + }, + { + name: "title filter - exact match", + filters: BookFilters{Title: "Go Programming"}, + book: Book{Title: "Go Programming", Author: "Author"}, + want: true, + }, + { + name: "title filter - partial match", + filters: BookFilters{Title: "Go"}, + book: Book{Title: "The Go Programming Language", Author: "Author"}, + want: true, + }, + { + name: "title filter - case insensitive", + filters: BookFilters{Title: "go"}, + book: Book{Title: "Go Programming", Author: "Author"}, + want: true, + }, + { + name: "title filter - no match", + filters: BookFilters{Title: "Python"}, + book: Book{Title: "Go Programming", Author: "Author"}, + want: false, + }, + { + name: "author filter - match", + filters: BookFilters{Author: "Donovan"}, + book: Book{Title: "Book", Author: "Alan A. A. Donovan"}, + want: true, + }, + { + name: "author filter - no match", + filters: BookFilters{Author: "Smith"}, + book: Book{Title: "Book", Author: "Alan Donovan"}, + want: false, + }, + { + name: "search - match in title", + filters: BookFilters{Search: "Programming"}, + book: Book{Title: "Go Programming", Author: "Author"}, + want: true, + }, + { + name: "search - match in author", + filters: BookFilters{Search: "Donovan"}, + book: Book{Title: "Book", Author: "Alan Donovan"}, + want: true, + }, + { + name: "search - no match", + filters: BookFilters{Search: "Python"}, + book: Book{Title: "Go Programming", Author: "Donovan"}, + want: false, + }, + { + name: "multiple filters - all match", + filters: BookFilters{Title: "Go", Author: "Donovan"}, + book: Book{Title: "Go Programming", Author: "Alan Donovan"}, + want: true, + }, + { + name: "multiple filters - one doesn't match", + filters: BookFilters{Title: "Python", Author: "Donovan"}, + book: Book{Title: "Go Programming", Author: "Alan Donovan"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.filters.Match(tt.book); got != tt.want { + t.Errorf("BookFilters.Match() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBookFilters_HasFilters(t *testing.T) { + tests := []struct { + name string + filters BookFilters + want bool + }{ + { + name: "no filters", + filters: BookFilters{}, + want: false, + }, + { + name: "title filter only", + filters: BookFilters{Title: "Go"}, + want: true, + }, + { + name: "author filter only", + filters: BookFilters{Author: "Donovan"}, + want: true, + }, + { + name: "search filter only", + filters: BookFilters{Search: "Programming"}, + want: true, + }, + { + name: "multiple filters", + filters: BookFilters{Title: "Go", Author: "Donovan"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.filters.HasFilters(); got != tt.want { + t.Errorf("BookFilters.HasFilters() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/models/pagination.go b/internal/models/pagination.go new file mode 100644 index 0000000..8609675 --- /dev/null +++ b/internal/models/pagination.go @@ -0,0 +1,64 @@ +package models + +import ( + "net/http" + "strconv" +) + +// PaginationParams holds pagination parameters +type PaginationParams struct { + Page int + PageSize int + Offset int +} + +// PaginatedResponse wraps paginated data with metadata +type PaginatedResponse struct { + Data interface{} `json:"data"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +// ParsePaginationParams extracts pagination parameters from request +func ParsePaginationParams(r *http.Request) PaginationParams { + page := 1 + pageSize := 10 + + if p := r.URL.Query().Get("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + + if ps := r.URL.Query().Get("page_size"); ps != "" { + if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { + pageSize = parsed + } + } + + offset := (page - 1) * pageSize + + return PaginationParams{ + Page: page, + PageSize: pageSize, + Offset: offset, + } +} + +// NewPaginatedResponse creates a paginated response +func NewPaginatedResponse(data interface{}, page, pageSize, total int) PaginatedResponse { + totalPages := total / pageSize + if total%pageSize != 0 { + totalPages++ + } + + return PaginatedResponse{ + Data: data, + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + } +} diff --git a/internal/storage/memory.go b/internal/storage/memory.go new file mode 100644 index 0000000..78c8431 --- /dev/null +++ b/internal/storage/memory.go @@ -0,0 +1,92 @@ +package storage + +import ( + "math/rand" + "sync" + "time" + + "github.com/codeforgood-org/golang-book-api/internal/models" +) + +// MemoryStorage implements in-memory storage for books +type MemoryStorage struct { + books []models.Book + mu sync.RWMutex + rng *rand.Rand +} + +// NewMemoryStorage creates a new in-memory storage instance +func NewMemoryStorage() *MemoryStorage { + return &MemoryStorage{ + books: make([]models.Book, 0), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// GetAll returns all books +func (s *MemoryStorage) GetAll() ([]models.Book, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Return a copy to prevent external modifications + booksCopy := make([]models.Book, len(s.books)) + copy(booksCopy, s.books) + return booksCopy, nil +} + +// GetByID returns a book by its ID +func (s *MemoryStorage) GetByID(id int) (*models.Book, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, book := range s.books { + if book.ID == id { + // Return a copy + bookCopy := book + return &bookCopy, nil + } + } + return nil, models.ErrBookNotFound +} + +// Create adds a new book and returns it with an assigned ID +func (s *MemoryStorage) Create(book models.Book) (*models.Book, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Generate a unique ID + book.ID = s.rng.Intn(1000000) + s.books = append(s.books, book) + + return &book, nil +} + +// Update updates an existing book +func (s *MemoryStorage) Update(id int, book models.Book) (*models.Book, error) { + s.mu.Lock() + defer s.mu.Unlock() + + for i, b := range s.books { + if b.ID == id { + // Preserve the original ID + book.ID = id + s.books[i] = book + return &book, nil + } + } + return nil, models.ErrBookNotFound +} + +// Delete removes a book by its ID +func (s *MemoryStorage) Delete(id int) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, book := range s.books { + if book.ID == id { + s.books = append(s.books[:i], s.books[i+1:]...) + return nil + } + } + return models.ErrBookNotFound +} diff --git a/internal/storage/memory_bench_test.go b/internal/storage/memory_bench_test.go new file mode 100644 index 0000000..0d52281 --- /dev/null +++ b/internal/storage/memory_bench_test.go @@ -0,0 +1,123 @@ +package storage + +import ( + "testing" + + "github.com/codeforgood-org/golang-book-api/internal/models" +) + +func BenchmarkMemoryStorage_Create(b *testing.B) { + storage := NewMemoryStorage() + book := models.Book{ + Title: "Benchmark Book", + Author: "Benchmark Author", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + storage.Create(book) + } +} + +func BenchmarkMemoryStorage_GetAll(b *testing.B) { + storage := NewMemoryStorage() + + // Pre-populate with books + for i := 0; i < 100; i++ { + storage.Create(models.Book{ + Title: "Book", + Author: "Author", + }) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + storage.GetAll() + } +} + +func BenchmarkMemoryStorage_GetByID(b *testing.B) { + storage := NewMemoryStorage() + + // Create a book to search for + book, _ := storage.Create(models.Book{ + Title: "Benchmark Book", + Author: "Benchmark Author", + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + storage.GetByID(book.ID) + } +} + +func BenchmarkMemoryStorage_Update(b *testing.B) { + storage := NewMemoryStorage() + + // Create a book to update + book, _ := storage.Create(models.Book{ + Title: "Original Title", + Author: "Original Author", + }) + + updatedBook := models.Book{ + Title: "Updated Title", + Author: "Updated Author", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + storage.Update(book.ID, updatedBook) + } +} + +func BenchmarkMemoryStorage_Delete(b *testing.B) { + storage := NewMemoryStorage() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + // Create a book for each iteration + book, _ := storage.Create(models.Book{ + Title: "Book to Delete", + Author: "Author", + }) + b.StartTimer() + + storage.Delete(book.ID) + } +} + +func BenchmarkMemoryStorage_ConcurrentReads(b *testing.B) { + storage := NewMemoryStorage() + + // Pre-populate + for i := 0; i < 100; i++ { + storage.Create(models.Book{ + Title: "Book", + Author: "Author", + }) + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + storage.GetAll() + } + }) +} + +func BenchmarkMemoryStorage_ConcurrentWrites(b *testing.B) { + storage := NewMemoryStorage() + book := models.Book{ + Title: "Concurrent Book", + Author: "Concurrent Author", + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + storage.Create(book) + } + }) +} diff --git a/internal/storage/memory_test.go b/internal/storage/memory_test.go new file mode 100644 index 0000000..954c504 --- /dev/null +++ b/internal/storage/memory_test.go @@ -0,0 +1,143 @@ +package storage + +import ( + "testing" + + "github.com/codeforgood-org/golang-book-api/internal/models" +) + +func TestMemoryStorage_Create(t *testing.T) { + storage := NewMemoryStorage() + + book := models.Book{ + Title: "Test Book", + Author: "Test Author", + } + + created, err := storage.Create(book) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if created.ID == 0 { + t.Error("expected book to have an ID assigned") + } + + if created.Title != book.Title { + t.Errorf("expected title %s, got %s", book.Title, created.Title) + } + + if created.Author != book.Author { + t.Errorf("expected author %s, got %s", book.Author, created.Author) + } +} + +func TestMemoryStorage_GetAll(t *testing.T) { + storage := NewMemoryStorage() + + // Initially empty + books, err := storage.GetAll() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(books) != 0 { + t.Errorf("expected 0 books, got %d", len(books)) + } + + // Add some books + storage.Create(models.Book{Title: "Book 1", Author: "Author 1"}) + storage.Create(models.Book{Title: "Book 2", Author: "Author 2"}) + + books, err = storage.GetAll() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(books) != 2 { + t.Errorf("expected 2 books, got %d", len(books)) + } +} + +func TestMemoryStorage_GetByID(t *testing.T) { + storage := NewMemoryStorage() + + book := models.Book{ + Title: "Test Book", + Author: "Test Author", + } + + created, _ := storage.Create(book) + + // Get existing book + found, err := storage.GetByID(created.ID) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if found.ID != created.ID { + t.Errorf("expected ID %d, got %d", created.ID, found.ID) + } + + // Get non-existing book + _, err = storage.GetByID(999999) + if err != models.ErrBookNotFound { + t.Errorf("expected ErrBookNotFound, got %v", err) + } +} + +func TestMemoryStorage_Delete(t *testing.T) { + storage := NewMemoryStorage() + + book := models.Book{ + Title: "Test Book", + Author: "Test Author", + } + + created, _ := storage.Create(book) + + // Delete existing book + err := storage.Delete(created.ID) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify deletion + _, err = storage.GetByID(created.ID) + if err != models.ErrBookNotFound { + t.Errorf("expected ErrBookNotFound after deletion, got %v", err) + } + + // Delete non-existing book + err = storage.Delete(999999) + if err != models.ErrBookNotFound { + t.Errorf("expected ErrBookNotFound, got %v", err) + } +} + +func TestMemoryStorage_Concurrent(t *testing.T) { + storage := NewMemoryStorage() + + // Test concurrent writes + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(n int) { + book := models.Book{ + Title: "Concurrent Book", + Author: "Test Author", + } + storage.Create(book) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + books, _ := storage.GetAll() + if len(books) != 10 { + t.Errorf("expected 10 books after concurrent writes, got %d", len(books)) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..2e607ae --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,21 @@ +package storage + +import "github.com/codeforgood-org/golang-book-api/internal/models" + +// Storage defines the interface for book storage operations +type Storage interface { + // GetAll returns all books + GetAll() ([]models.Book, error) + + // GetByID returns a book by its ID + GetByID(id int) (*models.Book, error) + + // Create adds a new book and returns it with an assigned ID + Create(book models.Book) (*models.Book, error) + + // Update updates an existing book + Update(id int, book models.Book) (*models.Book, error) + + // Delete removes a book by its ID + Delete(id int) error +} diff --git a/main.go b/main.go deleted file mode 100644 index 59bff42..0000000 --- a/main.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "math/rand" - "net/http" - "strconv" - "sync" -) - -type Book struct { - ID int `json:"id"` - Title string `json:"title"` - Author string `json:"author"` -} - -var ( - books = make([]Book, 0) - mu sync.Mutex -) - -func main() { - http.HandleFunc("/books", booksHandler) - http.HandleFunc("/books/", bookByIDHandler) - fmt.Println("Server running at http://localhost:8080") - log.Fatal(http.ListenAndServe(":8080", nil)) -} - -func booksHandler(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - mu.Lock() - defer mu.Unlock() - json.NewEncoder(w).Encode(books) - - case http.MethodPost: - var b Book - if err := json.NewDecoder(r.Body).Decode(&b); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } - mu.Lock() - b.ID = rand.Intn(1000000) - books = append(books, b) - mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(b) - - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -func bookByIDHandler(w http.ResponseWriter, r *http.Request) { - idStr := r.URL.Path[len("/books/"):] - id, err := strconv.Atoi(idStr) - if err != nil { - http.Error(w, "Invalid ID", http.StatusBadRequest) - return - } - - mu.Lock() - defer mu.Unlock() - - for i, b := range books { - if b.ID == id { - if r.Method == http.MethodDelete { - books = append(books[:i], books[i+1:]...) - w.WriteHeader(http.StatusNoContent) - return - } - json.NewEncoder(w).Encode(b) - return - } - } - - http.NotFound(w, r) -} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..354dc0b --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,21 @@ +package logger + +import ( + "log" + "os" +) + +var ( + // Info logger for informational messages + Info *log.Logger + // Warning logger for warning messages + Warning *log.Logger + // Error logger for error messages + Error *log.Logger +) + +func init() { + Info = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + Warning = log.New(os.Stdout, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile) + Error = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) +} diff --git a/scripts/seed.go b/scripts/seed.go new file mode 100644 index 0000000..c9e9d82 --- /dev/null +++ b/scripts/seed.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "github.com/codeforgood-org/golang-book-api/internal/models" +) + +var sampleBooks = []models.Book{ + {Title: "The Go Programming Language", Author: "Alan A. A. Donovan and Brian W. Kernighan"}, + {Title: "Clean Code", Author: "Robert C. Martin"}, + {Title: "Design Patterns", Author: "Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides"}, + {Title: "The Pragmatic Programmer", Author: "Andrew Hunt and David Thomas"}, + {Title: "Introduction to Algorithms", Author: "Thomas H. Cormen"}, + {Title: "Code Complete", Author: "Steve McConnell"}, + {Title: "Refactoring", Author: "Martin Fowler"}, + {Title: "The Clean Coder", Author: "Robert C. Martin"}, + {Title: "Head First Design Patterns", Author: "Eric Freeman and Elisabeth Robson"}, + {Title: "You Don't Know JS", Author: "Kyle Simpson"}, + {Title: "Eloquent JavaScript", Author: "Marijn Haverbeke"}, + {Title: "JavaScript: The Good Parts", Author: "Douglas Crockford"}, + {Title: "Python Crash Course", Author: "Eric Matthes"}, + {Title: "Effective Java", Author: "Joshua Bloch"}, + {Title: "Clean Architecture", Author: "Robert C. Martin"}, + {Title: "Domain-Driven Design", Author: "Eric Evans"}, + {Title: "Microservices Patterns", Author: "Chris Richardson"}, + {Title: "Building Microservices", Author: "Sam Newman"}, + {Title: "Site Reliability Engineering", Author: "Betsy Beyer, Chris Jones, Jennifer Petoff, Niall Richard Murphy"}, + {Title: "The DevOps Handbook", Author: "Gene Kim, Jez Humble, Patrick Debois, John Willis"}, +} + +func main() { + baseURL := os.Getenv("API_URL") + if baseURL == "" { + baseURL = "http://localhost:8080" + } + + fmt.Printf("Seeding data to %s/books\n", baseURL) + + for i, book := range sampleBooks { + data, err := json.Marshal(book) + if err != nil { + log.Printf("Failed to marshal book %d: %v", i+1, err) + continue + } + + resp, err := http.Post( + fmt.Sprintf("%s/books", baseURL), + "application/json", + bytes.NewBuffer(data), + ) + if err != nil { + log.Printf("Failed to create book %d: %v", i+1, err) + continue + } + + if resp.StatusCode != http.StatusCreated { + log.Printf("Failed to create book %d: status %d", i+1, resp.StatusCode) + resp.Body.Close() + continue + } + + var createdBook models.Book + if err := json.NewDecoder(resp.Body).Decode(&createdBook); err != nil { + log.Printf("Failed to decode response for book %d: %v", i+1, err) + resp.Body.Close() + continue + } + resp.Body.Close() + + fmt.Printf("✓ Created: %s (ID: %d)\n", createdBook.Title, createdBook.ID) + } + + fmt.Printf("\nSeeded %d books successfully!\n", len(sampleBooks)) +}