From 89c39515f0b30735de622b81e786b68eb0fb4b7a Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 20 Feb 2025 12:09:38 +0000 Subject: [PATCH] begin core module --- .devcontainer/Dockerfile | 18 ++++ .devcontainer/devcontainer.json | 22 +++++ .gitignore | 28 ++++++ .vscode/settings.json | 7 ++ .woodpecker/.build.yml | 17 ++++ .woodpecker/.lint.yml | 11 +++ .woodpecker/.publish.yml | 25 +++++ .woodpecker/.tests.yml | 22 +++++ Dockerfile | 33 +++++++ cmd/api/ascii | 5 + cmd/api/main.go | 111 ++++++++++++++++++++++ cmd/cron/resync.go | 7 ++ go.mod | 19 ++++ go.sum | 28 ++++++ internal/accounts/controller.go | 20 ++++ internal/accounts/errors.go | 13 +++ internal/accounts/repository.go | 13 +++ internal/accounts/routes.go | 14 +++ internal/accounts/service.go | 9 ++ internal/core/database.go | 21 ++++ internal/core/errors.go | 38 ++++++++ internal/core/helpers.go | 25 +++++ internal/core/http.go | 62 ++++++++++++ internal/core/middleware.go | 46 +++++++++ migrations/20240703103050_accounts.up.sql | 14 +++ migrations/20240703103105_movies.up.sql | 16 ++++ scripts/build.sh | 12 +++ scripts/startup.sh | 10 ++ 28 files changed, 666 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 .woodpecker/.build.yml create mode 100644 .woodpecker/.lint.yml create mode 100644 .woodpecker/.publish.yml create mode 100644 .woodpecker/.tests.yml create mode 100644 Dockerfile create mode 100644 cmd/api/ascii create mode 100644 cmd/api/main.go create mode 100644 cmd/cron/resync.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/accounts/controller.go create mode 100644 internal/accounts/errors.go create mode 100644 internal/accounts/repository.go create mode 100644 internal/accounts/routes.go create mode 100644 internal/accounts/service.go create mode 100644 internal/core/database.go create mode 100644 internal/core/errors.go create mode 100644 internal/core/helpers.go create mode 100644 internal/core/http.go create mode 100644 internal/core/middleware.go create mode 100644 migrations/20240703103050_accounts.up.sql create mode 100644 migrations/20240703103105_movies.up.sql create mode 100644 scripts/build.sh create mode 100644 scripts/startup.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a9a9e45 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,18 @@ +FROM debian:12 + +WORKDIR /app + +ARG GO_VERSION +ARG GOLANGCI_LINT_VERSION +ARG MIGRATE_VERSION + +RUN apt update &&\ + apt install git wget curl -y &&\ + wget https://go.dev/dl/go$GO_VERSION.linux-amd64.tar.gz &&\ + rm -rf /usr/local/go && tar -C /usr/local -xzf go$GO_VERSION.linux-amd64.tar.gz &&\ + curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/go/bin &&\ + wget https://github.com/golangci/golangci-lint/releases/download/v$GOLANGCI_LINT_VERSION/golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.deb &&\ + dpkg -i golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.deb &&\ + wget https://github.com/golang-migrate/migrate/releases/download/v$MIGRATE_VERSION/migrate.linux-amd64.deb &&\ + dpkg -i migrate.linux-amd64.deb &&\ + echo "export PATH=$PATH:/usr/local/go/bin" > /root/.bashrc \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b95c93c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z", + "build": { + "dockerfile": "Dockerfile", + "args": { + "GO_VERSION": "1.24.0", + "GOLANGCI_LINT_VERSION": "1.64.5", + "MIGRATE_VERSION": "4.18.2" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "golang.go" + ] + } + }, + "forwardPorts": [ + 3000 + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c759515 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# dist directory +dist/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..017bdb7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.formatOnSave": true, + "go.lintTool": "golangci-lint", + "go.lintFlags": [ + "--fast" + ] +} \ No newline at end of file diff --git a/.woodpecker/.build.yml b/.woodpecker/.build.yml new file mode 100644 index 0000000..e2fe848 --- /dev/null +++ b/.woodpecker/.build.yml @@ -0,0 +1,17 @@ +when: + event: [tag, push] + +steps: + build: + image: golang:1.23-alpine + commands: + - apk update + - apk add git + - go install github.com/goreleaser/goreleaser/v2@latest + - echo "$${SENTRY_DSN}" > cmd/api/sentry + - goreleaser build --snapshot + secrets: [sentry_dsn] + +depends_on: + - lint + - tests \ No newline at end of file diff --git a/.woodpecker/.lint.yml b/.woodpecker/.lint.yml new file mode 100644 index 0000000..07e0cff --- /dev/null +++ b/.woodpecker/.lint.yml @@ -0,0 +1,11 @@ +when: + event: [tag, push] + +steps: + lint: + image: golang:1.23-alpine + commands: + - apk update + - apk add bash curl jq + - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.60.1 + - $(go env GOPATH)/bin/golangci-lint run --fast diff --git a/.woodpecker/.publish.yml b/.woodpecker/.publish.yml new file mode 100644 index 0000000..5759267 --- /dev/null +++ b/.woodpecker/.publish.yml @@ -0,0 +1,25 @@ +when: + event: [tag] + +steps: + - name: publish-docker + image: docker:27-cli + commands: + - tag="tintounn/trepa:$${CI_COMMIT_TAG}" + - docker login -u="$${DOCKER_USERNAME}" -p="$${DOCKER_PASSWORD}" + - docker build --rm --tag $tag . + - docker push $tag + - docker rmi $tag + volumes: + - /var/run/docker.sock:/var/run/docker.sock + secrets: [docker_username, docker_password] + + - name: publish-binaries + image: goreleaser/goreleaser + commands: + - goreleaser release + secrets: [ gitea_token ] + +depends_on: + - build + \ No newline at end of file diff --git a/.woodpecker/.tests.yml b/.woodpecker/.tests.yml new file mode 100644 index 0000000..e36c03d --- /dev/null +++ b/.woodpecker/.tests.yml @@ -0,0 +1,22 @@ +when: + event: [tag, push] + +steps: + tests: + image: golang:1.23-alpine + environment: + TEST_DATABASE_URL: postgres://dev:dev@db/trepa?sslmode=disable + commands: + - sleep 30 + - wget https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz + - tar -xf migrate.linux-amd64.tar.gz + - ./migrate -source file://migrations/ -database "$${TEST_DATABASE_URL}" up + - go test -cover ./internal/... -v + +services: + - name: db + image: postgres:16-alpine + environment: + - POSTGRES_USER=dev + - POSTGRES_PASSWORD=dev + - POSTGRES_DB=trepa diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e1465b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.24 AS build + +WORKDIR /app + +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY go.mod . +COPY go.sum . +COPY scripts/ scripts/ + +RUN chmod +x scripts/build.sh && \ + ./scripts/build.sh + + +FROM debian:bookworm-slim AS runner + +WORKDIR /app + +RUN apt update &&\ + apt install bash curl -y &&\ + wget https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.deb &&\ + dpkg -i migrate.linux-amd64.deb &&\ + rm migrate.linux-amd64.deb &&\ + apt install cron -y + +COPY --from=build /app/dist/ dist/ +COPY migrations/ migrations/ +COPY scripts/startup.sh scripts/ + +RUN chmod +x scripts/startup.sh +RUN chmod +x -R dist/ + +CMD [ "/bin/bash", "scripts/startup.sh" ] diff --git a/cmd/api/ascii b/cmd/api/ascii new file mode 100644 index 0000000..779c244 --- /dev/null +++ b/cmd/api/ascii @@ -0,0 +1,5 @@ +████████ ██████ ███████ ██████ █████ + ██ ██ ██ ██ ██ ██ ██ ██ + ██ ██████ █████ ██████ ███████ + ██ ██ ██ ██ ██ ██ ██ + ██ ██ ██ ███████ ██ ██ ██ diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..c29443d --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "gitea.qpismont.fr/qpismont/trepa/internal/accounts" + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "github.com/jmoiron/sqlx" +) + +//go:embed ascii +var ascii string + +func main() { + fmt.Println(ascii) + + core.LoadEnvVars() + + db := setupDB() + router := setupRouter(db) + + server := &http.Server{ + Addr: ":3000", + Handler: router, + } + + shutdownChan := make(chan os.Signal, 1) + done := make(chan bool, 1) + signal.Notify(shutdownChan, os.Interrupt, os.Kill, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGKILL) + + go func() { + sig := <-shutdownChan + slog.Info("Shutdown signal received", "signal", sig) + + timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + slog.Info("Shutting down HTTP server") + + if err := server.Shutdown(timeoutCtx); err != nil { + slog.Error("HTTP server forced to shutdown", "error", err) + } + + slog.Info("Closing database connection") + + if err := db.Close(); err != nil { + slog.Error("Error closing database connection", "error", err) + } + + slog.Info("Shutting down application") + + select { + case <-timeoutCtx.Done(): + slog.Error("Shutdown timed out, forcing exit") + done <- false + default: + slog.Info("Graceful shutdown completed") + done <- true + } + }() + + if err := server.ListenAndServe(); err != nil { + if err != http.ErrServerClosed { + slog.Error("HTTP server failed", "error", err) + os.Exit(1) + } + } + + if success := <-done; !success { + os.Exit(1) + } + os.Exit(0) +} + +func setupDB() *sqlx.DB { + dbURL := core.ComputeDBURL( + core.MustGetEnvVar("DB_HOST"), + core.MustGetEnvVar("DB_PORT"), + core.MustGetEnvVar("DB_USER"), + core.MustGetEnvVar("DB_PASSWORD"), + core.MustGetEnvVar("DB_NAME"), + ) + + slog.Info("Connecting to database", "url", dbURL) + + db, err := core.SetupDB(dbURL) + if err != nil { + slog.Error("Failed to connect to database", "error", err) + panic(err) + } + + slog.Info("Connected to database") + + return db +} + +func setupRouter(db *sqlx.DB) *core.ServerMux { + router := core.NewServerMux() + + accounts.BindRoutes(router, db) + + return router +} diff --git a/cmd/cron/resync.go b/cmd/cron/resync.go new file mode 100644 index 0000000..069d34b --- /dev/null +++ b/cmd/cron/resync.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Resyncing") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c5edac --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module gitea.qpismont.fr/qpismont/trepa + +go 1.24.0 + +require ( + github.com/jackc/pgx v3.6.2+incompatible + github.com/jmoiron/sqlx v1.4.0 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/cockroachdb/apd v1.1.0 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..239523b --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= +github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= +github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/internal/accounts/controller.go b/internal/accounts/controller.go new file mode 100644 index 0000000..a807d79 --- /dev/null +++ b/internal/accounts/controller.go @@ -0,0 +1,20 @@ +package accounts + +import ( + "net/http" + + "gitea.qpismont.fr/qpismont/trepa/internal/core" +) + +type Controller struct { + service Service +} + +func NewController(service Service) Controller { + return Controller{service: service} +} + +func (c *Controller) CreateAccount(w *core.Response, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Json([]byte("test")) +} diff --git a/internal/accounts/errors.go b/internal/accounts/errors.go new file mode 100644 index 0000000..2f1428c --- /dev/null +++ b/internal/accounts/errors.go @@ -0,0 +1,13 @@ +package accounts + +import ( + "net/http" + + "gitea.qpismont.fr/qpismont/trepa/internal/core" +) + +var ( + ErrAccountNotFound = core.NewHTTPError(http.StatusNotFound, "Account not found", nil) + ErrAccountAlreadyExists = core.NewHTTPError(http.StatusConflict, "Account already exists", nil) + ErrBadPassword = core.NewHTTPError(http.StatusUnauthorized, "Bad password", nil) +) diff --git a/internal/accounts/repository.go b/internal/accounts/repository.go new file mode 100644 index 0000000..11adc24 --- /dev/null +++ b/internal/accounts/repository.go @@ -0,0 +1,13 @@ +package accounts + +import ( + "github.com/jmoiron/sqlx" +) + +type Repository struct { + db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) Repository { + return Repository{db: db} +} diff --git a/internal/accounts/routes.go b/internal/accounts/routes.go new file mode 100644 index 0000000..01b8553 --- /dev/null +++ b/internal/accounts/routes.go @@ -0,0 +1,14 @@ +package accounts + +import ( + "gitea.qpismont.fr/qpismont/trepa/internal/core" + "github.com/jmoiron/sqlx" +) + +func BindRoutes(srv *core.ServerMux, db *sqlx.DB) { + repository := NewRepository(db) + service := NewService(repository) + controller := NewController(service) + + srv.HandleFunc("GET /accounts", controller.CreateAccount) +} diff --git a/internal/accounts/service.go b/internal/accounts/service.go new file mode 100644 index 0000000..87f47c5 --- /dev/null +++ b/internal/accounts/service.go @@ -0,0 +1,9 @@ +package accounts + +type Service struct { + repository Repository +} + +func NewService(repository Repository) Service { + return Service{repository: repository} +} diff --git a/internal/core/database.go b/internal/core/database.go new file mode 100644 index 0000000..cf87383 --- /dev/null +++ b/internal/core/database.go @@ -0,0 +1,21 @@ +package core + +import ( + "fmt" + + _ "github.com/jackc/pgx/stdlib" + "github.com/jmoiron/sqlx" +) + +func SetupDB(url string) (*sqlx.DB, error) { + db, err := sqlx.Open("pgx", url) + if err != nil { + return nil, err + } + + return db, nil +} + +func ComputeDBURL(dbHost, dbPort, dbUser, dbPassword, dbName string) string { + return fmt.Sprintf("postgres://%s:%s@%s:%s/%s", dbUser, dbPassword, dbHost, dbPort, dbName) +} diff --git a/internal/core/errors.go b/internal/core/errors.go new file mode 100644 index 0000000..3801bb0 --- /dev/null +++ b/internal/core/errors.go @@ -0,0 +1,38 @@ +package core + +import ( + "net/http" +) + +type HTTPError struct { + Code int `json:"code"` + Message string `json:"message"` + Cause error `json:"-"` + Details string `json:"details,omitempty"` +} + +func (e *HTTPError) Error() string { + return e.Message +} + +func (e *HTTPError) Unwrap() error { + return e.Cause +} + +func NewHTTPError(code int, message string, cause error) *HTTPError { + details := "" + if cause != nil { + details = cause.Error() + } + + return &HTTPError{ + Code: code, + Message: message, + Cause: cause, + Details: details, + } +} + +func NewInternalServerError(cause error) *HTTPError { + return NewHTTPError(http.StatusInternalServerError, "Internal server error", cause) +} diff --git a/internal/core/helpers.go b/internal/core/helpers.go new file mode 100644 index 0000000..d09a742 --- /dev/null +++ b/internal/core/helpers.go @@ -0,0 +1,25 @@ +package core + +import ( + "log/slog" + "os" + + "github.com/joho/godotenv" +) + +func LoadEnvVars() { + err := godotenv.Load() + if err != nil { + slog.Warn("Error loading .env file") + } +} + +func MustGetEnvVar(key string) string { + value := os.Getenv(key) + if value == "" { + slog.Error("Environment variable is not set", "key", key) + panic("Environment variable is not set") + } + + return value +} diff --git a/internal/core/http.go b/internal/core/http.go new file mode 100644 index 0000000..c3447a3 --- /dev/null +++ b/internal/core/http.go @@ -0,0 +1,62 @@ +package core + +import ( + "net/http" +) + +type Response struct { + http.ResponseWriter + Status int + Size int +} + +func NewResponse(w http.ResponseWriter) *Response { + return &Response{ + ResponseWriter: w, + Status: 200, + Size: 0, + } +} + +func (r *Response) Header() http.Header { + return r.ResponseWriter.Header() +} + +func (r *Response) WriteHeader(status int) { + r.Status = status +} + +func (r *Response) Json(json []byte) { + r.Header().Set("Content-Type", "application/json") + r.Write(json) +} + +func (r *Response) Write(b []byte) (int, error) { + r.ResponseWriter.WriteHeader(r.Status) + n, err := r.ResponseWriter.Write(b) + r.Size += n + + return n, err +} + +type ServerMux struct { + mux *http.ServeMux +} + +func NewServerMux() *ServerMux { + return &ServerMux{ + mux: http.NewServeMux(), + } +} + +func (s *ServerMux) HandleFunc(pattern string, handler func(w *Response, r *http.Request)) { + s.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + response := NewResponse(w) + + LogMiddleware(ErrorMiddleware(handler))(response, r) + }) +} + +func (s *ServerMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} diff --git a/internal/core/middleware.go b/internal/core/middleware.go new file mode 100644 index 0000000..c3a3db6 --- /dev/null +++ b/internal/core/middleware.go @@ -0,0 +1,46 @@ +package core + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" +) + +func LogMiddleware(next func(w *Response, r *http.Request)) func(w *Response, r *http.Request) { + return func(w *Response, r *http.Request) { + now := time.Now() + next(w, r) + elapsed := time.Since(now) + + // Use NCSA common log format + ip := r.RemoteAddr + timestamp := time.Now().Format("02/Jan/2006:15:04:05 -0700") + method := r.Method + url := r.URL.Path + protocol := r.Proto + status := w.Status + size := w.Size + + slog.Info(fmt.Sprintf("%s - - [%s] \"%s %s %s\" %d %d %s", ip, timestamp, method, url, protocol, status, size, elapsed)) + } +} + +func ErrorMiddleware(next func(w *Response, r *http.Request)) func(w *Response, r *http.Request) { + return func(w *Response, r *http.Request) { + defer func() { + if err := recover(); err != nil { + if err, ok := err.(error); ok { + json, _ := json.Marshal(NewInternalServerError(err)) + w.Json(json) + } else { + json, _ := json.Marshal(NewInternalServerError(nil)) + w.Json(json) + } + } + }() + + next(w, r) + } +} diff --git a/migrations/20240703103050_accounts.up.sql b/migrations/20240703103050_accounts.up.sql new file mode 100644 index 0000000..0f09a49 --- /dev/null +++ b/migrations/20240703103050_accounts.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS public.accounts +( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + username text NOT NULL, + password text NOT NULL, + role_id smallint NOT NULL DEFAULT '1'::smallint, + created_at timestamp without time zone NOT NULL DEFAULT now(), + updated_at timestamp without time zone NOT NULL DEFAULT now(), + CONSTRAINT accounts_pkey PRIMARY KEY (id), + CONSTRAINT accounts_username_key UNIQUE (username) +); + +ALTER TABLE IF EXISTS public.accounts + OWNER to dev; \ No newline at end of file diff --git a/migrations/20240703103105_movies.up.sql b/migrations/20240703103105_movies.up.sql new file mode 100644 index 0000000..1e534c6 --- /dev/null +++ b/migrations/20240703103105_movies.up.sql @@ -0,0 +1,16 @@ +-- Add migration script here + +CREATE TABLE IF NOT EXISTS public.movies +( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + title text NOT NULL, + overview text NOT NULL, + poster_path text NOT NULL, + backdrop_path text, + release_date date NOT NULL, + tmdb_id integer NOT NULL, + PRIMARY KEY (id) +); + +ALTER TABLE IF EXISTS public.movies + OWNER to dev; \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..d459cbc --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +#Optimize build size +export CGO_ENABLED=0 +export GOOS=linux +export GOARCH=amd64 + +#Build the API +go build -ldflags="-s -w" -o dist/api ./cmd/api/main.go + +#Build the cron job +go build -ldflags="-s -w" -o dist/resync ./cmd/cron/resync.go \ No newline at end of file diff --git a/scripts/startup.sh b/scripts/startup.sh new file mode 100644 index 0000000..3e597fe --- /dev/null +++ b/scripts/startup.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +#Start cron service (Debian) +service cron start + +# Wait for the database to be ready +./migrate -source file://migrations/ -database "$DATABASE_URL" up + +# Run the API +./dist/api \ No newline at end of file