├── 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 |
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 |
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 |
96 |
97 |
98 |
99 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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 |
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 |
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 |
--------------------------------------------------------------------------------