From 84e150cb11cf879220a2cbd0b0903f9d2216168e Mon Sep 17 00:00:00 2001 From: rogueking Date: Tue, 6 May 2025 14:49:14 -0700 Subject: [PATCH] added workflows and fixed router --- .gitea/workflows/build.yml | 51 +++++++++++ .gitea/workflows/linter.yml | 26 ++++++ .gitea/workflows/test.yaml | 32 +++++++ cmd/junk2jive/main.go | 22 ----- coverage.out | 99 +++++++++++++++++++++ go.mod | 20 +++++ go.sum | 41 +++++++++ internal/config/config.go | 62 +++++++++---- internal/handlers/home.go | 0 internal/handlers/text.go | 4 +- internal/handlers/visual.go | 4 +- internal/limiter/limiter.go | 64 ++++++++++++++ internal/limiter/limiter_test.go | 136 +++++++++++++++++++++++++++++ internal/logger/application.go | 59 +++++++++++++ internal/logger/request.go | 43 +++++++++ internal/response/response.go | 35 ++++++++ internal/response/response_test.go | 65 ++++++++++++++ internal/routes/routes.go | 84 ++++++++++++++---- 18 files changed, 786 insertions(+), 61 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/linter.yml create mode 100644 .gitea/workflows/test.yaml create mode 100644 coverage.out create mode 100644 go.sum delete mode 100644 internal/handlers/home.go create mode 100644 internal/limiter/limiter.go create mode 100644 internal/limiter/limiter_test.go create mode 100644 internal/logger/application.go create mode 100644 internal/logger/request.go create mode 100644 internal/response/response.go create mode 100644 internal/response/response_test.go diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..d771b0f --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build and Push Docker Image +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: Build and push image + runs-on: ubuntu-latest + env: + RUNNER_TOOL_CACHE: /toolcache + container: + image: catthehacker/ubuntu:act-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-docker-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-docker- + + - name: Login to Docker Registry + uses: docker/login-action@v3 + with: + registry: registry.syntinel.dev + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: | + registry.syntinel.dev/junk2jive-server:latest + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache,mode=max diff --git a/.gitea/workflows/linter.yml b/.gitea/workflows/linter.yml new file mode 100644 index 0000000..b0f6194 --- /dev/null +++ b/.gitea/workflows/linter.yml @@ -0,0 +1,26 @@ +name: golangci-lint +on: + push: + branches: + - main + - master + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.0 \ No newline at end of file diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..b89a6a4 --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,32 @@ +name: Run Go Tests +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Cache Go Modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Build + run: go build ./cmd/junk2jive + + - name: Go Test + run: NONLOCAL_TESTS=1 go test ./... -v + + - name: Coverage Test + run: + diff --git a/cmd/junk2jive/main.go b/cmd/junk2jive/main.go index 0c7857e..f0266ea 100644 --- a/cmd/junk2jive/main.go +++ b/cmd/junk2jive/main.go @@ -9,32 +9,10 @@ import ( "path/filepath" "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config" - "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers" "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes" ) func main() { - // Parse command line flags - var configPath string - flag.StringVar(&configPath, "config", "", "Path to config file") - flag.Parse() - - // If no config file specified, look for it in default locations - if configPath == "" { - // Check current directory - if _, err := os.Stat("config.json"); err == nil { - configPath = "config.json" - } else { - // Check config directory relative to executable - exePath, err := os.Executable() - if err == nil { - potentialPath := filepath.Join(filepath.Dir(exePath), "../config/config.json") - if _, err := os.Stat(potentialPath); err == nil { - configPath = potentialPath - } - } - } - } // Load configuration cfg, err := config.Load(configPath) diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..12e6c9a --- /dev/null +++ b/coverage.out @@ -0,0 +1,99 @@ +mode: set +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:13.27,19.2 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:21.57,22.45 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:22.45,24.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:25.5,25.24 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/text.go:20.61,21.57 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/text.go:21.57,23.68 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/text.go:23.68,26.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/text.go:28.9,30.23 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/text.go:30.23,33.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/text.go:35.9,41.44 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:16.59,17.57 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:17.57,19.62 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:19.62,22.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:24.9,25.23 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:25.23,28.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:29.9,33.23 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:33.23,36.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:37.9,41.57 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:41.57,44.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:47.9,49.23 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:49.23,52.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:55.9,58.23 4 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:58.23,61.10 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers/visual.go:64.9,70.44 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:29.44,34.2 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:36.44,66.55 13 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:66.55,67.46 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:67.46,70.74 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:70.74,72.5 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:74.4,75.65 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:78.2,78.11 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/routes/routes.go:82.39,84.2 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:33.53,35.2 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:37.67,58.19 5 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:58.19,60.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:62.5,63.19 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:63.19,65.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:67.5,72.19 5 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:72.19,74.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:75.5,78.72 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:78.72,80.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:82.5,82.35 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:82.35,84.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:86.5,86.52 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:25.57,27.2 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:29.77,32.19 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:32.19,34.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:35.5,43.19 5 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:43.19,45.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:48.5,48.49 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:48.49,50.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:53.5,58.19 4 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:58.19,60.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:62.5,67.19 4 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:67.19,69.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:70.5,74.72 3 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:74.72,76.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:79.5,80.53 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:80.53,82.6 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/robowflow.go:84.5,84.24 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:25.13,31.62 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:31.62,32.31 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:32.31,33.27 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:34.22,38.8 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:41.5,41.13 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:45.2,45.25 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:48.66,49.39 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:49.39,50.20 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:50.20,52.4 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:53.3,55.26 2 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:55.26,57.4 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:13.69,14.46 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:14.46,15.72 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:15.72,32.47 5 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:32.47,34.5 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:36.4,36.80 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:36.80,38.5 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:38.10,40.5 1 0 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:13.43,16.2 2 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:18.38,19.63 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:19.63,21.3 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:22.2,22.12 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:25.76,30.2 4 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:32.100,35.2 2 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:19.25,23.2 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:25.36,27.16 2 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:27.16,29.3 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:30.2,30.11 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:33.91,37.45 3 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:37.45,39.3 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:41.2,41.54 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:41.54,43.3 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:45.2,47.16 3 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:50.100,51.46 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:51.46,52.72 1 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:52.72,56.24 3 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:56.24,59.5 2 1 +gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:61.4,61.24 1 1 diff --git a/go.mod b/go.mod index 50b4cad..75c247b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,23 @@ module gitea.miguelmuniz.com/rogueking/junk2jive-server go 1.24.2 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/cors v1.2.1 + github.com/joho/godotenv v1.5.1 + github.com/lmittmann/tint v1.0.7 + github.com/stretchr/testify v1.10.0 + go.uber.org/zap v1.27.0 + golang.org/x/time v0.11.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7694f7 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= +github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 32f1187..4c47820 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,26 +1,56 @@ package config import ( - "os" + "flag" + "fmt" + "os" + + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger" + "github.com/joho/godotenv" ) type Config struct { - OpenAIKey string - RoboflowKey string - Port string + OllamaKey string + RoboflowKey string + Port string } -func LoadConfig() *Config { - return &Config{ - OpenAIKey: os.Getenv("OPENAI_API_KEY"), - RoboflowKey: os.Getenv("ROBOFLOW_API_KEY"), - Port: getEnvWithDefault("PORT", "8080"), - } +type Flags struct { + OllamaKey string + RoboflowKey int + Port string } -func getEnvWithDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} \ No newline at end of file +func LoadEnv(filePath string) { + if err := godotenv.Load(filePath); err != nil { + logger.Fatal("Error loading .env file") + } +} + +func DeclareFlags() *Flags { + env := flag.String("e", "development", "Set the environment ( development | production )") + port := flag.Int("p", 0, "Set the port that will be used") + envFile := flag.String("ef", "", "Set the imported env file") + + flag.Parse() + + return &Flags{ + OllamaKey: *env, + RoboflowKey: *port, + Port: *envFile, + } +} + +func ConfigPort(flags *Flags) string { + var port string + if flags.Port != 0 { + port = fmt.Sprintf(":%d", flags.Port) + } else { + if os.Getenv("APP_PORT") == "" { + logger.Warn("No port specified, default port :80 used...") + return ":80" + } + port = fmt.Sprintf(":%s", os.Getenv("APP_PORT")) + } + return port +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/handlers/text.go b/internal/handlers/text.go index 81b503a..0e5608a 100644 --- a/internal/handlers/text.go +++ b/internal/handlers/text.go @@ -4,8 +4,8 @@ import ( "encoding/json" "net/http" - "gitea.miguelmuniz.com/junk2jive-server/internal/config" - "gitea.miguelmuniz.com/junk2jive-server/internal/services" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services" ) type TextPromptRequest struct { diff --git a/internal/handlers/visual.go b/internal/handlers/visual.go index 9ef04d3..e22bcba 100644 --- a/internal/handlers/visual.go +++ b/internal/handlers/visual.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "gitea.miguelmuniz.com/junk2jive-server/internal/config" - "gitea.miguelmuniz.com/junk2jive-server/internal/services" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services" ) func VisualAIHandler(cfg *config.Config) http.HandlerFunc { diff --git a/internal/limiter/limiter.go b/internal/limiter/limiter.go new file mode 100644 index 0000000..445bb2e --- /dev/null +++ b/internal/limiter/limiter.go @@ -0,0 +1,64 @@ +package limiter + +import ( + "fmt" + "net" + "net/http" + "sync" + + "golang.org/x/time/rate" + + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response" +) + +type RateLimiter struct { + limiters map[string]map[string]*rate.Limiter // map[path][ip] => limiter + mu sync.RWMutex +} + +func New() *RateLimiter { + return &RateLimiter{ + limiters: make(map[string]map[string]*rate.Limiter), + } +} + +func GetIP(r *http.Request) string { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +func (rl *RateLimiter) Get(path string, ip string, r rate.Limit, burst int) *rate.Limiter { + rl.mu.Lock() + defer rl.mu.Unlock() + + if _, exists := rl.limiters[path]; !exists { + rl.limiters[path] = make(map[string]*rate.Limiter) + } + + if limiter, exists := rl.limiters[path][ip]; exists { + return limiter + } + + limiter := rate.NewLimiter(r, burst) + rl.limiters[path][ip] = limiter + return limiter +} + +func (rl *RateLimiter) Middleware(rateLimit rate.Limit, burst int) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := GetIP(r) + limiter := rl.Get(r.URL.Path, ip, rateLimit, burst) + + if !limiter.Allow() { + response.RespondWithError(w, r, http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests), fmt.Errorf("too many requests")) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/limiter/limiter_test.go b/internal/limiter/limiter_test.go new file mode 100644 index 0000000..092add5 --- /dev/null +++ b/internal/limiter/limiter_test.go @@ -0,0 +1,136 @@ +package limiter + +import ( + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" +) + +func TestGetIP(t *testing.T) { + req1 := &http.Request{RemoteAddr: "192.168.1.100:12345"} + ip1 := GetIP(req1) + assert.Equal(t, "192.168.1.100", ip1) + + req2 := &http.Request{RemoteAddr: "invalid-address"} + ip2 := GetIP(req2) + assert.Equal(t, "invalid-address", ip2) +} + +func TestGetLimiter(t *testing.T) { + rl := New() + + limiter1 := rl.Get("/test", "127.0.0.1", 1, 1) + require.NotNil(t, limiter1) + + limiter2 := rl.Get("/test", "127.0.0.1", 1, 1) + assert.Same(t, limiter1, limiter2) + + limiter3 := rl.Get("/another", "127.0.0.1", 1, 1) + assert.NotSame(t, limiter1, limiter3) + + limiter4 := rl.Get("/test", "192.168.0.1", 1, 1) + assert.NotSame(t, limiter1, limiter4) +} + +func TestMiddleware(t *testing.T) { + rl := New() + rateLimit := rate.Limit(1) + burst := 1 + + var nextCalled int32 + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.StoreInt32(&nextCalled, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + handler := rl.Middleware(rateLimit, burst)(nextHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = net.JoinHostPort("127.0.0.1", "12345") + + rr := httptest.NewRecorder() + atomic.StoreInt32(&nextCalled, 0) + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, int32(1), atomic.LoadInt32(&nextCalled)) + + rr = httptest.NewRecorder() + atomic.StoreInt32(&nextCalled, 0) + handler.ServeHTTP(rr, req) + + assert.NotEqual(t, http.StatusOK, rr.Code) + + assert.Equal(t, http.StatusTooManyRequests, rr.Code) + assert.Equal(t, int32(0), atomic.LoadInt32(&nextCalled)) +} + +func TestMiddlewareIndependent(t *testing.T) { + rl := New() + rateLimit := rate.Limit(1) + burst := 1 + + var handlerCallCount int32 + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&handlerCallCount, 1) + w.WriteHeader(http.StatusOK) + }) + + middleware := rl.Middleware(rateLimit, burst) + + req1 := httptest.NewRequest(http.MethodGet, "/path1", nil) + req1.RemoteAddr = net.JoinHostPort("127.0.0.1", "1111") + + req2 := httptest.NewRequest(http.MethodGet, "/path2", nil) + req2.RemoteAddr = net.JoinHostPort("127.0.0.2", "2222") + + rr1 := httptest.NewRecorder() + middleware(nextHandler).ServeHTTP(rr1, req1) + assert.Equal(t, http.StatusOK, rr1.Code) + + rr2 := httptest.NewRecorder() + middleware(nextHandler).ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusOK, rr2.Code) + + assert.Equal(t, int32(2), atomic.LoadInt32(&handlerCallCount)) +} + +func TestLimiterConcurrency(t *testing.T) { + rl := New() + path := "/concurrent" + ip := "127.0.0.1" + + const goroutines = 50 + limiterCh := make(chan *rate.Limiter, goroutines) + doneCh := make(chan struct{}) + + for i := 0; i < goroutines; i++ { + go func() { + limiter := rl.Get(path, ip, 5, 10) + limiterCh <- limiter + doneCh <- struct{}{} + }() + } + + for i := 0; i < goroutines; i++ { + <-doneCh + } + + close(limiterCh) + var firstLimiter *rate.Limiter + for lim := range limiterCh { + if firstLimiter == nil { + firstLimiter = lim + } else { + assert.Equal(t, firstLimiter, lim) + } + } +} diff --git a/internal/logger/application.go b/internal/logger/application.go new file mode 100644 index 0000000..b612e5f --- /dev/null +++ b/internal/logger/application.go @@ -0,0 +1,59 @@ +package logger + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" + + "github.com/lmittmann/tint" +) + +var logger *slog.Logger + +const LevelFatal slog.Level = slog.LevelError + 4 + +var ( + Debug = makeLogFunc(slog.LevelDebug) + Info = makeLogFunc(slog.LevelInfo) + Warn = makeLogFunc(slog.LevelWarn) + Error = makeLogFunc(slog.LevelError) + Fatal = makeLogFunc(LevelFatal) +) + +func init() { + w := os.Stderr + logger = slog.New( + tint.NewHandler(w, &tint.Options{ + Level: slog.LevelDebug, + TimeFormat: time.RFC3339, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + switch a.Value.Any() { + case LevelFatal: + return slog.Attr{ + Key: slog.LevelKey, + Value: slog.StringValue("\x1b[91mFATAL\x1b[0m"), + } + } + } + return a + }, + }), + ) + slog.SetDefault(logger) +} + +func makeLogFunc(level slog.Level) func(msg string, args ...any) { + return func(msg string, args ...any) { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + logger.Log(context.Background(), level, msg) + + if level == LevelFatal { + os.Exit(1) + } + } +} diff --git a/internal/logger/request.go b/internal/logger/request.go new file mode 100644 index 0000000..3b6e346 --- /dev/null +++ b/internal/logger/request.go @@ -0,0 +1,43 @@ +package logger + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" + "go.uber.org/zap" + + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response" +) + +func Middleware(logger *zap.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r) + + logFields := []zap.Field{ + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("query", r.URL.RawQuery), + zap.Int("status", ww.Status()), + zap.String("ip", r.RemoteAddr), + zap.String("user-agent", r.UserAgent()), + zap.Duration("latency", time.Since(start)), + zap.String("time", time.Now().Format(time.RFC3339)), + } + + if err := response.GetError(r); err != nil { + logFields = append(logFields, zap.Error(err)) + } + + if ww.Status() >= http.StatusBadRequest && ww.Status() != http.StatusTeapot { + logger.Error("Request failed", logFields...) + } else { + logger.Info("Request", logFields...) + } + }) + } +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 0000000..332ca9f --- /dev/null +++ b/internal/response/response.go @@ -0,0 +1,35 @@ +package response + +import ( + "context" + "encoding/json" + "net/http" +) + +type errorKeyType struct{} + +var errorContextKey = errorKeyType{} + +func SetError(r *http.Request, err error) { + ctx := context.WithValue(r.Context(), errorContextKey, err) + *r = *r.WithContext(ctx) +} + +func GetError(r *http.Request) error { + if err, ok := r.Context().Value(errorContextKey).(error); ok { + return err + } + return nil +} + +func RespondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + response, _ := json.Marshal(payload) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(response) +} + +func RespondWithError(w http.ResponseWriter, r *http.Request, code int, message string, err error) { + SetError(r, err) + RespondWithJSON(w, code, map[string]string{"error": message}) +} diff --git a/internal/response/response_test.go b/internal/response/response_test.go new file mode 100644 index 0000000..137d388 --- /dev/null +++ b/internal/response/response_test.go @@ -0,0 +1,65 @@ +package response + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetAndGetError(t *testing.T) { + req, err := http.NewRequest("GET", "/test", nil) + require.NoError(t, err) + + assert.Nil(t, GetError(req)) + + testErr := errors.New("test error") + SetError(req, testErr) + errFromReq := GetError(req) + require.NotNil(t, errFromReq) + assert.Equal(t, testErr.Error(), errFromReq.Error()) +} + +func TestRespondWithJSON(t *testing.T) { + rr := httptest.NewRecorder() + payload := map[string]string{"message": "hello"} + code := http.StatusOK + + RespondWithJSON(rr, code, payload) + + assert.Equal(t, code, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + var data map[string]string + err := json.Unmarshal(rr.Body.Bytes(), &data) + require.NoError(t, err) + assert.Equal(t, payload, data) +} + +func TestRespondWithError(t *testing.T) { + rr := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/test", nil) + require.NoError(t, err) + code := http.StatusBadRequest + message := "error occurred" + testErr := errors.New("test error") + + RespondWithError(rr, req, code, message, testErr) + + errFromReq := GetError(req) + require.NotNil(t, errFromReq) + assert.Equal(t, testErr.Error(), errFromReq.Error()) + + assert.Equal(t, code, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + var data map[string]string + err = json.Unmarshal(rr.Body.Bytes(), &data) + require.NoError(t, err) + expected := map[string]string{"error": message} + assert.Equal(t, expected, data) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 07e349b..1f06b67 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -2,37 +2,83 @@ package routes import ( "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" - "gitea.miguelmuniz.com/junk2jive-server/internal/config" - "gitea.miguelmuniz.com/junk2jive-server/internal/handlers" + "go.uber.org/zap" + "golang.org/x/time/rate" + + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/handlers" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger" + "gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response" ) -func SetupRoutes(cfg *config.Config) *chi.Mux { - r := chi.NewRouter() +// Router encapsulates the Chi router and its dependencies +type Router struct { + router *chi.Mux + config *config.Config + logger *zap.Logger + rateLimiter *limiter.RateLimiter +} - // Middleware - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, +// NewRouter creates and configures a new Router instance +func NewRouter(cfg *config.Config) *Router { + return &Router{ + router: chi.NewRouter(), + config: cfg, + } +} + +func SetupRouter(origins []string) *Router { + router := chi.NewRouter() + + router.Use(cors.Handler(cors.Options{ + AllowedOrigins: origins, AllowedMethods: []string{"GET", "POST", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Content-Type"}, + AllowedHeaders: []string{"Content-Type", "X-CSRF-Token"}, AllowCredentials: true, + MaxAge: 300, })) - // Static files - fileServer := http.FileServer(http.Dir("./static")) - r.Handle("/static/*", http.StripPrefix("/static", fileServer)) + zlogger, _ := zap.NewProduction() + defer zlogger.Sync() - // API routes - r.Get("/", handlers.HomeHandler) - r.Route("/api", func(r chi.Router) { - r.Post("/text-prompt", handlers.TextPromptHandler(cfg)) - r.Post("/ai-prompt", handlers.VisualAIHandler(cfg)) + rl := limiter.New() + + router.Use(middleware.RequestID) + router.Use(middleware.RealIP) + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) + router.Use(middleware.Timeout(60 * time.Second)) + + router.Use(logger.Middleware(zlogger)) + + r := Router{ + router: router, + logger: zlogger, + rateLimiter: rl, + } + + r.router.Route("/v1/api", func(apiRouter chi.Router) { + apiRouter.Group(func(subRouter chi.Router) { + subRouter.Use(r.rateLimiter.Middleware(rate.Every(1*time.Second), 30)) + + subRouter.Get("/coffee", func(w http.ResponseWriter, r *http.Request) { + response.RespondWithJSON(w, http.StatusTeapot, map[string]string{"error": "I'm A Teapot!"}) + }) + + subRouter.Post("/text", handlers.TextPromptHandler(r.config)) + subRouter.Post("/visual", handlers.VisualAIHandler(r.config)) + }) }) + return &r - return r +} + +func (r *Router) GetRouter() *chi.Mux { + return r.router }