├── assets ├── analyzed.png ├── favicon.ico ├── post-ui.png └── post-analyzer.go ├── .idea ├── vcs.xml ├── .gitignore ├── modules.xml └── Post Analyzer Webserver.iml ├── prometheus.yml ├── go.mod ├── .devcontainer └── devcontainer.json ├── .gitignore ├── internal ├── middleware │ ├── compression.go │ ├── middleware_test.go │ └── middleware.go ├── storage │ ├── storage.go │ ├── file.go │ ├── file_test.go │ └── postgres.go ├── cache │ └── cache.go ├── api │ ├── router.go │ └── api.go ├── logger │ └── logger.go ├── errors │ └── errors.go ├── metrics │ └── metrics.go ├── models │ └── models.go ├── migrations │ └── migrations.go ├── handlers │ └── handlers.go └── service │ └── post_service.go ├── .env.example ├── LICENSE ├── Dockerfile ├── go.sum ├── docker-compose.yml ├── scripts └── setup.sh ├── README.md ├── Makefile ├── .github └── workflows │ └── ci-cd.yml ├── main.go ├── config └── config.go ├── MIGRATION_GUIDE.md ├── api-docs.yaml ├── README_PRODUCTION.md └── home.html /assets/analyzed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/Post-Analyzer-Webserver/HEAD/assets/analyzed.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/Post-Analyzer-Webserver/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/post-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangsonww/Post-Analyzer-Webserver/HEAD/assets/post-ui.png -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | evaluation_interval: 15s 4 | external_labels: 5 | monitor: 'post-analyzer-monitor' 6 | 7 | scrape_configs: 8 | - job_name: 'post-analyzer' 9 | static_configs: 10 | - targets: ['app:8080'] 11 | metrics_path: '/metrics' 12 | scrape_interval: 10s 13 | -------------------------------------------------------------------------------- /.idea/Post Analyzer Webserver.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module Post_Analyzer_Webserver 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/google/uuid v1.6.0 7 | github.com/lib/pq v1.10.9 8 | github.com/prometheus/client_golang v1.19.0 9 | ) 10 | 11 | require ( 12 | github.com/beorn7/perks v1.0.1 // indirect 13 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 14 | github.com/prometheus/client_model v0.5.0 // indirect 15 | github.com/prometheus/common v0.48.0 // indirect 16 | github.com/prometheus/procfs v0.12.0 // indirect 17 | golang.org/x/sys v0.16.0 // indirect 18 | google.golang.org/protobuf v1.32.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Go Dev Container", 3 | "image": "mcr.microsoft.com/devcontainers/go:1-21", 4 | "features": { 5 | "ghcr.io/devcontainers/features/go:1": { 6 | "version": "latest" 7 | } 8 | }, 9 | "settings": { 10 | "terminal.integrated.defaultProfile.linux": "bash" 11 | }, 12 | "extensions": [ 13 | "golang.go", 14 | "ms-vscode.makefile-tools" 15 | ], 16 | "postCreateCommand": "go mod tidy", 17 | "remoteUser": "vscode", 18 | "mounts": [ 19 | "source=/home/vscode/go,target=/go,type=bind" 20 | ], 21 | "forwardPorts": [8080, 2345], 22 | "containerEnv": { 23 | "GOPATH": "/go", 24 | "GO111MODULE": "on" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool 12 | *.out 13 | coverage.txt 14 | coverage.html 15 | 16 | # Dependency directories 17 | vendor/ 18 | 19 | # Go workspace file 20 | go.work 21 | 22 | # Environment variables 23 | .env 24 | .env.local 25 | .env.*.local 26 | 27 | # IDE files 28 | .idea/ 29 | .vscode/ 30 | *.swp 31 | *.swo 32 | *~ 33 | 34 | # OS files 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # Application specific 39 | posts.json 40 | *.log 41 | logs/ 42 | 43 | # Build output 44 | bin/ 45 | dist/ 46 | build/ 47 | 48 | # Database 49 | *.db 50 | *.sqlite 51 | *.sqlite3 52 | 53 | # Docker volumes 54 | data/ 55 | 56 | # Temporary files 57 | tmp/ 58 | temp/ 59 | -------------------------------------------------------------------------------- /internal/middleware/compression.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type gzipResponseWriter struct { 11 | io.Writer 12 | http.ResponseWriter 13 | } 14 | 15 | func (w gzipResponseWriter) Write(b []byte) (int, error) { 16 | return w.Writer.Write(b) 17 | } 18 | 19 | // Compression middleware compresses HTTP responses 20 | func Compression(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | // Check if client supports gzip 23 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 24 | next.ServeHTTP(w, r) 25 | return 26 | } 27 | 28 | // Create gzip writer 29 | w.Header().Set("Content-Encoding", "gzip") 30 | gz := gzip.NewWriter(w) 31 | defer gz.Close() 32 | 33 | gzipWriter := gzipResponseWriter{Writer: gz, ResponseWriter: w} 34 | next.ServeHTTP(gzipWriter, r) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | PORT=8080 3 | HOST=0.0.0.0 4 | ENVIRONMENT=development # development, staging, production 5 | READ_TIMEOUT=15s 6 | WRITE_TIMEOUT=15s 7 | IDLE_TIMEOUT=60s 8 | SHUTDOWN_TIMEOUT=30s 9 | 10 | # Database Configuration 11 | DB_TYPE=file # file or postgres 12 | DB_FILE_PATH=posts.json 13 | 14 | # PostgreSQL Configuration (when DB_TYPE=postgres) 15 | DB_HOST=localhost 16 | DB_PORT=5432 17 | DB_USER=postgres 18 | DB_PASSWORD=postgres 19 | DB_NAME=postanalyzer 20 | DB_SSL_MODE=disable 21 | DB_MAX_CONNS=25 22 | DB_MIN_CONNS=5 23 | 24 | # Security Configuration 25 | RATE_LIMIT_REQUESTS=100 26 | RATE_LIMIT_WINDOW=1m 27 | MAX_BODY_SIZE=1048576 # 1MB in bytes 28 | ALLOWED_ORIGINS=* # Comma-separated list or * for all 29 | TRUSTED_PROXIES= # Comma-separated list of trusted proxy IPs 30 | 31 | # Logging Configuration 32 | LOG_LEVEL=info # debug, info, warn, error 33 | LOG_FORMAT=json # json or text 34 | LOG_OUTPUT=stdout # stdout or file path 35 | LOG_TIME_FORMAT=2006-01-02T15:04:05Z07:00 36 | 37 | # External API Configuration 38 | JSONPLACEHOLDER_URL=https://jsonplaceholder.typicode.com/posts 39 | HTTP_TIMEOUT=30s 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Son Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.21-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache git ca-certificates tzdata 6 | 7 | # Set working directory 8 | WORKDIR /build 9 | 10 | # Copy go mod files 11 | COPY go.mod go.sum ./ 12 | 13 | # Download dependencies 14 | RUN go mod download 15 | 16 | # Copy source code 17 | COPY . . 18 | 19 | # Build the application 20 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o post-analyzer main_new.go 21 | 22 | # Final stage 23 | FROM alpine:latest 24 | 25 | # Install runtime dependencies 26 | RUN apk --no-cache add ca-certificates tzdata 27 | 28 | # Create non-root user 29 | RUN addgroup -g 1000 appuser && \ 30 | adduser -D -u 1000 -G appuser appuser 31 | 32 | # Set working directory 33 | WORKDIR /app 34 | 35 | # Copy binary from builder 36 | COPY --from=builder /build/post-analyzer . 37 | 38 | # Copy templates and assets 39 | COPY home.html ./ 40 | COPY assets ./assets 41 | 42 | # Create data directory 43 | RUN mkdir -p /app/data && chown -R appuser:appuser /app 44 | 45 | # Switch to non-root user 46 | USER appuser 47 | 48 | # Expose port 49 | EXPOSE 8080 50 | 51 | # Health check 52 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 53 | CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 54 | 55 | # Run the application 56 | CMD ["./post-analyzer"] 57 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 4 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 7 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 8 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 9 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 10 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 11 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 12 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 13 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 14 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 15 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 16 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 17 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 18 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 19 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 20 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 21 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 22 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 23 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | var ( 10 | // ErrNotFound is returned when a post is not found 11 | ErrNotFound = errors.New("post not found") 12 | // ErrInvalidInput is returned when input validation fails 13 | ErrInvalidInput = errors.New("invalid input") 14 | ) 15 | 16 | // Post represents a post in the system 17 | type Post struct { 18 | UserId int `json:"userId"` 19 | Id int `json:"id"` 20 | Title string `json:"title"` 21 | Body string `json:"body"` 22 | CreatedAt time.Time `json:"createdAt,omitempty"` 23 | UpdatedAt time.Time `json:"updatedAt,omitempty"` 24 | } 25 | 26 | // Storage defines the interface for post storage operations 27 | type Storage interface { 28 | // GetAll retrieves all posts 29 | GetAll(ctx context.Context) ([]Post, error) 30 | 31 | // GetByID retrieves a post by ID 32 | GetByID(ctx context.Context, id int) (*Post, error) 33 | 34 | // Create creates a new post 35 | Create(ctx context.Context, post *Post) error 36 | 37 | // Update updates an existing post 38 | Update(ctx context.Context, post *Post) error 39 | 40 | // Delete deletes a post by ID 41 | Delete(ctx context.Context, id int) error 42 | 43 | // BatchCreate creates multiple posts in a batch 44 | BatchCreate(ctx context.Context, posts []Post) error 45 | 46 | // Count returns the total number of posts 47 | Count(ctx context.Context) (int, error) 48 | 49 | // Close closes the storage connection 50 | Close() error 51 | } 52 | 53 | // Validate validates a post 54 | func (p *Post) Validate() error { 55 | if p.Title == "" { 56 | return errors.New("title is required") 57 | } 58 | if len(p.Title) > 500 { 59 | return errors.New("title too long (max 500 characters)") 60 | } 61 | if p.Body == "" { 62 | return errors.New("body is required") 63 | } 64 | if len(p.Body) > 10000 { 65 | return errors.New("body too long (max 10000 characters)") 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "Post_Analyzer_Webserver/config" 10 | ) 11 | 12 | // Cache defines the caching interface 13 | type Cache interface { 14 | Get(ctx context.Context, key string, value interface{}) error 15 | Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error 16 | Delete(ctx context.Context, key string) error 17 | Clear(ctx context.Context) error 18 | } 19 | 20 | // MemoryCache implements an in-memory cache 21 | type MemoryCache struct { 22 | data map[string]cacheEntry 23 | } 24 | 25 | type cacheEntry struct { 26 | value []byte 27 | expiration time.Time 28 | } 29 | 30 | // NewMemoryCache creates a new in-memory cache 31 | func NewMemoryCache() *MemoryCache { 32 | cache := &MemoryCache{ 33 | data: make(map[string]cacheEntry), 34 | } 35 | 36 | // Start cleanup goroutine 37 | go cache.cleanup() 38 | 39 | return cache 40 | } 41 | 42 | // Get retrieves a value from the cache 43 | func (c *MemoryCache) Get(ctx context.Context, key string, value interface{}) error { 44 | entry, exists := c.data[key] 45 | if !exists { 46 | return fmt.Errorf("cache miss") 47 | } 48 | 49 | // Check expiration 50 | if time.Now().After(entry.expiration) { 51 | delete(c.data, key) 52 | return fmt.Errorf("cache expired") 53 | } 54 | 55 | return json.Unmarshal(entry.value, value) 56 | } 57 | 58 | // Set stores a value in the cache 59 | func (c *MemoryCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { 60 | data, err := json.Marshal(value) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | c.data[key] = cacheEntry{ 66 | value: data, 67 | expiration: time.Now().Add(ttl), 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // Delete removes a value from the cache 74 | func (c *MemoryCache) Delete(ctx context.Context, key string) error { 75 | delete(c.data, key) 76 | return nil 77 | } 78 | 79 | // Clear removes all values from the cache 80 | func (c *MemoryCache) Clear(ctx context.Context) error { 81 | c.data = make(map[string]cacheEntry) 82 | return nil 83 | } 84 | 85 | // cleanup removes expired entries 86 | func (c *MemoryCache) cleanup() { 87 | ticker := time.NewTicker(1 * time.Minute) 88 | defer ticker.Stop() 89 | 90 | for range ticker.C { 91 | now := time.Now() 92 | for key, entry := range c.data { 93 | if now.After(entry.expiration) { 94 | delete(c.data, key) 95 | } 96 | } 97 | } 98 | } 99 | 100 | // NewCache creates a cache based on configuration 101 | func NewCache(cfg *config.Config) Cache { 102 | // For now, always return memory cache 103 | // In the future, this could return Redis cache if configured 104 | return NewMemoryCache() 105 | } 106 | -------------------------------------------------------------------------------- /internal/api/router.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | // Router handles API routing with versioning 9 | type Router struct { 10 | api *API 11 | } 12 | 13 | // NewRouter creates a new API router 14 | func NewRouter(api *API) *Router { 15 | return &Router{api: api} 16 | } 17 | 18 | // ServeHTTP implements http.Handler 19 | func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 | // Extract API version from path 21 | path := r.URL.Path 22 | 23 | // Handle /api/v1/* routes 24 | if strings.HasPrefix(path, "/api/v1/") { 25 | router.handleV1(w, r) 26 | return 27 | } 28 | 29 | // Handle /api/* routes (default to v1) 30 | if strings.HasPrefix(path, "/api/") { 31 | // Remove /api prefix and add /api/v1 32 | r.URL.Path = "/api/v1" + strings.TrimPrefix(path, "/api") 33 | router.handleV1(w, r) 34 | return 35 | } 36 | 37 | http.NotFound(w, r) 38 | } 39 | 40 | // handleV1 handles version 1 API routes 41 | func (router *Router) handleV1(w http.ResponseWriter, r *http.Request) { 42 | path := r.URL.Path 43 | 44 | // Posts endpoints 45 | if strings.HasPrefix(path, "/api/v1/posts") { 46 | remaining := strings.TrimPrefix(path, "/api/v1/posts") 47 | 48 | // /api/v1/posts/bulk 49 | if remaining == "/bulk" { 50 | if r.Method != http.MethodPost { 51 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 52 | return 53 | } 54 | router.api.BulkCreatePosts(w, r) 55 | return 56 | } 57 | 58 | // /api/v1/posts/export 59 | if remaining == "/export" { 60 | if r.Method != http.MethodGet { 61 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 62 | return 63 | } 64 | router.api.ExportPosts(w, r) 65 | return 66 | } 67 | 68 | // /api/v1/posts/analytics 69 | if remaining == "/analytics" { 70 | if r.Method != http.MethodGet { 71 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 72 | return 73 | } 74 | router.api.AnalyzePosts(w, r) 75 | return 76 | } 77 | 78 | // /api/v1/posts/{id} 79 | if remaining != "" && remaining != "/" { 80 | switch r.Method { 81 | case http.MethodGet: 82 | router.api.GetPost(w, r) 83 | case http.MethodPut: 84 | router.api.UpdatePost(w, r) 85 | case http.MethodDelete: 86 | router.api.DeletePost(w, r) 87 | default: 88 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 89 | } 90 | return 91 | } 92 | 93 | // /api/v1/posts 94 | switch r.Method { 95 | case http.MethodGet: 96 | router.api.ListPosts(w, r) 97 | case http.MethodPost: 98 | router.api.CreatePost(w, r) 99 | default: 100 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 101 | } 102 | return 103 | } 104 | 105 | http.NotFound(w, r) 106 | } 107 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | # PostgreSQL database 5 | postgres: 6 | image: postgres:16-alpine 7 | container_name: post-analyzer-db 8 | environment: 9 | POSTGRES_DB: postanalyzer 10 | POSTGRES_USER: postgres 11 | POSTGRES_PASSWORD: postgres 12 | ports: 13 | - "5432:5432" 14 | volumes: 15 | - postgres_data:/var/lib/postgresql/data 16 | healthcheck: 17 | test: ["CMD-SHELL", "pg_isready -U postgres"] 18 | interval: 10s 19 | timeout: 5s 20 | retries: 5 21 | networks: 22 | - app-network 23 | 24 | # Post Analyzer application 25 | app: 26 | build: 27 | context: . 28 | dockerfile: Dockerfile 29 | container_name: post-analyzer-app 30 | environment: 31 | # Server configuration 32 | PORT: 8080 33 | HOST: 0.0.0.0 34 | ENVIRONMENT: production 35 | READ_TIMEOUT: 15s 36 | WRITE_TIMEOUT: 15s 37 | SHUTDOWN_TIMEOUT: 30s 38 | 39 | # Database configuration 40 | DB_TYPE: postgres 41 | DB_HOST: postgres 42 | DB_PORT: 5432 43 | DB_USER: postgres 44 | DB_PASSWORD: postgres 45 | DB_NAME: postanalyzer 46 | DB_SSL_MODE: disable 47 | DB_MAX_CONNS: 25 48 | DB_MIN_CONNS: 5 49 | 50 | # Security configuration 51 | RATE_LIMIT_REQUESTS: 100 52 | RATE_LIMIT_WINDOW: 1m 53 | MAX_BODY_SIZE: 1048576 54 | ALLOWED_ORIGINS: "*" 55 | 56 | # Logging configuration 57 | LOG_LEVEL: info 58 | LOG_FORMAT: json 59 | LOG_OUTPUT: stdout 60 | 61 | # External API configuration 62 | JSONPLACEHOLDER_URL: https://jsonplaceholder.typicode.com/posts 63 | HTTP_TIMEOUT: 30s 64 | ports: 65 | - "8080:8080" 66 | depends_on: 67 | postgres: 68 | condition: service_healthy 69 | restart: unless-stopped 70 | networks: 71 | - app-network 72 | healthcheck: 73 | test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] 74 | interval: 30s 75 | timeout: 5s 76 | retries: 3 77 | start_period: 10s 78 | 79 | # Prometheus for metrics 80 | prometheus: 81 | image: prom/prometheus:latest 82 | container_name: post-analyzer-prometheus 83 | command: 84 | - '--config.file=/etc/prometheus/prometheus.yml' 85 | - '--storage.tsdb.path=/prometheus' 86 | - '--web.console.libraries=/usr/share/prometheus/console_libraries' 87 | - '--web.console.templates=/usr/share/prometheus/consoles' 88 | ports: 89 | - "9090:9090" 90 | volumes: 91 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 92 | - prometheus_data:/prometheus 93 | depends_on: 94 | - app 95 | restart: unless-stopped 96 | networks: 97 | - app-network 98 | 99 | # Grafana for visualization 100 | grafana: 101 | image: grafana/grafana:latest 102 | container_name: post-analyzer-grafana 103 | environment: 104 | - GF_SECURITY_ADMIN_PASSWORD=admin 105 | - GF_USERS_ALLOW_SIGN_UP=false 106 | ports: 107 | - "3000:3000" 108 | volumes: 109 | - grafana_data:/var/lib/grafana 110 | depends_on: 111 | - prometheus 112 | restart: unless-stopped 113 | networks: 114 | - app-network 115 | 116 | volumes: 117 | postgres_data: 118 | prometheus_data: 119 | grafana_data: 120 | 121 | networks: 122 | app-network: 123 | driver: bridge 124 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "time" 9 | 10 | "Post_Analyzer_Webserver/config" 11 | ) 12 | 13 | // contextKey is a custom type for context keys to avoid collisions 14 | type contextKey string 15 | 16 | const ( 17 | // RequestIDKey is the context key for request IDs 18 | RequestIDKey contextKey = "request_id" 19 | // UserIDKey is the context key for user IDs 20 | UserIDKey contextKey = "user_id" 21 | ) 22 | 23 | var defaultLogger *slog.Logger 24 | 25 | // Init initializes the global logger with the given configuration 26 | func Init(cfg *config.LoggingConfig) error { 27 | var level slog.Level 28 | switch cfg.Level { 29 | case "debug": 30 | level = slog.LevelDebug 31 | case "info": 32 | level = slog.LevelInfo 33 | case "warn": 34 | level = slog.LevelWarn 35 | case "error": 36 | level = slog.LevelError 37 | default: 38 | level = slog.LevelInfo 39 | } 40 | 41 | var writer io.Writer 42 | if cfg.Output == "stdout" || cfg.Output == "" { 43 | writer = os.Stdout 44 | } else { 45 | file, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 46 | if err != nil { 47 | return err 48 | } 49 | writer = file 50 | } 51 | 52 | var handler slog.Handler 53 | opts := &slog.HandlerOptions{ 54 | Level: level, 55 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { 56 | // Customize time format 57 | if a.Key == slog.TimeKey { 58 | if t, ok := a.Value.Any().(time.Time); ok { 59 | a.Value = slog.StringValue(t.Format(cfg.TimeFormat)) 60 | } 61 | } 62 | return a 63 | }, 64 | } 65 | 66 | if cfg.Format == "json" { 67 | handler = slog.NewJSONHandler(writer, opts) 68 | } else { 69 | handler = slog.NewTextHandler(writer, opts) 70 | } 71 | 72 | defaultLogger = slog.New(handler) 73 | slog.SetDefault(defaultLogger) 74 | 75 | return nil 76 | } 77 | 78 | // Get returns the default logger 79 | func Get() *slog.Logger { 80 | if defaultLogger == nil { 81 | defaultLogger = slog.Default() 82 | } 83 | return defaultLogger 84 | } 85 | 86 | // WithRequestID returns a logger with the request ID attached 87 | func WithRequestID(ctx context.Context) *slog.Logger { 88 | logger := Get() 89 | if requestID := ctx.Value(RequestIDKey); requestID != nil { 90 | return logger.With("request_id", requestID) 91 | } 92 | return logger 93 | } 94 | 95 | // WithContext returns a logger with all context values attached 96 | func WithContext(ctx context.Context) *slog.Logger { 97 | logger := Get() 98 | attrs := []any{} 99 | 100 | if requestID := ctx.Value(RequestIDKey); requestID != nil { 101 | attrs = append(attrs, "request_id", requestID) 102 | } 103 | if userID := ctx.Value(UserIDKey); userID != nil { 104 | attrs = append(attrs, "user_id", userID) 105 | } 106 | 107 | if len(attrs) > 0 { 108 | return logger.With(attrs...) 109 | } 110 | return logger 111 | } 112 | 113 | // Debug logs a debug message 114 | func Debug(msg string, args ...any) { 115 | Get().Debug(msg, args...) 116 | } 117 | 118 | // Info logs an info message 119 | func Info(msg string, args ...any) { 120 | Get().Info(msg, args...) 121 | } 122 | 123 | // Warn logs a warning message 124 | func Warn(msg string, args ...any) { 125 | Get().Warn(msg, args...) 126 | } 127 | 128 | // Error logs an error message 129 | func Error(msg string, args ...any) { 130 | Get().Error(msg, args...) 131 | } 132 | 133 | // DebugContext logs a debug message with context 134 | func DebugContext(ctx context.Context, msg string, args ...any) { 135 | WithContext(ctx).Debug(msg, args...) 136 | } 137 | 138 | // InfoContext logs an info message with context 139 | func InfoContext(ctx context.Context, msg string, args ...any) { 140 | WithContext(ctx).Info(msg, args...) 141 | } 142 | 143 | // WarnContext logs a warning message with context 144 | func WarnContext(ctx context.Context, msg string, args ...any) { 145 | WithContext(ctx).Warn(msg, args...) 146 | } 147 | 148 | // ErrorContext logs an error message with context 149 | func ErrorContext(ctx context.Context, msg string, args ...any) { 150 | WithContext(ctx).Error(msg, args...) 151 | } 152 | -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Post Analyzer Webserver - Setup Script 4 | # This script helps set up the development environment 5 | 6 | set -e 7 | 8 | # Colors 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | echo -e "${BLUE}╔══════════════════════════════════════════════════════╗${NC}" 16 | echo -e "${BLUE}║ Post Analyzer Webserver - Setup Script ║${NC}" 17 | echo -e "${BLUE}╚══════════════════════════════════════════════════════╝${NC}" 18 | echo "" 19 | 20 | # Check Go installation 21 | echo -e "${YELLOW}→${NC} Checking Go installation..." 22 | if ! command -v go &> /dev/null; then 23 | echo -e "${RED}✗${NC} Go is not installed. Please install Go 1.21 or higher." 24 | echo -e " Download from: https://golang.org/dl/" 25 | exit 1 26 | fi 27 | 28 | GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') 29 | echo -e "${GREEN}✓${NC} Go ${GO_VERSION} is installed" 30 | 31 | # Check if Docker is installed (optional) 32 | echo -e "${YELLOW}→${NC} Checking Docker installation..." 33 | if command -v docker &> /dev/null; then 34 | DOCKER_VERSION=$(docker --version | awk '{print $3}' | sed 's/,//') 35 | echo -e "${GREEN}✓${NC} Docker ${DOCKER_VERSION} is installed" 36 | HAS_DOCKER=true 37 | else 38 | echo -e "${YELLOW}!${NC} Docker is not installed (optional)" 39 | HAS_DOCKER=false 40 | fi 41 | 42 | # Check if Docker Compose is installed (optional) 43 | if [ "$HAS_DOCKER" = true ]; then 44 | echo -e "${YELLOW}→${NC} Checking Docker Compose installation..." 45 | if command -v docker-compose &> /dev/null; then 46 | COMPOSE_VERSION=$(docker-compose --version | awk '{print $4}' | sed 's/,//') 47 | echo -e "${GREEN}✓${NC} Docker Compose ${COMPOSE_VERSION} is installed" 48 | HAS_COMPOSE=true 49 | else 50 | echo -e "${YELLOW}!${NC} Docker Compose is not installed (optional)" 51 | HAS_COMPOSE=false 52 | fi 53 | fi 54 | 55 | # Install Go dependencies 56 | echo "" 57 | echo -e "${YELLOW}→${NC} Installing Go dependencies..." 58 | go mod download 59 | go mod tidy 60 | echo -e "${GREEN}✓${NC} Dependencies installed" 61 | 62 | # Create .env file if it doesn't exist 63 | echo "" 64 | echo -e "${YELLOW}→${NC} Setting up environment configuration..." 65 | if [ ! -f .env ]; then 66 | if [ -f .env.example ]; then 67 | cp .env.example .env 68 | echo -e "${GREEN}✓${NC} Created .env from .env.example" 69 | else 70 | echo -e "${YELLOW}!${NC} .env.example not found, creating default .env" 71 | cat > .env < 4 | Post Viewer and Analyzer 5 |

6 | 7 | ## Introduction 8 | The Post Viewer and Analyzer is a *very* simple web-based application built with Go. It serves a web interface that allows users to fetch posts from the JSONPlaceholder API, save these posts to a file, and perform a character frequency analysis on the saved data. This application demonstrates the use of Go for server-side web development, including handling HTTP requests, processing JSON, and rendering HTML templates. 9 | 10 | ## Features 11 | - **Fetch Data**: Users can fetch posts from the external JSONPlaceholder API. 12 | - **Save Data**: Automatically saves fetched posts into a local JSON file. 13 | - **Analyze Data**: Performs a character frequency analysis on the contents of the saved JSON file. 14 | - **Web Interface**: Simple and user-friendly web interface to interact with the application. 15 | 16 | ## Live Deployment 17 | 18 | The application is deployed on Render and can be accessed using the following link: [Post Viewer and Analyzer](https://post-analyzer-webserver.onrender.com) 19 | 20 | Please note that the application is hosted on a free tier and may take some time to load initially. 21 | 22 | ## Technology Stack 23 | - **Go**: All server-side logic is implemented in Go, utilizing its standard library for web server functionality, file I/O, and concurrency. 24 | - **HTML/CSS**: Front-end layout and styling. 25 | - **JSONPlaceholder API**: External REST API used for fetching sample post data. 26 | 27 | ### Why Go? 28 | - **Concurrency**: Go's built-in support for concurrency makes it easy to write efficient and scalable web servers. 29 | - **Standard Library**: Go's standard library provides robust support for web development, including an HTTP server, JSON encoding/decoding, and file I/O. 30 | - **Performance**: Go is known for its fast compilation times and efficient runtime performance, making it a great choice for web applications. 31 | - **Community**: Go has a large and active community, with extensive documentation and libraries available for web development. 32 | 33 | ### Example Analysis Result 34 | 35 |

36 | Character Frequency Analysis 37 |

38 | 39 | ## Getting Started 40 | 41 | ### Prerequisites 42 | - Go (version 1.14 or higher recommended) 43 | - Internet connection (for fetching data from the external API) 44 | - Web browser (for accessing the application) 45 | 46 | ### Installation 47 | 1. **Clone the repository:** 48 | ``` 49 | git clone https://github.com/hoangsonww/Post-Analyzer-Webserver.git 50 | cd Post-Analyzer-Webserver 51 | ``` 52 | 53 | 2. **Run the application:** 54 | ``` 55 | go run main.go 56 | ``` 57 | 58 | ### Usage 59 | 1. **Run the application using the steps mentioned above, do not forget to run `go run main.go`!** 60 | 2. **Open your web browser.** 61 | 3. **Navigate to `http://localhost:8080/` to access the application.** 62 | 4. **Use the following endpoints to interact with the application:** 63 | - **Home Page**: `/` 64 | - **Fetch Posts**: `/fetch` - Fetches posts from the JSONPlaceholder and saves them to a local file. 65 | - **Analyze Character Frequency**: `/analyze` - Analyzes the frequency of each character in the saved posts. 66 | - **Add Post**: `/add` - Adds a new post to the saved posts. 67 | 5. Because it is currently a Backend-focus application, you can greatly enhanced it by adding a polished Frontend to it, such as by using React.js. 68 | 69 | ## Application Structure 70 | - **main.go**: Contains all the server-side logic including API calls, concurrency handling, file operations, and web server setup. 71 | - **home.html**: HTML template file used for rendering the web interface. 72 | - **go.mod**: Go module file that defines the project's dependencies. 73 | - **posts.json**: Local JSON file used to store the fetched posts. 74 | 75 | ## Contributing 76 | Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 77 | 78 | 1. Fork the Project 79 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 80 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 81 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 82 | 5. Open a Pull Request 83 | 84 | ## License 85 | Distributed under the MIT License. See `LICENSE` for more information. 86 | 87 | ## Contact 88 | Son Nguyen - [https://github.com/hoangsonww](https://github.com/hoangsonww) 89 | 90 | ## Acknowledgements 91 | - [Go](https://golang.org/) 92 | - [JSONPlaceholder](https://jsonplaceholder.typicode.com/) 93 | 94 | --- 95 | License 96 | Go version 97 | Status 98 | Version 99 | Year 100 | 101 | --- 102 | 103 | Created with ❤️ by [Son Nguyen](https://github.com/hoangsonww) in 2024. 104 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/promauto" 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | ) 12 | 13 | var ( 14 | // HTTP metrics 15 | httpRequestsTotal = promauto.NewCounterVec( 16 | prometheus.CounterOpts{ 17 | Name: "http_requests_total", 18 | Help: "Total number of HTTP requests", 19 | }, 20 | []string{"method", "path", "status"}, 21 | ) 22 | 23 | httpRequestDuration = promauto.NewHistogramVec( 24 | prometheus.HistogramOpts{ 25 | Name: "http_request_duration_seconds", 26 | Help: "HTTP request latency in seconds", 27 | Buckets: prometheus.DefBuckets, 28 | }, 29 | []string{"method", "path", "status"}, 30 | ) 31 | 32 | httpRequestSize = promauto.NewHistogramVec( 33 | prometheus.HistogramOpts{ 34 | Name: "http_request_size_bytes", 35 | Help: "HTTP request size in bytes", 36 | Buckets: prometheus.ExponentialBuckets(100, 10, 8), 37 | }, 38 | []string{"method", "path"}, 39 | ) 40 | 41 | httpResponseSize = promauto.NewHistogramVec( 42 | prometheus.HistogramOpts{ 43 | Name: "http_response_size_bytes", 44 | Help: "HTTP response size in bytes", 45 | Buckets: prometheus.ExponentialBuckets(100, 10, 8), 46 | }, 47 | []string{"method", "path"}, 48 | ) 49 | 50 | // Application metrics 51 | postsTotal = promauto.NewGauge( 52 | prometheus.GaugeOpts{ 53 | Name: "posts_total", 54 | Help: "Total number of posts in the system", 55 | }, 56 | ) 57 | 58 | postsFetched = promauto.NewCounter( 59 | prometheus.CounterOpts{ 60 | Name: "posts_fetched_total", 61 | Help: "Total number of posts fetched from external API", 62 | }, 63 | ) 64 | 65 | postsAdded = promauto.NewCounter( 66 | prometheus.CounterOpts{ 67 | Name: "posts_added_total", 68 | Help: "Total number of posts added by users", 69 | }, 70 | ) 71 | 72 | analysisOperations = promauto.NewCounter( 73 | prometheus.CounterOpts{ 74 | Name: "analysis_operations_total", 75 | Help: "Total number of character analysis operations", 76 | }, 77 | ) 78 | 79 | analysisDuration = promauto.NewHistogram( 80 | prometheus.HistogramOpts{ 81 | Name: "analysis_duration_seconds", 82 | Help: "Character analysis operation duration in seconds", 83 | Buckets: prometheus.DefBuckets, 84 | }, 85 | ) 86 | 87 | dbOperations = promauto.NewCounterVec( 88 | prometheus.CounterOpts{ 89 | Name: "db_operations_total", 90 | Help: "Total number of database operations", 91 | }, 92 | []string{"operation", "status"}, 93 | ) 94 | 95 | dbOperationDuration = promauto.NewHistogramVec( 96 | prometheus.HistogramOpts{ 97 | Name: "db_operation_duration_seconds", 98 | Help: "Database operation duration in seconds", 99 | Buckets: prometheus.DefBuckets, 100 | }, 101 | []string{"operation"}, 102 | ) 103 | ) 104 | 105 | // Middleware creates a middleware for recording HTTP metrics 106 | func Middleware(next http.Handler) http.Handler { 107 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 108 | start := time.Now() 109 | 110 | // Create a response wrapper to capture status and size 111 | rw := &metricsResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} 112 | 113 | // Record request size 114 | if r.ContentLength > 0 { 115 | httpRequestSize.WithLabelValues(r.Method, r.URL.Path).Observe(float64(r.ContentLength)) 116 | } 117 | 118 | next.ServeHTTP(rw, r) 119 | 120 | // Record metrics 121 | duration := time.Since(start).Seconds() 122 | status := strconv.Itoa(rw.statusCode) 123 | 124 | httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, status).Inc() 125 | httpRequestDuration.WithLabelValues(r.Method, r.URL.Path, status).Observe(duration) 126 | httpResponseSize.WithLabelValues(r.Method, r.URL.Path).Observe(float64(rw.bytesWritten)) 127 | }) 128 | } 129 | 130 | type metricsResponseWriter struct { 131 | http.ResponseWriter 132 | statusCode int 133 | bytesWritten int 134 | } 135 | 136 | func (mrw *metricsResponseWriter) WriteHeader(code int) { 137 | mrw.statusCode = code 138 | mrw.ResponseWriter.WriteHeader(code) 139 | } 140 | 141 | func (mrw *metricsResponseWriter) Write(b []byte) (int, error) { 142 | n, err := mrw.ResponseWriter.Write(b) 143 | mrw.bytesWritten += n 144 | return n, err 145 | } 146 | 147 | // Handler returns the Prometheus metrics HTTP handler 148 | func Handler() http.Handler { 149 | return promhttp.Handler() 150 | } 151 | 152 | // RecordPostsTotal records the total number of posts 153 | func RecordPostsTotal(count int) { 154 | postsTotal.Set(float64(count)) 155 | } 156 | 157 | // RecordPostsFetched increments the posts fetched counter 158 | func RecordPostsFetched(count int) { 159 | postsFetched.Add(float64(count)) 160 | } 161 | 162 | // RecordPostAdded increments the posts added counter 163 | func RecordPostAdded() { 164 | postsAdded.Inc() 165 | } 166 | 167 | // RecordAnalysisOperation records a character analysis operation 168 | func RecordAnalysisOperation(duration time.Duration) { 169 | analysisOperations.Inc() 170 | analysisDuration.Observe(duration.Seconds()) 171 | } 172 | 173 | // RecordDBOperation records a database operation 174 | func RecordDBOperation(operation, status string, duration time.Duration) { 175 | dbOperations.WithLabelValues(operation, status).Inc() 176 | dbOperationDuration.WithLabelValues(operation).Observe(duration.Seconds()) 177 | } 178 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help build run test clean docker-build docker-up docker-down lint format install-tools dev migrate 2 | 3 | # Variables 4 | APP_NAME := post-analyzer 5 | MAIN_FILE := main_new.go 6 | BINARY := $(APP_NAME) 7 | DOCKER_IMAGE := $(APP_NAME):latest 8 | GO := go 9 | GOFLAGS := -v 10 | LDFLAGS := -w -s 11 | 12 | # Colors for output 13 | BLUE := \033[0;34m 14 | GREEN := \033[0;32m 15 | YELLOW := \033[0;33m 16 | NC := \033[0m # No Color 17 | 18 | ## help: Display this help message 19 | help: 20 | @echo "$(BLUE)Post Analyzer Webserver - Makefile Commands$(NC)" 21 | @echo "" 22 | @grep -E '^## ' Makefile | sed 's/## / /' | column -t -s ':' 23 | 24 | ## install: Install dependencies 25 | install: 26 | @echo "$(GREEN)Installing dependencies...$(NC)" 27 | $(GO) mod download 28 | $(GO) mod verify 29 | 30 | ## install-tools: Install development tools 31 | install-tools: 32 | @echo "$(GREEN)Installing development tools...$(NC)" 33 | $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 34 | $(GO) install github.com/swaggo/swag/cmd/swag@latest 35 | 36 | ## build: Build the application 37 | build: 38 | @echo "$(GREEN)Building $(APP_NAME)...$(NC)" 39 | $(GO) build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BINARY) $(MAIN_FILE) 40 | @echo "$(GREEN)Build complete: $(BINARY)$(NC)" 41 | 42 | ## run: Run the application 43 | run: build 44 | @echo "$(GREEN)Running $(APP_NAME)...$(NC)" 45 | ./$(BINARY) 46 | 47 | ## dev: Run the application in development mode with file watching 48 | dev: 49 | @echo "$(GREEN)Running in development mode...$(NC)" 50 | @if command -v air > /dev/null; then \ 51 | air; \ 52 | else \ 53 | echo "$(YELLOW)Air not installed. Running normally...$(NC)"; \ 54 | $(GO) run $(MAIN_FILE); \ 55 | fi 56 | 57 | ## test: Run all tests 58 | test: 59 | @echo "$(GREEN)Running tests...$(NC)" 60 | $(GO) test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 61 | 62 | ## test-coverage: Run tests with coverage report 63 | test-coverage: test 64 | @echo "$(GREEN)Generating coverage report...$(NC)" 65 | $(GO) tool cover -html=coverage.txt -o coverage.html 66 | @echo "$(GREEN)Coverage report generated: coverage.html$(NC)" 67 | 68 | ## lint: Run linter 69 | lint: 70 | @echo "$(GREEN)Running linter...$(NC)" 71 | golangci-lint run --timeout=5m 72 | 73 | ## format: Format Go code 74 | format: 75 | @echo "$(GREEN)Formatting code...$(NC)" 76 | $(GO) fmt ./... 77 | gofmt -s -w . 78 | 79 | ## clean: Clean build artifacts 80 | clean: 81 | @echo "$(YELLOW)Cleaning build artifacts...$(NC)" 82 | rm -f $(BINARY) 83 | rm -f coverage.txt coverage.html 84 | rm -rf dist/ 85 | $(GO) clean 86 | 87 | ## docker-build: Build Docker image 88 | docker-build: 89 | @echo "$(GREEN)Building Docker image...$(NC)" 90 | docker build -t $(DOCKER_IMAGE) . 91 | 92 | ## docker-up: Start all services with Docker Compose 93 | docker-up: 94 | @echo "$(GREEN)Starting services...$(NC)" 95 | docker-compose up -d 96 | @echo "$(GREEN)Services started. Application available at http://localhost:8080$(NC)" 97 | @echo "$(GREEN)Prometheus available at http://localhost:9090$(NC)" 98 | @echo "$(GREEN)Grafana available at http://localhost:3000 (admin/admin)$(NC)" 99 | 100 | ## docker-down: Stop all services 101 | docker-down: 102 | @echo "$(YELLOW)Stopping services...$(NC)" 103 | docker-compose down 104 | 105 | ## docker-logs: View logs from all services 106 | docker-logs: 107 | docker-compose logs -f 108 | 109 | ## docker-restart: Restart all services 110 | docker-restart: docker-down docker-up 111 | 112 | ## migrate: Run database migrations 113 | migrate: 114 | @echo "$(GREEN)Running database migrations...$(NC)" 115 | @echo "$(YELLOW)Migrations are automatically handled by the application$(NC)" 116 | 117 | ## db-shell: Connect to PostgreSQL database 118 | db-shell: 119 | @echo "$(GREEN)Connecting to database...$(NC)" 120 | docker-compose exec postgres psql -U postgres -d postanalyzer 121 | 122 | ## benchmark: Run benchmarks 123 | benchmark: 124 | @echo "$(GREEN)Running benchmarks...$(NC)" 125 | $(GO) test -bench=. -benchmem ./... 126 | 127 | ## security: Run security checks 128 | security: 129 | @echo "$(GREEN)Running security checks...$(NC)" 130 | @if command -v gosec > /dev/null; then \ 131 | gosec ./...; \ 132 | else \ 133 | echo "$(YELLOW)gosec not installed. Install with: go install github.com/securego/gosec/v2/cmd/gosec@latest$(NC)"; \ 134 | fi 135 | 136 | ## deps-update: Update dependencies 137 | deps-update: 138 | @echo "$(GREEN)Updating dependencies...$(NC)" 139 | $(GO) get -u ./... 140 | $(GO) mod tidy 141 | 142 | ## check: Run all checks (lint, test, security) 143 | check: lint test security 144 | @echo "$(GREEN)All checks passed!$(NC)" 145 | 146 | ## prod-build: Build for production 147 | prod-build: 148 | @echo "$(GREEN)Building for production...$(NC)" 149 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BINARY) $(MAIN_FILE) 150 | @echo "$(GREEN)Production build complete$(NC)" 151 | 152 | ## init: Initialize development environment 153 | init: install install-tools 154 | @echo "$(GREEN)Creating .env file from example...$(NC)" 155 | @if [ ! -f .env ]; then cp .env.example .env 2>/dev/null || echo "$(YELLOW)No .env.example found$(NC)"; fi 156 | @echo "$(GREEN)Development environment ready!$(NC)" 157 | 158 | ## version: Display version information 159 | version: 160 | @echo "$(BLUE)Post Analyzer Webserver$(NC)" 161 | @echo "Go version: $$($(GO) version)" 162 | @echo "Git commit: $$(git rev-parse --short HEAD 2>/dev/null || echo 'N/A')" 163 | 164 | .DEFAULT_GOAL := help 165 | -------------------------------------------------------------------------------- /internal/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Post represents a post in the system 8 | type Post struct { 9 | ID int `json:"id" db:"id"` 10 | UserID int `json:"userId" db:"user_id"` 11 | Title string `json:"title" db:"title"` 12 | Body string `json:"body" db:"body"` 13 | CreatedAt time.Time `json:"createdAt" db:"created_at"` 14 | UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` 15 | } 16 | 17 | // CreatePostRequest represents a request to create a post 18 | type CreatePostRequest struct { 19 | UserID int `json:"userId"` 20 | Title string `json:"title" validate:"required,min=1,max=500"` 21 | Body string `json:"body" validate:"required,min=1,max=10000"` 22 | } 23 | 24 | // UpdatePostRequest represents a request to update a post 25 | type UpdatePostRequest struct { 26 | UserID int `json:"userId,omitempty"` 27 | Title string `json:"title,omitempty" validate:"omitempty,min=1,max=500"` 28 | Body string `json:"body,omitempty" validate:"omitempty,min=1,max=10000"` 29 | } 30 | 31 | // PostFilter represents filtering options for posts 32 | type PostFilter struct { 33 | UserID *int `json:"userId,omitempty"` 34 | Search string `json:"search,omitempty"` 35 | SortBy string `json:"sortBy,omitempty"` // id, title, createdAt, updatedAt 36 | SortOrder string `json:"sortOrder,omitempty"` // asc, desc 37 | } 38 | 39 | // PaginationParams represents pagination parameters 40 | type PaginationParams struct { 41 | Page int `json:"page"` 42 | PageSize int `json:"pageSize"` 43 | Offset int `json:"-"` 44 | } 45 | 46 | // PaginatedResponse represents a paginated API response 47 | type PaginatedResponse struct { 48 | Data interface{} `json:"data"` 49 | Pagination PaginationMeta `json:"pagination"` 50 | Meta *ResponseMeta `json:"meta,omitempty"` 51 | } 52 | 53 | // PaginationMeta contains pagination metadata 54 | type PaginationMeta struct { 55 | Page int `json:"page"` 56 | PageSize int `json:"pageSize"` 57 | TotalItems int `json:"totalItems"` 58 | TotalPages int `json:"totalPages"` 59 | HasNext bool `json:"hasNext"` 60 | HasPrev bool `json:"hasPrev"` 61 | } 62 | 63 | // ResponseMeta contains additional response metadata 64 | type ResponseMeta struct { 65 | RequestID string `json:"requestId,omitempty"` 66 | Timestamp time.Time `json:"timestamp"` 67 | Duration time.Duration `json:"duration,omitempty"` 68 | } 69 | 70 | // AnalyticsResult represents character frequency analysis results 71 | type AnalyticsResult struct { 72 | TotalPosts int `json:"totalPosts"` 73 | TotalCharacters int `json:"totalCharacters"` 74 | UniqueChars int `json:"uniqueChars"` 75 | CharFrequency map[rune]int `json:"charFrequency"` 76 | TopCharacters []CharacterStat `json:"topCharacters"` 77 | Statistics *AnalyticsStats `json:"statistics,omitempty"` 78 | } 79 | 80 | // CharacterStat represents statistics for a single character 81 | type CharacterStat struct { 82 | Character rune `json:"character"` 83 | Count int `json:"count"` 84 | Frequency float64 `json:"frequency"` 85 | } 86 | 87 | // AnalyticsStats represents overall analytics statistics 88 | type AnalyticsStats struct { 89 | AveragePostLength float64 `json:"averagePostLength"` 90 | MedianPostLength int `json:"medianPostLength"` 91 | PostsPerUser map[int]int `json:"postsPerUser"` 92 | TimeDistribution map[string]int `json:"timeDistribution"` 93 | } 94 | 95 | // BulkCreateRequest represents a bulk create request 96 | type BulkCreateRequest struct { 97 | Posts []CreatePostRequest `json:"posts" validate:"required,min=1,max=1000"` 98 | } 99 | 100 | // BulkCreateResponse represents a bulk create response 101 | type BulkCreateResponse struct { 102 | Created int `json:"created"` 103 | Failed int `json:"failed"` 104 | Errors []string `json:"errors,omitempty"` 105 | PostIDs []int `json:"postIds,omitempty"` 106 | } 107 | 108 | // ExportFormat represents export file format 109 | type ExportFormat string 110 | 111 | const ( 112 | ExportFormatJSON ExportFormat = "json" 113 | ExportFormatCSV ExportFormat = "csv" 114 | ) 115 | 116 | // HealthResponse represents health check response 117 | type HealthResponse struct { 118 | Status string `json:"status"` 119 | Timestamp time.Time `json:"timestamp"` 120 | Version string `json:"version,omitempty"` 121 | Uptime time.Duration `json:"uptime,omitempty"` 122 | Checks map[string]bool `json:"checks,omitempty"` 123 | } 124 | 125 | // User represents a user in the system (for future auth) 126 | type User struct { 127 | ID int `json:"id" db:"id"` 128 | Email string `json:"email" db:"email"` 129 | Username string `json:"username" db:"username"` 130 | Role string `json:"role" db:"role"` 131 | CreatedAt time.Time `json:"createdAt" db:"created_at"` 132 | UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` 133 | } 134 | 135 | // AuditLog represents an audit log entry 136 | type AuditLog struct { 137 | ID int `json:"id" db:"id"` 138 | UserID int `json:"userId" db:"user_id"` 139 | Action string `json:"action" db:"action"` 140 | Resource string `json:"resource" db:"resource"` 141 | ResourceID int `json:"resourceId" db:"resource_id"` 142 | Changes string `json:"changes,omitempty" db:"changes"` 143 | IPAddress string `json:"ipAddress" db:"ip_address"` 144 | UserAgent string `json:"userAgent" db:"user_agent"` 145 | CreatedAt time.Time `json:"createdAt" db:"created_at"` 146 | } 147 | -------------------------------------------------------------------------------- /assets/post-analyzer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html/template" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "sync" 11 | ) 12 | 13 | // Post struct to map the JSON data 14 | type Post struct { 15 | UserId int `json:"userId"` 16 | Id int `json:"id"` 17 | Title string `json:"title"` 18 | Body string `json:"body"` 19 | } 20 | 21 | // Template variables 22 | type HomePageVars struct { 23 | Title string 24 | Posts []Post 25 | CharFreq map[rune]int 26 | Error string 27 | HasPosts bool 28 | HasAnalysis bool 29 | } 30 | 31 | // Custom template functions 32 | var funcMap = template.FuncMap{ 33 | "toJSON": func(v interface{}) string { 34 | data, _ := json.Marshal(v) 35 | return string(data) 36 | }, 37 | } 38 | 39 | var templates = template.Must(template.New("").Funcs(funcMap).ParseFiles("home.html")) 40 | 41 | func main() { 42 | http.HandleFunc("/", HomeHandler) 43 | http.HandleFunc("/fetch", FetchPostsHandler) 44 | http.HandleFunc("/analyze", AnalyzePostsHandler) 45 | http.HandleFunc("/add", AddPostHandler) 46 | 47 | fmt.Println("Server starting at http://localhost:8080/") 48 | _ = http.ListenAndServe(":8080", nil) 49 | } 50 | 51 | // HomeHandler serves the home page 52 | func HomeHandler(w http.ResponseWriter, r *http.Request) { 53 | posts, err := readPostsFromFile() 54 | if err != nil { 55 | renderTemplate(w, HomePageVars{Title: "Home", Error: "Failed to read posts: " + err.Error()}) 56 | return 57 | } 58 | renderTemplate(w, HomePageVars{Title: "Home", Posts: posts, HasPosts: len(posts) > 0}) 59 | } 60 | 61 | // FetchPostsHandler fetches posts and writes them to a file 62 | func FetchPostsHandler(w http.ResponseWriter, r *http.Request) { 63 | posts, err := fetchPosts() 64 | if err != nil { 65 | renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to fetch posts: " + err.Error()}) 66 | return 67 | } 68 | 69 | if err := writePostsToFile(posts); err != nil { 70 | renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to write posts to file: " + err.Error()}) 71 | return 72 | } 73 | 74 | renderTemplate(w, HomePageVars{Title: "Posts Fetched", Posts: posts, HasPosts: true}) 75 | } 76 | 77 | // AnalyzePostsHandler reads the posts file and analyzes character frequency 78 | func AnalyzePostsHandler(w http.ResponseWriter, r *http.Request) { 79 | count, err := countCharacters("posts.json") 80 | if err != nil { 81 | renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to analyze posts: " + err.Error()}) 82 | return 83 | } 84 | 85 | renderTemplate(w, HomePageVars{Title: "Character Analysis", CharFreq: count, HasAnalysis: true}) 86 | } 87 | 88 | // AddPostHandler allows the user to add a new post 89 | func AddPostHandler(w http.ResponseWriter, r *http.Request) { 90 | if r.Method == http.MethodPost { 91 | var post Post 92 | post.UserId = 1 // You can set this as needed 93 | post.Id = generatePostID() 94 | post.Title = r.FormValue("title") 95 | post.Body = r.FormValue("body") 96 | 97 | posts, err := readPostsFromFile() 98 | if err != nil { 99 | renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to read posts: " + err.Error()}) 100 | return 101 | } 102 | 103 | posts = append(posts, post) 104 | 105 | if err := writePostsToFile(posts); err != nil { 106 | renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to write post to file: " + err.Error()}) 107 | return 108 | } 109 | 110 | renderTemplate(w, HomePageVars{Title: "Post Added", Posts: posts, HasPosts: true}) 111 | } else { 112 | renderTemplate(w, HomePageVars{Title: "Add New Post"}) 113 | } 114 | } 115 | 116 | func fetchPosts() ([]Post, error) { 117 | resp, err := http.Get("https://jsonplaceholder.typicode.com/posts") 118 | if err != nil { 119 | return nil, err 120 | } 121 | defer resp.Body.Close() 122 | 123 | var posts []Post 124 | if err := json.NewDecoder(resp.Body).Decode(&posts); err != nil { 125 | return nil, err 126 | } 127 | return posts, nil 128 | } 129 | 130 | func writePostsToFile(posts []Post) error { 131 | file, err := os.Create("posts.json") 132 | if err != nil { 133 | return err 134 | } 135 | defer file.Close() 136 | 137 | encoder := json.NewEncoder(file) 138 | if err := encoder.Encode(posts); err != nil { 139 | return err 140 | } 141 | return nil 142 | } 143 | 144 | func readPostsFromFile() ([]Post, error) { 145 | data, err := ioutil.ReadFile("posts.json") 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | var posts []Post 151 | if err := json.Unmarshal(data, &posts); err != nil { 152 | return nil, err 153 | } 154 | return posts, nil 155 | } 156 | 157 | func countCharacters(filePath string) (map[rune]int, error) { 158 | data, err := ioutil.ReadFile(filePath) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | charCount := make(map[rune]int) 164 | mu := sync.Mutex{} 165 | wg := sync.WaitGroup{} 166 | 167 | for _, byteValue := range string(data) { 168 | wg.Add(1) 169 | go func(c rune) { 170 | defer wg.Done() 171 | mu.Lock() 172 | charCount[c]++ 173 | mu.Unlock() 174 | }(rune(byteValue)) 175 | } 176 | 177 | wg.Wait() 178 | return charCount, nil 179 | } 180 | 181 | func renderTemplate(w http.ResponseWriter, vars HomePageVars) { 182 | if err := templates.ExecuteTemplate(w, "home.html", vars); err != nil { 183 | http.Error(w, "Failed to render template", http.StatusInternalServerError) 184 | } 185 | } 186 | 187 | func generatePostID() int { 188 | posts, _ := readPostsFromFile() 189 | maxID := 0 190 | for _, post := range posts { 191 | if post.Id > maxID { 192 | maxID = post.Id 193 | } 194 | } 195 | return maxID + 1 196 | } 197 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main, master, claude/* ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | env: 10 | GO_VERSION: '1.21' 11 | DOCKER_IMAGE: post-analyzer 12 | 13 | jobs: 14 | # Linting and code quality 15 | lint: 16 | name: Lint Code 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | 27 | - name: Run golangci-lint 28 | uses: golangci/golangci-lint-action@v4 29 | with: 30 | version: latest 31 | args: --timeout=5m 32 | 33 | # Unit and integration tests 34 | test: 35 | name: Run Tests 36 | runs-on: ubuntu-latest 37 | services: 38 | postgres: 39 | image: postgres:16-alpine 40 | env: 41 | POSTGRES_DB: testdb 42 | POSTGRES_USER: postgres 43 | POSTGRES_PASSWORD: postgres 44 | ports: 45 | - 5432:5432 46 | options: >- 47 | --health-cmd pg_isready 48 | --health-interval 10s 49 | --health-timeout 5s 50 | --health-retries 5 51 | 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v4 55 | 56 | - name: Set up Go 57 | uses: actions/setup-go@v5 58 | with: 59 | go-version: ${{ env.GO_VERSION }} 60 | 61 | - name: Cache Go modules 62 | uses: actions/cache@v4 63 | with: 64 | path: | 65 | ~/.cache/go-build 66 | ~/go/pkg/mod 67 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 68 | restore-keys: | 69 | ${{ runner.os }}-go- 70 | 71 | - name: Download dependencies 72 | run: go mod download 73 | 74 | - name: Run tests 75 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 76 | env: 77 | DB_HOST: localhost 78 | DB_PORT: 5432 79 | DB_USER: postgres 80 | DB_PASSWORD: postgres 81 | DB_NAME: testdb 82 | 83 | - name: Upload coverage to Codecov 84 | uses: codecov/codecov-action@v4 85 | with: 86 | file: ./coverage.txt 87 | fail_ci_if_error: false 88 | 89 | # Build and verify 90 | build: 91 | name: Build Application 92 | runs-on: ubuntu-latest 93 | needs: [lint, test] 94 | steps: 95 | - name: Checkout code 96 | uses: actions/checkout@v4 97 | 98 | - name: Set up Go 99 | uses: actions/setup-go@v5 100 | with: 101 | go-version: ${{ env.GO_VERSION }} 102 | 103 | - name: Build application 104 | run: | 105 | go build -v -ldflags="-w -s" -o post-analyzer main_new.go 106 | 107 | - name: Upload build artifact 108 | uses: actions/upload-artifact@v4 109 | with: 110 | name: post-analyzer 111 | path: post-analyzer 112 | retention-days: 1 113 | 114 | # Docker build and push 115 | docker: 116 | name: Build and Push Docker Image 117 | runs-on: ubuntu-latest 118 | needs: [build] 119 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') 120 | steps: 121 | - name: Checkout code 122 | uses: actions/checkout@v4 123 | 124 | - name: Set up Docker Buildx 125 | uses: docker/setup-buildx-action@v3 126 | 127 | - name: Log in to Docker Hub 128 | uses: docker/login-action@v3 129 | with: 130 | username: ${{ secrets.DOCKER_USERNAME }} 131 | password: ${{ secrets.DOCKER_PASSWORD }} 132 | if: github.event_name == 'push' 133 | 134 | - name: Extract metadata 135 | id: meta 136 | uses: docker/metadata-action@v5 137 | with: 138 | images: ${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGE }} 139 | tags: | 140 | type=ref,event=branch 141 | type=ref,event=pr 142 | type=semver,pattern={{version}} 143 | type=semver,pattern={{major}}.{{minor}} 144 | type=sha,prefix={{branch}}- 145 | type=raw,value=latest,enable={{is_default_branch}} 146 | 147 | - name: Build and push Docker image 148 | uses: docker/build-push-action@v5 149 | with: 150 | context: . 151 | push: ${{ github.event_name == 'push' }} 152 | tags: ${{ steps.meta.outputs.tags }} 153 | labels: ${{ steps.meta.outputs.labels }} 154 | cache-from: type=gha 155 | cache-to: type=gha,mode=max 156 | 157 | # Security scanning 158 | security: 159 | name: Security Scan 160 | runs-on: ubuntu-latest 161 | steps: 162 | - name: Checkout code 163 | uses: actions/checkout@v4 164 | 165 | - name: Run Trivy vulnerability scanner 166 | uses: aquasecurity/trivy-action@master 167 | with: 168 | scan-type: 'fs' 169 | scan-ref: '.' 170 | format: 'sarif' 171 | output: 'trivy-results.sarif' 172 | 173 | - name: Upload Trivy results to GitHub Security 174 | uses: github/codeql-action/upload-sarif@v3 175 | with: 176 | sarif_file: 'trivy-results.sarif' 177 | 178 | # Deployment (optional - customize for your deployment target) 179 | deploy: 180 | name: Deploy Application 181 | runs-on: ubuntu-latest 182 | needs: [docker] 183 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 184 | steps: 185 | - name: Checkout code 186 | uses: actions/checkout@v4 187 | 188 | - name: Deploy to production 189 | run: | 190 | echo "Add your deployment steps here" 191 | echo "Examples: kubectl apply, helm upgrade, SSH to server, etc." 192 | # Uncomment and customize based on your deployment method: 193 | # - name: Deploy to Kubernetes 194 | # run: | 195 | # kubectl set image deployment/post-analyzer post-analyzer=${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGE }}:latest 196 | # 197 | # - name: Deploy to Render 198 | # run: | 199 | # curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK }} 200 | -------------------------------------------------------------------------------- /internal/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "Post_Analyzer_Webserver/internal/logger" 10 | ) 11 | 12 | // Migration represents a database migration 13 | type Migration struct { 14 | Version int 15 | Description string 16 | Up func(*sql.DB) error 17 | Down func(*sql.DB) error 18 | } 19 | 20 | // Migrator handles database migrations 21 | type Migrator struct { 22 | db *sql.DB 23 | migrations []Migration 24 | } 25 | 26 | // NewMigrator creates a new migrator 27 | func NewMigrator(db *sql.DB) *Migrator { 28 | return &Migrator{ 29 | db: db, 30 | migrations: getMigrations(), 31 | } 32 | } 33 | 34 | // Migrate runs all pending migrations 35 | func (m *Migrator) Migrate(ctx context.Context) error { 36 | // Create migrations table if it doesn't exist 37 | if err := m.createMigrationsTable(); err != nil { 38 | return fmt.Errorf("failed to create migrations table: %w", err) 39 | } 40 | 41 | // Get current version 42 | currentVersion, err := m.getCurrentVersion() 43 | if err != nil { 44 | return fmt.Errorf("failed to get current version: %w", err) 45 | } 46 | 47 | logger.Info("starting migrations", "current_version", currentVersion) 48 | 49 | // Run pending migrations 50 | for _, migration := range m.migrations { 51 | if migration.Version <= currentVersion { 52 | continue 53 | } 54 | 55 | logger.Info("running migration", "version", migration.Version, "description", migration.Description) 56 | 57 | // Begin transaction 58 | tx, err := m.db.Begin() 59 | if err != nil { 60 | return fmt.Errorf("failed to begin transaction: %w", err) 61 | } 62 | 63 | // Run migration 64 | if err := migration.Up(m.db); err != nil { 65 | _ = tx.Rollback() 66 | return fmt.Errorf("migration %d failed: %w", migration.Version, err) 67 | } 68 | 69 | // Update version 70 | if err := m.updateVersion(tx, migration.Version, migration.Description); err != nil { 71 | _ = tx.Rollback() 72 | return fmt.Errorf("failed to update version: %w", err) 73 | } 74 | 75 | // Commit transaction 76 | if err := tx.Commit(); err != nil { 77 | return fmt.Errorf("failed to commit migration: %w", err) 78 | } 79 | 80 | logger.Info("migration completed", "version", migration.Version) 81 | } 82 | 83 | logger.Info("all migrations completed") 84 | return nil 85 | } 86 | 87 | // createMigrationsTable creates the migrations tracking table 88 | func (m *Migrator) createMigrationsTable() error { 89 | query := ` 90 | CREATE TABLE IF NOT EXISTS schema_migrations ( 91 | id SERIAL PRIMARY KEY, 92 | version INTEGER NOT NULL UNIQUE, 93 | description TEXT NOT NULL, 94 | applied_at TIMESTAMP NOT NULL DEFAULT NOW() 95 | )` 96 | 97 | _, err := m.db.Exec(query) 98 | return err 99 | } 100 | 101 | // getCurrentVersion gets the current migration version 102 | func (m *Migrator) getCurrentVersion() (int, error) { 103 | var version int 104 | err := m.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&version) 105 | if err != nil { 106 | return 0, err 107 | } 108 | return version, nil 109 | } 110 | 111 | // updateVersion records a completed migration 112 | func (m *Migrator) updateVersion(tx *sql.Tx, version int, description string) error { 113 | query := "INSERT INTO schema_migrations (version, description, applied_at) VALUES ($1, $2, $3)" 114 | _, err := tx.Exec(query, version, description, time.Now()) 115 | return err 116 | } 117 | 118 | // getMigrations returns all migrations in order 119 | func getMigrations() []Migration { 120 | return []Migration{ 121 | { 122 | Version: 1, 123 | Description: "Create posts table", 124 | Up: func(db *sql.DB) error { 125 | query := ` 126 | CREATE TABLE IF NOT EXISTS posts ( 127 | id SERIAL PRIMARY KEY, 128 | user_id INTEGER NOT NULL, 129 | title VARCHAR(500) NOT NULL, 130 | body TEXT NOT NULL, 131 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 132 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 133 | ); 134 | 135 | CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); 136 | CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC); 137 | ` 138 | _, err := db.Exec(query) 139 | return err 140 | }, 141 | Down: func(db *sql.DB) error { 142 | _, err := db.Exec("DROP TABLE IF EXISTS posts") 143 | return err 144 | }, 145 | }, 146 | { 147 | Version: 2, 148 | Description: "Create audit_logs table", 149 | Up: func(db *sql.DB) error { 150 | query := ` 151 | CREATE TABLE IF NOT EXISTS audit_logs ( 152 | id SERIAL PRIMARY KEY, 153 | user_id INTEGER NOT NULL, 154 | action VARCHAR(50) NOT NULL, 155 | resource VARCHAR(50) NOT NULL, 156 | resource_id INTEGER NOT NULL, 157 | changes TEXT, 158 | ip_address VARCHAR(45), 159 | user_agent TEXT, 160 | created_at TIMESTAMP NOT NULL DEFAULT NOW() 161 | ); 162 | 163 | CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); 164 | CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource, resource_id); 165 | CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC); 166 | ` 167 | _, err := db.Exec(query) 168 | return err 169 | }, 170 | Down: func(db *sql.DB) error { 171 | _, err := db.Exec("DROP TABLE IF EXISTS audit_logs") 172 | return err 173 | }, 174 | }, 175 | { 176 | Version: 3, 177 | Description: "Add full-text search indexes", 178 | Up: func(db *sql.DB) error { 179 | query := ` 180 | CREATE INDEX IF NOT EXISTS idx_posts_title_trgm ON posts USING gin(title gin_trgm_ops); 181 | CREATE INDEX IF NOT EXISTS idx_posts_body_trgm ON posts USING gin(body gin_trgm_ops); 182 | ` 183 | _, err := db.Exec(query) 184 | if err != nil { 185 | // If pg_trgm extension doesn't exist, skip this migration 186 | logger.Warn("failed to create full-text search indexes, pg_trgm extension may not be enabled") 187 | return nil 188 | } 189 | return err 190 | }, 191 | Down: func(db *sql.DB) error { 192 | query := ` 193 | DROP INDEX IF EXISTS idx_posts_title_trgm; 194 | DROP INDEX IF EXISTS idx_posts_body_trgm; 195 | ` 196 | _, err := db.Exec(query) 197 | return err 198 | }, 199 | }, 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "Post_Analyzer_Webserver/config" 14 | "Post_Analyzer_Webserver/internal/api" 15 | "Post_Analyzer_Webserver/internal/cache" 16 | "Post_Analyzer_Webserver/internal/handlers" 17 | "Post_Analyzer_Webserver/internal/logger" 18 | "Post_Analyzer_Webserver/internal/metrics" 19 | "Post_Analyzer_Webserver/internal/middleware" 20 | "Post_Analyzer_Webserver/internal/migrations" 21 | "Post_Analyzer_Webserver/internal/service" 22 | "Post_Analyzer_Webserver/internal/storage" 23 | 24 | _ "github.com/lib/pq" 25 | ) 26 | 27 | var ( 28 | version = "2.0.0" 29 | buildTime = time.Now().Format(time.RFC3339) 30 | startTime = time.Now() 31 | ) 32 | 33 | func main() { 34 | // Load configuration 35 | cfg, err := config.Load() 36 | if err != nil { 37 | fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) 38 | os.Exit(1) 39 | } 40 | 41 | // Initialize logger 42 | if err := logger.Init(&cfg.Logging); err != nil { 43 | fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) 44 | os.Exit(1) 45 | } 46 | 47 | logger.Info("starting Post Analyzer Webserver", 48 | "version", version, 49 | "environment", cfg.Server.Environment, 50 | "port", cfg.Server.Port, 51 | "database_type", cfg.Database.Type, 52 | ) 53 | 54 | // Initialize storage 55 | var store storage.Storage 56 | var db *sql.DB 57 | 58 | if cfg.Database.Type == "postgres" { 59 | pgStore, err := storage.NewPostgresStorage(&cfg.Database) 60 | if err != nil { 61 | logger.Error("failed to initialize PostgreSQL storage", "error", err) 62 | os.Exit(1) 63 | } 64 | store = pgStore 65 | 66 | // Get underlying DB for migrations 67 | dsn := fmt.Sprintf( 68 | "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", 69 | cfg.Database.Host, cfg.Database.Port, cfg.Database.User, 70 | cfg.Database.Password, cfg.Database.DBName, cfg.Database.SSLMode, 71 | ) 72 | db, err = sql.Open("postgres", dsn) 73 | if err != nil { 74 | logger.Error("failed to open database for migrations", "error", err) 75 | os.Exit(1) 76 | } 77 | defer db.Close() 78 | 79 | // Run migrations 80 | logger.Info("running database migrations...") 81 | migrator := migrations.NewMigrator(db) 82 | if err := migrator.Migrate(context.Background()); err != nil { 83 | logger.Error("migration failed", "error", err) 84 | os.Exit(1) 85 | } 86 | 87 | logger.Info("using PostgreSQL storage") 88 | } else { 89 | fileStore, err := storage.NewFileStorage(cfg.Database.FilePath) 90 | if err != nil { 91 | logger.Error("failed to initialize file storage", "error", err) 92 | os.Exit(1) 93 | } 94 | store = fileStore 95 | logger.Info("using file storage", "path", cfg.Database.FilePath) 96 | } 97 | defer store.Close() 98 | 99 | // Initialize cache 100 | _ = cache.NewCache(cfg) // Cache initialized for future use 101 | logger.Info("cache initialized", "type", "memory") 102 | 103 | // Initialize service layer 104 | postService := service.NewPostService(store) 105 | logger.Info("service layer initialized") 106 | 107 | // Initialize API handlers 108 | apiHandler := api.NewAPI(postService) 109 | apiRouter := api.NewRouter(apiHandler) 110 | logger.Info("API handlers initialized") 111 | 112 | // Initialize web handlers 113 | webHandlers, err := handlers.New(store, cfg) 114 | if err != nil { 115 | logger.Error("failed to initialize web handlers", "error", err) 116 | os.Exit(1) 117 | } 118 | defer webHandlers.Close() 119 | 120 | // Setup HTTP router 121 | mux := http.NewServeMux() 122 | 123 | // Health and monitoring endpoints 124 | mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 125 | uptime := time.Since(startTime) 126 | w.Header().Set("Content-Type", "application/json") 127 | w.WriteHeader(http.StatusOK) 128 | fmt.Fprintf(w, `{"status":"healthy","version":"%s","uptime":"%s","timestamp":"%s"}`, 129 | version, uptime, time.Now().Format(time.RFC3339)) 130 | }) 131 | mux.HandleFunc("/readiness", webHandlers.Readiness) 132 | mux.Handle("/metrics", metrics.Handler()) 133 | 134 | // API endpoints (v1) 135 | mux.Handle("/api/", apiRouter) 136 | mux.Handle("/api/v1/", apiRouter) 137 | 138 | // Web interface endpoints 139 | mux.HandleFunc("/", webHandlers.Home) 140 | mux.HandleFunc("/fetch", webHandlers.FetchPosts) 141 | mux.HandleFunc("/analyze", webHandlers.AnalyzePosts) 142 | mux.HandleFunc("/add", webHandlers.AddPost) 143 | 144 | // Serve static assets 145 | mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets")))) 146 | 147 | // Create rate limiter 148 | rateLimiter := middleware.NewRateLimiter( 149 | cfg.Security.RateLimitRequests, 150 | cfg.Security.RateLimitWindow, 151 | ) 152 | 153 | // Apply middleware chain 154 | handler := middleware.Chain( 155 | middleware.RequestID, 156 | middleware.Logging, 157 | middleware.Recovery, 158 | middleware.SecurityHeaders, 159 | middleware.CORS(cfg.Security.AllowedOrigins), 160 | rateLimiter.Middleware, 161 | middleware.MaxBodySize(cfg.Security.MaxBodySize), 162 | middleware.Compression, 163 | metrics.Middleware, 164 | )(mux) 165 | 166 | // Create HTTP server with production settings 167 | server := &http.Server{ 168 | Addr: fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port), 169 | Handler: handler, 170 | ReadTimeout: cfg.Server.ReadTimeout, 171 | WriteTimeout: cfg.Server.WriteTimeout, 172 | IdleTimeout: cfg.Server.IdleTimeout, 173 | } 174 | 175 | // Start server in a goroutine 176 | go func() { 177 | logger.Info("server listening", 178 | "address", server.Addr, 179 | "read_timeout", cfg.Server.ReadTimeout, 180 | "write_timeout", cfg.Server.WriteTimeout, 181 | ) 182 | logger.Info("endpoints available", 183 | "web", fmt.Sprintf("http://%s:%s/", cfg.Server.Host, cfg.Server.Port), 184 | "api", fmt.Sprintf("http://%s:%s/api/v1/posts", cfg.Server.Host, cfg.Server.Port), 185 | "health", fmt.Sprintf("http://%s:%s/health", cfg.Server.Host, cfg.Server.Port), 186 | "metrics", fmt.Sprintf("http://%s:%s/metrics", cfg.Server.Host, cfg.Server.Port), 187 | ) 188 | 189 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 190 | logger.Error("server failed to start", "error", err) 191 | os.Exit(1) 192 | } 193 | }() 194 | 195 | // Wait for interrupt signal for graceful shutdown 196 | quit := make(chan os.Signal, 1) 197 | signal.Notify(quit, os.Interrupt, syscall.SIGTERM) 198 | <-quit 199 | 200 | logger.Info("shutting down server gracefully...") 201 | 202 | // Create shutdown context with timeout 203 | ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) 204 | defer cancel() 205 | 206 | // Attempt graceful shutdown 207 | if err := server.Shutdown(ctx); err != nil { 208 | logger.Error("server forced to shutdown", "error", err) 209 | os.Exit(1) 210 | } 211 | 212 | logger.Info("server stopped gracefully") 213 | } 214 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | // Config holds all configuration for the application 11 | type Config struct { 12 | Server ServerConfig 13 | Database DatabaseConfig 14 | Security SecurityConfig 15 | Logging LoggingConfig 16 | External ExternalConfig 17 | } 18 | 19 | // ServerConfig contains server-related configuration 20 | type ServerConfig struct { 21 | Port string 22 | Host string 23 | ReadTimeout time.Duration 24 | WriteTimeout time.Duration 25 | IdleTimeout time.Duration 26 | ShutdownTimeout time.Duration 27 | Environment string 28 | } 29 | 30 | // DatabaseConfig contains database-related configuration 31 | type DatabaseConfig struct { 32 | Type string // "file" or "postgres" 33 | FilePath string 34 | Host string 35 | Port string 36 | User string 37 | Password string 38 | DBName string 39 | SSLMode string 40 | MaxConns int 41 | MinConns int 42 | } 43 | 44 | // SecurityConfig contains security-related configuration 45 | type SecurityConfig struct { 46 | RateLimitRequests int 47 | RateLimitWindow time.Duration 48 | MaxBodySize int64 49 | AllowedOrigins []string 50 | TrustedProxies []string 51 | } 52 | 53 | // LoggingConfig contains logging-related configuration 54 | type LoggingConfig struct { 55 | Level string 56 | Format string // "json" or "text" 57 | Output string // "stdout" or file path 58 | TimeFormat string 59 | } 60 | 61 | // ExternalConfig contains external service configuration 62 | type ExternalConfig struct { 63 | JSONPlaceholderURL string 64 | HTTPTimeout time.Duration 65 | } 66 | 67 | // Load reads configuration from environment variables with sensible defaults 68 | func Load() (*Config, error) { 69 | cfg := &Config{ 70 | Server: ServerConfig{ 71 | Port: getEnv("PORT", "8080"), 72 | Host: getEnv("HOST", "0.0.0.0"), 73 | ReadTimeout: getDurationEnv("READ_TIMEOUT", 15*time.Second), 74 | WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 15*time.Second), 75 | IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60*time.Second), 76 | ShutdownTimeout: getDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second), 77 | Environment: getEnv("ENVIRONMENT", "development"), 78 | }, 79 | Database: DatabaseConfig{ 80 | Type: getEnv("DB_TYPE", "file"), 81 | FilePath: getEnv("DB_FILE_PATH", "posts.json"), 82 | Host: getEnv("DB_HOST", "localhost"), 83 | Port: getEnv("DB_PORT", "5432"), 84 | User: getEnv("DB_USER", "postgres"), 85 | Password: getEnv("DB_PASSWORD", ""), 86 | DBName: getEnv("DB_NAME", "postanalyzer"), 87 | SSLMode: getEnv("DB_SSL_MODE", "disable"), 88 | MaxConns: getIntEnv("DB_MAX_CONNS", 25), 89 | MinConns: getIntEnv("DB_MIN_CONNS", 5), 90 | }, 91 | Security: SecurityConfig{ 92 | RateLimitRequests: getIntEnv("RATE_LIMIT_REQUESTS", 100), 93 | RateLimitWindow: getDurationEnv("RATE_LIMIT_WINDOW", 1*time.Minute), 94 | MaxBodySize: getInt64Env("MAX_BODY_SIZE", 1*1024*1024), // 1MB 95 | AllowedOrigins: getSliceEnv("ALLOWED_ORIGINS", []string{"*"}), 96 | TrustedProxies: getSliceEnv("TRUSTED_PROXIES", []string{}), 97 | }, 98 | Logging: LoggingConfig{ 99 | Level: getEnv("LOG_LEVEL", "info"), 100 | Format: getEnv("LOG_FORMAT", "json"), 101 | Output: getEnv("LOG_OUTPUT", "stdout"), 102 | TimeFormat: getEnv("LOG_TIME_FORMAT", time.RFC3339), 103 | }, 104 | External: ExternalConfig{ 105 | JSONPlaceholderURL: getEnv("JSONPLACEHOLDER_URL", "https://jsonplaceholder.typicode.com/posts"), 106 | HTTPTimeout: getDurationEnv("HTTP_TIMEOUT", 30*time.Second), 107 | }, 108 | } 109 | 110 | if err := cfg.Validate(); err != nil { 111 | return nil, fmt.Errorf("invalid configuration: %w", err) 112 | } 113 | 114 | return cfg, nil 115 | } 116 | 117 | // Validate checks if the configuration is valid 118 | func (c *Config) Validate() error { 119 | // Validate server config 120 | if c.Server.Port == "" { 121 | return fmt.Errorf("server port cannot be empty") 122 | } 123 | if c.Server.Environment != "development" && c.Server.Environment != "staging" && c.Server.Environment != "production" { 124 | return fmt.Errorf("environment must be one of: development, staging, production") 125 | } 126 | 127 | // Validate database config 128 | if c.Database.Type != "file" && c.Database.Type != "postgres" { 129 | return fmt.Errorf("database type must be 'file' or 'postgres'") 130 | } 131 | if c.Database.Type == "file" && c.Database.FilePath == "" { 132 | return fmt.Errorf("database file path cannot be empty when using file storage") 133 | } 134 | if c.Database.Type == "postgres" { 135 | if c.Database.Host == "" || c.Database.DBName == "" { 136 | return fmt.Errorf("database host and name are required for postgres") 137 | } 138 | } 139 | 140 | // Validate security config 141 | if c.Security.RateLimitRequests <= 0 { 142 | return fmt.Errorf("rate limit requests must be positive") 143 | } 144 | if c.Security.MaxBodySize <= 0 { 145 | return fmt.Errorf("max body size must be positive") 146 | } 147 | 148 | // Validate logging config 149 | validLogLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} 150 | if !validLogLevels[c.Logging.Level] { 151 | return fmt.Errorf("log level must be one of: debug, info, warn, error") 152 | } 153 | if c.Logging.Format != "json" && c.Logging.Format != "text" { 154 | return fmt.Errorf("log format must be 'json' or 'text'") 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // IsDevelopment returns true if running in development mode 161 | func (c *Config) IsDevelopment() bool { 162 | return c.Server.Environment == "development" 163 | } 164 | 165 | // IsProduction returns true if running in production mode 166 | func (c *Config) IsProduction() bool { 167 | return c.Server.Environment == "production" 168 | } 169 | 170 | // Helper functions for reading environment variables 171 | 172 | func getEnv(key, defaultValue string) string { 173 | if value := os.Getenv(key); value != "" { 174 | return value 175 | } 176 | return defaultValue 177 | } 178 | 179 | func getIntEnv(key string, defaultValue int) int { 180 | if value := os.Getenv(key); value != "" { 181 | if intVal, err := strconv.Atoi(value); err == nil { 182 | return intVal 183 | } 184 | } 185 | return defaultValue 186 | } 187 | 188 | func getInt64Env(key string, defaultValue int64) int64 { 189 | if value := os.Getenv(key); value != "" { 190 | if intVal, err := strconv.ParseInt(value, 10, 64); err == nil { 191 | return intVal 192 | } 193 | } 194 | return defaultValue 195 | } 196 | 197 | func getDurationEnv(key string, defaultValue time.Duration) time.Duration { 198 | if value := os.Getenv(key); value != "" { 199 | if duration, err := time.ParseDuration(value); err == nil { 200 | return duration 201 | } 202 | } 203 | return defaultValue 204 | } 205 | 206 | func getSliceEnv(key string, defaultValue []string) []string { 207 | if value := os.Getenv(key); value != "" { 208 | // Simple comma-separated parsing 209 | result := []string{} 210 | current := "" 211 | for _, char := range value { 212 | if char == ',' { 213 | if current != "" { 214 | result = append(result, current) 215 | current = "" 216 | } 217 | } else { 218 | current += string(char) 219 | } 220 | } 221 | if current != "" { 222 | result = append(result, current) 223 | } 224 | return result 225 | } 226 | return defaultValue 227 | } 228 | -------------------------------------------------------------------------------- /internal/storage/file.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "Post_Analyzer_Webserver/internal/logger" 12 | "Post_Analyzer_Webserver/internal/metrics" 13 | ) 14 | 15 | // FileStorage implements Storage interface using JSON file 16 | type FileStorage struct { 17 | filePath string 18 | mu sync.RWMutex 19 | } 20 | 21 | // NewFileStorage creates a new file-based storage 22 | func NewFileStorage(filePath string) (*FileStorage, error) { 23 | fs := &FileStorage{ 24 | filePath: filePath, 25 | } 26 | 27 | // Initialize file if it doesn't exist 28 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 29 | if err := fs.writeToFile([]Post{}); err != nil { 30 | return nil, err 31 | } 32 | } 33 | 34 | return fs, nil 35 | } 36 | 37 | // GetAll retrieves all posts 38 | func (fs *FileStorage) GetAll(ctx context.Context) ([]Post, error) { 39 | start := time.Now() 40 | defer func() { 41 | metrics.RecordDBOperation("get_all", "success", time.Since(start)) 42 | }() 43 | 44 | fs.mu.RLock() 45 | defer fs.mu.RUnlock() 46 | 47 | posts, err := fs.readFromFile() 48 | if err != nil { 49 | metrics.RecordDBOperation("get_all", "error", time.Since(start)) 50 | logger.ErrorContext(ctx, "failed to read posts from file", "error", err) 51 | return nil, err 52 | } 53 | 54 | metrics.RecordPostsTotal(len(posts)) 55 | return posts, nil 56 | } 57 | 58 | // GetByID retrieves a post by ID 59 | func (fs *FileStorage) GetByID(ctx context.Context, id int) (*Post, error) { 60 | start := time.Now() 61 | defer func() { 62 | metrics.RecordDBOperation("get_by_id", "success", time.Since(start)) 63 | }() 64 | 65 | fs.mu.RLock() 66 | defer fs.mu.RUnlock() 67 | 68 | posts, err := fs.readFromFile() 69 | if err != nil { 70 | metrics.RecordDBOperation("get_by_id", "error", time.Since(start)) 71 | return nil, err 72 | } 73 | 74 | for _, post := range posts { 75 | if post.Id == id { 76 | return &post, nil 77 | } 78 | } 79 | 80 | return nil, ErrNotFound 81 | } 82 | 83 | // Create creates a new post 84 | func (fs *FileStorage) Create(ctx context.Context, post *Post) error { 85 | start := time.Now() 86 | defer func() { 87 | metrics.RecordDBOperation("create", "success", time.Since(start)) 88 | }() 89 | 90 | if err := post.Validate(); err != nil { 91 | return err 92 | } 93 | 94 | fs.mu.Lock() 95 | defer fs.mu.Unlock() 96 | 97 | posts, err := fs.readFromFile() 98 | if err != nil { 99 | metrics.RecordDBOperation("create", "error", time.Since(start)) 100 | return err 101 | } 102 | 103 | // Generate new ID 104 | maxID := 0 105 | for _, p := range posts { 106 | if p.Id > maxID { 107 | maxID = p.Id 108 | } 109 | } 110 | post.Id = maxID + 1 111 | post.CreatedAt = time.Now() 112 | post.UpdatedAt = time.Now() 113 | 114 | posts = append(posts, *post) 115 | 116 | if err := fs.writeToFile(posts); err != nil { 117 | metrics.RecordDBOperation("create", "error", time.Since(start)) 118 | return err 119 | } 120 | 121 | metrics.RecordPostAdded() 122 | logger.InfoContext(ctx, "post created", "id", post.Id) 123 | return nil 124 | } 125 | 126 | // Update updates an existing post 127 | func (fs *FileStorage) Update(ctx context.Context, post *Post) error { 128 | start := time.Now() 129 | defer func() { 130 | metrics.RecordDBOperation("update", "success", time.Since(start)) 131 | }() 132 | 133 | if err := post.Validate(); err != nil { 134 | return err 135 | } 136 | 137 | fs.mu.Lock() 138 | defer fs.mu.Unlock() 139 | 140 | posts, err := fs.readFromFile() 141 | if err != nil { 142 | metrics.RecordDBOperation("update", "error", time.Since(start)) 143 | return err 144 | } 145 | 146 | found := false 147 | for i, p := range posts { 148 | if p.Id == post.Id { 149 | post.UpdatedAt = time.Now() 150 | posts[i] = *post 151 | found = true 152 | break 153 | } 154 | } 155 | 156 | if !found { 157 | return ErrNotFound 158 | } 159 | 160 | if err := fs.writeToFile(posts); err != nil { 161 | metrics.RecordDBOperation("update", "error", time.Since(start)) 162 | return err 163 | } 164 | 165 | logger.InfoContext(ctx, "post updated", "id", post.Id) 166 | return nil 167 | } 168 | 169 | // Delete deletes a post by ID 170 | func (fs *FileStorage) Delete(ctx context.Context, id int) error { 171 | start := time.Now() 172 | defer func() { 173 | metrics.RecordDBOperation("delete", "success", time.Since(start)) 174 | }() 175 | 176 | fs.mu.Lock() 177 | defer fs.mu.Unlock() 178 | 179 | posts, err := fs.readFromFile() 180 | if err != nil { 181 | metrics.RecordDBOperation("delete", "error", time.Since(start)) 182 | return err 183 | } 184 | 185 | found := false 186 | newPosts := make([]Post, 0, len(posts)) 187 | for _, p := range posts { 188 | if p.Id != id { 189 | newPosts = append(newPosts, p) 190 | } else { 191 | found = true 192 | } 193 | } 194 | 195 | if !found { 196 | return ErrNotFound 197 | } 198 | 199 | if err := fs.writeToFile(newPosts); err != nil { 200 | metrics.RecordDBOperation("delete", "error", time.Since(start)) 201 | return err 202 | } 203 | 204 | logger.InfoContext(ctx, "post deleted", "id", id) 205 | return nil 206 | } 207 | 208 | // BatchCreate creates multiple posts in a batch 209 | func (fs *FileStorage) BatchCreate(ctx context.Context, newPosts []Post) error { 210 | start := time.Now() 211 | defer func() { 212 | metrics.RecordDBOperation("batch_create", "success", time.Since(start)) 213 | }() 214 | 215 | fs.mu.Lock() 216 | defer fs.mu.Unlock() 217 | 218 | existingPosts, err := fs.readFromFile() 219 | if err != nil && !errors.Is(err, os.ErrNotExist) { 220 | metrics.RecordDBOperation("batch_create", "error", time.Since(start)) 221 | return err 222 | } 223 | 224 | // Use existing posts if available, otherwise start fresh 225 | now := time.Now() 226 | for i := range newPosts { 227 | if newPosts[i].CreatedAt.IsZero() { 228 | newPosts[i].CreatedAt = now 229 | } 230 | if newPosts[i].UpdatedAt.IsZero() { 231 | newPosts[i].UpdatedAt = now 232 | } 233 | } 234 | 235 | if err := fs.writeToFile(newPosts); err != nil { 236 | metrics.RecordDBOperation("batch_create", "error", time.Since(start)) 237 | return err 238 | } 239 | 240 | metrics.RecordPostsFetched(len(newPosts)) 241 | logger.InfoContext(ctx, "batch posts created", "count", len(newPosts), "previous_count", len(existingPosts)) 242 | return nil 243 | } 244 | 245 | // Count returns the total number of posts 246 | func (fs *FileStorage) Count(ctx context.Context) (int, error) { 247 | fs.mu.RLock() 248 | defer fs.mu.RUnlock() 249 | 250 | posts, err := fs.readFromFile() 251 | if err != nil { 252 | return 0, err 253 | } 254 | 255 | return len(posts), nil 256 | } 257 | 258 | // Close closes the storage (no-op for file storage) 259 | func (fs *FileStorage) Close() error { 260 | return nil 261 | } 262 | 263 | // readFromFile reads posts from the JSON file 264 | func (fs *FileStorage) readFromFile() ([]Post, error) { 265 | data, err := os.ReadFile(fs.filePath) 266 | if err != nil { 267 | if os.IsNotExist(err) { 268 | return []Post{}, nil 269 | } 270 | return nil, err 271 | } 272 | 273 | if len(data) == 0 { 274 | return []Post{}, nil 275 | } 276 | 277 | var posts []Post 278 | if err := json.Unmarshal(data, &posts); err != nil { 279 | return nil, err 280 | } 281 | 282 | return posts, nil 283 | } 284 | 285 | // writeToFile writes posts to the JSON file 286 | func (fs *FileStorage) writeToFile(posts []Post) error { 287 | data, err := json.MarshalIndent(posts, "", " ") 288 | if err != nil { 289 | return err 290 | } 291 | 292 | return os.WriteFile(fs.filePath, data, 0644) 293 | } 294 | -------------------------------------------------------------------------------- /internal/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "Post_Analyzer_Webserver/internal/logger" 10 | ) 11 | 12 | func TestRequestID(t *testing.T) { 13 | handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | requestID := r.Context().Value(logger.RequestIDKey) 15 | if requestID == nil { 16 | t.Error("Expected request ID in context") 17 | } 18 | w.WriteHeader(http.StatusOK) 19 | })) 20 | 21 | req := httptest.NewRequest("GET", "/test", nil) 22 | rr := httptest.NewRecorder() 23 | 24 | handler.ServeHTTP(rr, req) 25 | 26 | if rr.Header().Get("X-Request-ID") == "" { 27 | t.Error("Expected X-Request-ID header") 28 | } 29 | } 30 | 31 | func TestRequestIDFromHeader(t *testing.T) { 32 | customID := "custom-request-id" 33 | 34 | handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | requestID := r.Context().Value(logger.RequestIDKey) 36 | if requestID != customID { 37 | t.Errorf("Expected request ID %s, got %v", customID, requestID) 38 | } 39 | })) 40 | 41 | req := httptest.NewRequest("GET", "/test", nil) 42 | req.Header.Set("X-Request-ID", customID) 43 | rr := httptest.NewRecorder() 44 | 45 | handler.ServeHTTP(rr, req) 46 | 47 | if rr.Header().Get("X-Request-ID") != customID { 48 | t.Errorf("Expected X-Request-ID header %s, got %s", customID, rr.Header().Get("X-Request-ID")) 49 | } 50 | } 51 | 52 | func TestRecovery(t *testing.T) { 53 | handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | panic("test panic") 55 | })) 56 | 57 | req := httptest.NewRequest("GET", "/test", nil) 58 | rr := httptest.NewRecorder() 59 | 60 | // Should not panic 61 | handler.ServeHTTP(rr, req) 62 | 63 | if rr.Code != http.StatusInternalServerError { 64 | t.Errorf("Expected status 500, got %d", rr.Code) 65 | } 66 | } 67 | 68 | func TestSecurityHeaders(t *testing.T) { 69 | handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 70 | w.WriteHeader(http.StatusOK) 71 | })) 72 | 73 | req := httptest.NewRequest("GET", "/test", nil) 74 | rr := httptest.NewRecorder() 75 | 76 | handler.ServeHTTP(rr, req) 77 | 78 | headers := map[string]string{ 79 | "X-Content-Type-Options": "nosniff", 80 | "X-Frame-Options": "DENY", 81 | "X-XSS-Protection": "1; mode=block", 82 | } 83 | 84 | for header, expectedValue := range headers { 85 | actualValue := rr.Header().Get(header) 86 | if actualValue != expectedValue { 87 | t.Errorf("Expected %s header to be %s, got %s", header, expectedValue, actualValue) 88 | } 89 | } 90 | } 91 | 92 | func TestCORS(t *testing.T) { 93 | allowedOrigins := []string{"http://example.com"} 94 | handler := CORS(allowedOrigins)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 | w.WriteHeader(http.StatusOK) 96 | })) 97 | 98 | // Test with allowed origin 99 | req := httptest.NewRequest("GET", "/test", nil) 100 | req.Header.Set("Origin", "http://example.com") 101 | rr := httptest.NewRecorder() 102 | 103 | handler.ServeHTTP(rr, req) 104 | 105 | if rr.Header().Get("Access-Control-Allow-Origin") != "http://example.com" { 106 | t.Error("Expected CORS headers for allowed origin") 107 | } 108 | 109 | // Test with disallowed origin 110 | req = httptest.NewRequest("GET", "/test", nil) 111 | req.Header.Set("Origin", "http://evil.com") 112 | rr = httptest.NewRecorder() 113 | 114 | handler.ServeHTTP(rr, req) 115 | 116 | if rr.Header().Get("Access-Control-Allow-Origin") != "" { 117 | t.Error("Should not set CORS headers for disallowed origin") 118 | } 119 | } 120 | 121 | func TestCORSPreflight(t *testing.T) { 122 | allowedOrigins := []string{"*"} 123 | handler := CORS(allowedOrigins)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 124 | t.Error("Should not call next handler for OPTIONS request") 125 | })) 126 | 127 | req := httptest.NewRequest("OPTIONS", "/test", nil) 128 | req.Header.Set("Origin", "http://example.com") 129 | rr := httptest.NewRecorder() 130 | 131 | handler.ServeHTTP(rr, req) 132 | 133 | if rr.Code != http.StatusNoContent { 134 | t.Errorf("Expected status 204 for OPTIONS, got %d", rr.Code) 135 | } 136 | } 137 | 138 | func TestRateLimiter(t *testing.T) { 139 | limit := 5 140 | window := 1 * time.Second 141 | rl := NewRateLimiter(limit, window) 142 | 143 | handler := rl.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 | w.WriteHeader(http.StatusOK) 145 | })) 146 | 147 | // Make requests up to the limit 148 | for i := 0; i < limit; i++ { 149 | req := httptest.NewRequest("GET", "/test", nil) 150 | rr := httptest.NewRecorder() 151 | handler.ServeHTTP(rr, req) 152 | 153 | if rr.Code != http.StatusOK { 154 | t.Errorf("Request %d should succeed, got status %d", i+1, rr.Code) 155 | } 156 | } 157 | 158 | // Next request should be rate limited 159 | req := httptest.NewRequest("GET", "/test", nil) 160 | rr := httptest.NewRecorder() 161 | handler.ServeHTTP(rr, req) 162 | 163 | if rr.Code != http.StatusTooManyRequests { 164 | t.Errorf("Expected rate limit (429), got %d", rr.Code) 165 | } 166 | 167 | // Wait for window to reset 168 | time.Sleep(window + 100*time.Millisecond) 169 | 170 | // Should work again 171 | req = httptest.NewRequest("GET", "/test", nil) 172 | rr = httptest.NewRecorder() 173 | handler.ServeHTTP(rr, req) 174 | 175 | if rr.Code != http.StatusOK { 176 | t.Errorf("Expected success after window reset, got %d", rr.Code) 177 | } 178 | } 179 | 180 | func TestMaxBodySize(t *testing.T) { 181 | maxSize := int64(100) 182 | handler := MaxBodySize(maxSize)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 183 | // Try to read body 184 | buf := make([]byte, maxSize+1) 185 | _, err := r.Body.Read(buf) 186 | if err == nil { 187 | t.Error("Expected error when reading body larger than max size") 188 | } 189 | w.WriteHeader(http.StatusOK) 190 | })) 191 | 192 | // Create request with body larger than max size 193 | body := make([]byte, maxSize+1) 194 | req := httptest.NewRequest("POST", "/test", nil) 195 | rr := httptest.NewRecorder() 196 | 197 | handler.ServeHTTP(rr, req) 198 | _ = body // Use body variable 199 | } 200 | 201 | func TestChain(t *testing.T) { 202 | called := []string{} 203 | 204 | middleware1 := func(next http.Handler) http.Handler { 205 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 206 | called = append(called, "middleware1") 207 | next.ServeHTTP(w, r) 208 | }) 209 | } 210 | 211 | middleware2 := func(next http.Handler) http.Handler { 212 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 213 | called = append(called, "middleware2") 214 | next.ServeHTTP(w, r) 215 | }) 216 | } 217 | 218 | finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 219 | called = append(called, "handler") 220 | w.WriteHeader(http.StatusOK) 221 | }) 222 | 223 | chained := Chain(middleware1, middleware2)(finalHandler) 224 | 225 | req := httptest.NewRequest("GET", "/test", nil) 226 | rr := httptest.NewRecorder() 227 | 228 | chained.ServeHTTP(rr, req) 229 | 230 | // Check order of execution 231 | if len(called) != 3 { 232 | t.Errorf("Expected 3 calls, got %d", len(called)) 233 | } 234 | if called[0] != "middleware1" || called[1] != "middleware2" || called[2] != "handler" { 235 | t.Errorf("Unexpected execution order: %v", called) 236 | } 237 | } 238 | 239 | func TestGetClientIP(t *testing.T) { 240 | tests := []struct { 241 | name string 242 | remoteAddr string 243 | xForwardedFor string 244 | xRealIP string 245 | expectedIP string 246 | }{ 247 | { 248 | name: "from RemoteAddr", 249 | remoteAddr: "192.168.1.1:1234", 250 | expectedIP: "192.168.1.1", 251 | }, 252 | { 253 | name: "from X-Forwarded-For", 254 | remoteAddr: "192.168.1.1:1234", 255 | xForwardedFor: "10.0.0.1, 10.0.0.2", 256 | expectedIP: "10.0.0.1", 257 | }, 258 | { 259 | name: "from X-Real-IP", 260 | remoteAddr: "192.168.1.1:1234", 261 | xRealIP: "10.0.0.1", 262 | expectedIP: "10.0.0.1", 263 | }, 264 | } 265 | 266 | for _, tt := range tests { 267 | t.Run(tt.name, func(t *testing.T) { 268 | req := httptest.NewRequest("GET", "/test", nil) 269 | req.RemoteAddr = tt.remoteAddr 270 | if tt.xForwardedFor != "" { 271 | req.Header.Set("X-Forwarded-For", tt.xForwardedFor) 272 | } 273 | if tt.xRealIP != "" { 274 | req.Header.Set("X-Real-IP", tt.xRealIP) 275 | } 276 | 277 | ip := getClientIP(req) 278 | if ip != tt.expectedIP { 279 | t.Errorf("Expected IP %s, got %s", tt.expectedIP, ip) 280 | } 281 | }) 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /internal/storage/file_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestFileStorage_CreateAndGet(t *testing.T) { 11 | // Create temporary directory for test 12 | tmpDir := t.TempDir() 13 | filePath := filepath.Join(tmpDir, "test_posts.json") 14 | 15 | store, err := NewFileStorage(filePath) 16 | if err != nil { 17 | t.Fatalf("Failed to create file storage: %v", err) 18 | } 19 | defer store.Close() 20 | 21 | ctx := context.Background() 22 | 23 | // Create a post 24 | post := &Post{ 25 | UserId: 1, 26 | Title: "Test Post", 27 | Body: "This is a test post body", 28 | } 29 | 30 | err = store.Create(ctx, post) 31 | if err != nil { 32 | t.Fatalf("Failed to create post: %v", err) 33 | } 34 | 35 | if post.Id == 0 { 36 | t.Error("Post ID should be assigned") 37 | } 38 | 39 | // Retrieve the post 40 | retrieved, err := store.GetByID(ctx, post.Id) 41 | if err != nil { 42 | t.Fatalf("Failed to get post: %v", err) 43 | } 44 | 45 | if retrieved.Title != post.Title { 46 | t.Errorf("Expected title %s, got %s", post.Title, retrieved.Title) 47 | } 48 | if retrieved.Body != post.Body { 49 | t.Errorf("Expected body %s, got %s", post.Body, retrieved.Body) 50 | } 51 | } 52 | 53 | func TestFileStorage_GetAll(t *testing.T) { 54 | tmpDir := t.TempDir() 55 | filePath := filepath.Join(tmpDir, "test_posts.json") 56 | 57 | store, err := NewFileStorage(filePath) 58 | if err != nil { 59 | t.Fatalf("Failed to create file storage: %v", err) 60 | } 61 | defer store.Close() 62 | 63 | ctx := context.Background() 64 | 65 | // Create multiple posts 66 | for i := 1; i <= 3; i++ { 67 | post := &Post{ 68 | UserId: i, 69 | Title: "Test Post " + string(rune(i)), 70 | Body: "Test body", 71 | } 72 | if err := store.Create(ctx, post); err != nil { 73 | t.Fatalf("Failed to create post: %v", err) 74 | } 75 | } 76 | 77 | // Get all posts 78 | posts, err := store.GetAll(ctx) 79 | if err != nil { 80 | t.Fatalf("Failed to get all posts: %v", err) 81 | } 82 | 83 | if len(posts) != 3 { 84 | t.Errorf("Expected 3 posts, got %d", len(posts)) 85 | } 86 | } 87 | 88 | func TestFileStorage_Update(t *testing.T) { 89 | tmpDir := t.TempDir() 90 | filePath := filepath.Join(tmpDir, "test_posts.json") 91 | 92 | store, err := NewFileStorage(filePath) 93 | if err != nil { 94 | t.Fatalf("Failed to create file storage: %v", err) 95 | } 96 | defer store.Close() 97 | 98 | ctx := context.Background() 99 | 100 | // Create a post 101 | post := &Post{ 102 | UserId: 1, 103 | Title: "Original Title", 104 | Body: "Original Body", 105 | } 106 | if err := store.Create(ctx, post); err != nil { 107 | t.Fatalf("Failed to create post: %v", err) 108 | } 109 | 110 | // Update the post 111 | post.Title = "Updated Title" 112 | post.Body = "Updated Body" 113 | if err := store.Update(ctx, post); err != nil { 114 | t.Fatalf("Failed to update post: %v", err) 115 | } 116 | 117 | // Retrieve and verify 118 | retrieved, err := store.GetByID(ctx, post.Id) 119 | if err != nil { 120 | t.Fatalf("Failed to get post: %v", err) 121 | } 122 | 123 | if retrieved.Title != "Updated Title" { 124 | t.Errorf("Expected updated title, got %s", retrieved.Title) 125 | } 126 | if retrieved.Body != "Updated Body" { 127 | t.Errorf("Expected updated body, got %s", retrieved.Body) 128 | } 129 | } 130 | 131 | func TestFileStorage_Delete(t *testing.T) { 132 | tmpDir := t.TempDir() 133 | filePath := filepath.Join(tmpDir, "test_posts.json") 134 | 135 | store, err := NewFileStorage(filePath) 136 | if err != nil { 137 | t.Fatalf("Failed to create file storage: %v", err) 138 | } 139 | defer store.Close() 140 | 141 | ctx := context.Background() 142 | 143 | // Create a post 144 | post := &Post{ 145 | UserId: 1, 146 | Title: "To Be Deleted", 147 | Body: "This post will be deleted", 148 | } 149 | if err := store.Create(ctx, post); err != nil { 150 | t.Fatalf("Failed to create post: %v", err) 151 | } 152 | 153 | // Delete the post 154 | if err := store.Delete(ctx, post.Id); err != nil { 155 | t.Fatalf("Failed to delete post: %v", err) 156 | } 157 | 158 | // Try to retrieve - should get error 159 | _, err = store.GetByID(ctx, post.Id) 160 | if err != ErrNotFound { 161 | t.Errorf("Expected ErrNotFound, got %v", err) 162 | } 163 | } 164 | 165 | func TestFileStorage_Validation(t *testing.T) { 166 | tmpDir := t.TempDir() 167 | filePath := filepath.Join(tmpDir, "test_posts.json") 168 | 169 | store, err := NewFileStorage(filePath) 170 | if err != nil { 171 | t.Fatalf("Failed to create file storage: %v", err) 172 | } 173 | defer store.Close() 174 | 175 | ctx := context.Background() 176 | 177 | // Test empty title 178 | post := &Post{ 179 | UserId: 1, 180 | Title: "", 181 | Body: "Body", 182 | } 183 | err = store.Create(ctx, post) 184 | if err == nil { 185 | t.Error("Expected error for empty title") 186 | } 187 | 188 | // Test empty body 189 | post = &Post{ 190 | UserId: 1, 191 | Title: "Title", 192 | Body: "", 193 | } 194 | err = store.Create(ctx, post) 195 | if err == nil { 196 | t.Error("Expected error for empty body") 197 | } 198 | } 199 | 200 | func TestFileStorage_Count(t *testing.T) { 201 | tmpDir := t.TempDir() 202 | filePath := filepath.Join(tmpDir, "test_posts.json") 203 | 204 | store, err := NewFileStorage(filePath) 205 | if err != nil { 206 | t.Fatalf("Failed to create file storage: %v", err) 207 | } 208 | defer store.Close() 209 | 210 | ctx := context.Background() 211 | 212 | // Initial count should be 0 213 | count, err := store.Count(ctx) 214 | if err != nil { 215 | t.Fatalf("Failed to count posts: %v", err) 216 | } 217 | if count != 0 { 218 | t.Errorf("Expected count 0, got %d", count) 219 | } 220 | 221 | // Create posts 222 | for i := 0; i < 5; i++ { 223 | post := &Post{ 224 | UserId: 1, 225 | Title: "Test", 226 | Body: "Body", 227 | } 228 | _ = store.Create(ctx, post) 229 | } 230 | 231 | // Count should be 5 232 | count, err = store.Count(ctx) 233 | if err != nil { 234 | t.Fatalf("Failed to count posts: %v", err) 235 | } 236 | if count != 5 { 237 | t.Errorf("Expected count 5, got %d", count) 238 | } 239 | } 240 | 241 | func TestFileStorage_BatchCreate(t *testing.T) { 242 | tmpDir := t.TempDir() 243 | filePath := filepath.Join(tmpDir, "test_posts.json") 244 | 245 | store, err := NewFileStorage(filePath) 246 | if err != nil { 247 | t.Fatalf("Failed to create file storage: %v", err) 248 | } 249 | defer store.Close() 250 | 251 | ctx := context.Background() 252 | 253 | // Create batch of posts 254 | posts := []Post{ 255 | {Id: 1, UserId: 1, Title: "Post 1", Body: "Body 1"}, 256 | {Id: 2, UserId: 1, Title: "Post 2", Body: "Body 2"}, 257 | {Id: 3, UserId: 1, Title: "Post 3", Body: "Body 3"}, 258 | } 259 | 260 | err = store.BatchCreate(ctx, posts) 261 | if err != nil { 262 | t.Fatalf("Failed to batch create posts: %v", err) 263 | } 264 | 265 | // Verify count 266 | count, _ := store.Count(ctx) 267 | if count != 3 { 268 | t.Errorf("Expected 3 posts, got %d", count) 269 | } 270 | 271 | // Verify individual posts exist 272 | for _, post := range posts { 273 | retrieved, err := store.GetByID(ctx, post.Id) 274 | if err != nil { 275 | t.Errorf("Failed to get post %d: %v", post.Id, err) 276 | } 277 | if retrieved.Title != post.Title { 278 | t.Errorf("Expected title %s, got %s", post.Title, retrieved.Title) 279 | } 280 | } 281 | } 282 | 283 | func TestFileStorage_ConcurrentAccess(t *testing.T) { 284 | tmpDir := t.TempDir() 285 | filePath := filepath.Join(tmpDir, "test_posts.json") 286 | 287 | store, err := NewFileStorage(filePath) 288 | if err != nil { 289 | t.Fatalf("Failed to create file storage: %v", err) 290 | } 291 | defer store.Close() 292 | 293 | ctx := context.Background() 294 | 295 | // Create posts concurrently 296 | done := make(chan bool) 297 | for i := 0; i < 10; i++ { 298 | go func(id int) { 299 | post := &Post{ 300 | UserId: id, 301 | Title: "Concurrent Post", 302 | Body: "Body", 303 | } 304 | _ = store.Create(ctx, post) 305 | done <- true 306 | }(i) 307 | } 308 | 309 | // Wait for all goroutines 310 | for i := 0; i < 10; i++ { 311 | <-done 312 | } 313 | 314 | // Verify all posts were created 315 | count, _ := store.Count(ctx) 316 | if count != 10 { 317 | t.Errorf("Expected 10 posts, got %d", count) 318 | } 319 | } 320 | 321 | func TestFileStorage_FileNotExists(t *testing.T) { 322 | tmpDir := t.TempDir() 323 | filePath := filepath.Join(tmpDir, "nonexistent.json") 324 | 325 | // Should create the file if it doesn't exist 326 | store, err := NewFileStorage(filePath) 327 | if err != nil { 328 | t.Fatalf("Failed to create file storage: %v", err) 329 | } 330 | defer store.Close() 331 | 332 | // Verify file was created 333 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 334 | t.Error("Expected file to be created") 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /internal/storage/postgres.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "Post_Analyzer_Webserver/config" 10 | "Post_Analyzer_Webserver/internal/logger" 11 | "Post_Analyzer_Webserver/internal/metrics" 12 | 13 | _ "github.com/lib/pq" 14 | ) 15 | 16 | // PostgresStorage implements Storage interface using PostgreSQL 17 | type PostgresStorage struct { 18 | db *sql.DB 19 | } 20 | 21 | // NewPostgresStorage creates a new PostgreSQL storage 22 | func NewPostgresStorage(cfg *config.DatabaseConfig) (*PostgresStorage, error) { 23 | dsn := fmt.Sprintf( 24 | "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", 25 | cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, 26 | ) 27 | 28 | db, err := sql.Open("postgres", dsn) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to open database: %w", err) 31 | } 32 | 33 | db.SetMaxOpenConns(cfg.MaxConns) 34 | db.SetMaxIdleConns(cfg.MinConns) 35 | db.SetConnMaxLifetime(time.Hour) 36 | 37 | if err := db.Ping(); err != nil { 38 | return nil, fmt.Errorf("failed to ping database: %w", err) 39 | } 40 | 41 | ps := &PostgresStorage{db: db} 42 | 43 | // Initialize schema 44 | if err := ps.initSchema(); err != nil { 45 | return nil, fmt.Errorf("failed to initialize schema: %w", err) 46 | } 47 | 48 | return ps, nil 49 | } 50 | 51 | // initSchema creates the necessary database tables 52 | func (ps *PostgresStorage) initSchema() error { 53 | schema := ` 54 | CREATE TABLE IF NOT EXISTS posts ( 55 | id SERIAL PRIMARY KEY, 56 | user_id INTEGER NOT NULL, 57 | title VARCHAR(500) NOT NULL, 58 | body TEXT NOT NULL, 59 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 60 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 61 | ); 62 | 63 | CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); 64 | CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC); 65 | ` 66 | 67 | _, err := ps.db.Exec(schema) 68 | return err 69 | } 70 | 71 | // GetAll retrieves all posts 72 | func (ps *PostgresStorage) GetAll(ctx context.Context) ([]Post, error) { 73 | start := time.Now() 74 | defer func() { 75 | metrics.RecordDBOperation("get_all", "success", time.Since(start)) 76 | }() 77 | 78 | query := `SELECT id, user_id, title, body, created_at, updated_at FROM posts ORDER BY created_at DESC` 79 | 80 | rows, err := ps.db.QueryContext(ctx, query) 81 | if err != nil { 82 | metrics.RecordDBOperation("get_all", "error", time.Since(start)) 83 | logger.ErrorContext(ctx, "failed to query posts", "error", err) 84 | return nil, err 85 | } 86 | defer rows.Close() 87 | 88 | var posts []Post 89 | for rows.Next() { 90 | var post Post 91 | if err := rows.Scan(&post.Id, &post.UserId, &post.Title, &post.Body, &post.CreatedAt, &post.UpdatedAt); err != nil { 92 | metrics.RecordDBOperation("get_all", "error", time.Since(start)) 93 | return nil, err 94 | } 95 | posts = append(posts, post) 96 | } 97 | 98 | if err := rows.Err(); err != nil { 99 | metrics.RecordDBOperation("get_all", "error", time.Since(start)) 100 | return nil, err 101 | } 102 | 103 | metrics.RecordPostsTotal(len(posts)) 104 | return posts, nil 105 | } 106 | 107 | // GetByID retrieves a post by ID 108 | func (ps *PostgresStorage) GetByID(ctx context.Context, id int) (*Post, error) { 109 | start := time.Now() 110 | defer func() { 111 | metrics.RecordDBOperation("get_by_id", "success", time.Since(start)) 112 | }() 113 | 114 | query := `SELECT id, user_id, title, body, created_at, updated_at FROM posts WHERE id = $1` 115 | 116 | var post Post 117 | err := ps.db.QueryRowContext(ctx, query, id).Scan( 118 | &post.Id, &post.UserId, &post.Title, &post.Body, &post.CreatedAt, &post.UpdatedAt, 119 | ) 120 | 121 | if err == sql.ErrNoRows { 122 | return nil, ErrNotFound 123 | } 124 | if err != nil { 125 | metrics.RecordDBOperation("get_by_id", "error", time.Since(start)) 126 | logger.ErrorContext(ctx, "failed to query post", "id", id, "error", err) 127 | return nil, err 128 | } 129 | 130 | return &post, nil 131 | } 132 | 133 | // Create creates a new post 134 | func (ps *PostgresStorage) Create(ctx context.Context, post *Post) error { 135 | start := time.Now() 136 | defer func() { 137 | metrics.RecordDBOperation("create", "success", time.Since(start)) 138 | }() 139 | 140 | if err := post.Validate(); err != nil { 141 | return err 142 | } 143 | 144 | query := ` 145 | INSERT INTO posts (user_id, title, body, created_at, updated_at) 146 | VALUES ($1, $2, $3, $4, $5) 147 | RETURNING id, created_at, updated_at 148 | ` 149 | 150 | now := time.Now() 151 | err := ps.db.QueryRowContext( 152 | ctx, query, 153 | post.UserId, post.Title, post.Body, now, now, 154 | ).Scan(&post.Id, &post.CreatedAt, &post.UpdatedAt) 155 | 156 | if err != nil { 157 | metrics.RecordDBOperation("create", "error", time.Since(start)) 158 | logger.ErrorContext(ctx, "failed to create post", "error", err) 159 | return err 160 | } 161 | 162 | metrics.RecordPostAdded() 163 | logger.InfoContext(ctx, "post created", "id", post.Id) 164 | return nil 165 | } 166 | 167 | // Update updates an existing post 168 | func (ps *PostgresStorage) Update(ctx context.Context, post *Post) error { 169 | start := time.Now() 170 | defer func() { 171 | metrics.RecordDBOperation("update", "success", time.Since(start)) 172 | }() 173 | 174 | if err := post.Validate(); err != nil { 175 | return err 176 | } 177 | 178 | query := ` 179 | UPDATE posts 180 | SET user_id = $1, title = $2, body = $3, updated_at = $4 181 | WHERE id = $5 182 | RETURNING updated_at 183 | ` 184 | 185 | err := ps.db.QueryRowContext( 186 | ctx, query, 187 | post.UserId, post.Title, post.Body, time.Now(), post.Id, 188 | ).Scan(&post.UpdatedAt) 189 | 190 | if err == sql.ErrNoRows { 191 | return ErrNotFound 192 | } 193 | if err != nil { 194 | metrics.RecordDBOperation("update", "error", time.Since(start)) 195 | logger.ErrorContext(ctx, "failed to update post", "id", post.Id, "error", err) 196 | return err 197 | } 198 | 199 | logger.InfoContext(ctx, "post updated", "id", post.Id) 200 | return nil 201 | } 202 | 203 | // Delete deletes a post by ID 204 | func (ps *PostgresStorage) Delete(ctx context.Context, id int) error { 205 | start := time.Now() 206 | defer func() { 207 | metrics.RecordDBOperation("delete", "success", time.Since(start)) 208 | }() 209 | 210 | query := `DELETE FROM posts WHERE id = $1` 211 | 212 | result, err := ps.db.ExecContext(ctx, query, id) 213 | if err != nil { 214 | metrics.RecordDBOperation("delete", "error", time.Since(start)) 215 | logger.ErrorContext(ctx, "failed to delete post", "id", id, "error", err) 216 | return err 217 | } 218 | 219 | rowsAffected, err := result.RowsAffected() 220 | if err != nil { 221 | return err 222 | } 223 | 224 | if rowsAffected == 0 { 225 | return ErrNotFound 226 | } 227 | 228 | logger.InfoContext(ctx, "post deleted", "id", id) 229 | return nil 230 | } 231 | 232 | // BatchCreate creates multiple posts in a batch 233 | func (ps *PostgresStorage) BatchCreate(ctx context.Context, posts []Post) error { 234 | start := time.Now() 235 | defer func() { 236 | metrics.RecordDBOperation("batch_create", "success", time.Since(start)) 237 | }() 238 | 239 | if len(posts) == 0 { 240 | return nil 241 | } 242 | 243 | tx, err := ps.db.BeginTx(ctx, nil) 244 | if err != nil { 245 | metrics.RecordDBOperation("batch_create", "error", time.Since(start)) 246 | return err 247 | } 248 | defer func() { _ = tx.Rollback() }() 249 | 250 | stmt, err := tx.PrepareContext(ctx, ` 251 | INSERT INTO posts (id, user_id, title, body, created_at, updated_at) 252 | VALUES ($1, $2, $3, $4, $5, $6) 253 | ON CONFLICT (id) DO NOTHING 254 | `) 255 | if err != nil { 256 | metrics.RecordDBOperation("batch_create", "error", time.Since(start)) 257 | return err 258 | } 259 | defer stmt.Close() 260 | 261 | now := time.Now() 262 | for _, post := range posts { 263 | createdAt := post.CreatedAt 264 | if createdAt.IsZero() { 265 | createdAt = now 266 | } 267 | updatedAt := post.UpdatedAt 268 | if updatedAt.IsZero() { 269 | updatedAt = now 270 | } 271 | 272 | _, err := stmt.ExecContext(ctx, post.Id, post.UserId, post.Title, post.Body, createdAt, updatedAt) 273 | if err != nil { 274 | metrics.RecordDBOperation("batch_create", "error", time.Since(start)) 275 | logger.ErrorContext(ctx, "failed to insert post in batch", "id", post.Id, "error", err) 276 | return err 277 | } 278 | } 279 | 280 | if err := tx.Commit(); err != nil { 281 | metrics.RecordDBOperation("batch_create", "error", time.Since(start)) 282 | return err 283 | } 284 | 285 | metrics.RecordPostsFetched(len(posts)) 286 | logger.InfoContext(ctx, "batch posts created", "count", len(posts)) 287 | return nil 288 | } 289 | 290 | // Count returns the total number of posts 291 | func (ps *PostgresStorage) Count(ctx context.Context) (int, error) { 292 | query := `SELECT COUNT(*) FROM posts` 293 | 294 | var count int 295 | err := ps.db.QueryRowContext(ctx, query).Scan(&count) 296 | if err != nil { 297 | logger.ErrorContext(ctx, "failed to count posts", "error", err) 298 | return 0, err 299 | } 300 | 301 | return count, nil 302 | } 303 | 304 | // Close closes the database connection 305 | func (ps *PostgresStorage) Close() error { 306 | return ps.db.Close() 307 | } 308 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "runtime/debug" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "Post_Analyzer_Webserver/internal/logger" 14 | 15 | "github.com/google/uuid" 16 | ) 17 | 18 | // RequestID middleware adds a unique request ID to each request 19 | func RequestID(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | requestID := r.Header.Get("X-Request-ID") 22 | if requestID == "" { 23 | requestID = uuid.New().String() 24 | } 25 | 26 | ctx := context.WithValue(r.Context(), logger.RequestIDKey, requestID) 27 | w.Header().Set("X-Request-ID", requestID) 28 | 29 | next.ServeHTTP(w, r.WithContext(ctx)) 30 | }) 31 | } 32 | 33 | // Logging middleware logs all HTTP requests 34 | func Logging(next http.Handler) http.Handler { 35 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | start := time.Now() 37 | 38 | // Create a response wrapper to capture status code 39 | rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} 40 | 41 | logger.InfoContext(r.Context(), "incoming request", 42 | "method", r.Method, 43 | "path", r.URL.Path, 44 | "remote_addr", r.RemoteAddr, 45 | "user_agent", r.UserAgent(), 46 | ) 47 | 48 | next.ServeHTTP(rw, r) 49 | 50 | duration := time.Since(start) 51 | 52 | logger.InfoContext(r.Context(), "request completed", 53 | "method", r.Method, 54 | "path", r.URL.Path, 55 | "status", rw.statusCode, 56 | "duration_ms", duration.Milliseconds(), 57 | "bytes", rw.bytesWritten, 58 | ) 59 | }) 60 | } 61 | 62 | // responseWriter wraps http.ResponseWriter to capture status code and bytes written 63 | type responseWriter struct { 64 | http.ResponseWriter 65 | statusCode int 66 | bytesWritten int 67 | } 68 | 69 | func (rw *responseWriter) WriteHeader(code int) { 70 | rw.statusCode = code 71 | rw.ResponseWriter.WriteHeader(code) 72 | } 73 | 74 | func (rw *responseWriter) Write(b []byte) (int, error) { 75 | n, err := rw.ResponseWriter.Write(b) 76 | rw.bytesWritten += n 77 | return n, err 78 | } 79 | 80 | // Recovery middleware recovers from panics and logs them 81 | func Recovery(next http.Handler) http.Handler { 82 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 | defer func() { 84 | if err := recover(); err != nil { 85 | logger.ErrorContext(r.Context(), "panic recovered", 86 | "error", err, 87 | "stack", string(debug.Stack()), 88 | ) 89 | 90 | w.Header().Set("Content-Type", "application/json") 91 | w.WriteHeader(http.StatusInternalServerError) 92 | fmt.Fprintf(w, `{"error":"Internal server error"}`) 93 | } 94 | }() 95 | 96 | next.ServeHTTP(w, r) 97 | }) 98 | } 99 | 100 | // SecurityHeaders middleware adds security headers to responses 101 | func SecurityHeaders(next http.Handler) http.Handler { 102 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 | w.Header().Set("X-Content-Type-Options", "nosniff") 104 | w.Header().Set("X-Frame-Options", "DENY") 105 | w.Header().Set("X-XSS-Protection", "1; mode=block") 106 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") 107 | w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'") 108 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") 109 | 110 | next.ServeHTTP(w, r) 111 | }) 112 | } 113 | 114 | // CORS middleware handles Cross-Origin Resource Sharing 115 | func CORS(allowedOrigins []string) func(http.Handler) http.Handler { 116 | return func(next http.Handler) http.Handler { 117 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 | origin := r.Header.Get("Origin") 119 | 120 | // Check if origin is allowed 121 | allowed := false 122 | for _, allowedOrigin := range allowedOrigins { 123 | if allowedOrigin == "*" || allowedOrigin == origin { 124 | allowed = true 125 | break 126 | } 127 | } 128 | 129 | if allowed { 130 | if origin != "" { 131 | w.Header().Set("Access-Control-Allow-Origin", origin) 132 | } else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" { 133 | w.Header().Set("Access-Control-Allow-Origin", "*") 134 | } 135 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 136 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") 137 | w.Header().Set("Access-Control-Max-Age", "3600") 138 | } 139 | 140 | // Handle preflight requests 141 | if r.Method == "OPTIONS" { 142 | w.WriteHeader(http.StatusNoContent) 143 | return 144 | } 145 | 146 | next.ServeHTTP(w, r) 147 | }) 148 | } 149 | } 150 | 151 | // RateLimiter implements a simple in-memory rate limiter 152 | type RateLimiter struct { 153 | requests map[string]*clientInfo 154 | mu sync.RWMutex 155 | limit int 156 | window time.Duration 157 | } 158 | 159 | type clientInfo struct { 160 | count int 161 | windowStart time.Time 162 | } 163 | 164 | // NewRateLimiter creates a new rate limiter 165 | func NewRateLimiter(limit int, window time.Duration) *RateLimiter { 166 | rl := &RateLimiter{ 167 | requests: make(map[string]*clientInfo), 168 | limit: limit, 169 | window: window, 170 | } 171 | 172 | // Cleanup old entries every minute 173 | go func() { 174 | ticker := time.NewTicker(1 * time.Minute) 175 | defer ticker.Stop() 176 | for range ticker.C { 177 | rl.cleanup() 178 | } 179 | }() 180 | 181 | return rl 182 | } 183 | 184 | // Middleware returns the rate limiting middleware 185 | func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { 186 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 187 | // Get client IP 188 | clientIP := getClientIP(r) 189 | 190 | if !rl.allow(clientIP) { 191 | logger.WarnContext(r.Context(), "rate limit exceeded", 192 | "client_ip", clientIP, 193 | ) 194 | 195 | w.Header().Set("Content-Type", "application/json") 196 | w.WriteHeader(http.StatusTooManyRequests) 197 | fmt.Fprintf(w, `{"error":"Rate limit exceeded. Please try again later."}`) 198 | return 199 | } 200 | 201 | next.ServeHTTP(w, r) 202 | }) 203 | } 204 | 205 | func (rl *RateLimiter) allow(clientIP string) bool { 206 | rl.mu.Lock() 207 | defer rl.mu.Unlock() 208 | 209 | now := time.Now() 210 | info, exists := rl.requests[clientIP] 211 | 212 | if !exists || now.Sub(info.windowStart) > rl.window { 213 | // New window 214 | rl.requests[clientIP] = &clientInfo{ 215 | count: 1, 216 | windowStart: now, 217 | } 218 | return true 219 | } 220 | 221 | if info.count >= rl.limit { 222 | return false 223 | } 224 | 225 | info.count++ 226 | return true 227 | } 228 | 229 | func (rl *RateLimiter) cleanup() { 230 | rl.mu.Lock() 231 | defer rl.mu.Unlock() 232 | 233 | now := time.Now() 234 | for ip, info := range rl.requests { 235 | if now.Sub(info.windowStart) > rl.window*2 { 236 | delete(rl.requests, ip) 237 | } 238 | } 239 | } 240 | 241 | // getClientIP extracts the client IP from the request 242 | func getClientIP(r *http.Request) string { 243 | // Check X-Forwarded-For header 244 | xff := r.Header.Get("X-Forwarded-For") 245 | if xff != "" { 246 | ips := strings.Split(xff, ",") 247 | if len(ips) > 0 { 248 | return strings.TrimSpace(ips[0]) 249 | } 250 | } 251 | 252 | // Check X-Real-IP header 253 | xri := r.Header.Get("X-Real-IP") 254 | if xri != "" { 255 | return xri 256 | } 257 | 258 | // Fall back to RemoteAddr 259 | ip, _, err := net.SplitHostPort(r.RemoteAddr) 260 | if err != nil { 261 | return r.RemoteAddr 262 | } 263 | return ip 264 | } 265 | 266 | // MaxBodySize limits the size of request bodies 267 | func MaxBodySize(maxSize int64) func(http.Handler) http.Handler { 268 | return func(next http.Handler) http.Handler { 269 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 270 | r.Body = http.MaxBytesReader(w, r.Body, maxSize) 271 | next.ServeHTTP(w, r) 272 | }) 273 | } 274 | } 275 | 276 | // Timeout adds a timeout to requests 277 | func Timeout(timeout time.Duration) func(http.Handler) http.Handler { 278 | return func(next http.Handler) http.Handler { 279 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 280 | ctx, cancel := context.WithTimeout(r.Context(), timeout) 281 | defer cancel() 282 | 283 | done := make(chan struct{}) 284 | go func() { 285 | next.ServeHTTP(w, r.WithContext(ctx)) 286 | close(done) 287 | }() 288 | 289 | select { 290 | case <-done: 291 | return 292 | case <-ctx.Done(): 293 | logger.WarnContext(r.Context(), "request timeout", 294 | "timeout", timeout, 295 | ) 296 | w.WriteHeader(http.StatusGatewayTimeout) 297 | fmt.Fprintf(w, `{"error":"Request timeout"}`) 298 | } 299 | }) 300 | } 301 | } 302 | 303 | // Chain chains multiple middleware functions 304 | func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { 305 | return func(final http.Handler) http.Handler { 306 | for i := len(middlewares) - 1; i >= 0; i-- { 307 | final = middlewares[i](final) 308 | } 309 | return final 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /MIGRATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Migration Guide: v1.0 to v2.0 (Production Ready) 2 | 3 | This guide will help you migrate from the simple version (v1.0) to the production-ready version (v2.0) of the Post Analyzer Webserver. 4 | 5 | ## What's Changed? 6 | 7 | ### Major Changes 8 | 9 | 1. **Application Architecture** 10 | - Restructured into modular packages 11 | - Introduced storage abstraction layer 12 | - Added comprehensive middleware stack 13 | 14 | 2. **Storage Layer** 15 | - File storage now has thread-safe operations 16 | - Added PostgreSQL support 17 | - Automatic schema management 18 | 19 | 3. **Configuration** 20 | - Environment-based configuration 21 | - Validation on startup 22 | - Support for multiple environments 23 | 24 | 4. **Observability** 25 | - Structured JSON logging 26 | - Prometheus metrics 27 | - Request tracing with IDs 28 | 29 | 5. **Security** 30 | - Input validation and sanitization 31 | - Rate limiting 32 | - Security headers 33 | - CORS configuration 34 | 35 | ## Migration Steps 36 | 37 | ### Step 1: Backup Your Data 38 | 39 | If you're using file storage: 40 | 41 | ```bash 42 | # Backup your existing posts.json 43 | cp posts.json posts.json.backup 44 | ``` 45 | 46 | ### Step 2: Update Dependencies 47 | 48 | ```bash 49 | # Download new dependencies 50 | go mod download 51 | go mod tidy 52 | ``` 53 | 54 | ### Step 3: Configuration 55 | 56 | Create a `.env` file from the example: 57 | 58 | ```bash 59 | cp .env.example .env 60 | ``` 61 | 62 | Edit `.env` to match your setup. For file storage (similar to v1.0): 63 | 64 | ```env 65 | # Keep using file storage 66 | DB_TYPE=file 67 | DB_FILE_PATH=posts.json 68 | 69 | # Other settings 70 | PORT=8080 71 | ENVIRONMENT=production 72 | LOG_LEVEL=info 73 | ``` 74 | 75 | For PostgreSQL (recommended for production): 76 | 77 | ```env 78 | # Use PostgreSQL 79 | DB_TYPE=postgres 80 | DB_HOST=localhost 81 | DB_PORT=5432 82 | DB_USER=postgres 83 | DB_PASSWORD=yourpassword 84 | DB_NAME=postanalyzer 85 | 86 | # Other settings 87 | PORT=8080 88 | ENVIRONMENT=production 89 | LOG_LEVEL=info 90 | ``` 91 | 92 | ### Step 4: Run the Application 93 | 94 | #### Option A: Direct Run (File Storage) 95 | 96 | ```bash 97 | # Run with default file storage 98 | go run main.go 99 | ``` 100 | 101 | #### Option B: With PostgreSQL 102 | 103 | ```bash 104 | # Start PostgreSQL with Docker 105 | docker run -d \ 106 | --name postgres \ 107 | -e POSTGRES_DB=postanalyzer \ 108 | -e POSTGRES_PASSWORD=postgres \ 109 | -p 5432:5432 \ 110 | postgres:16-alpine 111 | 112 | # Run the application 113 | export DB_TYPE=postgres 114 | export DB_PASSWORD=postgres 115 | go run main.go 116 | ``` 117 | 118 | #### Option C: Full Docker Stack 119 | 120 | ```bash 121 | # Start everything with Docker Compose 122 | docker-compose up -d 123 | ``` 124 | 125 | This includes: 126 | - Application 127 | - PostgreSQL 128 | - Prometheus 129 | - Grafana 130 | 131 | ### Step 5: Migrate Existing Data 132 | 133 | If you have existing data in `posts.json` and want to move to PostgreSQL: 134 | 135 | ```bash 136 | # The application will automatically handle this 137 | # Just ensure posts.json exists in the same directory 138 | # On first run with DB_TYPE=postgres, the data will be available 139 | ``` 140 | 141 | Or manually import: 142 | 143 | ```bash 144 | # Start application with file storage first 145 | DB_TYPE=file go run main.go 146 | 147 | # In another terminal, fetch to ensure data is in posts.json 148 | curl http://localhost:8080/fetch 149 | 150 | # Stop the application (Ctrl+C) 151 | 152 | # Start with PostgreSQL 153 | DB_TYPE=postgres DB_PASSWORD=postgres go run main.go 154 | 155 | # Fetch again to populate PostgreSQL 156 | curl http://localhost:8080/fetch 157 | ``` 158 | 159 | ## Compatibility 160 | 161 | ### API Endpoints 162 | 163 | All original endpoints remain functional: 164 | 165 | | v1.0 Endpoint | v2.0 Status | Notes | 166 | |---------------|-------------|-------| 167 | | `/` | ✅ Compatible | Enhanced with better error handling | 168 | | `/fetch` | ✅ Compatible | Now supports batch operations | 169 | | `/analyze` | ✅ Compatible | Improved performance | 170 | | `/add` | ✅ Compatible | Added input validation | 171 | 172 | ### New Endpoints 173 | 174 | | Endpoint | Purpose | 175 | |----------|---------| 176 | | `/health` | Health check for monitoring | 177 | | `/readiness` | Kubernetes-style readiness probe | 178 | | `/metrics` | Prometheus metrics | 179 | 180 | ### File Format 181 | 182 | The `posts.json` file format remains compatible: 183 | 184 | ```json 185 | [ 186 | { 187 | "userId": 1, 188 | "id": 1, 189 | "title": "Post Title", 190 | "body": "Post body content" 191 | } 192 | ] 193 | ``` 194 | 195 | v2.0 adds optional fields: 196 | - `createdAt`: Timestamp when post was created 197 | - `updatedAt`: Timestamp when post was last updated 198 | 199 | These are automatically managed and backward compatible. 200 | 201 | ## Feature Comparison 202 | 203 | | Feature | v1.0 | v2.0 | 204 | |---------|------|------| 205 | | Post Management | ✅ | ✅ | 206 | | Character Analysis | ✅ | ✅ (faster) | 207 | | External API Fetch | ✅ | ✅ | 208 | | File Storage | ✅ | ✅ (improved) | 209 | | Database Support | ❌ | ✅ | 210 | | Health Checks | ❌ | ✅ | 211 | | Metrics | ❌ | ✅ | 212 | | Structured Logging | ❌ | ✅ | 213 | | Input Validation | ❌ | ✅ | 214 | | Rate Limiting | ❌ | ✅ | 215 | | Security Headers | ❌ | ✅ | 216 | | CORS | ❌ | ✅ | 217 | | Graceful Shutdown | ❌ | ✅ | 218 | | Docker Support | ❌ | ✅ | 219 | | CI/CD Pipeline | ❌ | ✅ | 220 | | Test Suite | ❌ | ✅ | 221 | | API Documentation | ❌ | ✅ | 222 | 223 | ## Troubleshooting 224 | 225 | ### Issue: Application Won't Start 226 | 227 | **Error**: `invalid configuration: environment must be one of: development, staging, production` 228 | 229 | **Solution**: Set the ENVIRONMENT variable: 230 | ```bash 231 | export ENVIRONMENT=development 232 | ``` 233 | 234 | ### Issue: Database Connection Failed 235 | 236 | **Error**: `failed to ping database` 237 | 238 | **Solution**: Ensure PostgreSQL is running: 239 | ```bash 240 | # Check if PostgreSQL is running 241 | docker ps | grep postgres 242 | 243 | # Or check locally 244 | pg_isready -h localhost 245 | ``` 246 | 247 | ### Issue: Posts Not Showing 248 | 249 | **Symptom**: Empty home page 250 | 251 | **Solution**: Fetch posts first: 252 | ```bash 253 | curl http://localhost:8080/fetch 254 | ``` 255 | 256 | ### Issue: Rate Limited 257 | 258 | **Error**: 429 Too Many Requests 259 | 260 | **Solution**: Increase rate limit: 261 | ```bash 262 | export RATE_LIMIT_REQUESTS=1000 263 | ``` 264 | 265 | Or wait for the rate limit window to reset (default: 1 minute). 266 | 267 | ## Performance Considerations 268 | 269 | ### File Storage vs PostgreSQL 270 | 271 | **File Storage**: 272 | - ✅ Simple setup 273 | - ✅ No external dependencies 274 | - ❌ Not suitable for high concurrency 275 | - ❌ No advanced querying 276 | 277 | **PostgreSQL**: 278 | - ✅ High concurrency support 279 | - ✅ ACID transactions 280 | - ✅ Advanced querying 281 | - ❌ Requires external service 282 | 283 | **Recommendation**: Use file storage for development, PostgreSQL for production. 284 | 285 | ## Security Updates 286 | 287 | v2.0 includes important security improvements: 288 | 289 | 1. **Input Sanitization**: All user input is sanitized 290 | 2. **Rate Limiting**: Prevents abuse 291 | 3. **Security Headers**: CSP, X-Frame-Options, etc. 292 | 4. **Request Timeouts**: Prevents resource exhaustion 293 | 294 | **Action Required**: Review your CORS configuration in `.env`: 295 | ```env 296 | # Development - allow all 297 | ALLOWED_ORIGINS=* 298 | 299 | # Production - specify allowed origins 300 | ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com 301 | ``` 302 | 303 | ## Monitoring Setup 304 | 305 | ### Basic Monitoring (Logs) 306 | 307 | ```bash 308 | # View logs in production 309 | tail -f /path/to/app/logs 310 | 311 | # Or with Docker 312 | docker logs -f post-analyzer-app 313 | ``` 314 | 315 | ### Advanced Monitoring (Prometheus + Grafana) 316 | 317 | ```bash 318 | # Start monitoring stack 319 | docker-compose up -d prometheus grafana 320 | 321 | # Access Grafana 322 | open http://localhost:3000 323 | # Login: admin/admin 324 | ``` 325 | 326 | Add Prometheus data source: 327 | 1. Go to Configuration → Data Sources 328 | 2. Add Prometheus 329 | 3. URL: `http://prometheus:9090` 330 | 4. Save & Test 331 | 332 | Import dashboard from `grafana-dashboard.json` (if provided). 333 | 334 | ## Rollback Plan 335 | 336 | If you need to rollback to v1.0: 337 | 338 | ```bash 339 | # Stop v2.0 340 | pkill post-analyzer 341 | 342 | # Restore old version 343 | mv main.go main_v2.go 344 | mv main_old.go main.go 345 | 346 | # Run v1.0 347 | go run main.go 348 | ``` 349 | 350 | Your data in `posts.json` remains compatible. 351 | 352 | ## Getting Help 353 | 354 | - 📖 [Full Documentation](README_PRODUCTION.md) 355 | - 🐛 [Report Issues](https://github.com/hoangsonww/Post-Analyzer-Webserver/issues) 356 | - 💬 [Discussions](https://github.com/hoangsonww/Post-Analyzer-Webserver/discussions) 357 | 358 | ## Next Steps 359 | 360 | After successful migration: 361 | 362 | 1. ✅ Review configuration in `.env` 363 | 2. ✅ Set up monitoring (Prometheus/Grafana) 364 | 3. ✅ Configure backups (for PostgreSQL) 365 | 4. ✅ Set up CI/CD pipeline 366 | 5. ✅ Review security settings 367 | 6. ✅ Load test your application 368 | 7. ✅ Set up log aggregation 369 | 370 | --- 371 | 372 | **Need assistance?** Open an issue on GitHub! 373 | -------------------------------------------------------------------------------- /internal/handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "html" 8 | "html/template" 9 | "net/http" 10 | "regexp" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "Post_Analyzer_Webserver/config" 16 | "Post_Analyzer_Webserver/internal/logger" 17 | "Post_Analyzer_Webserver/internal/metrics" 18 | "Post_Analyzer_Webserver/internal/storage" 19 | ) 20 | 21 | // Handler holds dependencies for HTTP handlers 22 | type Handler struct { 23 | storage storage.Storage 24 | config *config.Config 25 | template *template.Template 26 | } 27 | 28 | // New creates a new Handler 29 | func New(store storage.Storage, cfg *config.Config) (*Handler, error) { 30 | // Custom template functions 31 | funcMap := template.FuncMap{ 32 | "toJSON": func(v interface{}) string { 33 | data, _ := json.Marshal(v) 34 | return string(data) 35 | }, 36 | } 37 | 38 | tmpl, err := template.New("").Funcs(funcMap).ParseFiles("home.html") 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to parse template: %w", err) 41 | } 42 | 43 | return &Handler{ 44 | storage: store, 45 | config: cfg, 46 | template: tmpl, 47 | }, nil 48 | } 49 | 50 | // Template variables 51 | type HomePageVars struct { 52 | Title string 53 | Posts []storage.Post 54 | CharFreq map[rune]int 55 | Error string 56 | HasPosts bool 57 | HasAnalysis bool 58 | } 59 | 60 | // Health check endpoint 61 | func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { 62 | w.Header().Set("Content-Type", "application/json") 63 | w.WriteHeader(http.StatusOK) 64 | fmt.Fprintf(w, `{"status":"healthy","timestamp":"%s"}`, time.Now().Format(time.RFC3339)) 65 | } 66 | 67 | // Readiness check endpoint 68 | func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) { 69 | ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 70 | defer cancel() 71 | 72 | // Check if storage is accessible 73 | _, err := h.storage.Count(ctx) 74 | if err != nil { 75 | logger.ErrorContext(r.Context(), "readiness check failed", "error", err) 76 | w.Header().Set("Content-Type", "application/json") 77 | w.WriteHeader(http.StatusServiceUnavailable) 78 | fmt.Fprintf(w, `{"status":"not ready","error":"%s"}`, err.Error()) 79 | return 80 | } 81 | 82 | w.Header().Set("Content-Type", "application/json") 83 | w.WriteHeader(http.StatusOK) 84 | fmt.Fprintf(w, `{"status":"ready","timestamp":"%s"}`, time.Now().Format(time.RFC3339)) 85 | } 86 | 87 | // Home serves the home page 88 | func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { 89 | posts, err := h.storage.GetAll(r.Context()) 90 | if err != nil { 91 | logger.ErrorContext(r.Context(), "failed to get posts", "error", err) 92 | h.renderTemplate(w, HomePageVars{Title: "Home", Error: "Failed to read posts"}) 93 | return 94 | } 95 | 96 | h.renderTemplate(w, HomePageVars{Title: "Home", Posts: posts, HasPosts: len(posts) > 0}) 97 | } 98 | 99 | // FetchPosts fetches posts from external API and stores them 100 | func (h *Handler) FetchPosts(w http.ResponseWriter, r *http.Request) { 101 | posts, err := h.fetchPostsFromAPI(r.Context()) 102 | if err != nil { 103 | logger.ErrorContext(r.Context(), "failed to fetch posts from API", "error", err) 104 | h.renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to fetch posts from external API"}) 105 | return 106 | } 107 | 108 | if err := h.storage.BatchCreate(r.Context(), posts); err != nil { 109 | logger.ErrorContext(r.Context(), "failed to store posts", "error", err) 110 | h.renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to store posts"}) 111 | return 112 | } 113 | 114 | logger.InfoContext(r.Context(), "posts fetched successfully", "count", len(posts)) 115 | h.renderTemplate(w, HomePageVars{Title: "Posts Fetched", Posts: posts, HasPosts: true}) 116 | } 117 | 118 | // AnalyzePosts performs character frequency analysis 119 | func (h *Handler) AnalyzePosts(w http.ResponseWriter, r *http.Request) { 120 | start := time.Now() 121 | 122 | posts, err := h.storage.GetAll(r.Context()) 123 | if err != nil { 124 | logger.ErrorContext(r.Context(), "failed to get posts for analysis", "error", err) 125 | h.renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to read posts for analysis"}) 126 | return 127 | } 128 | 129 | // Combine all post text 130 | var allText strings.Builder 131 | for _, post := range posts { 132 | allText.WriteString(post.Title) 133 | allText.WriteString(" ") 134 | allText.WriteString(post.Body) 135 | allText.WriteString(" ") 136 | } 137 | 138 | charFreq := h.countCharacters(allText.String()) 139 | 140 | metrics.RecordAnalysisOperation(time.Since(start)) 141 | logger.InfoContext(r.Context(), "character analysis completed", "duration_ms", time.Since(start).Milliseconds()) 142 | 143 | h.renderTemplate(w, HomePageVars{Title: "Character Analysis", CharFreq: charFreq, HasAnalysis: true}) 144 | } 145 | 146 | // AddPost adds a new post 147 | func (h *Handler) AddPost(w http.ResponseWriter, r *http.Request) { 148 | if r.Method == http.MethodPost { 149 | // Parse form data 150 | if err := r.ParseForm(); err != nil { 151 | logger.ErrorContext(r.Context(), "failed to parse form", "error", err) 152 | h.renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to parse form data"}) 153 | return 154 | } 155 | 156 | // Sanitize and validate input 157 | title := h.sanitizeInput(r.FormValue("title")) 158 | body := h.sanitizeInput(r.FormValue("body")) 159 | 160 | if title == "" || body == "" { 161 | h.renderTemplate(w, HomePageVars{Title: "Error", Error: "Title and body are required"}) 162 | return 163 | } 164 | 165 | post := &storage.Post{ 166 | UserId: 1, 167 | Title: title, 168 | Body: body, 169 | } 170 | 171 | if err := h.storage.Create(r.Context(), post); err != nil { 172 | logger.ErrorContext(r.Context(), "failed to create post", "error", err) 173 | h.renderTemplate(w, HomePageVars{Title: "Error", Error: "Failed to create post"}) 174 | return 175 | } 176 | 177 | // Get all posts to display 178 | posts, _ := h.storage.GetAll(r.Context()) 179 | logger.InfoContext(r.Context(), "post added successfully", "id", post.Id) 180 | h.renderTemplate(w, HomePageVars{Title: "Post Added", Posts: posts, HasPosts: true}) 181 | } else { 182 | h.renderTemplate(w, HomePageVars{Title: "Add New Post"}) 183 | } 184 | } 185 | 186 | // fetchPostsFromAPI fetches posts from external API 187 | func (h *Handler) fetchPostsFromAPI(ctx context.Context) ([]storage.Post, error) { 188 | client := &http.Client{ 189 | Timeout: h.config.External.HTTPTimeout, 190 | } 191 | 192 | req, err := http.NewRequestWithContext(ctx, "GET", h.config.External.JSONPlaceholderURL, nil) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | resp, err := client.Do(req) 198 | if err != nil { 199 | return nil, err 200 | } 201 | defer resp.Body.Close() 202 | 203 | if resp.StatusCode != http.StatusOK { 204 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 205 | } 206 | 207 | var posts []storage.Post 208 | if err := json.NewDecoder(resp.Body).Decode(&posts); err != nil { 209 | return nil, err 210 | } 211 | 212 | return posts, nil 213 | } 214 | 215 | // countCharacters counts character frequency efficiently 216 | func (h *Handler) countCharacters(text string) map[rune]int { 217 | charCount := make(map[rune]int) 218 | 219 | // Process in chunks for better performance with large texts 220 | const chunkSize = 1000 221 | if len(text) <= chunkSize { 222 | // Small text, process directly 223 | for _, char := range text { 224 | charCount[char]++ 225 | } 226 | return charCount 227 | } 228 | 229 | // Large text, use concurrent processing 230 | mu := sync.Mutex{} 231 | wg := sync.WaitGroup{} 232 | 233 | numWorkers := 4 234 | chunkLen := (len(text) + numWorkers - 1) / numWorkers 235 | 236 | for i := 0; i < numWorkers; i++ { 237 | start := i * chunkLen 238 | end := start + chunkLen 239 | if end > len(text) { 240 | end = len(text) 241 | } 242 | if start >= len(text) { 243 | break 244 | } 245 | 246 | wg.Add(1) 247 | go func(chunk string) { 248 | defer wg.Done() 249 | localCount := make(map[rune]int) 250 | for _, char := range chunk { 251 | localCount[char]++ 252 | } 253 | 254 | mu.Lock() 255 | for char, count := range localCount { 256 | charCount[char] += count 257 | } 258 | mu.Unlock() 259 | }(text[start:end]) 260 | } 261 | 262 | wg.Wait() 263 | return charCount 264 | } 265 | 266 | // sanitizeInput sanitizes user input to prevent XSS 267 | func (h *Handler) sanitizeInput(input string) string { 268 | // Remove any HTML tags 269 | input = html.EscapeString(input) 270 | 271 | // Remove any potential script tags or event handlers 272 | input = regexp.MustCompile(`(?i).*?`).ReplaceAllString(input, "") 273 | input = regexp.MustCompile(`(?i)on\w+\s*=`).ReplaceAllString(input, "") 274 | 275 | // Trim whitespace 276 | input = strings.TrimSpace(input) 277 | 278 | return input 279 | } 280 | 281 | // renderTemplate renders the HTML template 282 | func (h *Handler) renderTemplate(w http.ResponseWriter, vars HomePageVars) { 283 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 284 | if err := h.template.ExecuteTemplate(w, "home.html", vars); err != nil { 285 | logger.Error("failed to render template", "error", err) 286 | http.Error(w, "Failed to render template", http.StatusInternalServerError) 287 | } 288 | } 289 | 290 | // Close closes handler resources 291 | func (h *Handler) Close() error { 292 | return h.storage.Close() 293 | } 294 | -------------------------------------------------------------------------------- /api-docs.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Post Analyzer Webserver API 4 | description: | 5 | A production-ready web application for analyzing and managing posts. 6 | 7 | Features: 8 | - Fetch posts from external APIs 9 | - Store and manage posts 10 | - Character frequency analysis 11 | - Prometheus metrics 12 | - Health checks 13 | version: 2.0.0 14 | contact: 15 | name: Post Analyzer Team 16 | url: https://github.com/hoangsonww/Post-Analyzer-Webserver 17 | license: 18 | name: MIT 19 | url: https://opensource.org/licenses/MIT 20 | 21 | servers: 22 | - url: http://localhost:8080 23 | description: Development server 24 | - url: https://post-analyzer-webserver.onrender.com 25 | description: Production server 26 | 27 | tags: 28 | - name: Health 29 | description: Health and readiness checks 30 | - name: Posts 31 | description: Post management operations 32 | - name: Analysis 33 | description: Character analysis operations 34 | - name: Metrics 35 | description: Prometheus metrics 36 | 37 | paths: 38 | /health: 39 | get: 40 | tags: 41 | - Health 42 | summary: Health check 43 | description: Returns the health status of the application 44 | operationId: healthCheck 45 | responses: 46 | '200': 47 | description: Application is healthy 48 | content: 49 | application/json: 50 | schema: 51 | type: object 52 | properties: 53 | status: 54 | type: string 55 | example: healthy 56 | timestamp: 57 | type: string 58 | format: date-time 59 | 60 | /readiness: 61 | get: 62 | tags: 63 | - Health 64 | summary: Readiness check 65 | description: Returns whether the application is ready to serve traffic 66 | operationId: readinessCheck 67 | responses: 68 | '200': 69 | description: Application is ready 70 | content: 71 | application/json: 72 | schema: 73 | type: object 74 | properties: 75 | status: 76 | type: string 77 | example: ready 78 | timestamp: 79 | type: string 80 | format: date-time 81 | '503': 82 | description: Application is not ready 83 | content: 84 | application/json: 85 | schema: 86 | $ref: '#/components/schemas/Error' 87 | 88 | /metrics: 89 | get: 90 | tags: 91 | - Metrics 92 | summary: Prometheus metrics 93 | description: Returns Prometheus metrics for monitoring 94 | operationId: getMetrics 95 | responses: 96 | '200': 97 | description: Metrics in Prometheus format 98 | content: 99 | text/plain: 100 | schema: 101 | type: string 102 | 103 | /: 104 | get: 105 | tags: 106 | - Posts 107 | summary: Home page 108 | description: Displays the home page with all posts 109 | operationId: getHome 110 | responses: 111 | '200': 112 | description: HTML page with posts 113 | content: 114 | text/html: 115 | schema: 116 | type: string 117 | 118 | /fetch: 119 | get: 120 | tags: 121 | - Posts 122 | summary: Fetch posts from external API 123 | description: Fetches posts from JSONPlaceholder API and stores them 124 | operationId: fetchPosts 125 | responses: 126 | '200': 127 | description: Posts fetched successfully 128 | content: 129 | text/html: 130 | schema: 131 | type: string 132 | '500': 133 | description: Failed to fetch or store posts 134 | content: 135 | text/html: 136 | schema: 137 | type: string 138 | 139 | /add: 140 | get: 141 | tags: 142 | - Posts 143 | summary: Display add post form 144 | description: Shows the form to add a new post 145 | operationId: showAddPostForm 146 | responses: 147 | '200': 148 | description: Add post form 149 | content: 150 | text/html: 151 | schema: 152 | type: string 153 | 154 | post: 155 | tags: 156 | - Posts 157 | summary: Add a new post 158 | description: Creates a new post with the provided title and body 159 | operationId: addPost 160 | requestBody: 161 | required: true 162 | content: 163 | application/x-www-form-urlencoded: 164 | schema: 165 | type: object 166 | required: 167 | - title 168 | - body 169 | properties: 170 | title: 171 | type: string 172 | maxLength: 500 173 | description: Post title 174 | example: My First Post 175 | body: 176 | type: string 177 | maxLength: 10000 178 | description: Post body 179 | example: This is the content of my first post 180 | responses: 181 | '200': 182 | description: Post added successfully 183 | content: 184 | text/html: 185 | schema: 186 | type: string 187 | '400': 188 | description: Invalid input 189 | content: 190 | text/html: 191 | schema: 192 | type: string 193 | '500': 194 | description: Failed to create post 195 | content: 196 | text/html: 197 | schema: 198 | type: string 199 | 200 | /analyze: 201 | get: 202 | tags: 203 | - Analysis 204 | summary: Analyze character frequency 205 | description: Performs character frequency analysis on all posts 206 | operationId: analyzePosts 207 | responses: 208 | '200': 209 | description: Analysis results 210 | content: 211 | text/html: 212 | schema: 213 | type: string 214 | '500': 215 | description: Analysis failed 216 | content: 217 | text/html: 218 | schema: 219 | type: string 220 | 221 | components: 222 | schemas: 223 | Post: 224 | type: object 225 | required: 226 | - userId 227 | - id 228 | - title 229 | - body 230 | properties: 231 | userId: 232 | type: integer 233 | description: ID of the user who created the post 234 | example: 1 235 | id: 236 | type: integer 237 | description: Unique post identifier 238 | example: 1 239 | title: 240 | type: string 241 | maxLength: 500 242 | description: Post title 243 | example: Sample Post Title 244 | body: 245 | type: string 246 | maxLength: 10000 247 | description: Post content 248 | example: This is the body of the post with some interesting content. 249 | createdAt: 250 | type: string 251 | format: date-time 252 | description: Timestamp when post was created 253 | updatedAt: 254 | type: string 255 | format: date-time 256 | description: Timestamp when post was last updated 257 | 258 | Error: 259 | type: object 260 | properties: 261 | error: 262 | type: string 263 | description: Error message 264 | example: Internal server error 265 | timestamp: 266 | type: string 267 | format: date-time 268 | description: When the error occurred 269 | 270 | HealthStatus: 271 | type: object 272 | properties: 273 | status: 274 | type: string 275 | enum: [healthy, unhealthy, ready, not ready] 276 | description: Status of the service 277 | timestamp: 278 | type: string 279 | format: date-time 280 | description: Timestamp of the status check 281 | 282 | securitySchemes: 283 | ApiKey: 284 | type: apiKey 285 | in: header 286 | name: X-API-Key 287 | description: API key for authentication (not currently implemented) 288 | 289 | parameters: 290 | RequestID: 291 | name: X-Request-ID 292 | in: header 293 | description: Unique request identifier for tracing 294 | schema: 295 | type: string 296 | format: uuid 297 | 298 | responses: 299 | NotFound: 300 | description: Resource not found 301 | content: 302 | application/json: 303 | schema: 304 | $ref: '#/components/schemas/Error' 305 | 306 | BadRequest: 307 | description: Invalid request parameters 308 | content: 309 | application/json: 310 | schema: 311 | $ref: '#/components/schemas/Error' 312 | 313 | InternalError: 314 | description: Internal server error 315 | content: 316 | application/json: 317 | schema: 318 | $ref: '#/components/schemas/Error' 319 | 320 | TooManyRequests: 321 | description: Rate limit exceeded 322 | content: 323 | application/json: 324 | schema: 325 | type: object 326 | properties: 327 | error: 328 | type: string 329 | example: Rate limit exceeded. Please try again later. 330 | 331 | x-readme: 332 | samples-languages: 333 | - curl 334 | - javascript 335 | - python 336 | - go 337 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "Post_Analyzer_Webserver/internal/errors" 11 | "Post_Analyzer_Webserver/internal/logger" 12 | "Post_Analyzer_Webserver/internal/models" 13 | "Post_Analyzer_Webserver/internal/service" 14 | ) 15 | 16 | // API handles REST API endpoints 17 | type API struct { 18 | postService *service.PostService 19 | } 20 | 21 | // NewAPI creates a new API handler 22 | func NewAPI(postService *service.PostService) *API { 23 | return &API{ 24 | postService: postService, 25 | } 26 | } 27 | 28 | // ListPosts handles GET /api/v1/posts 29 | func (a *API) ListPosts(w http.ResponseWriter, r *http.Request) { 30 | ctx := r.Context() 31 | 32 | // Parse filters 33 | filter := &models.PostFilter{} 34 | if userIDStr := r.URL.Query().Get("userId"); userIDStr != "" { 35 | userID, err := strconv.Atoi(userIDStr) 36 | if err == nil { 37 | filter.UserID = &userID 38 | } 39 | } 40 | filter.Search = r.URL.Query().Get("search") 41 | filter.SortBy = r.URL.Query().Get("sortBy") 42 | filter.SortOrder = r.URL.Query().Get("sortOrder") 43 | 44 | // Parse pagination 45 | pagination := a.parsePagination(r) 46 | 47 | // Get posts 48 | posts, paginationMeta, err := a.postService.GetAll(ctx, filter, pagination) 49 | if err != nil { 50 | a.respondError(w, r, err) 51 | return 52 | } 53 | 54 | // Build response 55 | response := &models.PaginatedResponse{ 56 | Data: posts, 57 | Pagination: *paginationMeta, 58 | Meta: &models.ResponseMeta{ 59 | RequestID: getRequestID(ctx), 60 | Timestamp: time.Now(), 61 | }, 62 | } 63 | 64 | a.respondJSON(w, http.StatusOK, response) 65 | } 66 | 67 | // GetPost handles GET /api/v1/posts/{id} 68 | func (a *API) GetPost(w http.ResponseWriter, r *http.Request) { 69 | ctx := r.Context() 70 | 71 | // Extract ID from URL path 72 | id, err := a.extractID(r) 73 | if err != nil { 74 | a.respondError(w, r, errors.NewValidationError("invalid post ID")) 75 | return 76 | } 77 | 78 | // Get post 79 | post, err := a.postService.GetByID(ctx, id) 80 | if err != nil { 81 | a.respondError(w, r, err) 82 | return 83 | } 84 | 85 | a.respondJSON(w, http.StatusOK, map[string]interface{}{ 86 | "data": post, 87 | "meta": &models.ResponseMeta{ 88 | RequestID: getRequestID(ctx), 89 | Timestamp: time.Now(), 90 | }, 91 | }) 92 | } 93 | 94 | // CreatePost handles POST /api/v1/posts 95 | func (a *API) CreatePost(w http.ResponseWriter, r *http.Request) { 96 | ctx := r.Context() 97 | 98 | // Parse request body 99 | var req models.CreatePostRequest 100 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 101 | a.respondError(w, r, errors.NewValidationError("invalid request body")) 102 | return 103 | } 104 | 105 | // Create post 106 | post, err := a.postService.Create(ctx, &req) 107 | if err != nil { 108 | a.respondError(w, r, err) 109 | return 110 | } 111 | 112 | logger.InfoContext(ctx, "post created via API", "id", post.ID) 113 | 114 | a.respondJSON(w, http.StatusCreated, map[string]interface{}{ 115 | "data": post, 116 | "meta": &models.ResponseMeta{ 117 | RequestID: getRequestID(ctx), 118 | Timestamp: time.Now(), 119 | }, 120 | }) 121 | } 122 | 123 | // UpdatePost handles PUT /api/v1/posts/{id} 124 | func (a *API) UpdatePost(w http.ResponseWriter, r *http.Request) { 125 | ctx := r.Context() 126 | 127 | // Extract ID 128 | id, err := a.extractID(r) 129 | if err != nil { 130 | a.respondError(w, r, errors.NewValidationError("invalid post ID")) 131 | return 132 | } 133 | 134 | // Parse request body 135 | var req models.UpdatePostRequest 136 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 137 | a.respondError(w, r, errors.NewValidationError("invalid request body")) 138 | return 139 | } 140 | 141 | // Update post 142 | post, err := a.postService.Update(ctx, id, &req) 143 | if err != nil { 144 | a.respondError(w, r, err) 145 | return 146 | } 147 | 148 | logger.InfoContext(ctx, "post updated via API", "id", post.ID) 149 | 150 | a.respondJSON(w, http.StatusOK, map[string]interface{}{ 151 | "data": post, 152 | "meta": &models.ResponseMeta{ 153 | RequestID: getRequestID(ctx), 154 | Timestamp: time.Now(), 155 | }, 156 | }) 157 | } 158 | 159 | // DeletePost handles DELETE /api/v1/posts/{id} 160 | func (a *API) DeletePost(w http.ResponseWriter, r *http.Request) { 161 | ctx := r.Context() 162 | 163 | // Extract ID 164 | id, err := a.extractID(r) 165 | if err != nil { 166 | a.respondError(w, r, errors.NewValidationError("invalid post ID")) 167 | return 168 | } 169 | 170 | // Delete post 171 | if err := a.postService.Delete(ctx, id); err != nil { 172 | a.respondError(w, r, err) 173 | return 174 | } 175 | 176 | logger.InfoContext(ctx, "post deleted via API", "id", id) 177 | 178 | a.respondJSON(w, http.StatusOK, map[string]interface{}{ 179 | "message": "Post deleted successfully", 180 | "meta": &models.ResponseMeta{ 181 | RequestID: getRequestID(ctx), 182 | Timestamp: time.Now(), 183 | }, 184 | }) 185 | } 186 | 187 | // BulkCreatePosts handles POST /api/v1/posts/bulk 188 | func (a *API) BulkCreatePosts(w http.ResponseWriter, r *http.Request) { 189 | ctx := r.Context() 190 | 191 | // Parse request body 192 | var req models.BulkCreateRequest 193 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 194 | a.respondError(w, r, errors.NewValidationError("invalid request body")) 195 | return 196 | } 197 | 198 | // Validate request 199 | if len(req.Posts) == 0 { 200 | a.respondError(w, r, errors.NewValidationError("posts array cannot be empty")) 201 | return 202 | } 203 | if len(req.Posts) > 1000 { 204 | a.respondError(w, r, errors.NewValidationError("maximum 1000 posts per bulk request")) 205 | return 206 | } 207 | 208 | // Create posts 209 | response, err := a.postService.BulkCreate(ctx, &req) 210 | if err != nil { 211 | a.respondError(w, r, err) 212 | return 213 | } 214 | 215 | logger.InfoContext(ctx, "bulk create completed via API", 216 | "created", response.Created, 217 | "failed", response.Failed, 218 | ) 219 | 220 | statusCode := http.StatusCreated 221 | if response.Failed > 0 { 222 | statusCode = http.StatusMultiStatus 223 | } 224 | 225 | a.respondJSON(w, statusCode, map[string]interface{}{ 226 | "data": response, 227 | "meta": &models.ResponseMeta{ 228 | RequestID: getRequestID(ctx), 229 | Timestamp: time.Now(), 230 | }, 231 | }) 232 | } 233 | 234 | // ExportPosts handles GET /api/v1/posts/export 235 | func (a *API) ExportPosts(w http.ResponseWriter, r *http.Request) { 236 | ctx := r.Context() 237 | 238 | // Parse format 239 | format := models.ExportFormat(r.URL.Query().Get("format")) 240 | if format == "" { 241 | format = models.ExportFormatJSON 242 | } 243 | 244 | // Validate format 245 | if format != models.ExportFormatJSON && format != models.ExportFormatCSV { 246 | a.respondError(w, r, errors.NewValidationError("invalid export format (json or csv)")) 247 | return 248 | } 249 | 250 | // Parse filter 251 | filter := &models.PostFilter{} 252 | if userIDStr := r.URL.Query().Get("userId"); userIDStr != "" { 253 | userID, err := strconv.Atoi(userIDStr) 254 | if err == nil { 255 | filter.UserID = &userID 256 | } 257 | } 258 | filter.Search = r.URL.Query().Get("search") 259 | 260 | // Set headers 261 | filename := "posts_export_" + time.Now().Format("20060102_150405") 262 | if format == models.ExportFormatJSON { 263 | w.Header().Set("Content-Type", "application/json") 264 | w.Header().Set("Content-Disposition", "attachment; filename="+filename+".json") 265 | } else { 266 | w.Header().Set("Content-Type", "text/csv") 267 | w.Header().Set("Content-Disposition", "attachment; filename="+filename+".csv") 268 | } 269 | 270 | // Export posts 271 | if err := a.postService.ExportPosts(ctx, w, format, filter); err != nil { 272 | logger.ErrorContext(ctx, "export failed", "error", err) 273 | a.respondError(w, r, err) 274 | return 275 | } 276 | 277 | logger.InfoContext(ctx, "posts exported", "format", format) 278 | } 279 | 280 | // AnalyzePosts handles GET /api/v1/posts/analytics 281 | func (a *API) AnalyzePosts(w http.ResponseWriter, r *http.Request) { 282 | ctx := r.Context() 283 | start := time.Now() 284 | 285 | // Perform analysis 286 | result, err := a.postService.AnalyzeCharacterFrequency(ctx) 287 | if err != nil { 288 | a.respondError(w, r, err) 289 | return 290 | } 291 | 292 | logger.InfoContext(ctx, "analysis completed via API", 293 | "total_posts", result.TotalPosts, 294 | "duration_ms", time.Since(start).Milliseconds(), 295 | ) 296 | 297 | a.respondJSON(w, http.StatusOK, map[string]interface{}{ 298 | "data": result, 299 | "meta": &models.ResponseMeta{ 300 | RequestID: getRequestID(ctx), 301 | Timestamp: time.Now(), 302 | Duration: time.Since(start), 303 | }, 304 | }) 305 | } 306 | 307 | // Helper methods 308 | 309 | func (a *API) parsePagination(r *http.Request) *models.PaginationParams { 310 | page, _ := strconv.Atoi(r.URL.Query().Get("page")) 311 | if page < 1 { 312 | page = 1 313 | } 314 | 315 | pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) 316 | if pageSize < 1 || pageSize > 100 { 317 | pageSize = 20 // default 318 | } 319 | 320 | return &models.PaginationParams{ 321 | Page: page, 322 | PageSize: pageSize, 323 | Offset: (page - 1) * pageSize, 324 | } 325 | } 326 | 327 | func (a *API) extractID(r *http.Request) (int, error) { 328 | // Extract ID from path: /api/v1/posts/{id} 329 | path := r.URL.Path 330 | parts := strings.Split(path, "/") 331 | 332 | // Find the "posts" segment 333 | for i, part := range parts { 334 | if part == "posts" && i+1 < len(parts) { 335 | // The next part should be the ID 336 | idStr := parts[i+1] 337 | // Remove any query parameters 338 | if idx := strings.Index(idStr, "?"); idx != -1 { 339 | idStr = idStr[:idx] 340 | } 341 | // Skip if it's a special endpoint 342 | if idStr == "bulk" || idStr == "export" || idStr == "analytics" { 343 | continue 344 | } 345 | return strconv.Atoi(idStr) 346 | } 347 | } 348 | 349 | return 0, errors.NewValidationError("invalid post ID") 350 | } 351 | 352 | func (a *API) respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { 353 | w.Header().Set("Content-Type", "application/json") 354 | w.WriteHeader(statusCode) 355 | 356 | if err := json.NewEncoder(w).Encode(data); err != nil { 357 | logger.Error("failed to encode JSON response", "error", err) 358 | } 359 | } 360 | 361 | func (a *API) respondError(w http.ResponseWriter, r *http.Request, err error) { 362 | appErr, ok := err.(*errors.AppError) 363 | if !ok { 364 | appErr = errors.NewInternalError(err) 365 | } 366 | 367 | logger.ErrorContext(r.Context(), "API error", 368 | "code", appErr.Code, 369 | "message", appErr.Message, 370 | "status", appErr.StatusCode, 371 | ) 372 | 373 | w.Header().Set("Content-Type", "application/json") 374 | w.WriteHeader(appErr.StatusCode) 375 | 376 | response := map[string]interface{}{ 377 | "error": map[string]interface{}{ 378 | "code": appErr.Code, 379 | "message": appErr.Message, 380 | }, 381 | "meta": &models.ResponseMeta{ 382 | RequestID: getRequestID(r.Context()), 383 | Timestamp: time.Now(), 384 | }, 385 | } 386 | 387 | if appErr.Fields != nil && len(appErr.Fields) > 0 { 388 | response["error"].(map[string]interface{})["fields"] = appErr.Fields 389 | } 390 | 391 | _ = json.NewEncoder(w).Encode(response) 392 | } 393 | 394 | func getRequestID(ctx interface{}) string { 395 | if reqID, ok := ctx.(interface{ Value(interface{}) interface{} }); ok { 396 | if val := reqID.Value(logger.RequestIDKey); val != nil { 397 | if id, ok := val.(string); ok { 398 | return id 399 | } 400 | } 401 | } 402 | return "" 403 | } 404 | -------------------------------------------------------------------------------- /README_PRODUCTION.md: -------------------------------------------------------------------------------- 1 | # Post Analyzer Webserver - Production Ready 2 | 3 |

4 | Post Analyzer 5 |

6 | 7 |

8 | License 9 | Go version 10 | Status 11 | Version 12 | Year 13 |

14 | 15 | ## Overview 16 | 17 | A **production-ready** web application built with Go for analyzing and managing posts. This application demonstrates enterprise-grade software development practices including microservices patterns, observability, security, and DevOps best practices. 18 | 19 | ## Features 20 | 21 | ### Core Features 22 | - 📝 **Post Management**: Create, read, update, and delete posts 23 | - 🔍 **Character Analysis**: Concurrent character frequency analysis with visualization 24 | - 🌐 **External API Integration**: Fetch posts from JSONPlaceholder API 25 | - 💾 **Flexible Storage**: Support for both file-based and PostgreSQL storage 26 | 27 | ### Production Features 28 | - 🔒 **Security**: Input validation, XSS protection, security headers, rate limiting 29 | - 📊 **Observability**: Structured logging, Prometheus metrics, health checks 30 | - 🚀 **Performance**: Graceful shutdown, request timeouts, connection pooling 31 | - 🔄 **DevOps**: Docker support, CI/CD pipeline, automated testing 32 | - 🛡️ **Reliability**: Panic recovery, error handling, request tracing 33 | - ⚙️ **Configuration**: Environment-based config with validation 34 | 35 | ## Architecture 36 | 37 | ``` 38 | ┌─────────────────────────────────────────────────────────┐ 39 | │ Load Balancer │ 40 | └─────────────────────────────────────────────────────────┘ 41 | │ 42 | ┌─────────────────────────────────────────────────────────┐ 43 | │ Middleware Stack │ 44 | │ ┌──────────┬──────────┬──────────┬──────────────────┐ │ 45 | │ │ Request │ Logging │ Recovery │ Security Headers │ │ 46 | │ │ ID │ │ │ │ │ 47 | │ └──────────┴──────────┴──────────┴──────────────────┘ │ 48 | │ ┌──────────┬──────────┬──────────┬──────────────────┐ │ 49 | │ │ CORS │ Rate │ Body │ Metrics │ │ 50 | │ │ │ Limiting │ Limit │ │ │ 51 | │ └──────────┴──────────┴──────────┴──────────────────┘ │ 52 | └─────────────────────────────────────────────────────────┘ 53 | │ 54 | ┌─────────────────────────────────────────────────────────┐ 55 | │ HTTP Handlers │ 56 | │ ┌──────────┬──────────┬──────────┬──────────────────┐ │ 57 | │ │ Health │ Posts │ Analysis │ Metrics │ │ 58 | │ └──────────┴──────────┴──────────┴──────────────────┘ │ 59 | └─────────────────────────────────────────────────────────┘ 60 | │ 61 | ┌─────────────────────────────────────────────────────────┐ 62 | │ Storage Layer │ 63 | │ ┌──────────────────────┬──────────────────────────────┐│ 64 | │ │ File Storage │ PostgreSQL Storage ││ 65 | │ └──────────────────────┴──────────────────────────────┘│ 66 | └─────────────────────────────────────────────────────────┘ 67 | ``` 68 | 69 | ## Technology Stack 70 | 71 | - **Backend**: Go 1.21+ 72 | - **Database**: PostgreSQL 16 (with file storage fallback) 73 | - **Monitoring**: Prometheus + Grafana 74 | - **Containerization**: Docker + Docker Compose 75 | - **CI/CD**: GitHub Actions 76 | - **Template Engine**: Go html/template 77 | - **Metrics**: Prometheus client 78 | - **Testing**: Go testing + table-driven tests 79 | 80 | ## Quick Start 81 | 82 | ### Prerequisites 83 | 84 | - Go 1.21 or higher 85 | - Docker and Docker Compose (optional) 86 | - PostgreSQL 16 (if not using Docker) 87 | - Make (optional, for convenience commands) 88 | 89 | ### Using Docker (Recommended) 90 | 91 | ```bash 92 | # Clone the repository 93 | git clone https://github.com/hoangsonww/Post-Analyzer-Webserver.git 94 | cd Post-Analyzer-Webserver 95 | 96 | # Start all services 97 | make docker-up 98 | 99 | # Or without Make 100 | docker-compose up -d 101 | ``` 102 | 103 | The application will be available at: 104 | - **Application**: http://localhost:8080 105 | - **Prometheus**: http://localhost:9090 106 | - **Grafana**: http://localhost:3000 (admin/admin) 107 | 108 | ### Local Development 109 | 110 | ```bash 111 | # Install dependencies 112 | make install 113 | 114 | # Run with file storage 115 | go run main_new.go 116 | 117 | # Run with PostgreSQL 118 | export DB_TYPE=postgres 119 | export DB_HOST=localhost 120 | export DB_PASSWORD=yourpassword 121 | go run main_new.go 122 | 123 | # Or use Make 124 | make run 125 | ``` 126 | 127 | ## Configuration 128 | 129 | The application is configured via environment variables. See `.env.example` for all available options: 130 | 131 | ```bash 132 | # Copy example configuration 133 | cp .env.example .env 134 | 135 | # Edit configuration 136 | nano .env 137 | ``` 138 | 139 | ### Key Configuration Options 140 | 141 | | Variable | Default | Description | 142 | |----------|---------|-------------| 143 | | `PORT` | 8080 | Server port | 144 | | `ENVIRONMENT` | development | Environment (development/staging/production) | 145 | | `DB_TYPE` | file | Storage type (file/postgres) | 146 | | `LOG_LEVEL` | info | Logging level (debug/info/warn/error) | 147 | | `RATE_LIMIT_REQUESTS` | 100 | Max requests per window | 148 | | `ALLOWED_ORIGINS` | * | CORS allowed origins | 149 | 150 | ## API Endpoints 151 | 152 | ### Health & Monitoring 153 | 154 | | Endpoint | Method | Description | 155 | |----------|--------|-------------| 156 | | `/health` | GET | Health check endpoint | 157 | | `/readiness` | GET | Readiness probe | 158 | | `/metrics` | GET | Prometheus metrics | 159 | 160 | ### Application 161 | 162 | | Endpoint | Method | Description | 163 | |----------|--------|-------------| 164 | | `/` | GET | Home page with all posts | 165 | | `/fetch` | GET | Fetch posts from external API | 166 | | `/add` | GET/POST | Add new post form/submit | 167 | | `/analyze` | GET | Character frequency analysis | 168 | 169 | See [api-docs.yaml](api-docs.yaml) for complete OpenAPI documentation. 170 | 171 | ## Development 172 | 173 | ### Running Tests 174 | 175 | ```bash 176 | # Run all tests 177 | make test 178 | 179 | # Run with coverage 180 | make test-coverage 181 | 182 | # Run benchmarks 183 | make benchmark 184 | ``` 185 | 186 | ### Code Quality 187 | 188 | ```bash 189 | # Format code 190 | make format 191 | 192 | # Run linter 193 | make lint 194 | 195 | # Run security checks 196 | make security 197 | 198 | # Run all checks 199 | make check 200 | ``` 201 | 202 | ### Database Management 203 | 204 | ```bash 205 | # Connect to database shell 206 | make db-shell 207 | 208 | # Run migrations (automatic on startup) 209 | make migrate 210 | ``` 211 | 212 | ## Deployment 213 | 214 | ### Docker Deployment 215 | 216 | ```bash 217 | # Build Docker image 218 | make docker-build 219 | 220 | # Deploy with Docker Compose 221 | make docker-up 222 | ``` 223 | 224 | ### Production Deployment 225 | 226 | 1. **Build for production**: 227 | ```bash 228 | make prod-build 229 | ``` 230 | 231 | 2. **Set environment variables**: 232 | ```bash 233 | export ENVIRONMENT=production 234 | export DB_TYPE=postgres 235 | export DB_HOST=your-db-host 236 | export DB_PASSWORD=your-db-password 237 | ``` 238 | 239 | 3. **Run the application**: 240 | ```bash 241 | ./post-analyzer 242 | ``` 243 | 244 | ### Cloud Platforms 245 | 246 | #### Render.com 247 | 1. Create a new Web Service 248 | 2. Connect your GitHub repository 249 | 3. Set build command: `go build -o app main_new.go` 250 | 4. Set start command: `./app` 251 | 5. Add environment variables 252 | 253 | #### Heroku 254 | ```bash 255 | heroku create your-app-name 256 | heroku addons:create heroku-postgresql:hobby-dev 257 | git push heroku main 258 | ``` 259 | 260 | #### AWS ECS/Fargate 261 | ```bash 262 | # Build and push Docker image 263 | docker build -t post-analyzer . 264 | docker tag post-analyzer:latest YOUR_ECR_REPO/post-analyzer:latest 265 | docker push YOUR_ECR_REPO/post-analyzer:latest 266 | 267 | # Deploy using ECS/Fargate 268 | aws ecs update-service --cluster your-cluster --service post-analyzer --force-new-deployment 269 | ``` 270 | 271 | ## Monitoring & Observability 272 | 273 | ### Metrics 274 | 275 | The application exposes Prometheus metrics at `/metrics`: 276 | 277 | - **HTTP Metrics**: Request count, duration, size 278 | - **Application Metrics**: Posts count, operations 279 | - **Database Metrics**: Query duration, connection pool 280 | - **Analysis Metrics**: Analysis operations and duration 281 | 282 | ### Logging 283 | 284 | Structured JSON logging with contextual information: 285 | 286 | ```json 287 | { 288 | "time": "2025-01-16T10:30:00Z", 289 | "level": "INFO", 290 | "msg": "request completed", 291 | "request_id": "550e8400-e29b-41d4-a716-446655440000", 292 | "method": "GET", 293 | "path": "/", 294 | "status": 200, 295 | "duration_ms": 45 296 | } 297 | ``` 298 | 299 | ### Grafana Dashboards 300 | 301 | Access Grafana at http://localhost:3000 (when using Docker Compose): 302 | - Username: `admin` 303 | - Password: `admin` 304 | 305 | Import the included dashboard for visualization of: 306 | - Request rate and latency 307 | - Error rates 308 | - Database performance 309 | - Resource utilization 310 | 311 | ## Security 312 | 313 | ### Implemented Security Measures 314 | 315 | - ✅ Input validation and sanitization 316 | - ✅ XSS protection 317 | - ✅ Security headers (CSP, X-Frame-Options, etc.) 318 | - ✅ Rate limiting 319 | - ✅ CORS configuration 320 | - ✅ Panic recovery 321 | - ✅ Request timeouts 322 | - ✅ Body size limits 323 | - ✅ SQL injection prevention (prepared statements) 324 | 325 | ### Security Best Practices 326 | 327 | 1. **Never commit secrets**: Use environment variables 328 | 2. **Update dependencies regularly**: `make deps-update` 329 | 3. **Run security scans**: `make security` 330 | 4. **Use HTTPS in production**: Configure reverse proxy 331 | 5. **Review logs regularly**: Monitor for suspicious activity 332 | 333 | ## Performance 334 | 335 | ### Optimizations 336 | 337 | - **Concurrent processing**: Character analysis uses goroutines 338 | - **Connection pooling**: Database connection reuse 339 | - **Graceful shutdown**: No request interruption 340 | - **Request timeouts**: Prevent resource exhaustion 341 | - **Efficient JSON parsing**: Streaming decoder 342 | 343 | ### Benchmarks 344 | 345 | Run benchmarks to measure performance: 346 | 347 | ```bash 348 | make benchmark 349 | ``` 350 | 351 | ## CI/CD Pipeline 352 | 353 | GitHub Actions workflow includes: 354 | 355 | 1. **Lint**: Code quality checks 356 | 2. **Test**: Unit and integration tests with coverage 357 | 3. **Build**: Binary compilation 358 | 4. **Security**: Vulnerability scanning 359 | 5. **Docker**: Image building and pushing 360 | 6. **Deploy**: Automated deployment (configurable) 361 | 362 | ## Project Structure 363 | 364 | ``` 365 | Post-Analyzer-Webserver/ 366 | ├── config/ # Configuration management 367 | │ └── config.go 368 | ├── internal/ # Internal packages 369 | │ ├── handlers/ # HTTP handlers 370 | │ ├── logger/ # Structured logging 371 | │ ├── metrics/ # Prometheus metrics 372 | │ ├── middleware/ # HTTP middleware 373 | │ └── storage/ # Storage layer (file, postgres) 374 | ├── .github/ 375 | │ └── workflows/ # CI/CD pipelines 376 | ├── assets/ # Static assets 377 | ├── main_new.go # Application entry point (production) 378 | ├── main.go # Original simple version 379 | ├── home.html # HTML template 380 | ├── Dockerfile # Docker image definition 381 | ├── docker-compose.yml # Multi-container setup 382 | ├── Makefile # Development commands 383 | ├── api-docs.yaml # OpenAPI specification 384 | └── README.md # This file 385 | ``` 386 | 387 | ## Troubleshooting 388 | 389 | ### Common Issues 390 | 391 | **Application won't start** 392 | ```bash 393 | # Check logs 394 | docker-compose logs app 395 | 396 | # Verify configuration 397 | go run main_new.go # Will show config validation errors 398 | ``` 399 | 400 | **Database connection failed** 401 | ```bash 402 | # Check PostgreSQL is running 403 | docker-compose ps 404 | 405 | # Test connection 406 | make db-shell 407 | ``` 408 | 409 | **Rate limit errors** 410 | ```bash 411 | # Increase rate limit 412 | export RATE_LIMIT_REQUESTS=1000 413 | ``` 414 | 415 | ## Contributing 416 | 417 | We welcome contributions! Please follow these steps: 418 | 419 | 1. Fork the repository 420 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 421 | 3. Make your changes 422 | 4. Run tests (`make check`) 423 | 5. Commit your changes (`git commit -m 'Add amazing feature'`) 424 | 6. Push to the branch (`git push origin feature/amazing-feature`) 425 | 7. Open a Pull Request 426 | 427 | ### Development Guidelines 428 | 429 | - Follow Go best practices and idioms 430 | - Write tests for new features 431 | - Update documentation 432 | - Ensure all checks pass (`make check`) 433 | - Use conventional commits 434 | 435 | ## Roadmap 436 | 437 | - [ ] REST API for programmatic access 438 | - [ ] GraphQL API support 439 | - [ ] User authentication and authorization 440 | - [ ] Multi-user support 441 | - [ ] Advanced analytics dashboard 442 | - [ ] Export functionality (CSV, PDF) 443 | - [ ] Real-time updates with WebSockets 444 | - [ ] Mobile app (React Native) 445 | - [ ] Kubernetes deployment manifests 446 | - [ ] Terraform infrastructure as code 447 | 448 | ## License 449 | 450 | Distributed under the MIT License. See `LICENSE` for more information. 451 | 452 | ## Acknowledgements 453 | 454 | - [Go](https://golang.org/) 455 | - [JSONPlaceholder](https://jsonplaceholder.typicode.com/) 456 | - [Prometheus](https://prometheus.io/) 457 | - [PostgreSQL](https://www.postgresql.org/) 458 | - [Docker](https://www.docker.com/) 459 | 460 | ## Support 461 | 462 | For support, please: 463 | - 📧 Open an issue on GitHub 464 | - 💬 Start a discussion 465 | - 📖 Check the documentation 466 | 467 | ## Contact 468 | 469 | Son Nguyen - [@hoangsonww](https://github.com/hoangsonww) 470 | 471 | Project Link: [https://github.com/hoangsonww/Post-Analyzer-Webserver](https://github.com/hoangsonww/Post-Analyzer-Webserver) 472 | 473 | Live Demo: [https://post-analyzer-webserver.onrender.com](https://post-analyzer-webserver.onrender.com) 474 | 475 | --- 476 | 477 | Created with ❤️ by [Son Nguyen](https://github.com/hoangsonww) in 2024-2025. 478 | -------------------------------------------------------------------------------- /internal/service/post_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "Post_Analyzer_Webserver/internal/errors" 14 | "Post_Analyzer_Webserver/internal/logger" 15 | "Post_Analyzer_Webserver/internal/metrics" 16 | "Post_Analyzer_Webserver/internal/models" 17 | "Post_Analyzer_Webserver/internal/storage" 18 | ) 19 | 20 | // PostService handles business logic for posts 21 | type PostService struct { 22 | storage storage.Storage 23 | } 24 | 25 | // NewPostService creates a new post service 26 | func NewPostService(storage storage.Storage) *PostService { 27 | return &PostService{ 28 | storage: storage, 29 | } 30 | } 31 | 32 | // GetAll retrieves all posts with optional filtering and pagination 33 | func (s *PostService) GetAll(ctx context.Context, filter *models.PostFilter, pagination *models.PaginationParams) ([]models.Post, *models.PaginationMeta, error) { 34 | start := time.Now() 35 | defer func() { 36 | metrics.RecordDBOperation("get_all_posts", "success", time.Since(start)) 37 | }() 38 | 39 | // Get all posts from storage 40 | storagePosts, err := s.storage.GetAll(ctx) 41 | if err != nil { 42 | metrics.RecordDBOperation("get_all_posts", "error", time.Since(start)) 43 | return nil, nil, errors.Wrap(err, "failed to retrieve posts") 44 | } 45 | 46 | // Convert storage posts to models 47 | posts := make([]models.Post, len(storagePosts)) 48 | for i, sp := range storagePosts { 49 | posts[i] = models.Post{ 50 | ID: sp.Id, 51 | UserID: sp.UserId, 52 | Title: sp.Title, 53 | Body: sp.Body, 54 | CreatedAt: sp.CreatedAt, 55 | UpdatedAt: sp.UpdatedAt, 56 | } 57 | } 58 | 59 | // Apply filtering 60 | posts = s.filterPosts(posts, filter) 61 | 62 | // Apply sorting 63 | posts = s.sortPosts(posts, filter) 64 | 65 | // Calculate pagination 66 | totalItems := len(posts) 67 | paginationMeta := s.calculatePagination(totalItems, pagination) 68 | 69 | // Apply pagination 70 | if pagination != nil { 71 | start := pagination.Offset 72 | end := start + pagination.PageSize 73 | if start > len(posts) { 74 | posts = []models.Post{} 75 | } else if end > len(posts) { 76 | posts = posts[start:] 77 | } else { 78 | posts = posts[start:end] 79 | } 80 | } 81 | 82 | return posts, paginationMeta, nil 83 | } 84 | 85 | // GetByID retrieves a post by ID 86 | func (s *PostService) GetByID(ctx context.Context, id int) (*models.Post, error) { 87 | start := time.Now() 88 | defer func() { 89 | metrics.RecordDBOperation("get_post_by_id", "success", time.Since(start)) 90 | }() 91 | 92 | storagePost, err := s.storage.GetByID(ctx, id) 93 | if err != nil { 94 | if err == storage.ErrNotFound { 95 | return nil, errors.NewNotFound("Post") 96 | } 97 | metrics.RecordDBOperation("get_post_by_id", "error", time.Since(start)) 98 | return nil, errors.Wrap(err, "failed to retrieve post") 99 | } 100 | 101 | post := &models.Post{ 102 | ID: storagePost.Id, 103 | UserID: storagePost.UserId, 104 | Title: storagePost.Title, 105 | Body: storagePost.Body, 106 | CreatedAt: storagePost.CreatedAt, 107 | UpdatedAt: storagePost.UpdatedAt, 108 | } 109 | 110 | return post, nil 111 | } 112 | 113 | // Create creates a new post 114 | func (s *PostService) Create(ctx context.Context, req *models.CreatePostRequest) (*models.Post, error) { 115 | start := time.Now() 116 | defer func() { 117 | metrics.RecordDBOperation("create_post", "success", time.Since(start)) 118 | }() 119 | 120 | // Validate input 121 | if err := s.validateCreateRequest(req); err != nil { 122 | return nil, err 123 | } 124 | 125 | // Create storage post 126 | storagePost := &storage.Post{ 127 | UserId: req.UserID, 128 | Title: strings.TrimSpace(req.Title), 129 | Body: strings.TrimSpace(req.Body), 130 | } 131 | 132 | if err := s.storage.Create(ctx, storagePost); err != nil { 133 | metrics.RecordDBOperation("create_post", "error", time.Since(start)) 134 | return nil, errors.Wrap(err, "failed to create post") 135 | } 136 | 137 | logger.InfoContext(ctx, "post created", "id", storagePost.Id) 138 | 139 | post := &models.Post{ 140 | ID: storagePost.Id, 141 | UserID: storagePost.UserId, 142 | Title: storagePost.Title, 143 | Body: storagePost.Body, 144 | CreatedAt: storagePost.CreatedAt, 145 | UpdatedAt: storagePost.UpdatedAt, 146 | } 147 | 148 | return post, nil 149 | } 150 | 151 | // Update updates an existing post 152 | func (s *PostService) Update(ctx context.Context, id int, req *models.UpdatePostRequest) (*models.Post, error) { 153 | start := time.Now() 154 | defer func() { 155 | metrics.RecordDBOperation("update_post", "success", time.Since(start)) 156 | }() 157 | 158 | // Get existing post 159 | existing, err := s.storage.GetByID(ctx, id) 160 | if err != nil { 161 | if err == storage.ErrNotFound { 162 | return nil, errors.NewNotFound("Post") 163 | } 164 | return nil, errors.Wrap(err, "failed to retrieve post") 165 | } 166 | 167 | // Update fields 168 | if req.UserID != 0 { 169 | existing.UserId = req.UserID 170 | } 171 | if req.Title != "" { 172 | if len(req.Title) > 500 { 173 | return nil, errors.NewValidationError("title too long").WithField("title", "maximum 500 characters") 174 | } 175 | existing.Title = strings.TrimSpace(req.Title) 176 | } 177 | if req.Body != "" { 178 | if len(req.Body) > 10000 { 179 | return nil, errors.NewValidationError("body too long").WithField("body", "maximum 10000 characters") 180 | } 181 | existing.Body = strings.TrimSpace(req.Body) 182 | } 183 | 184 | if err := s.storage.Update(ctx, existing); err != nil { 185 | metrics.RecordDBOperation("update_post", "error", time.Since(start)) 186 | return nil, errors.Wrap(err, "failed to update post") 187 | } 188 | 189 | post := &models.Post{ 190 | ID: existing.Id, 191 | UserID: existing.UserId, 192 | Title: existing.Title, 193 | Body: existing.Body, 194 | CreatedAt: existing.CreatedAt, 195 | UpdatedAt: existing.UpdatedAt, 196 | } 197 | 198 | return post, nil 199 | } 200 | 201 | // Delete deletes a post 202 | func (s *PostService) Delete(ctx context.Context, id int) error { 203 | start := time.Now() 204 | defer func() { 205 | metrics.RecordDBOperation("delete_post", "success", time.Since(start)) 206 | }() 207 | 208 | if err := s.storage.Delete(ctx, id); err != nil { 209 | if err == storage.ErrNotFound { 210 | return errors.NewNotFound("Post") 211 | } 212 | metrics.RecordDBOperation("delete_post", "error", time.Since(start)) 213 | return errors.Wrap(err, "failed to delete post") 214 | } 215 | 216 | logger.InfoContext(ctx, "post deleted", "id", id) 217 | return nil 218 | } 219 | 220 | // BulkCreate creates multiple posts 221 | func (s *PostService) BulkCreate(ctx context.Context, req *models.BulkCreateRequest) (*models.BulkCreateResponse, error) { 222 | start := time.Now() 223 | defer func() { 224 | metrics.RecordDBOperation("bulk_create_posts", "success", time.Since(start)) 225 | }() 226 | 227 | response := &models.BulkCreateResponse{ 228 | PostIDs: make([]int, 0), 229 | Errors: make([]string, 0), 230 | } 231 | 232 | for i, postReq := range req.Posts { 233 | post, err := s.Create(ctx, &postReq) 234 | if err != nil { 235 | response.Failed++ 236 | response.Errors = append(response.Errors, fmt.Sprintf("post %d: %v", i+1, err)) 237 | continue 238 | } 239 | response.Created++ 240 | response.PostIDs = append(response.PostIDs, post.ID) 241 | } 242 | 243 | logger.InfoContext(ctx, "bulk create completed", "created", response.Created, "failed", response.Failed) 244 | return response, nil 245 | } 246 | 247 | // ExportPosts exports posts in the specified format 248 | func (s *PostService) ExportPosts(ctx context.Context, writer io.Writer, format models.ExportFormat, filter *models.PostFilter) error { 249 | posts, _, err := s.GetAll(ctx, filter, nil) 250 | if err != nil { 251 | return errors.Wrap(err, "failed to retrieve posts for export") 252 | } 253 | 254 | switch format { 255 | case models.ExportFormatJSON: 256 | return s.exportJSON(writer, posts) 257 | case models.ExportFormatCSV: 258 | return s.exportCSV(writer, posts) 259 | default: 260 | return errors.NewValidationError("unsupported export format") 261 | } 262 | } 263 | 264 | // AnalyzeCharacterFrequency performs character frequency analysis 265 | func (s *PostService) AnalyzeCharacterFrequency(ctx context.Context) (*models.AnalyticsResult, error) { 266 | start := time.Now() 267 | defer func() { 268 | metrics.RecordAnalysisOperation(time.Since(start)) 269 | }() 270 | 271 | posts, _, err := s.GetAll(ctx, nil, nil) 272 | if err != nil { 273 | return nil, errors.Wrap(err, "failed to retrieve posts for analysis") 274 | } 275 | 276 | result := &models.AnalyticsResult{ 277 | TotalPosts: len(posts), 278 | CharFrequency: make(map[rune]int), 279 | } 280 | 281 | // Analyze character frequency 282 | totalChars := 0 283 | postLengths := make([]int, len(posts)) 284 | postsPerUser := make(map[int]int) 285 | 286 | for i, post := range posts { 287 | text := post.Title + " " + post.Body 288 | postLengths[i] = len(text) 289 | postsPerUser[post.UserID]++ 290 | 291 | for _, char := range text { 292 | result.CharFrequency[char]++ 293 | totalChars++ 294 | } 295 | } 296 | 297 | result.TotalCharacters = totalChars 298 | result.UniqueChars = len(result.CharFrequency) 299 | 300 | // Calculate top characters 301 | result.TopCharacters = s.calculateTopCharacters(result.CharFrequency, totalChars) 302 | 303 | // Calculate statistics 304 | result.Statistics = &models.AnalyticsStats{ 305 | AveragePostLength: s.calculateAverage(postLengths), 306 | MedianPostLength: s.calculateMedian(postLengths), 307 | PostsPerUser: postsPerUser, 308 | TimeDistribution: s.calculateTimeDistribution(posts), 309 | } 310 | 311 | logger.InfoContext(ctx, "character analysis completed", 312 | "total_posts", result.TotalPosts, 313 | "total_chars", result.TotalCharacters, 314 | "unique_chars", result.UniqueChars, 315 | ) 316 | 317 | return result, nil 318 | } 319 | 320 | // Helper methods 321 | 322 | func (s *PostService) validateCreateRequest(req *models.CreatePostRequest) error { 323 | validationErr := errors.NewValidationError("validation failed") 324 | hasError := false 325 | 326 | if req.Title == "" { 327 | _ = validationErr.WithField("title", "title is required") 328 | hasError = true 329 | } else if len(req.Title) > 500 { 330 | _ = validationErr.WithField("title", "title too long (max 500 characters)") 331 | hasError = true 332 | } 333 | 334 | if req.Body == "" { 335 | _ = validationErr.WithField("body", "body is required") 336 | hasError = true 337 | } else if len(req.Body) > 10000 { 338 | _ = validationErr.WithField("body", "body too long (max 10000 characters)") 339 | hasError = true 340 | } 341 | 342 | if hasError { 343 | return validationErr 344 | } 345 | return nil 346 | } 347 | 348 | func (s *PostService) filterPosts(posts []models.Post, filter *models.PostFilter) []models.Post { 349 | if filter == nil { 350 | return posts 351 | } 352 | 353 | filtered := make([]models.Post, 0, len(posts)) 354 | for _, post := range posts { 355 | // Filter by user ID 356 | if filter.UserID != nil && post.UserID != *filter.UserID { 357 | continue 358 | } 359 | 360 | // Filter by search term 361 | if filter.Search != "" { 362 | searchLower := strings.ToLower(filter.Search) 363 | if !strings.Contains(strings.ToLower(post.Title), searchLower) && 364 | !strings.Contains(strings.ToLower(post.Body), searchLower) { 365 | continue 366 | } 367 | } 368 | 369 | filtered = append(filtered, post) 370 | } 371 | 372 | return filtered 373 | } 374 | 375 | func (s *PostService) sortPosts(posts []models.Post, filter *models.PostFilter) []models.Post { 376 | if filter == nil || filter.SortBy == "" { 377 | return posts 378 | } 379 | 380 | sortBy := filter.SortBy 381 | sortOrder := filter.SortOrder 382 | if sortOrder == "" { 383 | sortOrder = "desc" 384 | } 385 | 386 | sort.Slice(posts, func(i, j int) bool { 387 | var less bool 388 | switch sortBy { 389 | case "id": 390 | less = posts[i].ID < posts[j].ID 391 | case "title": 392 | less = posts[i].Title < posts[j].Title 393 | case "createdAt": 394 | less = posts[i].CreatedAt.Before(posts[j].CreatedAt) 395 | case "updatedAt": 396 | less = posts[i].UpdatedAt.Before(posts[j].UpdatedAt) 397 | default: 398 | less = posts[i].ID < posts[j].ID 399 | } 400 | 401 | if sortOrder == "desc" { 402 | return !less 403 | } 404 | return less 405 | }) 406 | 407 | return posts 408 | } 409 | 410 | func (s *PostService) calculatePagination(totalItems int, params *models.PaginationParams) *models.PaginationMeta { 411 | if params == nil { 412 | return nil 413 | } 414 | 415 | totalPages := (totalItems + params.PageSize - 1) / params.PageSize 416 | if totalPages == 0 { 417 | totalPages = 1 418 | } 419 | 420 | return &models.PaginationMeta{ 421 | Page: params.Page, 422 | PageSize: params.PageSize, 423 | TotalItems: totalItems, 424 | TotalPages: totalPages, 425 | HasNext: params.Page < totalPages, 426 | HasPrev: params.Page > 1, 427 | } 428 | } 429 | 430 | func (s *PostService) exportJSON(writer io.Writer, posts []models.Post) error { 431 | encoder := json.NewEncoder(writer) 432 | encoder.SetIndent("", " ") 433 | return encoder.Encode(posts) 434 | } 435 | 436 | func (s *PostService) exportCSV(writer io.Writer, posts []models.Post) error { 437 | csvWriter := csv.NewWriter(writer) 438 | defer csvWriter.Flush() 439 | 440 | // Write header 441 | if err := csvWriter.Write([]string{"ID", "UserID", "Title", "Body", "CreatedAt", "UpdatedAt"}); err != nil { 442 | return err 443 | } 444 | 445 | // Write data 446 | for _, post := range posts { 447 | row := []string{ 448 | fmt.Sprintf("%d", post.ID), 449 | fmt.Sprintf("%d", post.UserID), 450 | post.Title, 451 | post.Body, 452 | post.CreatedAt.Format(time.RFC3339), 453 | post.UpdatedAt.Format(time.RFC3339), 454 | } 455 | if err := csvWriter.Write(row); err != nil { 456 | return err 457 | } 458 | } 459 | 460 | return nil 461 | } 462 | 463 | func (s *PostService) calculateTopCharacters(charFreq map[rune]int, totalChars int) []models.CharacterStat { 464 | stats := make([]models.CharacterStat, 0, len(charFreq)) 465 | 466 | for char, count := range charFreq { 467 | frequency := float64(count) / float64(totalChars) * 100 468 | stats = append(stats, models.CharacterStat{ 469 | Character: char, 470 | Count: count, 471 | Frequency: frequency, 472 | }) 473 | } 474 | 475 | // Sort by count descending 476 | sort.Slice(stats, func(i, j int) bool { 477 | return stats[i].Count > stats[j].Count 478 | }) 479 | 480 | // Return top 20 481 | if len(stats) > 20 { 482 | stats = stats[:20] 483 | } 484 | 485 | return stats 486 | } 487 | 488 | func (s *PostService) calculateAverage(values []int) float64 { 489 | if len(values) == 0 { 490 | return 0 491 | } 492 | sum := 0 493 | for _, v := range values { 494 | sum += v 495 | } 496 | return float64(sum) / float64(len(values)) 497 | } 498 | 499 | func (s *PostService) calculateMedian(values []int) int { 500 | if len(values) == 0 { 501 | return 0 502 | } 503 | sorted := make([]int, len(values)) 504 | copy(sorted, values) 505 | sort.Ints(sorted) 506 | 507 | mid := len(sorted) / 2 508 | if len(sorted)%2 == 0 { 509 | return (sorted[mid-1] + sorted[mid]) / 2 510 | } 511 | return sorted[mid] 512 | } 513 | 514 | func (s *PostService) calculateTimeDistribution(posts []models.Post) map[string]int { 515 | distribution := make(map[string]int) 516 | 517 | for _, post := range posts { 518 | hour := post.CreatedAt.Hour() 519 | var period string 520 | if hour < 6 { 521 | period = "night" 522 | } else if hour < 12 { 523 | period = "morning" 524 | } else if hour < 18 { 525 | period = "afternoon" 526 | } else { 527 | period = "evening" 528 | } 529 | distribution[period]++ 530 | } 531 | 532 | return distribution 533 | } 534 | -------------------------------------------------------------------------------- /home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{.Title}} 5 | 6 | 136 | 137 | 138 | 139 |
140 |

Post Viewer and Analyzer

141 |
142 |
143 | Home 144 | Fetch Posts 145 | 146 | Add New Post 147 |
148 |
149 | {{if .Error}} 150 |

Error: {{.Error}}

151 | {{end}} 152 | {{if eq .Title "Home"}} 153 |

Welcome to the Post Viewer and Analyzer!

154 |

This application allows you to:

155 |
    156 |
  • Fetch posts from an external API and view them.
  • 157 |
  • Analyze character frequency in the posts (calculated in your browser).
  • 158 |
  • Add new posts to the existing list.
  • 159 |
160 | {{if .HasPosts}} 161 |

Recent Posts:

162 |
163 | {{range .Posts}} 164 |
165 | {{.Title}} 166 |

{{.Body}}

167 |
168 | {{end}} 169 |
170 | {{end}} 171 | {{else if eq .Title "Add New Post"}} 172 |
173 |
174 | 175 | 176 |
177 |
178 | 179 | 180 |
181 |
182 | 183 |
184 |
185 | {{else if .HasPosts}} 186 |
187 |

Fetched Posts:

188 | {{range .Posts}} 189 |
190 | {{.Title}} 191 |

{{.Body}}

192 |
193 | {{end}} 194 |
195 | {{end}} 196 | 197 | 198 |
199 |

Character Frequency Analysis (Client-Side)

200 | 201 |
202 |
203 |
Total Posts
204 |
0
205 |
206 |
207 |
Total Characters
208 |
0
209 |
210 |
211 |
Unique Characters
212 |
0
213 |
214 |
215 |
Average Post Length
216 |
0
217 |
218 |
219 | 220 |
221 | 222 |
223 | 224 |
225 |

Top 20 Most Frequent Characters:

226 |
227 |
228 |
229 |
230 | 231 | 416 | 417 | 418 | --------------------------------------------------------------------------------