diff --git a/.golangci.yml b/.golangci.yml index 4a6839a..e2be8d2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -68,7 +68,6 @@ linters: - diagnostic - performance disabled-checks: - - whyNoLint - hugeParam - rangeValCopy gosec: @@ -90,8 +89,6 @@ linters: - name: var-naming - name: unreachable-code sloglint: - no-mixed-args: true - kv-only: true attr-only: true context: "scope" staticcheck: @@ -117,13 +114,13 @@ linters: - unused - staticcheck +# gofumpt намеренно не включён в formatters golangci-lint: в v2.13 он сообщает +# о расхождениях, которых `golangci-lint fmt` не устраняет (проверено на v2.13.2). +# gofumpt применяется отдельным инструментом в целях make fmt / make go-lint. formatters: enable: - - gofumpt - goimports settings: - gofumpt: - extra-rules: true goimports: local-prefixes: - glchat diff --git a/Makefile b/Makefile index dc7a1ce..29d41b2 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ export GOCACHE := $(CACHE_DIR)/go-build export GOMODCACHE:= $(CACHE_DIR)/go-mod export GOPATH := $(CACHE_DIR)/gopath export GOTOOLCHAIN := local +export GOLANGCI_LINT_CACHE := $(CACHE_DIR)/golangci-lint export CGO_ENABLED ?= 1 GOFLAGS_LOCAL := -trimpath @@ -102,9 +103,11 @@ lint: tools go-lint web-lint ## Полный линт (Go + web) .PHONY: go-lint go-lint: tools ## Линт Go: формат, vet, golangci-lint - @echo "==> gofmt check" - @unformatted=$$(gofumpt -l . | grep -v '^\.cache/' || true); \ - if [ -n "$$unformatted" ]; then echo "$$unformatted"; echo "run: make fmt"; exit 1; fi + @echo "==> формат (gofumpt + golangci-lint fmt)" + @out=$$(gofumpt -l ./cmd ./internal ./scripts 2>/dev/null || true); \ + if [ -n "$$out" ]; then printf '%s\n' "$$out"; echo "run: make fmt"; exit 1; fi + @out=$$(golangci-lint fmt --diff $(GO_PACKAGES)); \ + if [ -n "$$out" ]; then printf '%s\n' "$$out"; echo "run: make fmt"; exit 1; fi @echo "==> go vet" @go vet $(GO_PACKAGES) @echo "==> golangci-lint" @@ -112,7 +115,8 @@ go-lint: tools ## Линт Go: формат, vet, golangci-lint .PHONY: fmt fmt: tools ## Форматировать Go-код - gofumpt -w . + gofumpt -w ./cmd ./internal + golangci-lint fmt $(GO_PACKAGES) .PHONY: generate generate: tools ## Проверить сгенерированные артефакты (OpenAPI, sqlc — по мере появления) diff --git a/internal/database/database.go b/internal/database/database.go index 5733914..8e406d4 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -6,6 +6,7 @@ import ( "embed" "errors" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "strings" "time" + // Регистрирует драйвер "sqlite3" для database/sql (CGO, AGENT.md 5.1). _ "github.com/mattn/go-sqlite3" "github.com/pressly/goose/v3" ) @@ -59,38 +61,38 @@ func Open(ctx context.Context, opts Options) (*DB, error) { writer.SetConnMaxLifetime(connMaxLifetime) if err := applyPragmas(ctx, writer, opts); err != nil { - writer.Close() + closeQuietly(writer) return nil, err } if err := writer.PingContext(ctx); err != nil { - writer.Close() + closeQuietly(writer) return nil, fmt.Errorf("ping writer connection: %w", err) } db := &DB{Writer: writer, path: opts.Path} if opts.Migrate { if err := db.Migrate(ctx); err != nil { - db.Close() + closeQuietly(db.Writer) return nil, err } } reader, err := sql.Open("sqlite3", dsn(opts, false)) if err != nil { - db.Close() + closeQuietly(db.Writer) return nil, fmt.Errorf("open reader connection: %w", err) } reader.SetMaxOpenConns(opts.ReadPool) reader.SetMaxIdleConns(opts.ReadPool) reader.SetConnMaxLifetime(connMaxLifetime) if err := applyPragmas(ctx, reader, opts); err != nil { - reader.Close() - db.Close() + closeQuietly(reader) + closeQuietly(db.Writer) return nil, err } if err := reader.PingContext(ctx); err != nil { - reader.Close() - db.Close() + closeQuietly(reader) + closeQuietly(db.Writer) return nil, fmt.Errorf("ping reader connection: %w", err) } db.Reader = reader @@ -226,6 +228,12 @@ func (db *DB) Close() error { return nil } +func closeQuietly(closer io.Closer) { + if err := closer.Close(); err != nil { + slog.Debug("close sql handle", slog.Any("error", err)) + } +} + func EnsureDir(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("create data directory: %w", err) diff --git a/internal/httpx/errors.go b/internal/httpx/errors.go index c83aea2..fc6055d 100644 --- a/internal/httpx/errors.go +++ b/internal/httpx/errors.go @@ -2,7 +2,6 @@ package httpx import ( "encoding/json" - "errors" "log/slog" "net/http" ) @@ -29,6 +28,8 @@ type errorEnvelope struct { func (e Error) HTTPStatus() int { switch e.Code { + case CodeInternalError: + return http.StatusInternalServerError case CodeNotFound: return http.StatusNotFound case CodeBadRequest: @@ -54,7 +55,7 @@ func WriteJSON(w http.ResponseWriter, status int, body any) { return } if err := json.NewEncoder(w).Encode(body); err != nil { - slog.Error("write json response", "error", err) + slog.Error("write json response", slog.Any("error", err)) } } @@ -67,5 +68,3 @@ func WriteErrorStatus(w http.ResponseWriter, status int, code ErrorCode, message } func StatusForCode(code ErrorCode) int { return NewError(code, "").HTTPStatus() } - -var errBodyTooLarge = errors.New("request body too large") diff --git a/internal/server/server.go b/internal/server/server.go index 720c5a0..f570e19 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -108,11 +108,11 @@ func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg)) } -func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) { +func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) if _, err := w.Write(openAPIDocument); err != nil { - s.logger.Error("write openapi document", slog.Any("error", err)) + s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err)) } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8fcf4c4..ae4dd84 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -3,7 +3,6 @@ package server import ( "context" "encoding/json" - "io" "log/slog" "net/http" "net/http/httptest" @@ -45,14 +44,14 @@ func newTestServer(t *testing.T) (*Server, *database.DB) { LogLevel: "error", LogFormat: "json", } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + logger := slog.New(slog.DiscardHandler) return New(cfg, db, logger), db } func TestHealthz(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -78,7 +77,7 @@ func TestHealthz(t *testing.T) { func TestReadyzReportsDatabase(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -104,7 +103,7 @@ func TestReadyzFailsWhenDatabaseClosed(t *testing.T) { t.Fatalf("close database: %v", err) } rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rec.Code) @@ -114,7 +113,7 @@ func TestReadyzFailsWhenDatabaseClosed(t *testing.T) { func TestMetaEndpoint(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/meta", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -137,7 +136,7 @@ func TestMetaEndpoint(t *testing.T) { func TestOpenAPIDocumentIsServed(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/openapi.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -157,7 +156,7 @@ func TestOpenAPIDocumentIsServed(t *testing.T) { func TestUnknownAPIRouteUsesErrorEnvelope(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/does-not-exist", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/does-not-exist", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) @@ -191,7 +190,7 @@ func TestServesWebClientWithSPAFallback(t *testing.T) { } for _, tc := range cases { rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil)) if rec.Code != tc.wantStatus { t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.wantStatus) } @@ -201,7 +200,7 @@ func TestServesWebClientWithSPAFallback(t *testing.T) { } rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/assets/app.js", nil)) if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") { t.Errorf("assets Cache-Control = %q, want immutable", got) } @@ -220,11 +219,11 @@ func TestServeWebClientReportsMissingBundle(t *testing.T) { t.Cleanup(func() { _ = db.Close() }) cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + logger := slog.New(slog.DiscardHandler) srv := New(cfg, db, logger) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)) if rec.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503 when the bundle is absent", rec.Code) } @@ -233,7 +232,7 @@ func TestServeWebClientReportsMissingBundle(t *testing.T) { func TestMethodNotAllowedOnMeta(t *testing.T) { srv, _ := newTestServer(t) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/meta", nil)) + srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/meta", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", rec.Code) diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..8c67951 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,20 @@ +# glchat — sqlite (CGO) +# Схема БД: internal/database/migrations (goose). Здесь — sqlc-запросы. + +version: "2" +sql: + - engine: "sqlite" + schema: "internal/database/migrations" + queries: "queries" + gen: + go: + package: "database" + out: "internal/database/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_interface: true + emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "INTEGER" + go_type: "int64"