├── apps ├── frontend │ ├── README.md │ ├── src │ │ ├── vite-env.d.ts │ │ ├── api │ │ │ ├── utils.ts │ │ │ ├── types.ts │ │ │ └── index.ts │ │ ├── App.tsx │ │ ├── lib │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── config │ │ │ └── env.ts │ │ └── index.css │ ├── .prettierrc │ ├── tsconfig.json │ ├── .gitignore │ ├── index.html │ ├── components.json │ ├── eslint.config.js │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ └── package.json └── backend │ ├── internal │ ├── lib │ │ ├── email │ │ │ ├── templates.go │ │ │ ├── preview.go │ │ │ ├── emails.go │ │ │ └── client.go │ │ ├── utils │ │ │ └── utils.go │ │ └── job │ │ │ ├── email_tasks.go │ │ │ ├── handlers.go │ │ │ └── job.go │ ├── database │ │ ├── migrations │ │ │ └── 001_setup.sql │ │ ├── migrator.go │ │ └── database.go │ ├── repository │ │ └── repositories.go │ ├── router │ │ ├── system.go │ │ └── router.go │ ├── service │ │ ├── auth.go │ │ └── services.go │ ├── handler │ │ ├── handlers.go │ │ ├── openapi.go │ │ ├── health.go │ │ └── base.go │ ├── middleware │ │ ├── rate_limit.go │ │ ├── request_id.go │ │ ├── middlewares.go │ │ ├── tracing.go │ │ ├── auth.go │ │ ├── context.go │ │ └── global.go │ ├── model │ │ └── base.go │ ├── errs │ │ ├── http.go │ │ └── types.go │ ├── testing │ │ ├── server.go │ │ ├── transaction.go │ │ ├── helpers.go │ │ ├── assertions.go │ │ └── container.go │ ├── config │ │ ├── observability.go │ │ └── config.go │ ├── validation │ │ └── utils.go │ ├── server │ │ └── server.go │ ├── sqlerr │ │ ├── error.go │ │ └── handler.go │ └── logger │ │ └── logger.go │ ├── static │ ├── openapi.html │ └── openapi.json │ ├── .gitignore │ ├── Taskfile.yml │ ├── cmd │ └── go-boilerplate │ │ └── main.go │ ├── .env.sample │ ├── .golangci.yml │ ├── go.mod │ ├── templates │ └── emails │ │ └── welcome.html │ ├── README.md │ └── go.sum ├── packages ├── zod │ ├── src │ │ ├── index.ts │ │ ├── health.ts │ │ └── utils.ts │ ├── package.json │ └── tsconfig.json ├── openapi │ ├── src │ │ ├── contracts │ │ │ ├── index.ts │ │ │ └── health.ts │ │ ├── utils.ts │ │ ├── gen.ts │ │ └── index.ts │ ├── package.json │ ├── tsconfig.json │ └── openapi.json └── emails │ ├── package.json │ ├── tsconfig.json │ └── src │ └── templates │ └── welcome.tsx ├── package.json ├── turbo.json ├── .gitignore ├── LICENSE └── README.md /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend -------------------------------------------------------------------------------- /apps/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/frontend/src/api/utils.ts: -------------------------------------------------------------------------------- 1 | export const QUERY_KEYS = {} as const satisfies Record< 2 | Uppercase, 3 | object 4 | >; 5 | -------------------------------------------------------------------------------- /apps/backend/internal/lib/email/templates.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | type Template string 4 | 5 | const ( 6 | TemplateWelcome Template = "welcome" 7 | ) 8 | -------------------------------------------------------------------------------- /apps/backend/internal/lib/email/preview.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | var PreviewData = map[string]map[string]string{ 4 | "welcome": { 5 | "UserFirstName": "John", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | 3 | function App() { 4 | return ( 5 |
6 |

Hello World

7 |
8 | ); 9 | } 10 | 11 | export default App; 12 | -------------------------------------------------------------------------------- /apps/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "all", 4 | "bracketSpacing": true, 5 | "endOfLine": "auto", 6 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/zod/src/index.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@anatine/zod-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | export * from "./utils.js"; 7 | export * from "./health.js"; -------------------------------------------------------------------------------- /apps/frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/src/api/types.ts: -------------------------------------------------------------------------------- 1 | import { apiContract } from "@boilerplate/openapi/contracts"; 2 | import type { ServerInferRequest } from "@ts-rest/core"; 3 | 4 | export type TRequests = ServerInferRequest; 5 | -------------------------------------------------------------------------------- /packages/openapi/src/contracts/index.ts: -------------------------------------------------------------------------------- 1 | import { initContract } from "@ts-rest/core"; 2 | import { healthContract } from "./health.js"; 3 | 4 | const c = initContract(); 5 | 6 | export const apiContract = c.router({ 7 | Health: healthContract, 8 | }); 9 | -------------------------------------------------------------------------------- /apps/backend/internal/database/migrations/001_setup.sql: -------------------------------------------------------------------------------- 1 | -- Write your migrate up statements here 2 | 3 | ---- create above / drop below ---- 4 | 5 | -- Write your migrate down statements here. If this migration is irreversible 6 | -- Then delete the separator line above. 7 | -------------------------------------------------------------------------------- /apps/backend/internal/repository/repositories.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "github.com/sriniously/go-boilerplate/internal/server" 4 | 5 | type Repositories struct{} 6 | 7 | func NewRepositories(s *server.Server) *Repositories { 8 | return &Repositories{} 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/internal/lib/email/emails.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | func (c *Client) SendWelcomeEmail(to, firstName string) error { 4 | data := map[string]string{ 5 | "UserFirstName": firstName, 6 | } 7 | 8 | return c.SendEmail( 9 | to, 10 | "Welcome to Boilerplate!", 11 | TemplateWelcome, 12 | data, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/internal/lib/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func PrintJSON(v interface{}) { 9 | json, err := json.MarshalIndent(v, "", " ") 10 | if err != nil { 11 | fmt.Println("Error marshalling to JSON:", err) 12 | return 13 | } 14 | fmt.Println("JSON:", string(json)) 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/backend/internal/router/system.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/sriniously/go-boilerplate/internal/handler" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | func registerSystemRoutes(r *echo.Echo, h *handler.Handlers) { 10 | r.GET("/status", h.Health.CheckHealth) 11 | 12 | r.Static("/static", "static") 13 | 14 | r.GET("/docs", h.OpenAPI.ServeOpenAPIUI) 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/internal/service/auth.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/sriniously/go-boilerplate/internal/server" 5 | 6 | "github.com/clerk/clerk-sdk-go/v2" 7 | ) 8 | 9 | type AuthService struct { 10 | server *server.Server 11 | } 12 | 13 | func NewAuthService(s *server.Server) *AuthService { 14 | clerk.SetKey(s.Config.Auth.SecretKey) 15 | return &AuthService{ 16 | server: s, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/static/openapi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | API Reference 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Go Boilerplate Frontend 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/zod/src/health.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const ZHealthCheck = z.object({ 4 | status: z.string(), 5 | response_time: z.string(), 6 | error: z.string().optional(), 7 | }); 8 | 9 | export const ZHealthResponse = z.object({ 10 | status: z.enum(["healthy", "unhealthy"]), 11 | timestamp: z.string().datetime(), 12 | environment: z.string(), 13 | checks: z.object({ 14 | database: ZHealthCheck, 15 | redis: ZHealthCheck.optional(), 16 | }), 17 | }); 18 | -------------------------------------------------------------------------------- /apps/backend/internal/handler/handlers.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/sriniously/go-boilerplate/internal/server" 5 | "github.com/sriniously/go-boilerplate/internal/service" 6 | ) 7 | 8 | type Handlers struct { 9 | Health *HealthHandler 10 | OpenAPI *OpenAPIHandler 11 | } 12 | 13 | func NewHandlers(s *server.Server, services *service.Services) *Handlers { 14 | return &Handlers{ 15 | Health: NewHealthHandler(s), 16 | OpenAPI: NewOpenAPIHandler(s), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/zod/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export type PaginatedResponse = { 4 | data: T[]; 5 | total: number; 6 | page: number; 7 | limit: number; 8 | totalPages: number; 9 | }; 10 | 11 | export const schemaWithPagination = ( 12 | schema: z.ZodSchema 13 | ): z.ZodSchema> => 14 | z.object({ 15 | data: z.array(schema), 16 | total: z.number(), 17 | page: z.number(), 18 | limit: z.number(), 19 | totalPages: z.number(), 20 | }); 21 | -------------------------------------------------------------------------------- /apps/frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /packages/openapi/src/contracts/health.ts: -------------------------------------------------------------------------------- 1 | import { initContract } from "@ts-rest/core"; 2 | import { z } from "zod"; 3 | import { ZHealthResponse } from "@boilerplate/zod"; 4 | import { getSecurityMetadata } from "@/utils.js"; 5 | 6 | const c = initContract(); 7 | 8 | export const healthContract = c.router({ 9 | getHealth: { 10 | summary: "Get health", 11 | path: "/status", 12 | method: "GET", 13 | description: "Get health status", 14 | responses: { 15 | 200: ZHealthResponse, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /apps/backend/internal/service/services.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/sriniously/go-boilerplate/internal/lib/job" 5 | "github.com/sriniously/go-boilerplate/internal/repository" 6 | "github.com/sriniously/go-boilerplate/internal/server" 7 | ) 8 | 9 | type Services struct { 10 | Auth *AuthService 11 | Job *job.JobService 12 | } 13 | 14 | func NewServices(s *server.Server, repos *repository.Repositories) (*Services, error) { 15 | authService := NewAuthService(s) 16 | 17 | return &Services{ 18 | Job: s.Job, 19 | Auth: authService, 20 | }, nil 21 | } 22 | -------------------------------------------------------------------------------- /packages/openapi/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { match } from "ts-pattern"; 2 | 3 | export const getSecurityMetadata = ({ 4 | security = true, 5 | securityType = "bearer", 6 | }: { 7 | security?: boolean; 8 | securityType?: "bearer" | "service"; 9 | } = {}) => { 10 | const openApiSecurity = match(securityType) 11 | .with("bearer", () => [ 12 | { 13 | bearerAuth: [], 14 | }, 15 | ]) 16 | .with("service", () => [ 17 | { 18 | "x-service-token": [], 19 | }, 20 | ]) 21 | .exhaustive(); 22 | 23 | return { 24 | ...(security && { openApiSecurity }), 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | tmp/ 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | -------------------------------------------------------------------------------- /apps/backend/internal/middleware/rate_limit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/sriniously/go-boilerplate/internal/server" 5 | ) 6 | 7 | type RateLimitMiddleware struct { 8 | server *server.Server 9 | } 10 | 11 | func NewRateLimitMiddleware(s *server.Server) *RateLimitMiddleware { 12 | return &RateLimitMiddleware{ 13 | server: s, 14 | } 15 | } 16 | 17 | func (r *RateLimitMiddleware) RecordRateLimitHit(endpoint string) { 18 | if r.server.LoggerService != nil && r.server.LoggerService.GetApplication() != nil { 19 | r.server.LoggerService.GetApplication().RecordCustomEvent("RateLimitHit", map[string]interface{}{ 20 | "endpoint": endpoint, 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boilerplate", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "turbo run build", 6 | "dev": "turbo run dev", 7 | "format:check": "turbo run format:check", 8 | "format:fix": "turbo run format:fix", 9 | "lint": "turbo run lint", 10 | "lint:fix": "turbo run lint:fix", 11 | "typecheck": "turbo run typecheck", 12 | "clean": "turbo run clean && rimraf node_modules" 13 | }, 14 | "packageManager": "bun@1.2.13", 15 | "engines": { 16 | "node": ">=22" 17 | }, 18 | "workspaces": [ 19 | "apps/*", 20 | "packages/*" 21 | ], 22 | "devDependencies": { 23 | "turbo": "^2.5.5", 24 | "typescript": "^5.8.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/emails/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boilerplate/emails", 3 | "version": "1.0.0", 4 | "description": "Package to manage emails", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "email dev --dir ./src/templates -p 3001", 8 | "export": "email export --pretty --dir ./src/templates --outDir ../../apps/backend/templates/emails" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@react-email/components": "0.0.34", 15 | "react": "19.1.0", 16 | "react-dom": "19.1.0", 17 | "react-email": "4.0.2" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^19.0.12", 21 | "@types/react-dom": "^19.0.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /apps/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import path from "path"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import react from "@vitejs/plugin-react"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | define: { 10 | "process.env": process.env, 11 | }, 12 | server: { 13 | port: 3000, 14 | }, 15 | resolve: { 16 | alias: { 17 | "@": path.resolve(__dirname, "./src"), 18 | "@boilerplate/openapi": path.resolve( 19 | __dirname, 20 | "../../packages/openapi/src" 21 | ), 22 | "@boilerplate/zod": path.resolve(__dirname, "../../packages/zod/src"), 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /apps/backend/internal/lib/job/email_tasks.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/hibiken/asynq" 8 | ) 9 | 10 | const ( 11 | TaskWelcome = "email:welcome" 12 | ) 13 | 14 | type WelcomeEmailPayload struct { 15 | To string `json:"to"` 16 | FirstName string `json:"first_name"` 17 | } 18 | 19 | func NewWelcomeEmailTask(to, firstName string) (*asynq.Task, error) { 20 | payload, err := json.Marshal(WelcomeEmailPayload{ 21 | To: to, 22 | FirstName: firstName, 23 | }) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return asynq.NewTask(TaskWelcome, payload, 29 | asynq.MaxRetry(3), 30 | asynq.Queue("default"), 31 | asynq.Timeout(30*time.Second)), nil 32 | } 33 | -------------------------------------------------------------------------------- /apps/backend/internal/model/base.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type BaseWithId struct { 10 | ID uuid.UUID `json:"id" db:"id"` 11 | } 12 | 13 | type BaseWithCreatedAt struct { 14 | CreatedAt time.Time `json:"createdAt" db:"created_at"` 15 | } 16 | 17 | type BaseWithUpdatedAt struct { 18 | UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` 19 | } 20 | 21 | type Base struct { 22 | BaseWithId 23 | BaseWithCreatedAt 24 | BaseWithUpdatedAt 25 | } 26 | 27 | type PaginatedResponse[T interface{}] struct { 28 | Data []T `json:"data"` 29 | Page int `json:"page"` 30 | Limit int `json:"limit"` 31 | Total int `json:"total"` 32 | TotalPages int `json:"totalPages"` 33 | } 34 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env*"] 8 | }, 9 | "lint": { 10 | "dependsOn": ["^lint"] 11 | }, 12 | "lint:fix": { 13 | "dependsOn": ["^lint:fix"] 14 | }, 15 | "format": { 16 | "dependsOn": ["^format"] 17 | }, 18 | "format:fix": { 19 | "dependsOn": ["^format:fix"] 20 | }, 21 | "typecheck": { 22 | "dependsOn": ["^typecheck"] 23 | }, 24 | "clean": { 25 | "cache": false 26 | }, 27 | "dev": { 28 | "cache": false, 29 | "persistent": true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/zod/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boilerplate/zod", 3 | "version": "1.0.0", 4 | "description": "Package which exports all Boilerplate zod schemas and types", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc && tsc-alias", 8 | "dev": "tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")", 9 | "clean": "rimraf dist tsconfig.tsbuildinfo .turbo node_modules" 10 | }, 11 | "exports": { 12 | ".": { 13 | "types": "./dist/index.d.ts", 14 | "default": "./dist/index.js" 15 | } 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@anatine/zod-openapi": "^2.2.7", 22 | "zod": "^3.24.2" 23 | }, 24 | "devDependencies": { 25 | "concurrently": "^9.1.2", 26 | "tsc-alias": "^1.8.12", 27 | "typescript": "^5.8.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/src/config/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const envVarsSchema = z.object({ 4 | VITE_CLERK_PUBLISHABLE_KEY: z 5 | .string() 6 | .min(1, "VITE_CLERK_PUBLISHABLE_KEY is required"), 7 | VITE_API_URL: z.url().default("http://localhost:3000"), 8 | VITE_ENV: z.enum(["production", "development", "local"]).default("local"), 9 | }); 10 | 11 | const parseResult = envVarsSchema.safeParse(process.env); 12 | 13 | if (!parseResult.success) { 14 | console.error( 15 | "❌ Invalid environment variables:", 16 | z.treeifyError(parseResult.error), 17 | ); 18 | throw new Error("Invalid environment variables"); 19 | } 20 | 21 | const envVars = parseResult.data; 22 | 23 | // export individual variables 24 | export const ENV = envVars.VITE_ENV; 25 | export const API_URL = envVars.VITE_API_URL; 26 | export const CLERK_PUBLISHABLE_KEY = envVars.VITE_CLERK_PUBLISHABLE_KEY; 27 | -------------------------------------------------------------------------------- /apps/backend/internal/middleware/request_id.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/labstack/echo/v4" 6 | ) 7 | 8 | const ( 9 | RequestIDHeader = "X-Request-ID" 10 | RequestIDKey = "request_id" 11 | ) 12 | 13 | func RequestID() echo.MiddlewareFunc { 14 | return func(next echo.HandlerFunc) echo.HandlerFunc { 15 | return func(c echo.Context) error { 16 | requestID := c.Request().Header.Get(RequestIDHeader) 17 | if requestID == "" { 18 | requestID = uuid.New().String() // 4c90fc3f-39cc-4b04-af21-c83ee64aa67e 19 | } 20 | 21 | c.Set(RequestIDKey, requestID) 22 | c.Response().Header().Set(RequestIDHeader, requestID) 23 | 24 | return next(c) 25 | } 26 | } 27 | } 28 | 29 | func GetRequestID(c echo.Context) string { 30 | if requestID, ok := c.Get(RequestIDKey).(string); ok { 31 | return requestID 32 | } 33 | return "" 34 | } 35 | -------------------------------------------------------------------------------- /apps/backend/internal/handler/openapi.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/sriniously/go-boilerplate/internal/server" 9 | 10 | "github.com/labstack/echo/v4" 11 | ) 12 | 13 | type OpenAPIHandler struct { 14 | Handler 15 | } 16 | 17 | func NewOpenAPIHandler(s *server.Server) *OpenAPIHandler { 18 | return &OpenAPIHandler{ 19 | Handler: NewHandler(s), 20 | } 21 | } 22 | 23 | func (h *OpenAPIHandler) ServeOpenAPIUI(c echo.Context) error { 24 | templateBytes, err := os.ReadFile("static/openapi.html") 25 | c.Response().Header().Set("Cache-Control", "no-cache") 26 | if err != nil { 27 | return fmt.Errorf("failed to read OpenAPI UI template: %w", err) 28 | } 29 | 30 | templateString := string(templateBytes) 31 | 32 | err = c.HTML(http.StatusOK, templateString) 33 | if err != nil { 34 | return fmt.Errorf("failed to write HTML response: %w", err) 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /apps/backend/internal/middleware/middlewares.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/newrelic/go-agent/v3/newrelic" 5 | "github.com/sriniously/go-boilerplate/internal/server" 6 | ) 7 | 8 | type Middlewares struct { 9 | Global *GlobalMiddlewares 10 | Auth *AuthMiddleware 11 | ContextEnhancer *ContextEnhancer 12 | Tracing *TracingMiddleware 13 | RateLimit *RateLimitMiddleware 14 | } 15 | 16 | func NewMiddlewares(s *server.Server) *Middlewares { 17 | // Get New Relic application instance from server 18 | var nrApp *newrelic.Application 19 | if s.LoggerService != nil { 20 | nrApp = s.LoggerService.GetApplication() 21 | } 22 | 23 | return &Middlewares{ 24 | Global: NewGlobalMiddlewares(s), 25 | Auth: NewAuthMiddleware(s), 26 | ContextEnhancer: NewContextEnhancer(s), 27 | Tracing: NewTracingMiddleware(s, nrApp), 28 | RateLimit: NewRateLimitMiddleware(s), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/openapi/src/gen.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import { OpenAPI } from "./index.js"; 4 | 5 | const replaceCustomFileTypesToOpenApiCompatible = ( 6 | jsonString: string 7 | ): string => { 8 | const searchPattern = 9 | /{"type":"object","properties":{"type":{"type":"string","enum":\["file"\]}},\s*"required":\["type"\]}/g; 10 | const replacement = `{"type":"string","format":"binary"}`; 11 | 12 | return jsonString.replace(searchPattern, replacement); 13 | }; 14 | 15 | const filteredDoc = replaceCustomFileTypesToOpenApiCompatible( 16 | JSON.stringify(OpenAPI) 17 | ); 18 | 19 | const formattedDoc = JSON.parse(filteredDoc); 20 | 21 | const filePaths = [ 22 | "./openapi.json", 23 | "../../apps/backend/static/openapi.json", 24 | ]; 25 | 26 | filePaths.forEach((filePath) => { 27 | fs.writeFile(filePath, JSON.stringify(formattedDoc, null, 2), (err) => { 28 | if (err) { 29 | console.error(`Error writing to ${filePath}:`, err); 30 | } 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | .DS_Store 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | # Go workspace file 23 | go.work 24 | go.work.sum 25 | 26 | # Project-specific 27 | config.toml 28 | 29 | # Editors 30 | .idea 31 | 32 | # Dev 33 | tmp/* 34 | 35 | # Dependencies 36 | node_modules 37 | .pnp 38 | .pnp.js 39 | 40 | # Build Outputs 41 | out/ 42 | build 43 | dist 44 | tmp 45 | 46 | # Debug 47 | npm-debug.log* 48 | yarn-debug.log* 49 | yarn-error.log* 50 | 51 | # Misc 52 | .DS_Store 53 | *.pem 54 | 55 | tsconfig.tsbuildinfo 56 | .env 57 | .turbo 58 | .vscode -------------------------------------------------------------------------------- /packages/openapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boilerplate/openapi", 3 | "version": "1.0.0", 4 | "description": "Generates OpenAPI doc", 5 | "type": "module", 6 | "scripts": { 7 | "gen": "tsx src/gen.ts", 8 | "build": "tsc && tsc-alias", 9 | "dev": "wait-on ../zod/dist/index.js && tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")", 10 | "clean": "rimraf dist tsconfig.tsbuildinfo .turbo node_modules" 11 | }, 12 | "exports": { 13 | "./contracts": { 14 | "types": "./dist/contracts/index.d.ts", 15 | "default": "./dist/contract/index.js" 16 | } 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@anatine/zod-openapi": "^2.2.7", 23 | "@boilerplate/zod": "workspace:*", 24 | "@ts-rest/core": "^3.52.1", 25 | "@ts-rest/open-api": "^3.52.1", 26 | "ts-pattern": "^5.7.0", 27 | "zod": "^3.24.2" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^22.13.14", 31 | "concurrently": "^9.1.2", 32 | "tsc-alias": "^1.8.12", 33 | "tsx": "^4.19.3", 34 | "typescript": "^5.8.2", 35 | "wait-on": "^8.0.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Go Boilerplate Contributors 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. -------------------------------------------------------------------------------- /apps/backend/internal/lib/job/handlers.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/hibiken/asynq" 9 | "github.com/rs/zerolog" 10 | "github.com/sriniously/go-boilerplate/internal/config" 11 | "github.com/sriniously/go-boilerplate/internal/lib/email" 12 | ) 13 | 14 | var emailClient *email.Client 15 | 16 | func (j *JobService) InitHandlers(config *config.Config, logger *zerolog.Logger) { 17 | emailClient = email.NewClient(config, logger) 18 | } 19 | 20 | func (j *JobService) handleWelcomeEmailTask(ctx context.Context, t *asynq.Task) error { 21 | var p WelcomeEmailPayload 22 | if err := json.Unmarshal(t.Payload(), &p); err != nil { 23 | return fmt.Errorf("failed to unmarshal welcome email payload: %w", err) 24 | } 25 | 26 | j.logger.Info(). 27 | Str("type", "welcome"). 28 | Str("to", p.To). 29 | Msg("Processing welcome email task") 30 | 31 | err := emailClient.SendWelcomeEmail( 32 | p.To, 33 | p.FirstName, 34 | ) 35 | if err != nil { 36 | j.logger.Error(). 37 | Str("type", "welcome"). 38 | Str("to", p.To). 39 | Err(err). 40 | Msg("Failed to send welcome email") 41 | return err 42 | } 43 | 44 | j.logger.Info(). 45 | Str("type", "welcome"). 46 | Str("to", p.To). 47 | Msg("Successfully sent welcome email") 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /apps/backend/internal/errs/http.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type FieldError struct { 8 | Field string `json:"field"` 9 | Error string `json:"error"` 10 | } 11 | 12 | type ActionType string 13 | 14 | const ( 15 | ActionTypeRedirect ActionType = "redirect" 16 | ) 17 | 18 | type Action struct { 19 | Type ActionType `json:"type"` 20 | Message string `json:"message"` 21 | Value string `json:"value"` 22 | } 23 | 24 | type HTTPError struct { 25 | Code string `json:"code"` 26 | Message string `json:"message"` 27 | Status int `json:"status"` 28 | Override bool `json:"override"` 29 | // field level errors 30 | Errors []FieldError `json:"errors"` 31 | // action to be taken 32 | Action *Action `json:"action"` 33 | } 34 | 35 | func (e *HTTPError) Error() string { 36 | return e.Message 37 | } 38 | 39 | func (e *HTTPError) Is(target error) bool { 40 | _, ok := target.(*HTTPError) 41 | 42 | return ok 43 | } 44 | 45 | func (e *HTTPError) WithMessage(message string) *HTTPError { 46 | return &HTTPError{ 47 | Code: e.Code, 48 | Message: message, 49 | Status: e.Status, 50 | Override: e.Override, 51 | Errors: e.Errors, 52 | Action: e.Action, 53 | } 54 | } 55 | 56 | func MakeUpperCaseWithUnderscores(str string) string { 57 | return strings.ToUpper(strings.ReplaceAll(str, " ", "_")) 58 | } 59 | -------------------------------------------------------------------------------- /apps/backend/internal/testing/server.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/sriniously/go-boilerplate/internal/config" 8 | "github.com/sriniously/go-boilerplate/internal/database" 9 | "github.com/sriniously/go-boilerplate/internal/server" 10 | ) 11 | 12 | // CreateTestServer creates a server instance for testing 13 | func CreateTestServer(logger *zerolog.Logger, db *TestDB) *server.Server { 14 | // Set up observability config with defaults if not present 15 | if db.Config.Observability == nil { 16 | db.Config.Observability = &config.ObservabilityConfig{ 17 | ServiceName: "alfred-test", 18 | Environment: "test", 19 | Logging: config.LoggingConfig{ 20 | Level: "info", 21 | Format: "json", 22 | SlowQueryThreshold: 100 * time.Millisecond, 23 | }, 24 | NewRelic: config.NewRelicConfig{ 25 | LicenseKey: "", // Empty for tests 26 | AppLogForwardingEnabled: false, // Disabled for tests 27 | DistributedTracingEnabled: false, // Disabled for tests 28 | DebugLogging: false, // Disabled for tests 29 | }, 30 | HealthChecks: config.HealthChecksConfig{ 31 | Enabled: false, 32 | }, 33 | } 34 | } 35 | 36 | testServer := &server.Server{ 37 | Logger: logger, 38 | DB: &database.Database{ 39 | Pool: db.Pool, 40 | }, 41 | Config: db.Config, 42 | } 43 | 44 | return testServer 45 | } -------------------------------------------------------------------------------- /apps/backend/internal/lib/email/client.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/resend/resend-go/v2" 10 | "github.com/rs/zerolog" 11 | "github.com/sriniously/go-boilerplate/internal/config" 12 | ) 13 | 14 | type Client struct { 15 | client *resend.Client 16 | logger *zerolog.Logger 17 | } 18 | 19 | func NewClient(cfg *config.Config, logger *zerolog.Logger) *Client { 20 | return &Client{ 21 | client: resend.NewClient(cfg.Integration.ResendAPIKey), 22 | logger: logger, 23 | } 24 | } 25 | 26 | func (c *Client) SendEmail(to, subject string, templateName Template, data map[string]string) error { 27 | tmplPath := fmt.Sprintf("%s/%s.html", "templates/emails", templateName) 28 | 29 | tmpl, err := template.ParseFiles(tmplPath) 30 | if err != nil { 31 | return errors.Wrapf(err, "failed to parse email template %s", templateName) 32 | } 33 | 34 | var body bytes.Buffer 35 | if err := tmpl.Execute(&body, data); err != nil { 36 | return errors.Wrapf(err, "failed to execute email template %s", templateName) 37 | } 38 | 39 | params := &resend.SendEmailRequest{ 40 | From: fmt.Sprintf("%s <%s>", "Boilerplate", "onboarding@resend.dev"), 41 | To: []string{to}, 42 | Subject: subject, 43 | Html: body.String(), 44 | } 45 | 46 | _, err = c.client.Emails.Send(params) 47 | if err != nil { 48 | return fmt.Errorf("failed to send email: %w", err) 49 | } 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /apps/backend/internal/lib/job/job.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "github.com/hibiken/asynq" 5 | "github.com/rs/zerolog" 6 | "github.com/sriniously/go-boilerplate/internal/config" 7 | ) 8 | 9 | type JobService struct { 10 | Client *asynq.Client 11 | server *asynq.Server 12 | logger *zerolog.Logger 13 | } 14 | 15 | func NewJobService(logger *zerolog.Logger, cfg *config.Config) *JobService { 16 | redisAddr := cfg.Redis.Address 17 | 18 | client := asynq.NewClient(asynq.RedisClientOpt{ 19 | Addr: redisAddr, 20 | }) 21 | 22 | server := asynq.NewServer( 23 | asynq.RedisClientOpt{Addr: redisAddr}, 24 | asynq.Config{ 25 | Concurrency: 10, 26 | Queues: map[string]int{ 27 | "critical": 6, // Higher priority queue for important emails 28 | "default": 3, // Default priority for most emails 29 | "low": 1, // Lower priority for non-urgent emails 30 | }, 31 | }, 32 | ) 33 | 34 | return &JobService{ 35 | Client: client, 36 | server: server, 37 | logger: logger, 38 | } 39 | } 40 | 41 | func (j *JobService) Start() error { 42 | // Register task handlers 43 | mux := asynq.NewServeMux() 44 | mux.HandleFunc(TaskWelcome, j.handleWelcomeEmailTask) 45 | 46 | j.logger.Info().Msg("Starting background job server") 47 | if err := j.server.Start(mux); err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (j *JobService) Stop() { 55 | j.logger.Info().Msg("Stopping background job server") 56 | j.server.Shutdown() 57 | j.Client.Close() 58 | } 59 | -------------------------------------------------------------------------------- /apps/backend/internal/testing/transaction.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jackc/pgx/v5" 8 | ) 9 | 10 | // TxFn represents a function that executes within a transaction 11 | type TxFn func(tx pgx.Tx) error 12 | 13 | // WithTransaction runs a function within a transaction and rolls it back afterward 14 | func WithTransaction(ctx context.Context, db *TestDB, fn TxFn) error { 15 | // Begin transaction 16 | tx, err := db.Pool.Begin(ctx) 17 | if err != nil { 18 | return fmt.Errorf("failed to begin transaction: %w", err) 19 | } 20 | 21 | // Ensure rollback happens if commit doesn't occur 22 | defer tx.Rollback(ctx) 23 | 24 | // Run the function within the transaction 25 | if err := fn(tx); err != nil { 26 | return err 27 | } 28 | 29 | // Transaction was successful, commit it 30 | if err := tx.Commit(ctx); err != nil { 31 | return fmt.Errorf("failed to commit transaction: %w", err) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // WithRollbackTransaction runs a function within a transaction and always rolls it back 38 | // Useful for tests where you want to execute operations but never persist them 39 | func WithRollbackTransaction(ctx context.Context, db *TestDB, fn TxFn) error { 40 | // Begin transaction 41 | tx, err := db.Pool.Begin(ctx) 42 | if err != nil { 43 | return fmt.Errorf("failed to begin transaction: %w", err) 44 | } 45 | 46 | // Always rollback at the end 47 | defer tx.Rollback(ctx) 48 | 49 | // Run the function within the transaction 50 | return fn(tx) 51 | } -------------------------------------------------------------------------------- /apps/backend/Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | vars: 4 | BOILERPLATE_DB_DSN: '{{.BOILERPLATE_DB_DSN | default ""}}' 5 | 6 | tasks: 7 | help: 8 | desc: print this help message 9 | cmds: 10 | - task --list-all 11 | silent: true 12 | 13 | confirm: 14 | desc: confirmation prompt 15 | cmds: 16 | - | 17 | echo -n 'Are you sure? [y/N] ' && read ans && [ ${ans:-N} = y ] 18 | silent: true 19 | internal: true 20 | 21 | run: 22 | desc: run the cmd/go-boilerplate application 23 | cmds: 24 | - go run ./cmd/go-boilerplate 25 | 26 | migrations:new: 27 | desc: create a new database migration 28 | vars: 29 | NAME: '{{.name | default ""}}' 30 | cmds: 31 | - | 32 | if [ -z "{{.NAME}}" ]; then 33 | echo "Error: name parameter is required" 34 | echo "Usage: task migrations:new name=migration_name" 35 | exit 1 36 | fi 37 | - echo 'Creating migration file for {{.NAME}}...' 38 | - tern new -m ./internal/database/migrations {{.NAME}} 39 | 40 | migrations:up: 41 | desc: apply all up database migrations 42 | deps: [ confirm ] 43 | cmds: 44 | - echo 'Running up migrations...' 45 | - tern migrate -m ./internal/database/migrations --conn-string {{.BOILERPLATE_DB_DSN}} 46 | 47 | tidy: 48 | desc: format all .go files, and tidy and vendor module dependencies 49 | cmds: 50 | - echo 'Formatting .go files...' 51 | - go fmt ./... 52 | - echo 'Tidying module dependencies...' 53 | - go mod tidy 54 | - echo 'Verifying dependencies...' 55 | - go mod verify 56 | -------------------------------------------------------------------------------- /apps/backend/internal/testing/helpers.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/rs/zerolog" 10 | "github.com/sriniously/go-boilerplate/internal/server" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | // SetupTest prepares a test environment with a database and server 15 | func SetupTest(t *testing.T) (*TestDB, *server.Server, func()) { 16 | t.Helper() 17 | 18 | logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}). 19 | Level(zerolog.InfoLevel). 20 | With(). 21 | Timestamp(). 22 | Logger() 23 | 24 | testDB, dbCleanup := SetupTestDB(t) 25 | 26 | testServer := CreateTestServer(&logger, testDB) 27 | 28 | cleanup := func() { 29 | if testDB.Pool != nil { 30 | testDB.Pool.Close() 31 | } 32 | 33 | dbCleanup() 34 | } 35 | 36 | return testDB, testServer, cleanup 37 | } 38 | 39 | // MustMarshalJSON marshals an object to JSON or fails the test 40 | func MustMarshalJSON(t *testing.T, v interface{}) []byte { 41 | t.Helper() 42 | 43 | jsonBytes, err := json.Marshal(v) 44 | require.NoError(t, err, "failed to marshal to JSON") 45 | 46 | return jsonBytes 47 | } 48 | 49 | // ProjectRoot returns the absolute path to the project root 50 | func ProjectRoot(t *testing.T) string { 51 | t.Helper() 52 | 53 | dir, err := os.Getwd() 54 | require.NoError(t, err, "failed to get working directory") 55 | 56 | for { 57 | if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { 58 | return dir 59 | } 60 | 61 | parentDir := filepath.Dir(dir) 62 | if parentDir == dir { 63 | t.Fatal("could not find project root (go.mod)") 64 | return "" 65 | } 66 | 67 | dir = parentDir 68 | } 69 | } 70 | 71 | // Ptr returns a pointer to the given value 72 | // Useful for creating pointers to values for optional fields 73 | func Ptr[T any](v T) *T { 74 | return &v 75 | } -------------------------------------------------------------------------------- /packages/openapi/src/index.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@anatine/zod-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | import { generateOpenApi } from "@ts-rest/open-api"; 6 | 7 | import { apiContract } from "./contracts/index.js"; 8 | 9 | type SecurityRequirementObject = { 10 | [key: string]: string[]; 11 | }; 12 | 13 | export type OperationMapper = NonNullable< 14 | Parameters[2] 15 | >["operationMapper"]; 16 | 17 | const hasSecurity = ( 18 | metadata: unknown 19 | ): metadata is { openApiSecurity: SecurityRequirementObject[] } => { 20 | return ( 21 | !!metadata && typeof metadata === "object" && "openApiSecurity" in metadata 22 | ); 23 | }; 24 | 25 | const operationMapper: OperationMapper = (operation, appRoute) => ({ 26 | ...operation, 27 | ...(hasSecurity(appRoute.metadata) 28 | ? { 29 | security: appRoute.metadata.openApiSecurity, 30 | } 31 | : {}), 32 | }); 33 | 34 | export const OpenAPI = Object.assign( 35 | generateOpenApi( 36 | apiContract, 37 | { 38 | openapi: "3.0.2", 39 | info: { 40 | version: "1.0.0", 41 | title: "Boilerplate REST API - Documentation", 42 | description: "Boilerplate REST API - Documentation", 43 | }, 44 | servers: [ 45 | { 46 | url: "http://localhost:8080", 47 | description: "Local Server", 48 | }, 49 | ], 50 | }, 51 | { 52 | operationMapper, 53 | setOperationId: true, 54 | } 55 | ), 56 | { 57 | components: { 58 | securitySchemes: { 59 | bearerAuth: { 60 | type: "http", 61 | scheme: "bearer", 62 | bearerFormat: "JWT", 63 | }, 64 | "x-service-token": { 65 | type: "apiKey", 66 | name: "x-service-token", 67 | in: "header", 68 | }, 69 | }, 70 | }, 71 | } 72 | ); 73 | -------------------------------------------------------------------------------- /apps/backend/internal/database/migrator.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "io/fs" 8 | "net" 9 | "net/url" 10 | "strconv" 11 | 12 | "github.com/sriniously/go-boilerplate/internal/config" 13 | 14 | "github.com/jackc/pgx/v5" 15 | tern "github.com/jackc/tern/v2/migrate" 16 | "github.com/rs/zerolog" 17 | ) 18 | 19 | //go:embed migrations/*.sql 20 | var migrations embed.FS 21 | 22 | func Migrate(ctx context.Context, logger *zerolog.Logger, cfg *config.Config) error { 23 | hostPort := net.JoinHostPort(cfg.Database.Host, strconv.Itoa(cfg.Database.Port)) 24 | 25 | // URL-encode the password 26 | encodedPassword := url.QueryEscape(cfg.Database.Password) 27 | dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", 28 | cfg.Database.User, 29 | encodedPassword, 30 | hostPort, 31 | cfg.Database.Name, 32 | cfg.Database.SSLMode, 33 | ) 34 | 35 | conn, err := pgx.Connect(ctx, dsn) 36 | if err != nil { 37 | return err 38 | } 39 | defer conn.Close(ctx) 40 | 41 | m, err := tern.NewMigrator(ctx, conn, "schema_version") 42 | if err != nil { 43 | return fmt.Errorf("constructing database migrator: %w", err) 44 | } 45 | subtree, err := fs.Sub(migrations, "migrations") 46 | if err != nil { 47 | return fmt.Errorf("retrieving database migrations subtree: %w", err) 48 | } 49 | if err := m.LoadMigrations(subtree); err != nil { 50 | return fmt.Errorf("loading database migrations: %w", err) 51 | } 52 | from, err := m.GetCurrentVersion(ctx) 53 | if err != nil { 54 | return fmt.Errorf("retreiving current database migration version") 55 | } 56 | if err := m.Migrate(ctx); err != nil { 57 | return err 58 | } 59 | if from == int32(len(m.Migrations)) { 60 | logger.Info().Msgf("database schema up to date, version %d", len(m.Migrations)) 61 | } else { 62 | logger.Info().Msgf("migrated database schema, from %d to %d", from, len(m.Migrations)) 63 | } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "wait-on ../../packages/openapi/dist/index.js && vite", 8 | "build": "bun install && tsc && env-cmd -f .env.local vite build", 9 | "test": "vite test", 10 | "preview": "vite preview", 11 | "lint:fix": "eslint src --fix", 12 | "lint": "eslint src", 13 | "format": "prettier . --check", 14 | "format:fix": "prettier --write .", 15 | "clean": "rimraf tsconfig.tsbuildinfo .turbo node_modules", 16 | "typecheck": "tsc" 17 | }, 18 | "dependencies": { 19 | "@tailwindcss/vite": "^4.1.11", 20 | "@boilerplate/openapi": "workspace:*", 21 | "@boilerplate/zod": "workspace:*", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "lucide-react": "^0.536.0", 25 | "react": "^19.1.0", 26 | "react-dom": "^19.1.0", 27 | "tailwind-merge": "^3.3.1", 28 | "tailwindcss": "^4.1.11", 29 | "@clerk/clerk-react": "^5.38.1", 30 | "@hookform/resolvers": "^5.2.1", 31 | "@tanstack/react-query": "^5.84.1", 32 | "@tanstack/react-query-devtools": "^5.84.1", 33 | "@ts-rest/core": "^3.52.1", 34 | "axios": "^1.11.0", 35 | "react-hook-form": "^7.62.0", 36 | "react-router-dom": "^7.7.1", 37 | "sonner": "^2.0.7", 38 | "ts-pattern": "^5.8.0", 39 | "zod": "^4.0.14" 40 | }, 41 | "devDependencies": { 42 | "@eslint/js": "^9.30.1", 43 | "@types/react": "^19.1.8", 44 | "@types/react-dom": "^19.1.6", 45 | "@vitejs/plugin-react": "^4.6.0", 46 | "eslint": "^9.30.1", 47 | "eslint-plugin-react-hooks": "^5.2.0", 48 | "eslint-plugin-react-refresh": "^0.4.20", 49 | "globals": "^16.3.0", 50 | "prettier": "^3.6.2", 51 | "tw-animate-css": "^1.3.6", 52 | "typescript": "~5.8.3", 53 | "typescript-eslint": "^8.35.1", 54 | "vite": "^7.0.4", 55 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 56 | "wait-on": "^8.0.4" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apps/backend/internal/errs/types.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func NewUnauthorizedError(message string, override bool) *HTTPError { 8 | return &HTTPError{ 9 | Code: MakeUpperCaseWithUnderscores(http.StatusText(http.StatusUnauthorized)), 10 | Message: message, 11 | Status: http.StatusUnauthorized, 12 | Override: override, 13 | } 14 | } 15 | 16 | func NewForbiddenError(message string, override bool) *HTTPError { 17 | return &HTTPError{ 18 | Code: MakeUpperCaseWithUnderscores(http.StatusText(http.StatusForbidden)), 19 | Message: message, 20 | Status: http.StatusForbidden, 21 | Override: override, 22 | } 23 | } 24 | 25 | func NewBadRequestError(message string, override bool, code *string, errors []FieldError, action *Action) *HTTPError { 26 | formattedCode := MakeUpperCaseWithUnderscores(http.StatusText(http.StatusBadRequest)) 27 | 28 | if code != nil { 29 | formattedCode = *code 30 | } 31 | 32 | return &HTTPError{ 33 | Code: formattedCode, 34 | Message: message, 35 | Status: http.StatusBadRequest, 36 | Override: override, 37 | Errors: errors, 38 | Action: action, 39 | } 40 | } 41 | 42 | func NewNotFoundError(message string, override bool, code *string) *HTTPError { 43 | formattedCode := MakeUpperCaseWithUnderscores(http.StatusText(http.StatusNotFound)) 44 | 45 | if code != nil { 46 | formattedCode = *code 47 | } 48 | 49 | return &HTTPError{ 50 | Code: formattedCode, 51 | Message: message, 52 | Status: http.StatusNotFound, 53 | Override: override, 54 | } 55 | } 56 | 57 | func NewInternalServerError() *HTTPError { 58 | return &HTTPError{ 59 | Code: MakeUpperCaseWithUnderscores(http.StatusText(http.StatusInternalServerError)), 60 | Message: http.StatusText(http.StatusInternalServerError), 61 | Status: http.StatusInternalServerError, 62 | Override: false, 63 | } 64 | } 65 | 66 | func ValidationError(err error) *HTTPError { 67 | return NewBadRequestError("Validation failed: "+err.Error(), false, nil, nil, nil) 68 | } 69 | -------------------------------------------------------------------------------- /apps/backend/internal/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | echoMiddleware "github.com/labstack/echo/v4/middleware" 8 | "github.com/sriniously/go-boilerplate/internal/handler" 9 | "github.com/sriniously/go-boilerplate/internal/middleware" 10 | "github.com/sriniously/go-boilerplate/internal/server" 11 | "github.com/sriniously/go-boilerplate/internal/service" 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | func NewRouter(s *server.Server, h *handler.Handlers, services *service.Services) *echo.Echo { 16 | middlewares := middleware.NewMiddlewares(s) 17 | 18 | router := echo.New() 19 | 20 | router.HTTPErrorHandler = middlewares.Global.GlobalErrorHandler 21 | 22 | // global middlewares 23 | router.Use( 24 | echoMiddleware.RateLimiterWithConfig(echoMiddleware.RateLimiterConfig{ 25 | Store: echoMiddleware.NewRateLimiterMemoryStore(rate.Limit(20)), 26 | DenyHandler: func(c echo.Context, identifier string, err error) error { 27 | // Record rate limit hit metrics 28 | if rateLimitMiddleware := middlewares.RateLimit; rateLimitMiddleware != nil { 29 | rateLimitMiddleware.RecordRateLimitHit(c.Path()) 30 | } 31 | 32 | s.Logger.Warn(). 33 | Str("request_id", middleware.GetRequestID(c)). 34 | Str("identifier", identifier). 35 | Str("path", c.Path()). 36 | Str("method", c.Request().Method). 37 | Str("ip", c.RealIP()). 38 | Msg("rate limit exceeded") 39 | 40 | return echo.NewHTTPError(http.StatusTooManyRequests, "Rate limit exceeded") 41 | }, 42 | }), 43 | middlewares.Global.CORS(), 44 | middlewares.Global.Secure(), 45 | middleware.RequestID(), 46 | middlewares.Tracing.NewRelicMiddleware(), 47 | middlewares.Tracing.EnhanceTracing(), 48 | middlewares.ContextEnhancer.EnhanceContext(), 49 | middlewares.Global.RequestLogger(), 50 | middlewares.Global.Recover(), 51 | ) 52 | 53 | // register system routes 54 | registerSystemRoutes(router, h) 55 | 56 | // register versioned routes 57 | router.Group("/api/v1") 58 | 59 | return router 60 | } 61 | -------------------------------------------------------------------------------- /packages/zod/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | 4 | "compilerOptions": { 5 | // Module and runtime settings 6 | "module": "NodeNext", // Use Node.js-style module resolution with ESM support 7 | "target": "es2022", // Target modern ES2022 JavaScript features 8 | "lib": ["es2022"], // Include ES2022 standard library definitions 9 | "moduleDetection": "force", // Ensure explicit module detection rules 10 | "esModuleInterop": true, // Enable compatibility with CommonJS modules 11 | "resolveJsonModule": true, // Allow importing JSON files 12 | "allowJs": true, // Allow JavaScript files alongside TypeScript 13 | "isolatedModules": true, // Enforce module boundary rules for isolated compilation 14 | "verbatimModuleSyntax": true, // Preserve module import/export syntax without rewriting 15 | 16 | // Strictness settings 17 | "strict": true, // Enable all strict type-checking options 18 | "noUncheckedIndexedAccess": true, // Require explicit checks for indexed properties 19 | "noImplicitOverride": true, // Enforce explicit overrides in subclass methods 20 | 21 | // Output control 22 | "outDir": "dist", // Specify the output directory for compiled files 23 | "sourceMap": true, // Generate source map files for debugging 24 | "declaration": true, // Emit TypeScript declaration files 25 | "declarationMap": true, // Emit source maps for declaration files 26 | "composite": true, // Enable project references and incremental builds 27 | 28 | // Path and resolution 29 | "rootDir": "src", // Specify the root directory for input files 30 | "baseUrl": "./src", // Base URL for module resolution 31 | "paths": { 32 | // Aliases for module paths 33 | "@/*": ["./*"] 34 | }, 35 | 36 | // Type checking 37 | "skipLibCheck": true // Skip type checking of declaration files 38 | }, 39 | 40 | "include": ["src/**/*.ts"], // Include all TypeScript files in the src directory 41 | "exclude": ["node_modules", "dist"] // Exclude node_modules and the output directory 42 | } 43 | -------------------------------------------------------------------------------- /packages/openapi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | 4 | "compilerOptions": { 5 | // Module and runtime settings 6 | "module": "NodeNext", // Use Node.js-style module resolution with ESM support 7 | "target": "es2022", // Target modern ES2022 JavaScript features 8 | "lib": ["es2022"], // Include ES2022 standard library definitions 9 | "moduleDetection": "force", // Ensure explicit module detection rules 10 | "esModuleInterop": true, // Enable compatibility with CommonJS modules 11 | "resolveJsonModule": true, // Allow importing JSON files 12 | "allowJs": true, // Allow JavaScript files alongside TypeScript 13 | "isolatedModules": true, // Enforce module boundary rules for isolated compilation 14 | "verbatimModuleSyntax": true, // Preserve module import/export syntax without rewriting 15 | 16 | // Strictness settings 17 | "strict": true, // Enable all strict type-checking options 18 | "noUncheckedIndexedAccess": true, // Require explicit checks for indexed properties 19 | "noImplicitOverride": true, // Enforce explicit overrides in subclass methods 20 | 21 | // Output control 22 | "outDir": "dist", // Specify the output directory for compiled files 23 | "sourceMap": true, // Generate source map files for debugging 24 | "declaration": true, // Emit TypeScript declaration files 25 | "declarationMap": true, // Emit source maps for declaration files 26 | "composite": true, // Enable project references and incremental builds 27 | 28 | // Path and resolution 29 | "rootDir": "src", // Specify the root directory for input files 30 | "baseUrl": "./src", // Base URL for module resolution 31 | "paths": { 32 | // Aliases for module paths 33 | "@/*": ["./*"] 34 | }, 35 | 36 | // Type checking 37 | "skipLibCheck": true // Skip type checking of declaration files 38 | }, 39 | 40 | "include": ["src/**/*.ts"], // Include all TypeScript files in the src directory 41 | "exclude": ["node_modules", "dist"] // Exclude node_modules and the output directory 42 | } 43 | -------------------------------------------------------------------------------- /packages/emails/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | 4 | "compilerOptions": { 5 | // Module and runtime settings 6 | "module": "NodeNext", // Use Node.js-style module resolution with ESM support 7 | "target": "es2022", // Target modern ES2022 JavaScript features 8 | "lib": ["es2022", "DOM", "DOM.Iterable"], // Include ES2022 standard library definitions 9 | "moduleDetection": "force", // Ensure explicit module detection rules 10 | "esModuleInterop": true, // Enable compatibility with CommonJS modules 11 | "resolveJsonModule": true, // Allow importing JSON files 12 | "allowJs": true, // Allow JavaScript files alongside TypeScript 13 | "isolatedModules": true, // Enforce module boundary rules for isolated compilation 14 | "verbatimModuleSyntax": true, // Preserve module import/export syntax without rewriting 15 | "jsx": "react-jsx", // Preserve JSX as is 16 | 17 | // Strictness settings 18 | "strict": true, // Enable all strict type-checking options 19 | "noUncheckedIndexedAccess": true, // Require explicit checks for indexed properties 20 | "noImplicitOverride": true, // Enforce explicit overrides in subclass methods 21 | 22 | // Output control 23 | "outDir": "dist", // Specify the output directory for compiled files 24 | "sourceMap": true, // Generate source map files for debugging 25 | "declaration": true, // Emit TypeScript declaration files 26 | "declarationMap": true, // Emit source maps for declaration files 27 | "composite": true, // Enable project references and incremental builds 28 | 29 | // Path and resolution 30 | "rootDir": "src", // Specify the root directory for input files 31 | "baseUrl": "./src", // Base URL for module resolution 32 | "paths": { 33 | // Aliases for module paths 34 | "@/*": ["./*"] 35 | }, 36 | 37 | // Type checking 38 | "skipLibCheck": true // Skip type checking of declaration files 39 | }, 40 | 41 | "include": ["src/**/*.ts", "src/**/*.tsx"], // Include all TypeScript files in the src directory 42 | "exclude": ["node_modules", "dist"] // Exclude node_modules and the output directory 43 | } 44 | -------------------------------------------------------------------------------- /apps/backend/internal/middleware/tracing.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/newrelic/go-agent/v3/integrations/nrecho-v4" 6 | "github.com/newrelic/go-agent/v3/integrations/nrpkgerrors" 7 | "github.com/newrelic/go-agent/v3/newrelic" 8 | 9 | "github.com/sriniously/go-boilerplate/internal/server" 10 | ) 11 | 12 | type TracingMiddleware struct { 13 | server *server.Server 14 | nrApp *newrelic.Application 15 | } 16 | 17 | func NewTracingMiddleware(s *server.Server, nrApp *newrelic.Application) *TracingMiddleware { 18 | return &TracingMiddleware{ 19 | server: s, 20 | nrApp: nrApp, 21 | } 22 | } 23 | 24 | // NewRelicMiddleware returns the New Relic middleware for Echo 25 | func (tm *TracingMiddleware) NewRelicMiddleware() echo.MiddlewareFunc { 26 | if tm.nrApp == nil { 27 | // Return a no-op middleware if New Relic is not initialized 28 | return func(next echo.HandlerFunc) echo.HandlerFunc { 29 | return next 30 | } 31 | } 32 | return nrecho.Middleware(tm.nrApp) 33 | } 34 | 35 | // EnhanceTracing adds custom attributes to New Relic transactions 36 | func (tm *TracingMiddleware) EnhanceTracing() echo.MiddlewareFunc { 37 | return func(next echo.HandlerFunc) echo.HandlerFunc { 38 | return func(c echo.Context) error { 39 | // Get New Relic transaction from context 40 | txn := newrelic.FromContext(c.Request().Context()) 41 | if txn == nil { 42 | return next(c) 43 | } 44 | 45 | // service.name and service.environment are already set in logger and New Relic config 46 | txn.AddAttribute("http.real_ip", c.RealIP()) 47 | txn.AddAttribute("http.user_agent", c.Request().UserAgent()) 48 | 49 | // Add request ID if available 50 | if requestID := GetRequestID(c); requestID != "" { 51 | txn.AddAttribute("request.id", requestID) 52 | } 53 | 54 | // Add user context if available 55 | if userID := c.Get("user_id"); userID != nil { 56 | if userIDStr, ok := userID.(string); ok { 57 | txn.AddAttribute("user.id", userIDStr) 58 | } 59 | } 60 | 61 | // Execute next handler 62 | err := next(c) 63 | // Record error if any with enhanced stack traces 64 | if err != nil { 65 | txn.NoticeError(nrpkgerrors.Wrap(err)) 66 | } 67 | 68 | // Add response status 69 | txn.AddAttribute("http.status_code", c.Response().Status) 70 | 71 | return err 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /apps/backend/internal/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/clerk/clerk-sdk-go/v2" 9 | clerkhttp "github.com/clerk/clerk-sdk-go/v2/http" 10 | "github.com/labstack/echo/v4" 11 | "github.com/sriniously/go-boilerplate/internal/errs" 12 | "github.com/sriniously/go-boilerplate/internal/server" 13 | ) 14 | 15 | type AuthMiddleware struct { 16 | server *server.Server 17 | } 18 | 19 | func NewAuthMiddleware(s *server.Server) *AuthMiddleware { 20 | return &AuthMiddleware{ 21 | server: s, 22 | } 23 | } 24 | 25 | func (auth *AuthMiddleware) RequireAuth(next echo.HandlerFunc) echo.HandlerFunc { 26 | return echo.WrapMiddleware( 27 | clerkhttp.WithHeaderAuthorization( 28 | clerkhttp.AuthorizationFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | start := time.Now() 30 | 31 | w.Header().Set("Content-Type", "application/json") 32 | w.WriteHeader(http.StatusUnauthorized) 33 | 34 | response := map[string]string{ 35 | "code": "UNAUTHORIZED", 36 | "message": "Unauthorized", 37 | "override": "false", 38 | "status": "401", 39 | } 40 | 41 | if err := json.NewEncoder(w).Encode(response); err != nil { 42 | auth.server.Logger.Error().Err(err).Str("function", "RequireAuth").Dur( 43 | "duration", time.Since(start)).Msg("failed to write JSON response") 44 | } else { 45 | auth.server.Logger.Error().Str("function", "RequireAuth").Dur("duration", time.Since(start)).Msg( 46 | "could not get session claims from context") 47 | } 48 | }))))(func(c echo.Context) error { 49 | start := time.Now() 50 | claims, ok := clerk.SessionClaimsFromContext(c.Request().Context()) 51 | 52 | if !ok { 53 | auth.server.Logger.Error(). 54 | Str("function", "RequireAuth"). 55 | Str("request_id", GetRequestID(c)). 56 | Dur("duration", time.Since(start)). 57 | Msg("could not get session claims from context") 58 | return errs.NewUnauthorizedError("Unauthorized", false) 59 | } 60 | 61 | c.Set("user_id", claims.Subject) 62 | c.Set("user_role", claims.ActiveOrganizationRole) 63 | c.Set("permissions", claims.Claims.ActiveOrganizationPermissions) 64 | 65 | auth.server.Logger.Info(). 66 | Str("function", "RequireAuth"). 67 | Str("user_id", claims.Subject). 68 | Str("request_id", GetRequestID(c)). 69 | Dur("duration", time.Since(start)). 70 | Msg("user authenticated successfully") 71 | 72 | return next(c) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /apps/frontend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from "@/config/env"; 2 | import { apiContract } from "@boilerplate/openapi/contracts"; 3 | import { useAuth } from "@clerk/clerk-react"; 4 | import { initClient } from "@ts-rest/core"; 5 | import axios, { 6 | type Method, 7 | AxiosError, 8 | isAxiosError, 9 | type AxiosResponse, 10 | } from "axios"; 11 | 12 | type Headers = Awaited< 13 | ReturnType[1]["api"]>> 14 | >["headers"]; 15 | 16 | export type TApiClient = ReturnType; 17 | 18 | export const useApiClient = ({ isBlob = false }: { isBlob?: boolean } = {}) => { 19 | const { getToken } = useAuth(); 20 | 21 | return initClient(apiContract, { 22 | baseUrl: "", 23 | baseHeaders: { 24 | "Content-Type": "application/json", 25 | }, 26 | api: async ({ path, method, headers, body }) => { 27 | const token = await getToken({ template: "custom" }); 28 | 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | const makeRequest = async (retryCount = 0): Promise => { 31 | try { 32 | const result = await axios.request({ 33 | method: method as Method, 34 | url: `${API_URL}/api${path}`, 35 | headers: { 36 | ...headers, 37 | ...(token ? { Authorization: `Bearer ${token}` } : {}), 38 | }, 39 | data: body, 40 | ...(isBlob ? { responseType: "blob" } : {}), 41 | }); 42 | return { 43 | status: result.status, 44 | body: result.data, 45 | headers: result.headers as unknown as Headers, 46 | }; 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | } catch (e: Error | AxiosError | any) { 49 | if (isAxiosError(e)) { 50 | const error = e as AxiosError; 51 | const response = error.response as AxiosResponse; 52 | 53 | // If unauthorized and we haven't retried yet, retry 54 | if (response?.status === 401 && retryCount < 2) { 55 | return makeRequest(retryCount + 1); 56 | } 57 | 58 | return { 59 | status: response?.status || 500, 60 | body: response?.data || { message: "Internal server error" }, 61 | headers: (response?.headers as unknown as Headers) || {}, 62 | }; 63 | } 64 | throw e; 65 | } 66 | }; 67 | 68 | return makeRequest(); 69 | }, 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /apps/backend/cmd/go-boilerplate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "time" 10 | 11 | "github.com/sriniously/go-boilerplate/internal/config" 12 | "github.com/sriniously/go-boilerplate/internal/database" 13 | "github.com/sriniously/go-boilerplate/internal/handler" 14 | "github.com/sriniously/go-boilerplate/internal/logger" 15 | "github.com/sriniously/go-boilerplate/internal/repository" 16 | "github.com/sriniously/go-boilerplate/internal/router" 17 | "github.com/sriniously/go-boilerplate/internal/server" 18 | "github.com/sriniously/go-boilerplate/internal/service" 19 | ) 20 | 21 | const DefaultContextTimeout = 30 22 | 23 | func main() { 24 | cfg, err := config.LoadConfig() 25 | if err != nil { 26 | panic("failed to load config: " + err.Error()) 27 | } 28 | 29 | // Initialize New Relic logger service 30 | loggerService := logger.NewLoggerService(cfg.Observability) 31 | defer loggerService.Shutdown() 32 | 33 | log := logger.NewLoggerWithService(cfg.Observability, loggerService) 34 | 35 | if cfg.Primary.Env != "local" { 36 | if err := database.Migrate(context.Background(), &log, cfg); err != nil { 37 | log.Fatal().Err(err).Msg("failed to migrate database") 38 | } 39 | } 40 | 41 | // Initialize server 42 | srv, err := server.New(cfg, &log, loggerService) 43 | if err != nil { 44 | log.Fatal().Err(err).Msg("failed to initialize server") 45 | } 46 | 47 | // Initialize repositories, services, and handlers 48 | repos := repository.NewRepositories(srv) 49 | services, serviceErr := service.NewServices(srv, repos) 50 | if serviceErr != nil { 51 | log.Fatal().Err(serviceErr).Msg("could not create services") 52 | } 53 | handlers := handler.NewHandlers(srv, services) 54 | 55 | // Initialize router 56 | r := router.NewRouter(srv, handlers, services) 57 | 58 | // Setup HTTP server 59 | srv.SetupHTTPServer(r) 60 | 61 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 62 | 63 | // Start server 64 | go func() { 65 | if err = srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { 66 | log.Fatal().Err(err).Msg("failed to start server") 67 | } 68 | }() 69 | 70 | // Wait for interrupt signal to gracefully shutdown the server 71 | <-ctx.Done() 72 | ctx, cancel := context.WithTimeout(context.Background(), DefaultContextTimeout*time.Second) 73 | 74 | if err = srv.Shutdown(ctx); err != nil { 75 | log.Fatal().Err(err).Msg("server forced to shutdown") 76 | } 77 | stop() 78 | cancel() 79 | 80 | log.Info().Msg("server exited properly") 81 | } 82 | -------------------------------------------------------------------------------- /packages/emails/src/templates/welcome.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Button, 4 | Container, 5 | Head, 6 | Heading, 7 | Hr, 8 | Html, 9 | Img, 10 | Link, 11 | Preview, 12 | Section, 13 | Text, 14 | Tailwind, 15 | } from "@react-email/components"; 16 | 17 | interface WelcomeEmailProps { 18 | userFirstName: string; 19 | } 20 | 21 | export const WelcomeEmail = ({ 22 | userFirstName = "{{.UserFirstName}}", 23 | }: WelcomeEmailProps) => { 24 | return ( 25 | 26 | 27 | Welcome to Boilerplate 28 | 29 | 30 | 31 | 32 | Welcome to Boilerplate! 33 | 34 | 35 |
36 | 37 | Hi {userFirstName}, 38 | 39 | 40 | Thank you for joining! 41 | 42 |
43 | 44 |
45 | 51 |
52 | 53 |
54 | 55 |
56 | 57 | If you have any questions, feel free to{" "} 58 | 59 | contact our support team 60 | 61 | . 62 | 63 |
64 | 65 |
66 | 67 | © {new Date().getFullYear()} Alfred. All rights reserved. 68 | 69 | 70 | 123 Project Street, Suite 100, San Francisco, CA 94103 71 | 72 |
73 |
74 | 75 |
76 | 77 | ); 78 | }; 79 | 80 | WelcomeEmail.PreviewProps = { 81 | userFirstName: "John", 82 | }; 83 | 84 | export default WelcomeEmail; 85 | -------------------------------------------------------------------------------- /apps/backend/.env.sample: -------------------------------------------------------------------------------- 1 | BOILERPLATE_PRIMARY.ENV="local" 2 | 3 | BOILERPLATE_SERVER.PORT="8080" 4 | BOILERPLATE_SERVER.READ_TIMEOUT="30" 5 | BOILERPLATE_SERVER.WRITE_TIMEOUT="30" 6 | BOILERPLATE_SERVER.IDLE_TIMEOUT="60" 7 | BOILERPLATE_SERVER.CORS_ALLOWED_ORIGINS="http://localhost:3000" 8 | 9 | BOILERPLATE_DATABASE.HOST="localhost" 10 | BOILERPLATE_DATABASE.PORT="5432" 11 | BOILERPLATE_DATABASE.USER="postgres" 12 | BOILERPLATE_DATABASE.PASSWORD="" 13 | BOILERPLATE_DATABASE.NAME="boilerplate" 14 | BOILERPLATE_DATABASE.SSL_MODE="disable" 15 | BOILERPLATE_DATABASE.MAX_OPEN_CONNS="25" 16 | BOILERPLATE_DATABASE.MAX_IDLE_CONNS="25" 17 | BOILERPLATE_DATABASE.CONN_MAX_LIFETIME="300" 18 | BOILERPLATE_DATABASE.CONN_MAX_IDLE_TIME="300" 19 | 20 | BOILERPLATE_AUTH.SECRET_KEY="secret" 21 | 22 | BOILERPLATE_INTEGRATION.RESEND_API_KEY="resend_key" 23 | 24 | BOILERPLATE_REDIS.ADDRESS="redis://localhost:6379" 25 | 26 | # ============================================================================ 27 | # OBSERVABILITY CONFIGURATION 28 | # ============================================================================ 29 | 30 | # Service Identity 31 | BOILERPLATE_OBSERVABILITY.SERVICE_NAME="boilerplate" 32 | BOILERPLATE_OBSERVABILITY.ENVIRONMENT="development" 33 | 34 | # ============================================================================ 35 | # LOGGING CONFIGURATION 36 | # ============================================================================ 37 | 38 | # Basic Logging Settings 39 | BOILERPLATE_OBSERVABILITY.LOGGING.LEVEL="debug" 40 | BOILERPLATE_OBSERVABILITY.LOGGING.FORMAT="console" 41 | BOILERPLATE_OBSERVABILITY.LOGGING.SLOW_QUERY_THRESHOLD="100ms" 42 | 43 | # ============================================================================ 44 | # NEW RELIC CONFIGURATION 45 | # ============================================================================ 46 | 47 | # New Relic APM 48 | BOILERPLATE_OBSERVABILITY.NEW_RELIC.LICENSE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 49 | BOILERPLATE_OBSERVABILITY.NEW_RELIC.APP_LOG_FORWARDING_ENABLED="true" 50 | BOILERPLATE_OBSERVABILITY.NEW_RELIC.DISTRIBUTED_TRACING_ENABLED="true" 51 | BOILERPLATE_OBSERVABILITY.NEW_RELIC.DEBUG_LOGGING="false" 52 | 53 | # ============================================================================ 54 | # HEALTH CHECKS CONFIGURATION 55 | # ============================================================================ 56 | 57 | # Health Check Settings 58 | BOILERPLATE_OBSERVABILITY.HEALTH_CHECKS.ENABLED="true" 59 | BOILERPLATE_OBSERVABILITY.HEALTH_CHECKS.INTERVAL="30s" 60 | BOILERPLATE_OBSERVABILITY.HEALTH_CHECKS.TIMEOUT="5s" 61 | BOILERPLATE_OBSERVABILITY.HEALTH_CHECKS.CHECKS="database,redis" -------------------------------------------------------------------------------- /apps/backend/internal/middleware/context.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/newrelic/go-agent/v3/newrelic" 8 | "github.com/rs/zerolog" 9 | "github.com/sriniously/go-boilerplate/internal/logger" 10 | "github.com/sriniously/go-boilerplate/internal/server" 11 | ) 12 | 13 | const ( 14 | UserIDKey = "user_id" 15 | UserRoleKey = "user_role" 16 | LoggerKey = "logger" 17 | ) 18 | 19 | type ContextEnhancer struct { 20 | server *server.Server 21 | } 22 | 23 | func NewContextEnhancer(s *server.Server) *ContextEnhancer { 24 | return &ContextEnhancer{server: s} 25 | } 26 | 27 | func (ce *ContextEnhancer) EnhanceContext() echo.MiddlewareFunc { 28 | return func(next echo.HandlerFunc) echo.HandlerFunc { 29 | return func(c echo.Context) error { 30 | // Extract request ID 31 | requestID := GetRequestID(c) 32 | 33 | // Create enhanced logger with request context 34 | contextLogger := ce.server.Logger.With(). 35 | Str("request_id", requestID). 36 | Str("method", c.Request().Method). 37 | Str("path", c.Path()). 38 | Str("ip", c.RealIP()). 39 | Logger() 40 | 41 | // Add trace context if available 42 | if txn := newrelic.FromContext(c.Request().Context()); txn != nil { 43 | contextLogger = logger.WithTraceContext(contextLogger, txn) 44 | } 45 | 46 | // Extract user information from JWT token or session 47 | if userID := ce.extractUserID(c); userID != "" { 48 | contextLogger = contextLogger.With().Str("user_id", userID).Logger() 49 | } 50 | 51 | if userRole := ce.extractUserRole(c); userRole != "" { 52 | contextLogger = contextLogger.With().Str("user_role", userRole).Logger() 53 | } 54 | 55 | // Store the enhanced logger in context 56 | c.Set(LoggerKey, &contextLogger) 57 | 58 | // Create a new context with the logger 59 | ctx := context.WithValue(c.Request().Context(), LoggerKey, &contextLogger) 60 | c.SetRequest(c.Request().WithContext(ctx)) 61 | 62 | return next(c) 63 | } 64 | } 65 | } 66 | 67 | func (ce *ContextEnhancer) extractUserID(c echo.Context) string { 68 | // Check if user_id was already set by auth middleware (Clerk) 69 | if userID, ok := c.Get("user_id").(string); ok && userID != "" { 70 | return userID 71 | } 72 | return "" 73 | } 74 | 75 | func (ce *ContextEnhancer) extractUserRole(c echo.Context) string { 76 | // Check if user_role was set by auth middleware (Clerk) 77 | if userRole, ok := c.Get("user_role").(string); ok && userRole != "" { 78 | return userRole 79 | } 80 | return "" 81 | } 82 | 83 | func GetUserID(c echo.Context) string { 84 | if userID, ok := c.Get(UserIDKey).(string); ok { 85 | return userID 86 | } 87 | return "" 88 | } 89 | 90 | func GetLogger(c echo.Context) *zerolog.Logger { 91 | if logger, ok := c.Get(LoggerKey).(*zerolog.Logger); ok { 92 | return logger 93 | } 94 | // Fallback to a basic logger if not found 95 | logger := zerolog.Nop() 96 | return &logger 97 | } 98 | -------------------------------------------------------------------------------- /apps/backend/internal/testing/assertions.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | // AssertTimestampsValid checks that created_at and updated_at fields are set 16 | func AssertTimestampsValid(t *testing.T, obj interface{}) { 17 | t.Helper() 18 | 19 | val := reflect.ValueOf(obj) 20 | if val.Kind() == reflect.Ptr { 21 | val = val.Elem() 22 | } 23 | 24 | createdField := val.FieldByName("CreatedAt") 25 | if createdField.IsValid() { 26 | createdAt, ok := createdField.Interface().(time.Time) 27 | require.True(t, ok, "CreatedAt is not a time.Time") 28 | assert.False(t, createdAt.IsZero(), "CreatedAt should not be zero") 29 | } 30 | 31 | updatedField := val.FieldByName("UpdatedAt") 32 | if updatedField.IsValid() { 33 | updatedAt, ok := updatedField.Interface().(time.Time) 34 | require.True(t, ok, "UpdatedAt is not a time.Time") 35 | assert.False(t, updatedAt.IsZero(), "UpdatedAt should not be zero") 36 | } 37 | } 38 | 39 | // AssertValidUUID checks that the UUID is valid and not nil 40 | func AssertValidUUID(t *testing.T, id uuid.UUID, message ...string) { 41 | t.Helper() 42 | 43 | msg := "UUID should not be nil" 44 | if len(message) > 0 { 45 | msg = message[0] 46 | } 47 | 48 | assert.NotEqual(t, uuid.Nil, id, msg) 49 | } 50 | 51 | // AssertEqualExceptTime asserts that two objects are equal, ignoring time fields 52 | func AssertEqualExceptTime(t *testing.T, expected, actual interface{}) { 53 | t.Helper() 54 | 55 | expectedVal := reflect.ValueOf(expected) 56 | if expectedVal.Kind() == reflect.Ptr { 57 | expectedVal = expectedVal.Elem() 58 | } 59 | 60 | actualVal := reflect.ValueOf(actual) 61 | if actualVal.Kind() == reflect.Ptr { 62 | actualVal = actualVal.Elem() 63 | } 64 | 65 | // Ensure same type 66 | require.Equal(t, expectedVal.Type(), actualVal.Type(), "objects are not the same type") 67 | 68 | // Check fields 69 | for i := 0; i < expectedVal.NumField(); i++ { 70 | field := expectedVal.Type().Field(i) 71 | 72 | // Skip time fields 73 | if field.Type == reflect.TypeOf(time.Time{}) || 74 | field.Type == reflect.TypeOf(&time.Time{}) { 75 | continue 76 | } 77 | 78 | expectedField := expectedVal.Field(i) 79 | actualField := actualVal.Field(i) 80 | 81 | assert.Equal( 82 | t, 83 | expectedField.Interface(), 84 | actualField.Interface(), 85 | fmt.Sprintf("field %s should be equal", field.Name), 86 | ) 87 | } 88 | } 89 | 90 | // AssertStringContains checks if a string contains all specified substrings 91 | func AssertStringContains(t *testing.T, s string, substrings ...string) { 92 | t.Helper() 93 | 94 | for _, sub := range substrings { 95 | assert.True( 96 | t, 97 | strings.Contains(s, sub), 98 | fmt.Sprintf("expected string to contain '%s', but it didn't: %s", sub, s), 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /apps/backend/internal/config/observability.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type ObservabilityConfig struct { 9 | ServiceName string `koanf:"service_name" validate:"required"` 10 | Environment string `koanf:"environment" validate:"required"` 11 | Logging LoggingConfig `koanf:"logging" validate:"required"` 12 | NewRelic NewRelicConfig `koanf:"new_relic" validate:"required"` 13 | HealthChecks HealthChecksConfig `koanf:"health_checks" validate:"required"` 14 | } 15 | 16 | type LoggingConfig struct { 17 | Level string `koanf:"level" validate:"required"` 18 | Format string `koanf:"format" validate:"required"` 19 | SlowQueryThreshold time.Duration `koanf:"slow_query_threshold"` 20 | } 21 | 22 | type NewRelicConfig struct { 23 | LicenseKey string `koanf:"license_key" validate:"required"` 24 | AppLogForwardingEnabled bool `koanf:"app_log_forwarding_enabled"` 25 | DistributedTracingEnabled bool `koanf:"distributed_tracing_enabled"` 26 | DebugLogging bool `koanf:"debug_logging"` 27 | } 28 | 29 | type HealthChecksConfig struct { 30 | Enabled bool `koanf:"enabled"` 31 | Interval time.Duration `koanf:"interval" validate:"min=1s"` 32 | Timeout time.Duration `koanf:"timeout" validate:"min=1s"` 33 | Checks []string `koanf:"checks"` 34 | } 35 | 36 | func DefaultObservabilityConfig() *ObservabilityConfig { 37 | return &ObservabilityConfig{ 38 | ServiceName: "boilerplate", 39 | Environment: "development", 40 | Logging: LoggingConfig{ 41 | Level: "info", 42 | Format: "json", 43 | SlowQueryThreshold: 100 * time.Millisecond, 44 | }, 45 | NewRelic: NewRelicConfig{ 46 | LicenseKey: "", 47 | AppLogForwardingEnabled: true, 48 | DistributedTracingEnabled: true, 49 | DebugLogging: false, // Disabled by default to avoid mixed log formats 50 | }, 51 | HealthChecks: HealthChecksConfig{ 52 | Enabled: true, 53 | Interval: 30 * time.Second, 54 | Timeout: 5 * time.Second, 55 | Checks: []string{"database", "redis"}, 56 | }, 57 | } 58 | } 59 | 60 | func (c *ObservabilityConfig) Validate() error { 61 | if c.ServiceName == "" { 62 | return fmt.Errorf("service_name is required") 63 | } 64 | 65 | // Validate log level 66 | validLevels := map[string]bool{ 67 | "debug": true, "info": true, "warn": true, "error": true, 68 | } 69 | if !validLevels[c.Logging.Level] { 70 | return fmt.Errorf("invalid logging level: %s (must be one of: debug, info, warn, error)", c.Logging.Level) 71 | } 72 | 73 | // Validate slow query threshold 74 | if c.Logging.SlowQueryThreshold < 0 { 75 | return fmt.Errorf("logging slow_query_threshold must be non-negative") 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (c *ObservabilityConfig) GetLogLevel() string { 82 | switch c.Environment { 83 | case "production": 84 | if c.Logging.Level == "" { 85 | return "info" 86 | } 87 | case "development": 88 | if c.Logging.Level == "" { 89 | return "debug" 90 | } 91 | } 92 | return c.Logging.Level 93 | } 94 | 95 | func (c *ObservabilityConfig) IsProduction() bool { 96 | return c.Environment == "production" 97 | } 98 | -------------------------------------------------------------------------------- /apps/backend/internal/validation/utils.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/go-playground/validator/v10" 10 | "github.com/labstack/echo/v4" 11 | "github.com/sriniously/go-boilerplate/internal/errs" 12 | ) 13 | 14 | type Validatable interface { 15 | Validate() error 16 | } 17 | 18 | type CustomValidationError struct { 19 | Field string 20 | Message string 21 | } 22 | 23 | type CustomValidationErrors []CustomValidationError 24 | 25 | func (c CustomValidationErrors) Error() string { 26 | return "Validation failed" 27 | } 28 | 29 | func BindAndValidate(c echo.Context, payload Validatable) error { 30 | if err := c.Bind(payload); err != nil { 31 | message := strings.Split(strings.Split(err.Error(), ",")[1], "message=")[1] 32 | return errs.NewBadRequestError(message, false, nil, nil, nil) 33 | } 34 | 35 | if msg, fieldErrors := validateStruct(payload); fieldErrors != nil { 36 | return errs.NewBadRequestError(msg, true, nil, fieldErrors, nil) 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func validateStruct(v Validatable) (string, []errs.FieldError) { 43 | if err := v.Validate(); err != nil { 44 | return extractValidationErrors(err) 45 | } 46 | return "", nil 47 | } 48 | 49 | func extractValidationErrors(err error) (string, []errs.FieldError) { 50 | var fieldErrors []errs.FieldError 51 | validationErrors, ok := err.(validator.ValidationErrors) 52 | if !ok { 53 | customValidationErrors := err.(CustomValidationErrors) 54 | for _, err := range customValidationErrors { 55 | fieldErrors = append(fieldErrors, errs.FieldError{ 56 | Field: err.Field, 57 | Error: err.Message, 58 | }) 59 | } 60 | } 61 | 62 | for _, err := range validationErrors { 63 | field := strings.ToLower(err.Field()) 64 | var msg string 65 | 66 | switch err.Tag() { 67 | case "required": 68 | msg = "is required" 69 | case "min": 70 | if err.Type().Kind() == reflect.String { 71 | msg = fmt.Sprintf("must be at least %s characters", err.Param()) 72 | } else { 73 | msg = fmt.Sprintf("must be at least %s", err.Param()) 74 | } 75 | case "max": 76 | if err.Type().Kind() == reflect.String { 77 | msg = fmt.Sprintf("must not exceed %s characters", err.Param()) 78 | } else { 79 | msg = fmt.Sprintf("must not exceed %s", err.Param()) 80 | } 81 | case "oneof": 82 | msg = fmt.Sprintf("must be one of: %s", err.Param()) 83 | case "email": 84 | msg = "must be a valid email address" 85 | case "e164": 86 | msg = "must be a valid phone number with country code" 87 | case "uuid": 88 | msg = "must be a valid UUID" 89 | case "uuidList": 90 | msg = "must be a comma-separated list of valid UUIDs" 91 | case "dive": 92 | msg = "some items are invalid" 93 | default: 94 | if err.Param() != "" { 95 | msg = fmt.Sprintf("%s: %s:%s", field, err.Tag(), err.Param()) 96 | } else { 97 | msg = fmt.Sprintf("%s: %s", field, err.Tag()) 98 | } 99 | } 100 | 101 | fieldErrors = append(fieldErrors, errs.FieldError{ 102 | Field: strings.ToLower(err.Field()), 103 | Error: msg, 104 | }) 105 | } 106 | 107 | return "Validation failed", fieldErrors 108 | } 109 | 110 | var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) 111 | 112 | func IsValidUUID(uuid string) bool { 113 | return uuidRegex.MatchString(uuid) 114 | } 115 | -------------------------------------------------------------------------------- /apps/backend/internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/newrelic/go-agent/v3/integrations/nrredis-v9" 11 | "github.com/redis/go-redis/v9" 12 | "github.com/rs/zerolog" 13 | "github.com/sriniously/go-boilerplate/internal/config" 14 | "github.com/sriniously/go-boilerplate/internal/database" 15 | "github.com/sriniously/go-boilerplate/internal/lib/job" 16 | loggerPkg "github.com/sriniously/go-boilerplate/internal/logger" 17 | ) 18 | 19 | type Server struct { 20 | Config *config.Config 21 | Logger *zerolog.Logger 22 | LoggerService *loggerPkg.LoggerService 23 | DB *database.Database 24 | Redis *redis.Client 25 | httpServer *http.Server 26 | Job *job.JobService 27 | } 28 | 29 | func New(cfg *config.Config, logger *zerolog.Logger, loggerService *loggerPkg.LoggerService) (*Server, error) { 30 | db, err := database.New(cfg, logger, loggerService) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to initialize database: %w", err) 33 | } 34 | 35 | // Redis client with New Relic integration 36 | redisClient := redis.NewClient(&redis.Options{ 37 | Addr: cfg.Redis.Address, 38 | }) 39 | 40 | // Add New Relic Redis hooks if available 41 | if loggerService != nil && loggerService.GetApplication() != nil { 42 | redisClient.AddHook(nrredis.NewHook(redisClient.Options())) 43 | } 44 | 45 | // Test Redis connection 46 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 47 | defer cancel() 48 | 49 | if err := redisClient.Ping(ctx).Err(); err != nil { 50 | logger.Error().Err(err).Msg("Failed to connect to Redis, continuing without Redis") 51 | // Don't fail startup if Redis is unavailable 52 | } 53 | 54 | // job service 55 | jobService := job.NewJobService(logger, cfg) 56 | jobService.InitHandlers(cfg, logger) 57 | 58 | // Start job server 59 | if err := jobService.Start(); err != nil { 60 | return nil, err 61 | } 62 | 63 | server := &Server{ 64 | Config: cfg, 65 | Logger: logger, 66 | LoggerService: loggerService, 67 | DB: db, 68 | Redis: redisClient, 69 | Job: jobService, 70 | } 71 | 72 | // Start metrics collection 73 | // Runtime metrics are automatically collected by New Relic Go agent 74 | 75 | return server, nil 76 | } 77 | 78 | func (s *Server) SetupHTTPServer(handler http.Handler) { 79 | s.httpServer = &http.Server{ 80 | Addr: ":" + s.Config.Server.Port, 81 | Handler: handler, 82 | ReadTimeout: time.Duration(s.Config.Server.ReadTimeout) * time.Second, 83 | WriteTimeout: time.Duration(s.Config.Server.WriteTimeout) * time.Second, 84 | IdleTimeout: time.Duration(s.Config.Server.IdleTimeout) * time.Second, 85 | } 86 | } 87 | 88 | func (s *Server) Start() error { 89 | if s.httpServer == nil { 90 | return errors.New("HTTP server not initialized") 91 | } 92 | 93 | s.Logger.Info(). 94 | Str("port", s.Config.Server.Port). 95 | Str("env", s.Config.Primary.Env). 96 | Msg("starting server") 97 | 98 | return s.httpServer.ListenAndServe() 99 | } 100 | 101 | func (s *Server) Shutdown(ctx context.Context) error { 102 | if err := s.httpServer.Shutdown(ctx); err != nil { 103 | return fmt.Errorf("failed to shutdown HTTP server: %w", err) 104 | } 105 | 106 | if err := s.DB.Close(); err != nil { 107 | return fmt.Errorf("failed to close database connection: %w", err) 108 | } 109 | 110 | if s.Job != nil { 111 | s.Job.Stop() 112 | } 113 | 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /apps/backend/internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/go-playground/validator/v10" 8 | _ "github.com/joho/godotenv/autoload" 9 | "github.com/knadh/koanf/providers/env" 10 | "github.com/knadh/koanf/v2" 11 | "github.com/rs/zerolog" 12 | ) 13 | 14 | type Config struct { 15 | Primary Primary `koanf:"primary" validate:"required"` 16 | Server ServerConfig `koanf:"server" validate:"required"` 17 | Database DatabaseConfig `koanf:"database" validate:"required"` 18 | Auth AuthConfig `koanf:"auth" validate:"required"` 19 | Redis RedisConfig `koanf:"redis" validate:"required"` 20 | Integration IntegrationConfig `koanf:"integration" validate:"required"` 21 | Observability *ObservabilityConfig `koanf:"observability"` 22 | } 23 | 24 | type Primary struct { 25 | Env string `koanf:"env" validate:"required"` 26 | } 27 | 28 | type ServerConfig struct { 29 | Port string `koanf:"port" validate:"required"` 30 | ReadTimeout int `koanf:"read_timeout" validate:"required"` 31 | WriteTimeout int `koanf:"write_timeout" validate:"required"` 32 | IdleTimeout int `koanf:"idle_timeout" validate:"required"` 33 | CORSAllowedOrigins []string `koanf:"cors_allowed_origins" validate:"required"` 34 | } 35 | 36 | type DatabaseConfig struct { 37 | Host string `koanf:"host" validate:"required"` 38 | Port int `koanf:"port" validate:"required"` 39 | User string `koanf:"user" validate:"required"` 40 | Password string `koanf:"password"` 41 | Name string `koanf:"name" validate:"required"` 42 | SSLMode string `koanf:"ssl_mode" validate:"required"` 43 | MaxOpenConns int `koanf:"max_open_conns" validate:"required"` 44 | MaxIdleConns int `koanf:"max_idle_conns" validate:"required"` 45 | ConnMaxLifetime int `koanf:"conn_max_lifetime" validate:"required"` 46 | ConnMaxIdleTime int `koanf:"conn_max_idle_time" validate:"required"` 47 | } 48 | type RedisConfig struct { 49 | Address string `koanf:"address" validate:"required"` 50 | } 51 | 52 | type IntegrationConfig struct { 53 | ResendAPIKey string `koanf:"resend_api_key" validate:"required"` 54 | } 55 | 56 | type AuthConfig struct { 57 | SecretKey string `koanf:"secret_key" validate:"required"` 58 | } 59 | 60 | func LoadConfig() (*Config, error) { 61 | logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger() 62 | 63 | k := koanf.New(".") 64 | 65 | err := k.Load(env.Provider("BOILERPLATE_", ".", func(s string) string { 66 | return strings.ToLower(strings.TrimPrefix(s, "BOILERPLATE_")) 67 | }), nil) 68 | if err != nil { 69 | logger.Fatal().Err(err).Msg("could not load initial env variables") 70 | } 71 | 72 | mainConfig := &Config{} 73 | 74 | err = k.Unmarshal("", mainConfig) 75 | if err != nil { 76 | logger.Fatal().Err(err).Msg("could not unmarshal main config") 77 | } 78 | 79 | validate := validator.New() 80 | 81 | err = validate.Struct(mainConfig) 82 | if err != nil { 83 | logger.Fatal().Err(err).Msg("config validation failed") 84 | } 85 | 86 | // Set default observability config if not provided 87 | if mainConfig.Observability == nil { 88 | mainConfig.Observability = DefaultObservabilityConfig() 89 | } 90 | 91 | // Override service name and environment from primary config 92 | mainConfig.Observability.ServiceName = "boilerplate" 93 | mainConfig.Observability.Environment = mainConfig.Primary.Env 94 | 95 | // Validate observability config 96 | if err := mainConfig.Observability.Validate(); err != nil { 97 | logger.Fatal().Err(err).Msg("invalid observability config") 98 | } 99 | 100 | return mainConfig, nil 101 | } 102 | -------------------------------------------------------------------------------- /packages/openapi/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.2", 3 | "paths": { 4 | "/status": { 5 | "get": { 6 | "description": "Get health status", 7 | "summary": "Get health", 8 | "tags": [ 9 | "Health" 10 | ], 11 | "parameters": [], 12 | "operationId": "getHealth", 13 | "responses": { 14 | "200": { 15 | "description": "200", 16 | "content": { 17 | "application/json": { 18 | "schema": { 19 | "type": "object", 20 | "properties": { 21 | "status": { 22 | "type": "string", 23 | "enum": [ 24 | "healthy", 25 | "unhealthy" 26 | ] 27 | }, 28 | "timestamp": { 29 | "type": "string", 30 | "format": "date-time" 31 | }, 32 | "environment": { 33 | "type": "string" 34 | }, 35 | "checks": { 36 | "type": "object", 37 | "properties": { 38 | "database": { 39 | "type": "object", 40 | "properties": { 41 | "status": { 42 | "type": "string" 43 | }, 44 | "response_time": { 45 | "type": "string" 46 | }, 47 | "error": { 48 | "type": "string" 49 | } 50 | }, 51 | "required": [ 52 | "status", 53 | "response_time" 54 | ] 55 | }, 56 | "redis": { 57 | "type": "object", 58 | "properties": { 59 | "status": { 60 | "type": "string" 61 | }, 62 | "response_time": { 63 | "type": "string" 64 | }, 65 | "error": { 66 | "type": "string" 67 | } 68 | }, 69 | "required": [ 70 | "status", 71 | "response_time" 72 | ] 73 | } 74 | }, 75 | "required": [ 76 | "database" 77 | ] 78 | } 79 | }, 80 | "required": [ 81 | "status", 82 | "timestamp", 83 | "environment", 84 | "checks" 85 | ] 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | }, 94 | "info": { 95 | "version": "1.0.0", 96 | "title": "Boilerplate REST API - Documentation", 97 | "description": "Boilerplate REST API - Documentation" 98 | }, 99 | "servers": [ 100 | { 101 | "url": "http://localhost:8080", 102 | "description": "Local Server" 103 | } 104 | ], 105 | "components": { 106 | "securitySchemes": { 107 | "bearerAuth": { 108 | "type": "http", 109 | "scheme": "bearer", 110 | "bearerFormat": "JWT" 111 | }, 112 | "x-service-token": { 113 | "type": "apiKey", 114 | "name": "x-service-token", 115 | "in": "header" 116 | } 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /apps/backend/static/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.2", 3 | "paths": { 4 | "/status": { 5 | "get": { 6 | "description": "Get health status", 7 | "summary": "Get health", 8 | "tags": [ 9 | "Health" 10 | ], 11 | "parameters": [], 12 | "operationId": "getHealth", 13 | "responses": { 14 | "200": { 15 | "description": "200", 16 | "content": { 17 | "application/json": { 18 | "schema": { 19 | "type": "object", 20 | "properties": { 21 | "status": { 22 | "type": "string", 23 | "enum": [ 24 | "healthy", 25 | "unhealthy" 26 | ] 27 | }, 28 | "timestamp": { 29 | "type": "string", 30 | "format": "date-time" 31 | }, 32 | "environment": { 33 | "type": "string" 34 | }, 35 | "checks": { 36 | "type": "object", 37 | "properties": { 38 | "database": { 39 | "type": "object", 40 | "properties": { 41 | "status": { 42 | "type": "string" 43 | }, 44 | "response_time": { 45 | "type": "string" 46 | }, 47 | "error": { 48 | "type": "string" 49 | } 50 | }, 51 | "required": [ 52 | "status", 53 | "response_time" 54 | ] 55 | }, 56 | "redis": { 57 | "type": "object", 58 | "properties": { 59 | "status": { 60 | "type": "string" 61 | }, 62 | "response_time": { 63 | "type": "string" 64 | }, 65 | "error": { 66 | "type": "string" 67 | } 68 | }, 69 | "required": [ 70 | "status", 71 | "response_time" 72 | ] 73 | } 74 | }, 75 | "required": [ 76 | "database" 77 | ] 78 | } 79 | }, 80 | "required": [ 81 | "status", 82 | "timestamp", 83 | "environment", 84 | "checks" 85 | ] 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | }, 94 | "info": { 95 | "version": "1.0.0", 96 | "title": "Boilerplate REST API - Documentation", 97 | "description": "Boilerplate REST API - Documentation" 98 | }, 99 | "servers": [ 100 | { 101 | "url": "http://localhost:8080", 102 | "description": "Local Server" 103 | } 104 | ], 105 | "components": { 106 | "securitySchemes": { 107 | "bearerAuth": { 108 | "type": "http", 109 | "scheme": "bearer", 110 | "bearerFormat": "JWT" 111 | }, 112 | "x-service-token": { 113 | "type": "apiKey", 114 | "name": "x-service-token", 115 | "in": "header" 116 | } 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /apps/backend/internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | "strconv" 9 | "time" 10 | 11 | pgxzero "github.com/jackc/pgx-zerolog" 12 | "github.com/jackc/pgx/v5" 13 | "github.com/jackc/pgx/v5/pgxpool" 14 | "github.com/jackc/pgx/v5/tracelog" 15 | "github.com/newrelic/go-agent/v3/integrations/nrpgx5" 16 | "github.com/rs/zerolog" 17 | "github.com/sriniously/go-boilerplate/internal/config" 18 | loggerConfig "github.com/sriniously/go-boilerplate/internal/logger" 19 | ) 20 | 21 | type Database struct { 22 | Pool *pgxpool.Pool 23 | log *zerolog.Logger 24 | } 25 | 26 | // multiTracer allows chaining multiple tracers 27 | type multiTracer struct { 28 | tracers []any 29 | } 30 | 31 | // TraceQueryStart implements pgx tracer interface 32 | func (mt *multiTracer) TraceQueryStart(ctx context.Context, conn *pgx.Conn, data pgx.TraceQueryStartData) context.Context { 33 | for _, tracer := range mt.tracers { 34 | if t, ok := tracer.(interface { 35 | TraceQueryStart(context.Context, *pgx.Conn, pgx.TraceQueryStartData) context.Context 36 | }); ok { 37 | ctx = t.TraceQueryStart(ctx, conn, data) 38 | } 39 | } 40 | return ctx 41 | } 42 | 43 | // TraceQueryEnd implements pgx tracer interface 44 | func (mt *multiTracer) TraceQueryEnd(ctx context.Context, conn *pgx.Conn, data pgx.TraceQueryEndData) { 45 | for _, tracer := range mt.tracers { 46 | if t, ok := tracer.(interface { 47 | TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) 48 | }); ok { 49 | t.TraceQueryEnd(ctx, conn, data) 50 | } 51 | } 52 | } 53 | 54 | const DatabasePingTimeout = 10 55 | 56 | func New(cfg *config.Config, logger *zerolog.Logger, loggerService *loggerConfig.LoggerService) (*Database, error) { 57 | hostPort := net.JoinHostPort(cfg.Database.Host, strconv.Itoa(cfg.Database.Port)) 58 | 59 | // URL-encode the password 60 | encodedPassword := url.QueryEscape(cfg.Database.Password) 61 | dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", 62 | cfg.Database.User, 63 | encodedPassword, 64 | hostPort, 65 | cfg.Database.Name, 66 | cfg.Database.SSLMode, 67 | ) 68 | 69 | pgxPoolConfig, err := pgxpool.ParseConfig(dsn) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to parse pgx pool config: %w", err) 72 | } 73 | 74 | // Add New Relic PostgreSQL instrumentation 75 | if loggerService != nil && loggerService.GetApplication() != nil { 76 | pgxPoolConfig.ConnConfig.Tracer = nrpgx5.NewTracer() 77 | } 78 | 79 | if cfg.Primary.Env == "local" { 80 | globalLevel := logger.GetLevel() 81 | pgxLogger := loggerConfig.NewPgxLogger(globalLevel) 82 | // Chain tracers - New Relic first, then local logging 83 | if pgxPoolConfig.ConnConfig.Tracer != nil { 84 | // If New Relic tracer exists, create a multi-tracer 85 | localTracer := &tracelog.TraceLog{ 86 | Logger: pgxzero.NewLogger(pgxLogger), 87 | LogLevel: tracelog.LogLevel(loggerConfig.GetPgxTraceLogLevel(globalLevel)), 88 | } 89 | pgxPoolConfig.ConnConfig.Tracer = &multiTracer{ 90 | tracers: []any{pgxPoolConfig.ConnConfig.Tracer, localTracer}, 91 | } 92 | } else { 93 | pgxPoolConfig.ConnConfig.Tracer = &tracelog.TraceLog{ 94 | Logger: pgxzero.NewLogger(pgxLogger), 95 | LogLevel: tracelog.LogLevel(loggerConfig.GetPgxTraceLogLevel(globalLevel)), 96 | } 97 | } 98 | } 99 | 100 | pool, err := pgxpool.NewWithConfig(context.Background(), pgxPoolConfig) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to create pgx pool: %w", err) 103 | } 104 | 105 | database := &Database{ 106 | Pool: pool, 107 | log: logger, 108 | } 109 | 110 | ctx, cancel := context.WithTimeout(context.Background(), DatabasePingTimeout*time.Second) 111 | defer cancel() 112 | if err = pool.Ping(ctx); err != nil { 113 | return nil, fmt.Errorf("failed to ping database: %w", err) 114 | } 115 | 116 | logger.Info().Msg("connected to the database") 117 | 118 | return database, nil 119 | } 120 | 121 | func (db *Database) Close() error { 122 | db.log.Info().Msg("closing database connection pool") 123 | db.Pool.Close() 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /apps/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --radius-sm: calc(var(--radius) - 4px); 8 | --radius-md: calc(var(--radius) - 2px); 9 | --radius-lg: var(--radius); 10 | --radius-xl: calc(var(--radius) + 4px); 11 | --color-background: var(--background); 12 | --color-foreground: var(--foreground); 13 | --color-card: var(--card); 14 | --color-card-foreground: var(--card-foreground); 15 | --color-popover: var(--popover); 16 | --color-popover-foreground: var(--popover-foreground); 17 | --color-primary: var(--primary); 18 | --color-primary-foreground: var(--primary-foreground); 19 | --color-secondary: var(--secondary); 20 | --color-secondary-foreground: var(--secondary-foreground); 21 | --color-muted: var(--muted); 22 | --color-muted-foreground: var(--muted-foreground); 23 | --color-accent: var(--accent); 24 | --color-accent-foreground: var(--accent-foreground); 25 | --color-destructive: var(--destructive); 26 | --color-border: var(--border); 27 | --color-input: var(--input); 28 | --color-ring: var(--ring); 29 | --color-chart-1: var(--chart-1); 30 | --color-chart-2: var(--chart-2); 31 | --color-chart-3: var(--chart-3); 32 | --color-chart-4: var(--chart-4); 33 | --color-chart-5: var(--chart-5); 34 | --color-sidebar: var(--sidebar); 35 | --color-sidebar-foreground: var(--sidebar-foreground); 36 | --color-sidebar-primary: var(--sidebar-primary); 37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 38 | --color-sidebar-accent: var(--sidebar-accent); 39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 40 | --color-sidebar-border: var(--sidebar-border); 41 | --color-sidebar-ring: var(--sidebar-ring); 42 | } 43 | 44 | :root { 45 | --radius: 0.625rem; 46 | --background: oklch(1 0 0); 47 | --foreground: oklch(0.145 0 0); 48 | --card: oklch(1 0 0); 49 | --card-foreground: oklch(0.145 0 0); 50 | --popover: oklch(1 0 0); 51 | --popover-foreground: oklch(0.145 0 0); 52 | --primary: oklch(0.205 0 0); 53 | --primary-foreground: oklch(0.985 0 0); 54 | --secondary: oklch(0.97 0 0); 55 | --secondary-foreground: oklch(0.205 0 0); 56 | --muted: oklch(0.97 0 0); 57 | --muted-foreground: oklch(0.556 0 0); 58 | --accent: oklch(0.97 0 0); 59 | --accent-foreground: oklch(0.205 0 0); 60 | --destructive: oklch(0.577 0.245 27.325); 61 | --border: oklch(0.922 0 0); 62 | --input: oklch(0.922 0 0); 63 | --ring: oklch(0.708 0 0); 64 | --chart-1: oklch(0.646 0.222 41.116); 65 | --chart-2: oklch(0.6 0.118 184.704); 66 | --chart-3: oklch(0.398 0.07 227.392); 67 | --chart-4: oklch(0.828 0.189 84.429); 68 | --chart-5: oklch(0.769 0.188 70.08); 69 | --sidebar: oklch(0.985 0 0); 70 | --sidebar-foreground: oklch(0.145 0 0); 71 | --sidebar-primary: oklch(0.205 0 0); 72 | --sidebar-primary-foreground: oklch(0.985 0 0); 73 | --sidebar-accent: oklch(0.97 0 0); 74 | --sidebar-accent-foreground: oklch(0.205 0 0); 75 | --sidebar-border: oklch(0.922 0 0); 76 | --sidebar-ring: oklch(0.708 0 0); 77 | } 78 | 79 | .dark { 80 | --background: oklch(0.145 0 0); 81 | --foreground: oklch(0.985 0 0); 82 | --card: oklch(0.205 0 0); 83 | --card-foreground: oklch(0.985 0 0); 84 | --popover: oklch(0.205 0 0); 85 | --popover-foreground: oklch(0.985 0 0); 86 | --primary: oklch(0.922 0 0); 87 | --primary-foreground: oklch(0.205 0 0); 88 | --secondary: oklch(0.269 0 0); 89 | --secondary-foreground: oklch(0.985 0 0); 90 | --muted: oklch(0.269 0 0); 91 | --muted-foreground: oklch(0.708 0 0); 92 | --accent: oklch(0.269 0 0); 93 | --accent-foreground: oklch(0.985 0 0); 94 | --destructive: oklch(0.704 0.191 22.216); 95 | --border: oklch(1 0 0 / 10%); 96 | --input: oklch(1 0 0 / 15%); 97 | --ring: oklch(0.556 0 0); 98 | --chart-1: oklch(0.488 0.243 264.376); 99 | --chart-2: oklch(0.696 0.17 162.48); 100 | --chart-3: oklch(0.769 0.188 70.08); 101 | --chart-4: oklch(0.627 0.265 303.9); 102 | --chart-5: oklch(0.645 0.246 16.439); 103 | --sidebar: oklch(0.205 0 0); 104 | --sidebar-foreground: oklch(0.985 0 0); 105 | --sidebar-primary: oklch(0.488 0.243 264.376); 106 | --sidebar-primary-foreground: oklch(0.985 0 0); 107 | --sidebar-accent: oklch(0.269 0 0); 108 | --sidebar-accent-foreground: oklch(0.985 0 0); 109 | --sidebar-border: oklch(1 0 0 / 10%); 110 | --sidebar-ring: oklch(0.556 0 0); 111 | } 112 | 113 | @layer base { 114 | * { 115 | @apply border-border outline-ring/50; 116 | } 117 | body { 118 | @apply bg-background text-foreground; 119 | } 120 | } -------------------------------------------------------------------------------- /apps/backend/internal/testing/container.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/jackc/pgx/v5/pgxpool" 11 | "github.com/rs/zerolog" 12 | "github.com/sriniously/go-boilerplate/internal/config" 13 | "github.com/sriniously/go-boilerplate/internal/database" 14 | "github.com/stretchr/testify/require" 15 | "github.com/testcontainers/testcontainers-go" 16 | "github.com/testcontainers/testcontainers-go/wait" 17 | ) 18 | 19 | type TestDB struct { 20 | Pool *pgxpool.Pool 21 | Container testcontainers.Container 22 | Config *config.Config 23 | } 24 | 25 | // SetupTestDB creates a Postgres container and applies migrations 26 | func SetupTestDB(t *testing.T) (*TestDB, func()) { 27 | t.Helper() 28 | 29 | ctx := context.Background() 30 | dbName := fmt.Sprintf("test_db_%s", uuid.New().String()[:8]) 31 | dbUser := "testuser" 32 | dbPassword := "testpassword" 33 | 34 | req := testcontainers.ContainerRequest{ 35 | Image: "postgres:15-alpine", 36 | ExposedPorts: []string{"5432/tcp"}, 37 | Env: map[string]string{ 38 | "POSTGRES_DB": dbName, 39 | "POSTGRES_USER": dbUser, 40 | "POSTGRES_PASSWORD": dbPassword, 41 | }, 42 | WaitingFor: wait.ForLog("database system is ready to accept connections").WithStartupTimeout(30 * time.Second), 43 | } 44 | 45 | pgContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 46 | ContainerRequest: req, 47 | Started: true, 48 | }) 49 | require.NoError(t, err, "failed to start postgres container") 50 | 51 | host, err := pgContainer.Host(ctx) 52 | require.NoError(t, err, "failed to get container host") 53 | 54 | mappedPort, err := pgContainer.MappedPort(ctx, "5432") 55 | require.NoError(t, err, "failed to get mapped port") 56 | port := mappedPort.Int() 57 | 58 | // Make sure the test cleans up the container 59 | t.Cleanup(func() { 60 | if err := pgContainer.Terminate(ctx); err != nil { 61 | t.Logf("failed to terminate container: %v", err) 62 | } 63 | }) 64 | 65 | // Create configuration 66 | cfg := &config.Config{ 67 | Database: config.DatabaseConfig{ 68 | Host: host, 69 | Port: port, 70 | User: dbUser, 71 | Password: dbPassword, 72 | Name: dbName, 73 | SSLMode: "disable", 74 | MaxOpenConns: 25, 75 | MaxIdleConns: 25, 76 | ConnMaxLifetime: 300, 77 | ConnMaxIdleTime: 300, 78 | }, 79 | Primary: config.Primary{ 80 | Env: "test", 81 | }, 82 | Server: config.ServerConfig{ 83 | Port: "8080", 84 | ReadTimeout: 30, 85 | WriteTimeout: 30, 86 | IdleTimeout: 30, 87 | CORSAllowedOrigins: []string{"*"}, 88 | }, 89 | Integration: config.IntegrationConfig{ 90 | ResendAPIKey: "test-key", 91 | }, 92 | Redis: config.RedisConfig{ 93 | Address: "localhost:6379", 94 | }, 95 | Auth: config.AuthConfig{ 96 | SecretKey: "test-secret", 97 | }, 98 | } 99 | 100 | logger := zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger() 101 | 102 | var db *database.Database 103 | var lastErr error 104 | for i := 0; i < 5; i++ { 105 | // Sleep before first attempt too to give PostgreSQL time to initialize 106 | time.Sleep(2 * time.Second) 107 | 108 | db, lastErr = database.New(cfg, &logger, nil) 109 | if lastErr == nil { 110 | // Try a ping to verify the connection 111 | if err := db.Pool.Ping(ctx); err == nil { 112 | break 113 | } else { 114 | lastErr = err 115 | logger.Warn().Err(err).Msg("Failed to ping database, will retry") 116 | db.Pool.Close() // Close the failed connection 117 | } 118 | } else { 119 | logger.Warn().Err(lastErr).Msgf("Failed to connect to database (attempt %d/5)", i+1) 120 | } 121 | } 122 | require.NoError(t, lastErr, "failed to connect to database after multiple attempts") 123 | 124 | // Apply migrations 125 | err = database.Migrate(ctx, &logger, cfg) 126 | require.NoError(t, err, "failed to apply database migrations") 127 | 128 | testDB := &TestDB{ 129 | Pool: db.Pool, 130 | Container: pgContainer, 131 | Config: cfg, 132 | } 133 | 134 | // Return cleanup function that just closes the pool (container is managed by t.Cleanup) 135 | cleanup := func() { 136 | if db.Pool != nil { 137 | db.Pool.Close() 138 | } 139 | } 140 | 141 | return testDB, cleanup 142 | } 143 | 144 | // CleanupTestDB closes the database connection and terminates the container 145 | func (db *TestDB) CleanupTestDB(ctx context.Context, logger *zerolog.Logger) error { 146 | logger.Info().Msg("cleaning up test database") 147 | 148 | if db.Pool != nil { 149 | db.Pool.Close() 150 | } 151 | 152 | if db.Container != nil { 153 | if err := db.Container.Terminate(ctx); err != nil { 154 | return fmt.Errorf("failed to terminate container: %w", err) 155 | } 156 | } 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Boilerplate 2 | 3 | A production-ready monorepo template for building scalable web applications with Go backend and TypeScript frontend. Built with modern best practices, clean architecture, and comprehensive tooling. 4 | 5 | ## Features 6 | 7 | - **Monorepo Structure**: Organized with Turborepo for efficient builds and development 8 | - **Go Backend**: High-performance REST API with Echo framework 9 | - **Authentication**: Integrated Clerk SDK for secure user management 10 | - **Database**: PostgreSQL with migrations and connection pooling 11 | - **Background Jobs**: Redis-based async job processing with Asynq 12 | - **Observability**: New Relic APM integration and structured logging 13 | - **Email Service**: Transactional emails with Resend and HTML templates 14 | - **Testing**: Comprehensive test infrastructure with Testcontainers 15 | - **API Documentation**: OpenAPI/Swagger specification 16 | - **Security**: Rate limiting, CORS, secure headers, and JWT validation 17 | 18 | ## Project Structure 19 | 20 | ``` 21 | go-boilerplate/ 22 | ├── apps/backend/ # Go backend application 23 | ├── packages/ # Frontend packages (React, Vue, etc.) 24 | ├── package.json # Monorepo configuration 25 | ├── turbo.json # Turborepo configuration 26 | └── README.md # This file 27 | ``` 28 | 29 | ## Quick Start 30 | 31 | ### Prerequisites 32 | 33 | - Go 1.24 or higher 34 | - Node.js 22+ and Bun 35 | - PostgreSQL 16+ 36 | - Redis 8+ 37 | 38 | ### Installation 39 | 40 | 1. Clone the repository: 41 | ```bash 42 | git clone https://github.com/sriniously/go-boilerplate.git 43 | cd go-boilerplate 44 | ``` 45 | 46 | 2. Install dependencies: 47 | ```bash 48 | # Install frontend dependencies 49 | bun install 50 | 51 | # Install backend dependencies 52 | cd apps/backend 53 | go mod download 54 | ``` 55 | 56 | 3. Set up environment variables: 57 | ```bash 58 | cp apps/backend/.env.example apps/backend/.env 59 | # Edit apps/backend/.env with your configuration 60 | ``` 61 | 62 | 4. Start the database and Redis. 63 | 64 | 5. Run database migrations: 65 | ```bash 66 | cd apps/backend 67 | task migrations:up 68 | ``` 69 | 70 | 6. Start the development server: 71 | ```bash 72 | # From root directory 73 | bun dev 74 | 75 | # Or just the backend 76 | cd apps/backend 77 | task run 78 | ``` 79 | 80 | The API will be available at `http://localhost:8080` 81 | 82 | ## Development 83 | 84 | ### Available Commands 85 | 86 | ```bash 87 | # Backend commands (from backend/ directory) 88 | task help # Show all available tasks 89 | task run # Run the application 90 | task migrations:new # Create a new migration 91 | task migrations:up # Apply migrations 92 | task test # Run tests 93 | task tidy # Format code and manage dependencies 94 | 95 | # Frontend commands (from root directory) 96 | bun dev # Start development servers 97 | bun build # Build all packages 98 | bun lint # Lint all packages 99 | ``` 100 | 101 | ### Environment Variables 102 | 103 | The backend uses environment variables prefixed with `BOILERPLATE_`. Key variables include: 104 | 105 | - `BOILERPLATE_DATABASE_*` - PostgreSQL connection settings 106 | - `BOILERPLATE_SERVER_*` - Server configuration 107 | - `BOILERPLATE_AUTH_*` - Authentication settings 108 | - `BOILERPLATE_REDIS_*` - Redis connection 109 | - `BOILERPLATE_EMAIL_*` - Email service configuration 110 | - `BOILERPLATE_OBSERVABILITY_*` - Monitoring settings 111 | 112 | See `apps/backend/.env.example` for a complete list. 113 | 114 | ## Architecture 115 | 116 | This boilerplate follows clean architecture principles: 117 | 118 | - **Handlers**: HTTP request/response handling 119 | - **Services**: Business logic implementation 120 | - **Repositories**: Data access layer 121 | - **Models**: Domain entities 122 | - **Infrastructure**: External services (database, cache, email) 123 | 124 | ## Testing 125 | 126 | ```bash 127 | # Run backend tests 128 | cd apps/backend 129 | go test ./... 130 | 131 | # Run with coverage 132 | go test -cover ./... 133 | 134 | # Run integration tests (requires Docker) 135 | go test -tags=integration ./... 136 | ``` 137 | 138 | ### Production Considerations 139 | 140 | 1. Use environment-specific configuration 141 | 2. Enable production logging levels 142 | 3. Configure proper database connection pooling 143 | 4. Set up monitoring and alerting 144 | 5. Use a reverse proxy (nginx, Caddy) 145 | 6. Enable rate limiting and security headers 146 | 7. Configure CORS for your domains 147 | 148 | ## Contributing 149 | 150 | 1. Fork the repository 151 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 152 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 153 | 4. Push to the branch (`git push origin feature/amazing-feature`) 154 | 5. Open a Pull Request 155 | 156 | ## License 157 | 158 | This project is licensed under the MIT License - see the LICENSE file for details. 159 | -------------------------------------------------------------------------------- /apps/backend/internal/sqlerr/error.go: -------------------------------------------------------------------------------- 1 | package sqlerr 2 | 3 | // Code describes a specific type of database error. 4 | // The value Other is reported when an error does not map to any of the defined codes. 5 | type Code string 6 | 7 | const ( 8 | // Other is reported when an error does not map to any of the defined codes. 9 | Other Code = "other" 10 | 11 | // NotNullViolation is reported when a not null constraint would be violated. 12 | NotNullViolation Code = "not_null_violation" 13 | 14 | // ForeignKeyViolation is reported when a foreign key constraint would be violated. 15 | ForeignKeyViolation Code = "foreign_key_violation" 16 | 17 | // UniqueViolation is reported when a unique constraint would be violated. 18 | UniqueViolation Code = "unique_violation" 19 | 20 | // CheckViolation is reported when a check constraint would be violated. 21 | CheckViolation Code = "check_violation" 22 | 23 | // ExcludeViolation is reported when an exclusion constraint would be violated. 24 | ExcludeViolation Code = "exclude_violation" 25 | 26 | // TransactionFailed is reported when running a command in a failed transaction, 27 | // due to some previous command failure. 28 | TransactionFailed Code = "transaction_failed" 29 | 30 | // DeadlockDetected is reported when a deadlock is detected. 31 | // Deadlock detection is done on a best-effort basis and not all deadlocks 32 | // can be detected. 33 | DeadlockDetected Code = "deadlock_detected" 34 | 35 | // TooManyConnections is reported when the database rejects a connection request 36 | // due to reaching the maximum number of connections. 37 | // This is different from blocking waiting on a connection pool. 38 | TooManyConnections Code = "too_many_connections" 39 | ) 40 | 41 | // MapCode maps an underlying database error to a Code. 42 | func MapCode(code string) Code { 43 | switch code { 44 | case "23502": 45 | return NotNullViolation 46 | case "23503": 47 | return ForeignKeyViolation 48 | case "23505": 49 | return UniqueViolation 50 | case "23514": 51 | return CheckViolation 52 | case "23P01": 53 | return ExcludeViolation 54 | case "25P02": 55 | return TransactionFailed 56 | case "40P01": 57 | return DeadlockDetected 58 | case "53300": 59 | return TooManyConnections 60 | default: 61 | return Other 62 | } 63 | } 64 | 65 | // Severity defines the severity of a database error. 66 | type Severity string 67 | 68 | const ( 69 | SeverityError Severity = "ERROR" 70 | SeverityFatal Severity = "FATAL" 71 | SeverityPanic Severity = "PANIC" 72 | SeverityWarning Severity = "WARNING" 73 | SeverityNotice Severity = "NOTICE" 74 | SeverityDebug Severity = "DEBUG" 75 | SeverityInfo Severity = "INFO" 76 | SeverityLog Severity = "LOG" 77 | ) 78 | 79 | // MapSeverity maps the severity string from the underlying database 80 | // to a Severity. 81 | func MapSeverity(severity string) Severity { 82 | switch severity { 83 | case "ERROR": 84 | return SeverityError 85 | case "FATAL": 86 | return SeverityFatal 87 | case "PANIC": 88 | return SeverityPanic 89 | case "WARNING": 90 | return SeverityWarning 91 | case "NOTICE": 92 | return SeverityNotice 93 | case "DEBUG": 94 | return SeverityDebug 95 | case "INFO": 96 | return SeverityInfo 97 | case "LOG": 98 | return SeverityLog 99 | default: 100 | return SeverityError 101 | } 102 | } 103 | 104 | // Error represents an error reported by the database server. 105 | // It's not guaranteed all errors reported by database functions will be of this type; 106 | // it is only returned when the database reports an error. 107 | type Error struct { 108 | // Code defines the general class of the error. 109 | Code Code 110 | 111 | // Severity is the severity of the error. 112 | Severity Severity 113 | 114 | // DatabaseCode is the database server-specific error code. 115 | DatabaseCode string 116 | 117 | // Message: the primary human-readable error message. 118 | Message string 119 | 120 | // SchemaName: if the error was associated with a specific database object, 121 | // the name of the schema containing that object, if any. 122 | SchemaName string 123 | 124 | // TableName: if the error was associated with a specific table, the name of the table. 125 | TableName string 126 | 127 | // ColumnName: if the error was associated with a specific table column, 128 | // the name of the column. 129 | ColumnName string 130 | 131 | // DataTypeName: if the error was associated with a specific data type, 132 | // the name of the data type. 133 | DataTypeName string 134 | 135 | // ConstraintName: if the error was associated with a specific constraint, 136 | // the name of the constraint. 137 | ConstraintName string 138 | 139 | // driverErr is the underlying error from the driver. 140 | driverErr error 141 | } 142 | 143 | func (pe *Error) Error() string { 144 | return string(pe.Severity) + ": " + pe.Message + " (Code " + string(pe.Code) + ": SQLSTATE " + pe.DatabaseCode + ")" 145 | } 146 | 147 | func (pe *Error) Unwrap() error { 148 | return pe.driverErr 149 | } 150 | -------------------------------------------------------------------------------- /apps/backend/internal/handler/health.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/sriniously/go-boilerplate/internal/middleware" 10 | "github.com/sriniously/go-boilerplate/internal/server" 11 | 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | type HealthHandler struct { 16 | Handler 17 | } 18 | 19 | func NewHealthHandler(s *server.Server) *HealthHandler { 20 | return &HealthHandler{ 21 | Handler: NewHandler(s), 22 | } 23 | } 24 | 25 | func (h *HealthHandler) CheckHealth(c echo.Context) error { 26 | start := time.Now() 27 | logger := middleware.GetLogger(c).With(). 28 | Str("operation", "health_check"). 29 | Logger() 30 | 31 | response := map[string]interface{}{ 32 | "status": "healthy", 33 | "timestamp": time.Now().UTC(), 34 | "environment": h.server.Config.Primary.Env, 35 | "checks": make(map[string]interface{}), 36 | } 37 | 38 | checks := response["checks"].(map[string]interface{}) 39 | isHealthy := true 40 | 41 | // Check database connectivity 42 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 43 | defer cancel() 44 | 45 | dbStart := time.Now() 46 | if err := h.server.DB.Pool.Ping(ctx); err != nil { 47 | checks["database"] = map[string]interface{}{ 48 | "status": "unhealthy", 49 | "response_time": time.Since(dbStart).String(), 50 | "error": err.Error(), 51 | } 52 | isHealthy = false 53 | logger.Error().Err(err).Dur("response_time", time.Since(dbStart)).Msg("database health check failed") 54 | if h.server.LoggerService != nil && h.server.LoggerService.GetApplication() != nil { 55 | h.server.LoggerService.GetApplication().RecordCustomEvent( 56 | "HealthCheckError", map[string]interface{}{ 57 | "check_type": "database", 58 | "operation": "health_check", 59 | "error_type": "database_unhealthy", 60 | "response_time_ms": time.Since(dbStart).Milliseconds(), 61 | "error_message": err.Error(), 62 | }) 63 | } 64 | } else { 65 | checks["database"] = map[string]interface{}{ 66 | "status": "healthy", 67 | "response_time": time.Since(dbStart).String(), 68 | } 69 | logger.Info().Dur("response_time", time.Since(dbStart)).Msg("database health check passed") 70 | } 71 | 72 | // Database connection metrics are automatically captured by New Relic nrpgx5 integration 73 | 74 | // Check Redis connectivity 75 | if h.server.Redis != nil { 76 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 77 | defer cancel() 78 | 79 | redisStart := time.Now() 80 | if err := h.server.Redis.Ping(ctx).Err(); err != nil { 81 | checks["redis"] = map[string]interface{}{ 82 | "status": "unhealthy", 83 | "response_time": time.Since(redisStart).String(), 84 | "error": err.Error(), 85 | } 86 | logger.Error().Err(err).Dur("response_time", time.Since(redisStart)).Msg("redis health check failed") 87 | if h.server.LoggerService != nil && h.server.LoggerService.GetApplication() != nil { 88 | h.server.LoggerService.GetApplication().RecordCustomEvent( 89 | "HealthCheckError", map[string]interface{}{ 90 | "check_type": "redis", 91 | "operation": "health_check", 92 | "error_type": "redis_unhealthy", 93 | "response_time_ms": time.Since(redisStart).Milliseconds(), 94 | "error_message": err.Error(), 95 | }) 96 | } 97 | } else { 98 | checks["redis"] = map[string]interface{}{ 99 | "status": "healthy", 100 | "response_time": time.Since(redisStart).String(), 101 | } 102 | logger.Info().Dur("response_time", time.Since(redisStart)).Msg("redis health check passed") 103 | } 104 | } 105 | 106 | // Set overall status 107 | if !isHealthy { 108 | response["status"] = "unhealthy" 109 | logger.Warn(). 110 | Dur("total_duration", time.Since(start)). 111 | Msg("health check failed") 112 | if h.server.LoggerService != nil && h.server.LoggerService.GetApplication() != nil { 113 | h.server.LoggerService.GetApplication().RecordCustomEvent( 114 | "HealthCheckError", map[string]interface{}{ 115 | "check_type": "overall", 116 | "operation": "health_check", 117 | "error_type": "overall_unhealthy", 118 | "total_duration_ms": time.Since(start).Milliseconds(), 119 | }) 120 | } 121 | return c.JSON(http.StatusServiceUnavailable, response) 122 | } 123 | 124 | logger.Info(). 125 | Dur("total_duration", time.Since(start)). 126 | Msg("health check passed") 127 | 128 | err := c.JSON(http.StatusOK, response) 129 | if err != nil { 130 | logger.Error().Err(err).Msg("failed to write JSON response") 131 | if h.server.LoggerService != nil && h.server.LoggerService.GetApplication() != nil { 132 | h.server.LoggerService.GetApplication().RecordCustomEvent( 133 | "HealthCheckError", map[string]interface{}{ 134 | "check_type": "response", 135 | "operation": "health_check", 136 | "error_type": "json_response_error", 137 | "error_message": err.Error(), 138 | }) 139 | } 140 | return fmt.Errorf("failed to write JSON response: %w", err) 141 | } 142 | 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /apps/backend/internal/middleware/global.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/labstack/echo/v4/middleware" 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog" 10 | "github.com/sriniously/go-boilerplate/internal/errs" 11 | "github.com/sriniously/go-boilerplate/internal/server" 12 | "github.com/sriniously/go-boilerplate/internal/sqlerr" 13 | ) 14 | 15 | type GlobalMiddlewares struct { 16 | server *server.Server 17 | } 18 | 19 | func NewGlobalMiddlewares(s *server.Server) *GlobalMiddlewares { 20 | return &GlobalMiddlewares{ 21 | server: s, 22 | } 23 | } 24 | 25 | func (global *GlobalMiddlewares) CORS() echo.MiddlewareFunc { 26 | return middleware.CORSWithConfig(middleware.CORSConfig{ 27 | AllowOrigins: global.server.Config.Server.CORSAllowedOrigins, 28 | }) 29 | } 30 | 31 | func (global *GlobalMiddlewares) RequestLogger() echo.MiddlewareFunc { 32 | return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ 33 | LogURI: true, 34 | LogStatus: true, 35 | LogError: true, 36 | LogLatency: true, 37 | LogHost: true, 38 | LogMethod: true, 39 | LogURIPath: true, 40 | LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { 41 | statusCode := v.Status 42 | 43 | // note that the status code is not set yet as it gets picked up by the global err handler 44 | // see here: https://github.com/labstack/echo/issues/2310#issuecomment-1288196898 45 | if v.Error != nil { 46 | var httpErr *errs.HTTPError 47 | var echoErr *echo.HTTPError 48 | if errors.As(v.Error, &httpErr) { 49 | statusCode = httpErr.Status 50 | } else if errors.As(v.Error, &echoErr) { 51 | statusCode = echoErr.Code 52 | } 53 | } 54 | 55 | // Get enhanced logger from context 56 | logger := GetLogger(c) 57 | 58 | var e *zerolog.Event 59 | 60 | switch { 61 | case statusCode >= 500: 62 | e = logger.Error().Err(v.Error) 63 | case statusCode >= 400: 64 | e = logger.Warn() 65 | default: 66 | e = logger.Info() 67 | } 68 | 69 | // Add request ID if available 70 | if requestID := GetRequestID(c); requestID != "" { 71 | e = e.Str("request_id", requestID) 72 | } 73 | 74 | // Add user context if available 75 | if userID := GetUserID(c); userID != "" { 76 | e = e.Str("user_id", userID) 77 | } 78 | 79 | e. 80 | Dur("latency", v.Latency). 81 | Int("status", statusCode). 82 | Str("method", v.Method). 83 | Str("uri", v.URI). 84 | Str("host", v.Host). 85 | Str("ip", c.RealIP()). 86 | Str("user_agent", c.Request().UserAgent()). 87 | Msg("API") 88 | 89 | return nil 90 | }, 91 | }) 92 | } 93 | 94 | func (global *GlobalMiddlewares) Recover() echo.MiddlewareFunc { 95 | return middleware.Recover() 96 | } 97 | 98 | func (global *GlobalMiddlewares) Secure() echo.MiddlewareFunc { 99 | return middleware.Secure() 100 | } 101 | 102 | func (global *GlobalMiddlewares) GlobalErrorHandler(err error, c echo.Context) { 103 | // First try to handle database errors and convert them to appropriate HTTP errors 104 | originalErr := err 105 | 106 | // Try to handle known database errors 107 | // Only do this for errors that haven't already been converted to HTTPError 108 | var httpErr *errs.HTTPError 109 | if !errors.As(err, &httpErr) { 110 | var echoErr *echo.HTTPError 111 | if errors.As(err, &echoErr) { 112 | if echoErr.Code == http.StatusNotFound { 113 | err = errs.NewNotFoundError("Route not found", false, nil) 114 | } 115 | } else { 116 | // Here we call our sqlerr handler which will convert database errors 117 | // to appropriate application errors 118 | err = sqlerr.HandleError(err) 119 | } 120 | } 121 | 122 | // Now process the possibly converted error 123 | var echoErr *echo.HTTPError 124 | var status int 125 | var code string 126 | var message string 127 | var fieldErrors []errs.FieldError 128 | var action *errs.Action 129 | 130 | switch { 131 | case errors.As(err, &httpErr): 132 | status = httpErr.Status 133 | code = httpErr.Code 134 | message = httpErr.Message 135 | fieldErrors = httpErr.Errors 136 | action = httpErr.Action 137 | 138 | case errors.As(err, &echoErr): 139 | status = echoErr.Code 140 | code = errs.MakeUpperCaseWithUnderscores(http.StatusText(status)) 141 | if msg, ok := echoErr.Message.(string); ok { 142 | message = msg 143 | } else { 144 | message = http.StatusText(echoErr.Code) 145 | } 146 | 147 | default: 148 | status = http.StatusInternalServerError 149 | code = errs.MakeUpperCaseWithUnderscores( 150 | http.StatusText(http.StatusInternalServerError)) 151 | message = http.StatusText(http.StatusInternalServerError) 152 | } 153 | 154 | // Log the original error to help with debugging 155 | // Use enhanced logger from context which already includes request_id, method, path, ip, user context, and trace context 156 | logger := *GetLogger(c) 157 | 158 | logger.Error().Stack(). 159 | Err(originalErr). 160 | Int("status", status). 161 | Str("error_code", code). 162 | Msg(message) 163 | 164 | if !c.Response().Committed { 165 | _ = c.JSON(status, errs.HTTPError{ 166 | Code: code, 167 | Message: message, 168 | Status: status, 169 | Override: httpErr != nil && httpErr.Override, 170 | Errors: fieldErrors, 171 | Action: action, 172 | }) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /apps/backend/.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | timeout: 3m 5 | 6 | linters: 7 | default: none 8 | enable: 9 | - errcheck 10 | - gosimple 11 | - govet 12 | - ineffassign 13 | - staticcheck 14 | - typecheck 15 | - unused 16 | - asasalint 17 | - asciicheck 18 | - bidichk 19 | - bodyclose 20 | - canonicalheader 21 | - copyloopvar 22 | - cyclop 23 | - dupl 24 | - durationcheck 25 | - errname 26 | - errorlint 27 | - exhaustive 28 | - fatcontext 29 | - forbidigo 30 | - funlen 31 | - gocheckcompilerdirectives 32 | - gochecknoglobals 33 | - gochecknoinits 34 | - gochecksumtype 35 | - gocognit 36 | - goconst 37 | - gocritic 38 | - gocyclo 39 | - goimports 40 | - gomoddirectives 41 | - gomodguard 42 | - goprintffuncname 43 | - gosec 44 | - iface 45 | - intrange 46 | - lll 47 | - loggercheck 48 | - makezero 49 | - mirror 50 | - mnd 51 | - musttag 52 | - nakedret 53 | - nestif 54 | - nilerr 55 | - nilnil 56 | - noctx 57 | - nolintlint 58 | - nonamedreturns 59 | - nosprintfhostport 60 | - perfsprint 61 | - predeclared 62 | - promlinter 63 | - protogetter 64 | - reassign 65 | - recvcheck 66 | - revive 67 | - rowserrcheck 68 | - sloglint 69 | - spancheck 70 | - sqlclosecheck 71 | - stylecheck 72 | 73 | - testableexamples 74 | - testifylint 75 | - testpackage 76 | - tparallel 77 | - unconvert 78 | - unparam 79 | - usestdlibvars 80 | - wastedassign 81 | - whitespace 82 | 83 | settings: 84 | cyclop: 85 | max-complexity: 30 86 | package-average: 10.0 87 | 88 | errcheck: 89 | check-type-assertions: true 90 | 91 | exhaustive: 92 | check: 93 | - switch 94 | - map 95 | 96 | exhaustruct: 97 | exclude: 98 | - "^net/http.Client$" 99 | - "^net/http.Cookie$" 100 | - "^net/http.Request$" 101 | - "^net/http.Response$" 102 | - "^net/http.Server$" 103 | - "^net/http.Transport$" 104 | - "^net/url.URL$" 105 | - "^os/exec.Cmd$" 106 | - "^reflect.StructField$" 107 | - "^github.com/Shopify/sarama.Config$" 108 | - "^github.com/Shopify/sarama.ProducerMessage$" 109 | - "^github.com/mitchellh/mapstructure.DecoderConfig$" 110 | - "^github.com/prometheus/client_golang/.+Opts$" 111 | - "^github.com/spf13/cobra.Command$" 112 | - "^github.com/spf13/cobra.CompletionOptions$" 113 | - "^github.com/stretchr/testify/mock.Mock$" 114 | - "^github.com/testcontainers/testcontainers-go.+Request$" 115 | - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" 116 | - "^golang.org/x/tools/go/analysis.Analyzer$" 117 | - "^google.golang.org/protobuf/.+Options$" 118 | - "^gopkg.in/yaml.v3.Node$" 119 | 120 | funlen: 121 | lines: 100 122 | statements: 50 123 | ignore-comments: true 124 | 125 | gochecksumtype: 126 | default-signifies-exhaustive: false 127 | 128 | gocognit: 129 | min-complexity: 20 130 | 131 | gocritic: 132 | settings: 133 | captLocal: 134 | paramsOnly: false 135 | underef: 136 | skipRecvDeref: false 137 | 138 | gomodguard: 139 | blocked: 140 | modules: 141 | - github.com/golang/protobuf: 142 | recommendations: 143 | - google.golang.org/protobuf 144 | reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" 145 | - github.com/satori/go.uuid: 146 | recommendations: 147 | - github.com/google/uuid 148 | reason: "satori's package is not maintained" 149 | - github.com/gofrs/uuid: 150 | recommendations: 151 | - github.com/gofrs/uuid/v5 152 | reason: "gofrs' package was not go module before v5" 153 | 154 | govet: 155 | enable-all: true 156 | disable: 157 | - fieldalignment 158 | settings: 159 | shadow: 160 | strict: true 161 | 162 | inamedparam: 163 | skip-single-param: true 164 | 165 | mnd: 166 | ignored-functions: 167 | - args.Error 168 | - flag.Arg 169 | - flag.Duration.* 170 | - flag.Float.* 171 | - flag.Int.* 172 | - flag.Uint.* 173 | - os.Chmod 174 | - os.Mkdir.* 175 | - os.OpenFile 176 | - os.WriteFile 177 | - prometheus.ExponentialBuckets.* 178 | - prometheus.LinearBuckets 179 | 180 | nakedret: 181 | max-func-lines: 0 182 | 183 | nolintlint: 184 | allow-no-explanation: [funlen, gocognit, lll] 185 | require-explanation: true 186 | require-specific: true 187 | 188 | perfsprint: 189 | strconcat: false 190 | 191 | reassign: 192 | patterns: 193 | - ".*" 194 | 195 | rowserrcheck: 196 | packages: 197 | - github.com/jmoiron/sqlx 198 | 199 | sloglint: 200 | no-global: "all" 201 | context: "scope" 202 | 203 | 204 | 205 | exclusions: 206 | rules: 207 | - source: "(noinspection|TODO)" 208 | linters: [godot] 209 | - source: "//noinspection" 210 | linters: [gocritic] 211 | - path: "_test\\.go" 212 | linters: 213 | - bodyclose 214 | - dupl 215 | - errcheck 216 | - funlen 217 | - goconst 218 | - gosec 219 | - noctx 220 | - wrapcheck 221 | 222 | issues: 223 | max-same-issues: 50 -------------------------------------------------------------------------------- /apps/backend/internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "github.com/newrelic/go-agent/v3/integrations/logcontext-v2/zerologWriter" 11 | "github.com/newrelic/go-agent/v3/newrelic" 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/pkgerrors" 14 | "github.com/sriniously/go-boilerplate/internal/config" 15 | ) 16 | 17 | // LoggerService manages New Relic integration and logger creation 18 | type LoggerService struct { 19 | nrApp *newrelic.Application 20 | } 21 | 22 | // NewLoggerService creates a new logger service with New Relic integration 23 | func NewLoggerService(cfg *config.ObservabilityConfig) *LoggerService { 24 | service := &LoggerService{} 25 | 26 | if cfg.NewRelic.LicenseKey == "" { 27 | return service 28 | } 29 | 30 | var configOptions []newrelic.ConfigOption 31 | configOptions = append(configOptions, 32 | newrelic.ConfigAppName(cfg.ServiceName), 33 | newrelic.ConfigLicense(cfg.NewRelic.LicenseKey), 34 | newrelic.ConfigAppLogForwardingEnabled(cfg.NewRelic.AppLogForwardingEnabled), 35 | newrelic.ConfigDistributedTracerEnabled(cfg.NewRelic.DistributedTracingEnabled), 36 | ) 37 | 38 | // Add debug logging only if explicitly enabled 39 | if cfg.NewRelic.DebugLogging { 40 | configOptions = append(configOptions, newrelic.ConfigDebugLogger(os.Stdout)) 41 | } 42 | 43 | app, err := newrelic.NewApplication(configOptions...) 44 | if err != nil { 45 | return service 46 | } 47 | 48 | service.nrApp = app 49 | return service 50 | } 51 | 52 | // Shutdown shuts down New Relic 53 | func (ls *LoggerService) Shutdown() { 54 | if ls.nrApp != nil { 55 | ls.nrApp.Shutdown(10 * time.Second) 56 | } 57 | } 58 | 59 | // GetApplication returns the New Relic application instance 60 | func (ls *LoggerService) GetApplication() *newrelic.Application { 61 | return ls.nrApp 62 | } 63 | 64 | 65 | // NewLoggerWithService creates a logger with full config and logger service 66 | func NewLoggerWithService(cfg *config.ObservabilityConfig, loggerService *LoggerService) zerolog.Logger { 67 | var logLevel zerolog.Level 68 | level := cfg.GetLogLevel() 69 | 70 | switch level { 71 | case "debug": 72 | logLevel = zerolog.DebugLevel 73 | case "info": 74 | logLevel = zerolog.InfoLevel 75 | case "warn": 76 | logLevel = zerolog.WarnLevel 77 | case "error": 78 | logLevel = zerolog.ErrorLevel 79 | default: 80 | logLevel = zerolog.InfoLevel 81 | } 82 | 83 | // Don't set global level - let each logger have its own level 84 | zerolog.TimeFieldFormat = "2006-01-02 15:04:05" 85 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 86 | 87 | var writer io.Writer 88 | 89 | // Setup base writer 90 | var baseWriter io.Writer 91 | if cfg.IsProduction() && cfg.Logging.Format == "json" { 92 | // In production, write to stdout 93 | baseWriter = os.Stdout 94 | 95 | // Wrap with New Relic zerologWriter for log forwarding in production 96 | if loggerService != nil && loggerService.nrApp != nil { 97 | nrWriter := zerologWriter.New(baseWriter, loggerService.nrApp) 98 | writer = nrWriter 99 | } else { 100 | writer = baseWriter 101 | } 102 | } else { 103 | // Development mode - use console writer 104 | consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "2006-01-02 15:04:05"} 105 | writer = consoleWriter 106 | } 107 | 108 | // Note: New Relic log forwarding is now handled automatically by zerologWriter integration 109 | 110 | logger := zerolog.New(writer). 111 | Level(logLevel). 112 | With(). 113 | Timestamp(). 114 | Str("service", cfg.ServiceName). 115 | Str("environment", cfg.Environment). 116 | Logger() 117 | 118 | // Include stack traces for errors in development 119 | if !cfg.IsProduction() { 120 | logger = logger.With().Stack().Logger() 121 | } 122 | 123 | return logger 124 | } 125 | 126 | // WithTraceContext adds New Relic transaction context to logger 127 | func WithTraceContext(logger zerolog.Logger, txn *newrelic.Transaction) zerolog.Logger { 128 | if txn == nil { 129 | return logger 130 | } 131 | 132 | // Get trace metadata from transaction 133 | metadata := txn.GetTraceMetadata() 134 | 135 | return logger.With(). 136 | Str("trace.id", metadata.TraceID). 137 | Str("span.id", metadata.SpanID). 138 | Logger() 139 | } 140 | 141 | // NewPgxLogger creates a database logger 142 | func NewPgxLogger(level zerolog.Level) zerolog.Logger { 143 | writer := zerolog.ConsoleWriter{ 144 | Out: os.Stdout, 145 | TimeFormat: "2006-01-02 15:04:05", 146 | FormatFieldValue: func(i any) string { 147 | switch v := i.(type) { 148 | case string: 149 | // Clean and format SQL for better readability 150 | if len(v) > 200 { 151 | // Truncate very long SQL statements 152 | return v[:200] + "..." 153 | } 154 | return v 155 | case []byte: 156 | var obj interface{} 157 | if err := json.Unmarshal(v, &obj); err == nil { 158 | pretty, _ := json.MarshalIndent(obj, "", " ") 159 | return "\n" + string(pretty) 160 | } 161 | return string(v) 162 | default: 163 | return fmt.Sprintf("%v", v) 164 | } 165 | }, 166 | } 167 | 168 | return zerolog.New(writer). 169 | Level(level). 170 | With(). 171 | Timestamp(). 172 | Str("component", "database"). 173 | Logger() 174 | } 175 | 176 | // GetPgxTraceLogLevel converts zerolog level to pgx tracelog level 177 | func GetPgxTraceLogLevel(level zerolog.Level) int { 178 | switch level { 179 | case zerolog.DebugLevel: 180 | return 6 // tracelog.LogLevelDebug 181 | case zerolog.InfoLevel: 182 | return 4 // tracelog.LogLevelInfo 183 | case zerolog.WarnLevel: 184 | return 3 // tracelog.LogLevelWarn 185 | case zerolog.ErrorLevel: 186 | return 2 // tracelog.LogLevelError 187 | default: 188 | return 0 // tracelog.LogLevelNone 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /apps/backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sriniously/go-boilerplate 2 | 3 | go 1.24.5 4 | 5 | require ( 6 | github.com/clerk/clerk-sdk-go/v2 v2.3.1 7 | github.com/go-playground/validator/v10 v10.27.0 8 | github.com/google/uuid v1.6.0 9 | github.com/hibiken/asynq v0.25.1 10 | github.com/jackc/pgx-zerolog v0.0.0-20230315001418-f978528409eb 11 | github.com/jackc/pgx/v5 v5.7.5 12 | github.com/jackc/tern/v2 v2.3.3 13 | github.com/joho/godotenv v1.5.1 14 | github.com/knadh/koanf/providers/env v1.1.0 15 | github.com/knadh/koanf/v2 v2.2.2 16 | github.com/labstack/echo/v4 v4.13.4 17 | github.com/newrelic/go-agent/v3 v3.40.1 18 | github.com/newrelic/go-agent/v3/integrations/logcontext-v2/zerologWriter v1.0.4 19 | github.com/newrelic/go-agent/v3/integrations/nrecho-v4 v1.1.4 20 | github.com/newrelic/go-agent/v3/integrations/nrpgx5 v1.3.1 21 | github.com/newrelic/go-agent/v3/integrations/nrpkgerrors v1.1.0 22 | github.com/newrelic/go-agent/v3/integrations/nrredis-v9 v1.1.1 23 | github.com/pkg/errors v0.9.1 24 | github.com/redis/go-redis/v9 v9.7.0 25 | github.com/resend/resend-go/v2 v2.21.0 26 | github.com/rs/zerolog v1.34.0 27 | github.com/stretchr/testify v1.10.0 28 | github.com/testcontainers/testcontainers-go v0.38.0 29 | golang.org/x/text v0.25.0 30 | golang.org/x/time v0.11.0 31 | ) 32 | 33 | require ( 34 | dario.cat/mergo v1.0.1 // indirect 35 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 36 | github.com/Masterminds/goutils v1.1.1 // indirect 37 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 38 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 39 | github.com/Microsoft/go-winio v0.6.2 // indirect 40 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 41 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 42 | github.com/containerd/errdefs v1.0.0 // indirect 43 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 44 | github.com/containerd/log v0.1.0 // indirect 45 | github.com/containerd/platforms v0.2.1 // indirect 46 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 47 | github.com/davecgh/go-spew v1.1.1 // indirect 48 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 49 | github.com/distribution/reference v0.6.0 // indirect 50 | github.com/docker/docker v28.2.2+incompatible // indirect 51 | github.com/docker/go-connections v0.5.0 // indirect 52 | github.com/docker/go-units v0.5.0 // indirect 53 | github.com/ebitengine/purego v0.8.4 // indirect 54 | github.com/felixge/httpsnoop v1.0.4 // indirect 55 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 56 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect 57 | github.com/go-logr/logr v1.4.3 // indirect 58 | github.com/go-logr/stdr v1.2.2 // indirect 59 | github.com/go-ole/go-ole v1.2.6 // indirect 60 | github.com/go-playground/locales v0.14.1 // indirect 61 | github.com/go-playground/universal-translator v0.18.1 // indirect 62 | github.com/go-viper/mapstructure/v2 v2.3.0 // indirect 63 | github.com/gogo/protobuf v1.3.2 // indirect 64 | github.com/huandu/xstrings v1.5.0 // indirect 65 | github.com/jackc/pgpassfile v1.0.0 // indirect 66 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 67 | github.com/jackc/puddle/v2 v2.2.2 // indirect 68 | github.com/klauspost/compress v1.18.0 // indirect 69 | github.com/knadh/koanf/maps v0.1.2 // indirect 70 | github.com/labstack/gommon v0.4.2 // indirect 71 | github.com/leodido/go-urn v1.4.0 // indirect 72 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 73 | github.com/magiconair/properties v1.8.10 // indirect 74 | github.com/mattn/go-colorable v0.1.14 // indirect 75 | github.com/mattn/go-isatty v0.0.20 // indirect 76 | github.com/mitchellh/copystructure v1.2.0 // indirect 77 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 78 | github.com/moby/docker-image-spec v1.3.1 // indirect 79 | github.com/moby/go-archive v0.1.0 // indirect 80 | github.com/moby/patternmatcher v0.6.0 // indirect 81 | github.com/moby/sys/sequential v0.6.0 // indirect 82 | github.com/moby/sys/user v0.4.0 // indirect 83 | github.com/moby/sys/userns v0.1.0 // indirect 84 | github.com/moby/term v0.5.0 // indirect 85 | github.com/morikuni/aec v1.0.0 // indirect 86 | github.com/newrelic/go-agent/v3/integrations/logcontext-v2/nrwriter v1.0.1 // indirect 87 | github.com/opencontainers/go-digest v1.0.0 // indirect 88 | github.com/opencontainers/image-spec v1.1.1 // indirect 89 | github.com/pmezard/go-difflib v1.0.0 // indirect 90 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 91 | github.com/robfig/cron/v3 v3.0.1 // indirect 92 | github.com/shirou/gopsutil/v4 v4.25.5 // indirect 93 | github.com/shopspring/decimal v1.4.0 // indirect 94 | github.com/sirupsen/logrus v1.9.3 // indirect 95 | github.com/spf13/cast v1.7.0 // indirect 96 | github.com/tklauser/go-sysconf v0.3.12 // indirect 97 | github.com/tklauser/numcpus v0.6.1 // indirect 98 | github.com/valyala/bytebufferpool v1.0.0 // indirect 99 | github.com/valyala/fasttemplate v1.2.2 // indirect 100 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 101 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 102 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 103 | go.opentelemetry.io/otel v1.37.0 // indirect 104 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect 105 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 106 | go.opentelemetry.io/otel/sdk v1.37.0 // indirect 107 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 108 | go.opentelemetry.io/proto/otlp v1.7.0 // indirect 109 | golang.org/x/crypto v0.38.0 // indirect 110 | golang.org/x/net v0.40.0 // indirect 111 | golang.org/x/sync v0.14.0 // indirect 112 | golang.org/x/sys v0.33.0 // indirect 113 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect 114 | google.golang.org/grpc v1.72.2 // indirect 115 | google.golang.org/protobuf v1.36.6 // indirect 116 | gopkg.in/yaml.v3 v3.0.1 // indirect 117 | ) 118 | -------------------------------------------------------------------------------- /apps/backend/internal/sqlerr/handler.go: -------------------------------------------------------------------------------- 1 | package sqlerr 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/sriniously/go-boilerplate/internal/errs" 11 | 12 | "github.com/jackc/pgx/v5" 13 | "github.com/jackc/pgx/v5/pgconn" 14 | "golang.org/x/text/cases" 15 | "golang.org/x/text/language" 16 | ) 17 | 18 | // ErrCode reports the error code for a given error. 19 | // If the error is nil or is not of type *Error it reports sqlerr.Other. 20 | func ErrCode(err error) Code { 21 | var pgerr *Error 22 | if errors.As(err, &pgerr) { 23 | return pgerr.Code 24 | } 25 | return Other 26 | } 27 | 28 | // ConvertPgError converts a pgconn.PgError to our custom Error type 29 | func ConvertPgError(src *pgconn.PgError) *Error { 30 | return &Error{ 31 | Code: MapCode(src.Code), 32 | Severity: MapSeverity(src.Severity), 33 | DatabaseCode: src.Code, 34 | Message: src.Message, 35 | SchemaName: src.SchemaName, 36 | TableName: src.TableName, 37 | ColumnName: src.ColumnName, 38 | DataTypeName: src.DataTypeName, 39 | ConstraintName: src.ConstraintName, 40 | driverErr: src, 41 | } 42 | } 43 | 44 | // generateErrorCode creates consistent error codes from database errors 45 | func generateErrorCode(tableName string, errType Code) string { 46 | if tableName == "" { 47 | tableName = "RECORD" 48 | } 49 | 50 | domain := strings.ToUpper(tableName) 51 | // Singularize the table name 52 | if strings.HasSuffix(domain, "S") && len(domain) > 1 { 53 | domain = domain[:len(domain)-1] 54 | } 55 | 56 | action := "ERROR" 57 | switch errType { 58 | case ForeignKeyViolation: 59 | action = "NOT_FOUND" 60 | case UniqueViolation: 61 | action = "ALREADY_EXISTS" 62 | case NotNullViolation: 63 | action = "REQUIRED" 64 | case CheckViolation: 65 | action = "INVALID" 66 | } 67 | 68 | return fmt.Sprintf("%s_%s", domain, action) 69 | } 70 | 71 | // formatUserFriendlyMessage generates a user-friendly error message 72 | func formatUserFriendlyMessage(sqlErr *Error) string { 73 | entityName := getEntityName(sqlErr.TableName, sqlErr.ColumnName) 74 | 75 | switch sqlErr.Code { 76 | case ForeignKeyViolation: 77 | return fmt.Sprintf("The referenced %s does not exist", entityName) 78 | case UniqueViolation: 79 | return fmt.Sprintf("A %s with this identifier already exists", entityName) 80 | case NotNullViolation: 81 | fieldName := humanizeText(sqlErr.ColumnName) 82 | if fieldName == "" { 83 | fieldName = "field" 84 | } 85 | return fmt.Sprintf("The %s is required", fieldName) 86 | case CheckViolation: 87 | fieldName := humanizeText(sqlErr.ColumnName) 88 | if fieldName != "" { 89 | return fmt.Sprintf("The %s value does not meet required conditions", fieldName) 90 | } 91 | return "One or more values do not meet required conditions" 92 | default: 93 | return "An error occurred while processing your request" 94 | } 95 | } 96 | 97 | // getEntityName extracts entity name from database information with consistent rules 98 | func getEntityName(tableName, columnName string) string { 99 | // First priority: column name logic (most reliable for FK relationships) 100 | if columnName != "" && strings.HasSuffix(strings.ToLower(columnName), "_id") { 101 | entity := strings.TrimSuffix(strings.ToLower(columnName), "_id") 102 | return humanizeText(entity) 103 | } 104 | 105 | // Second priority: table name (fallback option) 106 | if tableName != "" { 107 | // Use singular form 108 | entity := tableName 109 | if strings.HasSuffix(entity, "s") && len(entity) > 1 { 110 | entity = entity[:len(entity)-1] 111 | } 112 | return humanizeText(entity) 113 | } 114 | 115 | // Default fallback 116 | return "record" 117 | } 118 | 119 | // humanizeText converts snake_case to human-readable text 120 | func humanizeText(text string) string { 121 | if text == "" { 122 | return "" 123 | } 124 | return cases.Title(language.English).String(strings.ReplaceAll(text, "_", " ")) 125 | } 126 | 127 | // extractColumnForUniqueViolation gets field name from unique constraint 128 | func extractColumnForUniqueViolation(constraintName string) string { 129 | if constraintName == "" { 130 | return "" 131 | } 132 | 133 | // Try standard naming convention first (unique_table_column) 134 | if strings.HasPrefix(constraintName, "unique_") { 135 | parts := strings.Split(constraintName, "_") 136 | if len(parts) >= 3 { 137 | return parts[len(parts)-1] 138 | } 139 | } 140 | 141 | // Try alternate convention (table_column_key) 142 | re := regexp.MustCompile(`_([^_]+)_(?:key|ukey)$`) 143 | matches := re.FindStringSubmatch(constraintName) 144 | if len(matches) > 1 { 145 | return matches[1] 146 | } 147 | 148 | return "" 149 | } 150 | 151 | // HandleError processes a database error into an appropriate application error 152 | func HandleError(err error) error { 153 | // If it's already a custom HTTP error, just return it 154 | var httpErr *errs.HTTPError 155 | if errors.As(err, &httpErr) { 156 | return err 157 | } 158 | 159 | // Handle pgx specific errors 160 | var pgerr *pgconn.PgError 161 | if errors.As(err, &pgerr) { 162 | sqlErr := ConvertPgError(pgerr) 163 | 164 | // Generate an appropriate error code and message 165 | errorCode := generateErrorCode(sqlErr.TableName, sqlErr.Code) 166 | userMessage := formatUserFriendlyMessage(sqlErr) 167 | 168 | switch sqlErr.Code { 169 | case ForeignKeyViolation: 170 | return errs.NewBadRequestError(userMessage, false, &errorCode, nil, nil) 171 | 172 | case UniqueViolation: 173 | columnName := extractColumnForUniqueViolation(sqlErr.ConstraintName) 174 | if columnName != "" { 175 | userMessage = strings.ReplaceAll(userMessage, "identifier", humanizeText(columnName)) 176 | } 177 | return errs.NewBadRequestError(userMessage, true, &errorCode, nil, nil) 178 | 179 | case NotNullViolation: 180 | fieldErrors := []errs.FieldError{ 181 | { 182 | Field: strings.ToLower(sqlErr.ColumnName), 183 | Error: "is required", 184 | }, 185 | } 186 | return errs.NewBadRequestError(userMessage, true, &errorCode, fieldErrors, nil) 187 | 188 | case CheckViolation: 189 | return errs.NewBadRequestError(userMessage, true, &errorCode, nil, nil) 190 | 191 | default: 192 | return errs.NewInternalServerError() 193 | } 194 | } 195 | 196 | // Handle common pgx errors 197 | switch { 198 | case errors.Is(err, pgx.ErrNoRows), errors.Is(err, sql.ErrNoRows): 199 | errMsg := err.Error() 200 | tablePrefix := "table:" 201 | if strings.Contains(errMsg, tablePrefix) { 202 | table := strings.Split(strings.Split(errMsg, tablePrefix)[1], ":")[0] 203 | entityName := getEntityName(table, "") 204 | return errs.NewNotFoundError(fmt.Sprintf("%s not found", 205 | entityName), true, nil) 206 | } 207 | return errs.NewNotFoundError("Resource not found", false, nil) 208 | } 209 | 210 | return errs.NewInternalServerError() 211 | } 212 | -------------------------------------------------------------------------------- /apps/backend/templates/emails/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 |
12 | Welcome to Boilerplate 13 |
14 |  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ 15 |
16 |
17 | 25 | 26 | 27 | 136 | 137 | 138 |
28 |

30 | Welcome to Boilerplate! 31 |

32 | 39 | 40 | 41 | 52 | 53 | 54 |
42 |

44 | Hi 45 | {{.UserFirstName}}, 46 |

47 |

49 | Thank you for joining! 50 |

51 |
55 | 63 | 64 | 65 | 81 | 82 | 83 |
66 | Get Started 80 |
84 |
86 | 93 | 94 | 95 | 107 | 108 | 109 |
96 |

98 | If you have any questions, feel free to 99 | contact our support team. 105 |

106 |
110 | 118 | 119 | 120 | 132 | 133 | 134 |
121 |

123 | © 124 | 2025 125 | Alfred. All rights reserved. 126 |

127 |

129 | 123 Project Street, Suite 100, San Francisco, CA 94103 130 |

131 |
135 |
139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /apps/backend/README.md: -------------------------------------------------------------------------------- 1 | # Go Boilerplate Backend 2 | 3 | A production-ready Go backend service built with Echo framework, featuring clean architecture, comprehensive middleware, and modern DevOps practices. 4 | 5 | ## Architecture Overview 6 | 7 | This backend follows clean architecture principles with clear separation of concerns: 8 | 9 | ``` 10 | backend/ 11 | ├── cmd/go-boilerplate/ # Application entry point 12 | ├── internal/ # Private application code 13 | │ ├── config/ # Configuration management 14 | │ ├── database/ # Database connections and migrations 15 | │ ├── handler/ # HTTP request handlers 16 | │ ├── service/ # Business logic layer 17 | │ ├── repository/ # Data access layer 18 | │ ├── model/ # Domain models 19 | │ ├── middleware/ # HTTP middleware 20 | │ ├── lib/ # Shared libraries 21 | │ └── validation/ # Request validation 22 | ├── static/ # Static files (OpenAPI spec) 23 | ├── templates/ # Email templates 24 | └── Taskfile.yml # Task automation 25 | ``` 26 | 27 | ## Features 28 | 29 | ### Core Framework 30 | - **Echo v4**: High-performance, minimalist web framework 31 | - **Clean Architecture**: Handlers → Services → Repositories → Models 32 | - **Dependency Injection**: Constructor-based DI for testability 33 | 34 | ### Database 35 | - **PostgreSQL**: Primary database with pgx/v5 driver 36 | - **Migration System**: Tern for schema versioning 37 | - **Connection Pooling**: Optimized for production workloads 38 | - **Transaction Support**: ACID compliance for critical operations 39 | 40 | ### Authentication & Security 41 | - **Clerk Integration**: Modern authentication service 42 | - **JWT Validation**: Secure token verification 43 | - **Role-Based Access**: Configurable permission system 44 | - **Rate Limiting**: 20 requests/second per IP 45 | - **Security Headers**: XSS, CSRF, and clickjacking protection 46 | 47 | ### Observability 48 | - **New Relic APM**: Application performance monitoring 49 | - **Structured Logging**: JSON logs with Zerolog 50 | - **Request Tracing**: Distributed tracing support 51 | - **Health Checks**: Readiness and liveness endpoints 52 | - **Custom Metrics**: Business-specific monitoring 53 | 54 | ### Background Jobs 55 | - **Asynq**: Redis-based distributed task queue 56 | - **Priority Queues**: Critical, default, and low priority 57 | - **Job Scheduling**: Cron-like task scheduling 58 | - **Retry Logic**: Exponential backoff for failed jobs 59 | - **Job Monitoring**: Real-time job status tracking 60 | 61 | ### Email Service 62 | - **Resend Integration**: Reliable email delivery 63 | - **HTML Templates**: Beautiful transactional emails 64 | - **Preview Mode**: Test emails in development 65 | - **Batch Sending**: Efficient bulk operations 66 | 67 | ### API Documentation 68 | - **OpenAPI 3.0**: Complete API specification 69 | - **Swagger UI**: Interactive API explorer 70 | - **Auto-generation**: Code-first approach 71 | 72 | ## Getting Started 73 | 74 | ### Prerequisites 75 | - Go 1.24+ 76 | - PostgreSQL 16+ 77 | - Redis 8+ 78 | - Task (taskfile.dev) 79 | 80 | ### Installation 81 | 82 | 1. Install dependencies: 83 | ```bash 84 | go mod download 85 | ``` 86 | 87 | 2. Set up environment: 88 | ```bash 89 | cp .env.example .env 90 | # Configure your environment variables 91 | ``` 92 | 93 | 3. Run migrations: 94 | ```bash 95 | task migrations:up 96 | ``` 97 | 98 | 4. Start the server: 99 | ```bash 100 | task run 101 | ``` 102 | 103 | ## Configuration 104 | 105 | Configuration is managed through environment variables with the `BOILERPLATE_` prefix: 106 | 107 | ## Development 108 | 109 | ### Available Tasks 110 | 111 | ```bash 112 | task help # Show all available tasks 113 | task run # Run the application 114 | task test # Run tests 115 | task migrations:new name=X # Create new migration 116 | task migrations:up # Apply migrations 117 | task migrations:down # Rollback last migration 118 | task tidy # Format and tidy dependencies 119 | ``` 120 | 121 | ### Project Structure 122 | 123 | #### Handlers (`internal/handler/`) 124 | HTTP request handlers that: 125 | - Parse and validate requests 126 | - Call appropriate services 127 | - Format responses 128 | - Handle HTTP-specific concerns 129 | 130 | #### Services (`internal/service/`) 131 | Business logic layer that: 132 | - Implements use cases 133 | - Orchestrates operations 134 | - Enforces business rules 135 | - Handles transactions 136 | 137 | #### Repositories (`internal/repository/`) 138 | Data access layer that: 139 | - Encapsulates database queries 140 | - Provides data mapping 141 | - Handles database-specific logic 142 | - Supports multiple data sources 143 | 144 | #### Models (`internal/model/`) 145 | Domain entities that: 146 | - Define core business objects 147 | - Include validation rules 148 | - Remain database-agnostic 149 | 150 | #### Middleware (`internal/middleware/`) 151 | Cross-cutting concerns: 152 | - Authentication/Authorization 153 | - Request logging 154 | - Error handling 155 | - Rate limiting 156 | - CORS 157 | - Security headers 158 | 159 | ### Testing 160 | 161 | #### Unit Tests 162 | ```bash 163 | go test ./... 164 | ``` 165 | 166 | ## Logging 167 | 168 | Structured logging with Zerolog: 169 | 170 | ```go 171 | log.Info(). 172 | Str("user_id", userID). 173 | Str("action", "login"). 174 | Msg("User logged in successfully") 175 | ``` 176 | 177 | Log levels: 178 | - `debug`: Detailed debugging information 179 | - `info`: General informational messages 180 | - `warn`: Warning messages 181 | - `error`: Error messages 182 | - `fatal`: Fatal errors that cause shutdown 183 | 184 | ### Production Checklist 185 | 186 | - [ ] Set production environment variables 187 | - [ ] Enable SSL/TLS 188 | - [ ] Configure production database 189 | - [ ] Set up monitoring alerts 190 | - [ ] Configure log aggregation 191 | - [ ] Enable rate limiting 192 | - [ ] Set up backup strategy 193 | - [ ] Configure auto-scaling 194 | - [ ] Implement graceful shutdown 195 | - [ ] Set up CI/CD pipeline 196 | 197 | ## Performance Optimization 198 | 199 | ### Database 200 | - Connection pooling configured 201 | - Prepared statements for frequent queries 202 | - Indexes on commonly queried fields 203 | - Query optimization with EXPLAIN ANALYZE 204 | 205 | ### Caching 206 | - Redis for session storage 207 | - In-memory caching for hot data 208 | - HTTP caching headers 209 | 210 | ### Concurrency 211 | - Goroutine pools for parallel processing 212 | - Context-based cancellation 213 | - Proper mutex usage 214 | 215 | ## Security Best Practices 216 | 217 | 1. **Input Validation**: All inputs validated and sanitized 218 | 2. **SQL Injection**: Parameterized queries only 219 | 3. **XSS Protection**: Output encoding and CSP headers 220 | 4. **CSRF Protection**: Token-based protection 221 | 5. **Rate Limiting**: Per-IP and per-user limits 222 | 6. **Secrets Management**: Environment variables, never in code 223 | 7. **HTTPS Only**: Enforce TLS in production 224 | 8. **Dependency Scanning**: Regular vulnerability checks 225 | 226 | ## Contributing 227 | 228 | 1. Follow Go best practices and idioms 229 | 2. Write tests for new features 230 | 3. Update documentation 231 | 4. Run linters before committing 232 | 5. Keep commits atomic and well-described 233 | 234 | ## License 235 | 236 | See the parent project's LICENSE file. -------------------------------------------------------------------------------- /apps/backend/internal/handler/base.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/newrelic/go-agent/v3/integrations/nrpkgerrors" 8 | "github.com/newrelic/go-agent/v3/newrelic" 9 | "github.com/sriniously/go-boilerplate/internal/middleware" 10 | "github.com/sriniously/go-boilerplate/internal/server" 11 | "github.com/sriniously/go-boilerplate/internal/validation" 12 | ) 13 | 14 | // Handler provides base functionality for all handlers 15 | type Handler struct { 16 | server *server.Server 17 | } 18 | 19 | // NewHandler creates a new base handler 20 | func NewHandler(s *server.Server) Handler { 21 | return Handler{server: s} 22 | } 23 | 24 | // HandlerFunc represents a typed handler function that processes a request and returns a response 25 | type HandlerFunc[Req validation.Validatable, Res any] func(c echo.Context, req Req) (Res, error) 26 | 27 | // HandlerFuncNoContent represents a typed handler function that processes a request without returning content 28 | type HandlerFuncNoContent[Req validation.Validatable] func(c echo.Context, req Req) error 29 | 30 | // ResponseHandler defines the interface for handling different response types 31 | type ResponseHandler interface { 32 | Handle(c echo.Context, result interface{}) error 33 | GetOperation() string 34 | AddAttributes(txn *newrelic.Transaction, result interface{}) 35 | } 36 | 37 | // JSONResponseHandler handles JSON responses 38 | type JSONResponseHandler struct { 39 | status int 40 | } 41 | 42 | func (h JSONResponseHandler) Handle(c echo.Context, result interface{}) error { 43 | return c.JSON(h.status, result) 44 | } 45 | 46 | func (h JSONResponseHandler) GetOperation() string { 47 | return "handler" 48 | } 49 | 50 | func (h JSONResponseHandler) AddAttributes(txn *newrelic.Transaction, result interface{}) { 51 | // http.status_code is already set by tracing middleware 52 | } 53 | 54 | // NoContentResponseHandler handles no-content responses 55 | type NoContentResponseHandler struct { 56 | status int 57 | } 58 | 59 | func (h NoContentResponseHandler) Handle(c echo.Context, result interface{}) error { 60 | return c.NoContent(h.status) 61 | } 62 | 63 | func (h NoContentResponseHandler) GetOperation() string { 64 | return "handler_no_content" 65 | } 66 | 67 | func (h NoContentResponseHandler) AddAttributes(txn *newrelic.Transaction, result interface{}) { 68 | // http.status_code is already set by tracing middleware 69 | } 70 | 71 | // FileResponseHandler handles file responses 72 | type FileResponseHandler struct { 73 | status int 74 | filename string 75 | contentType string 76 | } 77 | 78 | func (h FileResponseHandler) Handle(c echo.Context, result interface{}) error { 79 | data := result.([]byte) 80 | c.Response().Header().Set("Content-Disposition", "attachment; filename="+h.filename) 81 | return c.Blob(h.status, h.contentType, data) 82 | } 83 | 84 | func (h FileResponseHandler) GetOperation() string { 85 | return "handler_file" 86 | } 87 | 88 | func (h FileResponseHandler) AddAttributes(txn *newrelic.Transaction, result interface{}) { 89 | if txn != nil { 90 | // http.status_code is already set by tracing middleware 91 | txn.AddAttribute("file.name", h.filename) 92 | txn.AddAttribute("file.content_type", h.contentType) 93 | if data, ok := result.([]byte); ok { 94 | txn.AddAttribute("file.size_bytes", len(data)) 95 | } 96 | } 97 | } 98 | 99 | // handleRequest is the unified handler function that eliminates code duplication 100 | func handleRequest[Req validation.Validatable]( 101 | c echo.Context, 102 | req Req, 103 | handler func(c echo.Context, req Req) (interface{}, error), 104 | responseHandler ResponseHandler, 105 | ) error { 106 | start := time.Now() 107 | method := c.Request().Method 108 | path := c.Path() 109 | route := path 110 | 111 | // Get New Relic transaction from context 112 | txn := newrelic.FromContext(c.Request().Context()) 113 | if txn != nil { 114 | txn.AddAttribute("handler.name", route) 115 | // http.method and http.route are already set by nrecho middleware 116 | responseHandler.AddAttributes(txn, nil) 117 | } 118 | 119 | // Get context-enhanced logger 120 | loggerBuilder := middleware.GetLogger(c).With(). 121 | Str("operation", responseHandler.GetOperation()). 122 | Str("method", method). 123 | Str("path", path). 124 | Str("route", route) 125 | 126 | // Add file-specific fields to logger if it's a file handler 127 | if fileHandler, ok := responseHandler.(FileResponseHandler); ok { 128 | loggerBuilder = loggerBuilder. 129 | Str("filename", fileHandler.filename). 130 | Str("content_type", fileHandler.contentType) 131 | } 132 | 133 | logger := loggerBuilder.Logger() 134 | 135 | // user.id is already set by tracing middleware 136 | 137 | logger.Info().Msg("handling request") 138 | 139 | // Validation with observability 140 | validationStart := time.Now() 141 | if err := validation.BindAndValidate(c, req); err != nil { 142 | validationDuration := time.Since(validationStart) 143 | 144 | logger.Error(). 145 | Err(err). 146 | Dur("validation_duration", validationDuration). 147 | Msg("request validation failed") 148 | 149 | if txn != nil { 150 | txn.NoticeError(nrpkgerrors.Wrap(err)) 151 | txn.AddAttribute("validation.status", "failed") 152 | txn.AddAttribute("validation.duration_ms", validationDuration.Milliseconds()) 153 | } 154 | return err 155 | } 156 | 157 | validationDuration := time.Since(validationStart) 158 | if txn != nil { 159 | txn.AddAttribute("validation.status", "success") 160 | txn.AddAttribute("validation.duration_ms", validationDuration.Milliseconds()) 161 | } 162 | 163 | logger.Debug(). 164 | Dur("validation_duration", validationDuration). 165 | Msg("request validation successful") 166 | 167 | // Execute handler with observability 168 | handlerStart := time.Now() 169 | result, err := handler(c, req) 170 | handlerDuration := time.Since(handlerStart) 171 | 172 | if err != nil { 173 | totalDuration := time.Since(start) 174 | 175 | logger.Error(). 176 | Err(err). 177 | Dur("handler_duration", handlerDuration). 178 | Dur("total_duration", totalDuration). 179 | Msg("handler execution failed") 180 | 181 | if txn != nil { 182 | txn.NoticeError(nrpkgerrors.Wrap(err)) 183 | txn.AddAttribute("handler.status", "error") 184 | txn.AddAttribute("handler.duration_ms", handlerDuration.Milliseconds()) 185 | txn.AddAttribute("total.duration_ms", totalDuration.Milliseconds()) 186 | } 187 | return err 188 | } 189 | 190 | totalDuration := time.Since(start) 191 | 192 | // Record success metrics and tracing 193 | if txn != nil { 194 | txn.AddAttribute("handler.status", "success") 195 | txn.AddAttribute("handler.duration_ms", handlerDuration.Milliseconds()) 196 | txn.AddAttribute("total.duration_ms", totalDuration.Milliseconds()) 197 | responseHandler.AddAttributes(txn, result) 198 | } 199 | 200 | logger.Info(). 201 | Dur("handler_duration", handlerDuration). 202 | Dur("validation_duration", validationDuration). 203 | Dur("total_duration", totalDuration). 204 | Msg("request completed successfully") 205 | 206 | return responseHandler.Handle(c, result) 207 | } 208 | 209 | // Handle wraps a handler with validation, error handling, logging, metrics, and tracing 210 | func Handle[Req validation.Validatable, Res any]( 211 | h Handler, 212 | handler HandlerFunc[Req, Res], 213 | status int, 214 | req Req, 215 | ) echo.HandlerFunc { 216 | return func(c echo.Context) error { 217 | return handleRequest(c, req, func(c echo.Context, req Req) (interface{}, error) { 218 | return handler(c, req) 219 | }, JSONResponseHandler{status: status}) 220 | } 221 | } 222 | 223 | func HandleFile[Req validation.Validatable]( 224 | h Handler, 225 | handler HandlerFunc[Req, []byte], 226 | status int, 227 | req Req, 228 | filename string, 229 | contentType string, 230 | ) echo.HandlerFunc { 231 | return func(c echo.Context) error { 232 | return handleRequest(c, req, func(c echo.Context, req Req) (interface{}, error) { 233 | return handler(c, req) 234 | }, FileResponseHandler{ 235 | status: status, 236 | filename: filename, 237 | contentType: contentType, 238 | }) 239 | } 240 | } 241 | 242 | // HandleNoContent wraps a handler with validation, error handling, logging, metrics, and tracing for endpoints that don't return content 243 | func HandleNoContent[Req validation.Validatable]( 244 | h Handler, 245 | handler HandlerFuncNoContent[Req], 246 | status int, 247 | req Req, 248 | ) echo.HandlerFunc { 249 | return func(c echo.Context) error { 250 | return handleRequest(c, req, func(c echo.Context, req Req) (interface{}, error) { 251 | err := handler(c, req) 252 | return nil, err 253 | }, NoContentResponseHandler{status: status}) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /apps/backend/go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= 4 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 5 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 6 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 7 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 8 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 9 | github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= 10 | github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 11 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 12 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 13 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 14 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 15 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 16 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 17 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 18 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 19 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 20 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 21 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 22 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 23 | github.com/clerk/clerk-sdk-go/v2 v2.3.1 h1:eQ6I7LouzdEvPUwLAYOfSk1Ktc4Ee2UKGMVOKBKtMXo= 24 | github.com/clerk/clerk-sdk-go/v2 v2.3.1/go.mod h1:tA+JDYh9xEmysBRs+BfJH9HeR0J0HOh8txfsiB115zY= 25 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 26 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 27 | github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= 28 | github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= 29 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 30 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 31 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 32 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 33 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 34 | github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= 35 | github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 36 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 37 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 38 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 40 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 42 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 43 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 44 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 45 | github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= 46 | github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 47 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 48 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 49 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 50 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 51 | github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= 52 | github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 53 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 54 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 55 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 56 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 57 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 58 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 59 | github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= 60 | github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 61 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 62 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 63 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 64 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 65 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 66 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 67 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 68 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 69 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 70 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 71 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 72 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 73 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 74 | github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= 75 | github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 76 | github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= 77 | github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 78 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 79 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 80 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 81 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 82 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 83 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 84 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 85 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 86 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 87 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 88 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 89 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 90 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 91 | github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= 92 | github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= 93 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 94 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 95 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 96 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 97 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 98 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 99 | github.com/jackc/pgx-zerolog v0.0.0-20230315001418-f978528409eb h1:pSv+zRVeAYjbXRFjyytFIMRBSKWVowCi7KbXSMR/+ug= 100 | github.com/jackc/pgx-zerolog v0.0.0-20230315001418-f978528409eb/go.mod h1:CRUuPsmIajLt3dZIlJ5+O8IDSib6y8yrst8DkCthTa4= 101 | github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= 102 | github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 103 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 104 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 105 | github.com/jackc/tern/v2 v2.3.3 h1:d6QNRyjk9HttJtSF5pUB8UaXrHwCgEai3/yxYjgci/k= 106 | github.com/jackc/tern/v2 v2.3.3/go.mod h1:0/9jqEreuC+ywjB7C5ta6Xkhl+HSaxFmCAggEDcp6v0= 107 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 108 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 109 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 110 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 111 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 112 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 113 | github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= 114 | github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 115 | github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= 116 | github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= 117 | github.com/knadh/koanf/v2 v2.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A= 118 | github.com/knadh/koanf/v2 v2.2.2/go.mod h1:abWQc0cBXLSF/PSOMCB/SK+T13NXDsPvOksbpi5e/9Q= 119 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 120 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 121 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 122 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 123 | github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= 124 | github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 125 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 126 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 127 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 128 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 129 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 130 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 131 | github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= 132 | github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 133 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 134 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 135 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 136 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 137 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 138 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 139 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 140 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 141 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 142 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 143 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 144 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 145 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 146 | github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= 147 | github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= 148 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 149 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 150 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= 151 | github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= 152 | github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= 153 | github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 154 | github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= 155 | github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= 156 | github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= 157 | github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= 158 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 159 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 160 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 161 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 162 | github.com/newrelic/go-agent/v3 v3.0.0/go.mod h1:H28zDNUC0U/b7kLoY4EFOhuth10Xu/9dchozUiOseQQ= 163 | github.com/newrelic/go-agent/v3 v3.40.1 h1:8nb4R252Fpuc3oySvlHpDwqySqaPWL5nf7ZVEhqtUeA= 164 | github.com/newrelic/go-agent/v3 v3.40.1/go.mod h1:X0TLXDo+ttefTIue1V96Y5seb8H6wqf6uUq4UpPsYj8= 165 | github.com/newrelic/go-agent/v3/integrations/logcontext-v2/nrwriter v1.0.1 h1:toG3gbzYlm9LxL6KFbfykVHk8RXivM7OD2hmqJG7bpY= 166 | github.com/newrelic/go-agent/v3/integrations/logcontext-v2/nrwriter v1.0.1/go.mod h1:7N1iAritq/sxgJ8Cbak13iQeQdVZS+wFyoRSMGXGksg= 167 | github.com/newrelic/go-agent/v3/integrations/logcontext-v2/zerologWriter v1.0.4 h1:AsrvW54o1KRptIoxA+66igDzqKHvaeBpFFlOaQ42G2U= 168 | github.com/newrelic/go-agent/v3/integrations/logcontext-v2/zerologWriter v1.0.4/go.mod h1:CTjlZ5aTOzuZs2kcNntm1/FhLLbAimhZQCRYzadBuhA= 169 | github.com/newrelic/go-agent/v3/integrations/nrecho-v4 v1.1.4 h1:OuJzdtws9pq9LJM4YUXwHeiL02ix3euaUbgNo2h36zw= 170 | github.com/newrelic/go-agent/v3/integrations/nrecho-v4 v1.1.4/go.mod h1:BD0BhdQzCdXlNITYp4TYtSaWmCyTKEiQs3R/Bfasw44= 171 | github.com/newrelic/go-agent/v3/integrations/nrpgx5 v1.3.1 h1:dF3ZxV4Y8fSKUVaG6q425y2b+TyW3dNZtRoH5APvofQ= 172 | github.com/newrelic/go-agent/v3/integrations/nrpgx5 v1.3.1/go.mod h1:iAU42DcrqzYiFqfqciwgnl+y11MAiSGvjwVf3s1MuGc= 173 | github.com/newrelic/go-agent/v3/integrations/nrpkgerrors v1.1.0 h1:TmAihIxCqgz3v9OR19J7mK2ggEQjGdhz2FEOvszU1SI= 174 | github.com/newrelic/go-agent/v3/integrations/nrpkgerrors v1.1.0/go.mod h1:yXUqcAzlKNVIsSyoaI2ILdpvBeMCz3Ko/ASl4Vbg2i4= 175 | github.com/newrelic/go-agent/v3/integrations/nrredis-v9 v1.1.1 h1:IW3rmAGBWc8V7uNBNyb2BWVTBSyNnC730CeiRmsBNVE= 176 | github.com/newrelic/go-agent/v3/integrations/nrredis-v9 v1.1.1/go.mod h1:TQC2+0VXNTPWoW3APy/nKS8sNo5MPMd4o+FDoOSPyB8= 177 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 178 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 179 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 180 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 181 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 182 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 183 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 184 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 185 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 186 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 187 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 188 | github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= 189 | github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= 190 | github.com/resend/resend-go/v2 v2.21.0 h1:8aZwFd5Mry5fcBXSuZYHyKhsbnQooj5+Q/ebyMtd3Rc= 191 | github.com/resend/resend-go/v2 v2.21.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 192 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 193 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 194 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 195 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 196 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 197 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 198 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 199 | github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= 200 | github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= 201 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 202 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 203 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 204 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 205 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 206 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 207 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 208 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 209 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 210 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 211 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 212 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 213 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 214 | github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= 215 | github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= 216 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 217 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 218 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 219 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 220 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 221 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 222 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 223 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 224 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 225 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 226 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 227 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 228 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 229 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 230 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 231 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 232 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 233 | go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 234 | go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 235 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= 236 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= 237 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= 238 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= 239 | go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 240 | go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 241 | go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 242 | go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 243 | go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= 244 | go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= 245 | go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 246 | go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 247 | go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= 248 | go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= 249 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 250 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 251 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 252 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 253 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 254 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 255 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 256 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 257 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 258 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 259 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 260 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 261 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 262 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 263 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 264 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 265 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 266 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 267 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 268 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 269 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 270 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 271 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 272 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 273 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 274 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 275 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 276 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 277 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 278 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 279 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 280 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 281 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 282 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 283 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 284 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 285 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 286 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 287 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 288 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 289 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 290 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 291 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 292 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 293 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 294 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 295 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 296 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 297 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 298 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 299 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 300 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 301 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 302 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 303 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 304 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 305 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 306 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 307 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 308 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 309 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 310 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 311 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 312 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 313 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 314 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 315 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 316 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 317 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 318 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 319 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 320 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 321 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 322 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 323 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 324 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 325 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 326 | google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= 327 | google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= 328 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= 329 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 330 | google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= 331 | google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 332 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 333 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 334 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 335 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 336 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 337 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 338 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 339 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 340 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 341 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 342 | --------------------------------------------------------------------------------