diff --git a/.claude/agents/connect-rpc-go-review.md b/.claude/agents/connect-rpc-go-review.md new file mode 100644 index 0000000..4714242 --- /dev/null +++ b/.claude/agents/connect-rpc-go-review.md @@ -0,0 +1,174 @@ +# Connect RPC Go — Review Agent + +Review a Connect RPC Go implementation against the platform backend architecture and the connect-rpc-go agent template. + +## Inputs + +Before starting, read these reference files: +1. `_architecture/platform-backend.md` — architecture invariants and layer rules +2. `.claude/agents/connect-rpc-go.md` — Go-specific patterns, code conventions, pinned versions + +Then read the full implementation under `projects///`. + +## Review Checklist + +Work through each section below. For every violation found, report: +- **File and line** where the issue is +- **Rule** being violated (reference the section) +- **Severity**: `error` (breaks architecture), `warning` (inconsistency), `info` (style nit) + +### 1. Directory Structure + +Verify the implementation matches the expected layout: + +- [ ] `cmd/server/` contains `main.go`, `setup_connections.go`, `setup_domains.go`, `setup_gateway.go` +- [ ] `internal/api//` contains `handler.go`, `mapper.go`, and `route_.go` files +- [ ] `internal/domain//` contains `errors.go`, `service.go`, and `op_.go` files +- [ ] `internal/outbox/` contains `river.go` and `/event_.go` files +- [ ] `pkg/` contains `config/`, `connectapp/`, `connectutil/`, `cache/`, `outbox/`, `migrate/` +- [ ] `sql/migrations/` contains `migrations.go` (embed helper) and `.sql` files +- [ ] `sql/queries//` contains sqlc query files +- [ ] No extra files or directories that don't fit the structure + +### 2. Layer Dependencies + +Verify dependency direction — each layer only imports from layers below it: + +- [ ] `pkg/` imports nothing from `internal/`, `cmd/`, or `gen/` +- [ ] `internal/domain/` imports only from `gen/sqlc/` and `pkg/` — never from `internal/api/`, `internal/outbox/`, or `gen/proto/` +- [ ] `internal/outbox/` imports only from `gen/sqlc/`, `pkg/outbox`, and the queue library — never from `internal/api/` or `internal/domain/` +- [ ] `internal/api/` imports from `internal/domain/`, `gen/proto/`, `gen/sqlc/`, `pkg/` — never from `internal/outbox/` or `cmd/` +- [ ] `cmd/` wires all layers but contains no business logic + +### 3. Interface-First Pattern + +- [ ] Every `pkg/` package exposes an interface as its public API +- [ ] Every `internal/domain/` package exposes a `Service` interface +- [ ] Structs are unexported (lowercase names) +- [ ] Constructors return the interface type, not the struct +- [ ] Each layer defines an exported `Dependencies` struct for injection +- [ ] Constructor takes `Dependencies` as the single parameter +- [ ] Private struct inlines dependency fields directly (not embedding `Dependencies`) + +### 4. File-Per-Concern Convention + +- [ ] API layer: one `route_.go` per RPC method, each containing a single method on the handler +- [ ] Domain layer: one `op_.go` per business operation, each containing a single method on the service +- [ ] Outbox layer: one `event_.go` per concern (index, audit, etc.), not per event type +- [ ] `handler.go` contains only the struct, constructor, and error mappings — no RPC methods +- [ ] `service.go` contains only the interface, dependencies, and constructor — no operations +- [ ] `mapper.go` contains only mapping functions — no business logic + +### 5. Error Handling + +- [ ] Domain layer defines sentinel errors in `errors.go` (e.g., `ErrNotFound`, `ErrAlreadyExists`) +- [ ] API layer maps sentinel errors to Connect codes via `connectutil.NewErrorFrom(err, errorMappings)` +- [ ] Error mappings defined as a package-level `var` in `handler.go` +- [ ] `op_get.go` distinguishes `pgx.ErrNoRows` from other errors (not swallowing all as `ErrNotFound`) +- [ ] `op_delete.go` uses `:execrows` return to detect not-found (checking `rows == 0`) +- [ ] `cmd/` handles all fallible calls with `if err != nil { log.Fatal()... }` — no `_, _ :=` ignoring +- [ ] UUID parsing errors in route handlers return `connect.CodeInvalidArgument` + +### 6. Transactional Outbox Pattern + +- [ ] Write operations (create, update, delete) begin a transaction +- [ ] Store query runs within the transaction via `queries.WithTx(tx)` +- [ ] Outbox event emitted within the same transaction via `outbox.Emit(ctx, tx, ...)` +- [ ] Transaction committed after both store and outbox operations succeed +- [ ] `defer tx.Rollback(ctx)` immediately after `pool.Begin(ctx)` +- [ ] Cache updated only after successful commit (post-commit, best-effort) +- [ ] Event struct uses `Type`, `ID`, and `Data` fields + +### 7. Read-Through Cache Pattern + +- [ ] `op_get.go` checks cache first, returns on hit +- [ ] Falls back to store query on cache miss +- [ ] Populates cache after successful store read +- [ ] `op_create.go` and `op_update.go` set cache after commit +- [ ] `op_delete.go` deletes from cache after commit + +### 8. Import Correctness + +- [ ] All internal imports use full module path (`/gen/...`, `/pkg/...`, `/internal/...`) +- [ ] No relative import paths +- [ ] No unused imports in any file +- [ ] `internal/api//` aliases domain import as `contentdomain` to avoid package name collision +- [ ] `gen/sqlc//` aliased as `sqlc` where needed +- [ ] `gen/proto//v1/` aliased as `v1` +- [ ] `_ "github.com/jackc/pgx/v5/stdlib"` imported in `setup_connections.go` for `sql.Open("pgx", ...)` + +### 9. Proto / Store Code Generation + +- [ ] `buf.gen.yaml` uses managed mode with `go_package_prefix` set to `/gen/proto` +- [ ] `buf.gen.yaml` has `disable` rule for `buf.build/bufbuild/protovalidate` go_package rewriting +- [ ] `sqlc.yaml` uses `sql_package: pgx/v5` +- [ ] `sqlc.yaml` has `uuid` override pointing to `github.com/gofrs/uuid/v5` +- [ ] `sqlc.yaml` has `timestamptz` override pointing to `time.Time` +- [ ] `sqlc.yaml` has one `sql` block per domain with isolated output under `gen/sqlc//` +- [ ] SQL queries use `sqlc.narg()` for nullable update fields +- [ ] Mapper uses `pgtype.Text` / `pgtype.Int4` for nullable fields (not pointer types) + +### 10. Proto Contract Compliance + +Read the proto service definition from `projects//_shared/protobuf/` and verify: + +- [ ] Every RPC in the proto service has a corresponding `route_.go` file +- [ ] Every domain operation has a corresponding `op_.go` file +- [ ] Route handler return types match proto response messages (wrapped, e.g., `CreateContentResponse{Content: ...}`) +- [ ] SQL migration schema covers all fields in the proto model message +- [ ] sqlc queries cover all CRUD operations needed by domain operations + +### 11. Server and Interceptors + +- [ ] Single h2c server on `:8080` via `pkg/connectapp` +- [ ] `/health` endpoint registered by `connectapp.New()` — returns `{"status":"up"}`, no interceptors +- [ ] Connect RPC handlers registered with `connect.WithInterceptors(interceptors...)` +- [ ] Interceptors include: recovery (panic → internal error), logging, buf validate +- [ ] `validate.NewInterceptor()` called with single return value (not two) + +### 12. Infrastructure + +- [ ] Dockerfile uses multi-stage build: generate → build → runtime +- [ ] Dockerfile tool versions match agent pinned versions (Go, buf, sqlc, protoc-gen-go, protoc-gen-connect-go) +- [ ] Runtime stage is minimal (alpine + curl) +- [ ] `docker-compose.yml` includes: postgres, opensearch, opensearch-dashboards, codegen (profile), api +- [ ] Codegen service uses `target: generate` and copies `gen/` to host via volume mount +- [ ] API service depends on postgres with `condition: service_healthy` +- [ ] Health check configured on the API service +- [ ] Makefile includes: codegen, tidy, vet, build, test, start, stop, clean + +### 13. Migrations + +- [ ] `sql/migrations/migrations.go` embeds `.sql` files via `go:embed` +- [ ] `setup_connections.go` imports the migrations package (not inline embed) +- [ ] `migrate.Run(stdDB, migrations.FS, ".")` called with correct args +- [ ] River migrations run before domain migrations +- [ ] Both run before server listeners start + +## Output Format + +Produce a structured review report: + +``` +## Review: / + +### Summary +- Total issues: N (E errors, W warnings, I info) +- Architecture compliance: pass/fail + +### Issues + +#### [error] : +<description of the violation and which rule it breaks> + +#### [warning] <file>:<line> — <title> +<description> + +#### [info] <file>:<line> — <title> +<description> + +### Passed Checks +<list of sections with no issues> +``` + +If no issues are found, report a clean bill of health. diff --git a/.claude/agents/connect-rpc-go.md b/.claude/agents/connect-rpc-go.md new file mode 100644 index 0000000..f72c51f --- /dev/null +++ b/.claude/agents/connect-rpc-go.md @@ -0,0 +1,1231 @@ +# Connect RPC Go — Architecture Agent + +Generate a layered Go + Connect RPC implementation following the platform backend architecture. + +Read the API spec and k6 script from `projects/<project>/_shared/<api-style>/` to understand the contract, then generate ALL files listed below. + +## Directory Structure + +``` +<implementation>/ +├── cmd/server/ +│ ├── main.go +│ ├── setup_connections.go +│ ├── setup_domains.go +│ └── setup_gateway.go +├── internal/ +│ ├── api/<domain>/ +│ │ ├── handler.go +│ │ ├── mapper.go +│ │ ├── route_create_<domain>.go +│ │ ├── route_get_<domain>.go +│ │ ├── route_list_<domain>.go +│ │ ├── route_update_<domain>.go +│ │ └── route_delete_<domain>.go +│ ├── domain/<domain>/ +│ │ ├── errors.go +│ │ ├── service.go +│ │ ├── op_create.go +│ │ ├── op_get.go +│ │ ├── op_list.go +│ │ ├── op_update.go +│ │ └── op_delete.go +│ └── outbox/ +│ ├── river.go # River implementation of pkg/outbox.Outbox +│ └── <domain>/ +│ ├── event_index.go # River worker + job args (indexing concern) +│ └── event_audit.go # River worker + job args (auditing concern) +├── pkg/ +│ ├── config/config.go # Env-based config loaded via godotenv +│ ├── connectapp/app.go +│ ├── connectutil/errors.go +│ ├── connectutil/interceptors.go +│ ├── cache/cache.go +│ ├── outbox/outbox.go +│ └── migrate/migrate.go +├── gen/ +│ ├── proto/ # buf-generated (proto + connect) +│ └── sqlc/<domain>/ # sqlc-generated (per-domain) +├── sql/ +│ ├── migrations/ +│ │ ├── migrations.go # go:embed for .sql files +│ │ └── 001_create_<domain>.sql +│ └── queries/<domain>/<domain>.sql +├── sqlc.yaml +├── buf.gen.yaml +├── go.mod +├── Makefile +├── Dockerfile +└── docker-compose.yml +``` + +## Conventions + +- **Interface-first**: every package exposes an interface as its public API. Structs are unexported (lowercase). Constructors return the interface type (e.g., `func New(deps Dependencies) Service`). +- **Dependencies struct**: each layer defines an exported `Dependencies` struct listing its injected dependencies. Constructors take `Dependencies` as the single parameter. The private struct inlines the dependencies directly (not `deps Dependencies`) — the `Dependencies` struct is only for the public constructor signature. +- **File prefixes**: `route_<rpc>.go` in API, `op_<operation>.go` in domain, `event_<name>.go` in outbox. +- **Single server**: one h2c server on `:8080` serves `/health` (no interceptors) and Connect RPC paths (with per-handler interceptors via `connect.WithInterceptors`). +- **No unused imports**: every import in every file must be used. Only import a package in files that directly reference it — do not import packages just because sibling files in the same Go package use them. Run `go vet ./...` after generation to catch issues. + +## Layer Rules + +- `pkg/` depends on nothing — purely generic, extractable as a shared module +- `domain/` depends on `gen/sqlc/` + `pkg/` — business logic, orchestrates cache/store/outbox +- `outbox/` depends on `gen/sqlc/` + `pkg/outbox` + river — implements outbox, defines workers +- `api/` depends on `domain/`, `gen/proto/`, `gen/sqlc/`, `pkg/` +- `cmd/` wires all layers together + +## pkg/ — Generic Reusable Packages + +### pkg/config/config.go + +Env-based configuration loaded via godotenv. Consolidates all environment variables into a typed struct. Loaded once at startup in `main.go`. + +```go +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + DatabaseURL string + OpenSearchURL string + ServerAddr string +} + +func Load() (*Config, error) { + _ = godotenv.Load() // .env file is optional, env vars take precedence + return &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://benchmark:benchmark@localhost:5432/benchmark?sslmode=disable"), + OpenSearchURL: getEnv("OPENSEARCH_URL", "http://localhost:9200"), + ServerAddr: getEnv("SERVER_ADDR", ":8080"), + }, nil +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} +``` + +### pkg/connectapp/app.go + +Reusable Connect RPC application lifecycle. Single server with h2c, path-based routing, graceful shutdown. Health and API handlers served from different paths on the same port — enabling different interceptor chains per path via Connect's `WithInterceptors`. + +```go +package connectapp + +import ( + "context" + "net/http" + + "github.com/rs/zerolog/log" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +// App is the public interface for the Connect RPC application. +type App interface { + Handle(path string, handler http.Handler) + Run(ctx context.Context) error +} + +type Option func(*app) + +func WithAddr(addr string) Option { return func(a *app) { a.addr = addr } } + +func New(opts ...Option) App { + a := &app{addr: ":8080", mux: http.NewServeMux()} + for _, o := range opts { + o(a) + } + // /health is always registered — no interceptors, plain HTTP + a.mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"up"}`)) + }) + return a +} + +type app struct { + addr string + mux *http.ServeMux +} + +func (a *app) Handle(path string, handler http.Handler) { + a.mux.Handle(path, handler) +} + +func (a *app) Run(ctx context.Context) error { + server := &http.Server{ + Addr: a.addr, + Handler: h2c.NewHandler(a.mux, &http2.Server{}), + } + + log.Info().Str("addr", a.addr).Msg("server started") + + errCh := make(chan error, 1) + go func() { errCh <- server.ListenAndServe() }() + + select { + case <-ctx.Done(): + return server.Close() + case err := <-errCh: + return err + } +} +``` + +Single port serves both `/health` (plain HTTP, no auth) and Connect RPC paths (e.g., `/content.v1.ContentService/*`). Different interceptor chains are configured per handler via `connect.WithInterceptors()` when calling `NewContentServiceHandler`. + +### pkg/connectutil/errors.go + +Map domain sentinel errors to connect error codes. + +```go +package connectutil + +import ( + "errors" + + "connectrpc.com/connect" +) + +func NewErrorFrom(err error, mappings map[error]connect.Code) *connect.Error { + for sentinel, code := range mappings { + if errors.Is(err, sentinel) { + return connect.NewError(code, err) + } + } + return connect.NewError(connect.CodeInternal, err) +} +``` + +### pkg/connectutil/interceptors.go + +Shared interceptors: recovery, logging, buf validate. + +```go +package connectutil + +import ( + "context" + "fmt" + "time" + + "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/rs/zerolog/log" +) + +func NewInterceptors() []connect.Interceptor { + validateInterceptor := validate.NewInterceptor() + return []connect.Interceptor{ + NewRecoveryInterceptor(), + NewLoggingInterceptor(), + validateInterceptor, + } +} + +func NewRecoveryInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) { + defer func() { + if r := recover(); r != nil { + err = connect.NewError(connect.CodeInternal, fmt.Errorf("panic: %v", r)) + } + }() + return next(ctx, req) + } + } +} + +func NewLoggingInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + start := time.Now() + resp, err := next(ctx, req) + evt := log.Info() + if err != nil { + evt = log.Error().Err(err) + } + evt. + Str("procedure", req.Spec().Procedure). + Dur("duration", time.Since(start)). + Msg("rpc") + return resp, err + } + } +} +``` + +### pkg/cache/cache.go + +Generic cache interface. Struct is private; constructor returns the interface. + +```go +package cache + +import ( + "sync" + "time" +) + +// Cache is the public interface. Implementations are private. +type Cache[K comparable, V any] interface { + Get(key K) (V, bool) + Set(key K, value V, ttl time.Duration) + Delete(key K) +} + +// NewInMemory returns a Cache backed by a sync.RWMutex map. +func NewInMemory[K comparable, V any]() Cache[K, V] { + return &inMemory[K, V]{items: make(map[K]entry[V])} +} + +type inMemory[K comparable, V any] struct { + mu sync.RWMutex + items map[K]entry[V] +} + +type entry[V any] struct { + value V + expiresAt time.Time +} + +func (c *inMemory[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.items[key] + if !ok || (!e.expiresAt.IsZero() && time.Now().After(e.expiresAt)) { + var zero V + return zero, false + } + return e.value, true +} + +func (c *inMemory[K, V]) Set(key K, value V, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + var exp time.Time + if ttl > 0 { + exp = time.Now().Add(ttl) + } + c.items[key] = entry[V]{value: value, expiresAt: exp} +} + +func (c *inMemory[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.items, key) +} +``` + +### pkg/outbox/outbox.go + +Outbox interface for emitting domain events. The domain doesn't know about jobs or queues — it just emits events. The implementation decides what to do (e.g., insert river jobs for indexing, auditing, analytics). + +```go +package outbox + +import "context" + +// Event represents a domain event to be processed asynchronously. +type Event struct { + Type string + ID string + Data any +} + +// Outbox emits domain events within a transaction. +// Generic over the transaction type to avoid unsafe casts while keeping pkg dependency-free. +type Outbox[T any] interface { + Emit(ctx context.Context, tx T, events ...Event) error +} +``` + +### pkg/migrate/migrate.go + +Goose wrapper with embedded migrations. + +```go +package migrate + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" +) + +func Run(db *sql.DB, migrations embed.FS, dir string) error { + goose.SetBaseFS(migrations) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.Up(db, dir) +} +``` + +## internal/api/ — API Layer + +### handler.go + +Private struct implementing the generated Connect service interface. Constructor returns the Connect-generated interface type. + +```go +package content + +import ( + "connectrpc.com/connect" + + contentv1connect "<module>/gen/proto/content/v1/contentv1connect" + contentdomain "<module>/internal/domain/content" +) + +// Dependencies defines the dependencies for the content API handler. +type Dependencies struct { + Service contentdomain.Service +} + +// New returns the Connect-generated interface. Struct is private. +func New(deps Dependencies) contentv1connect.ContentServiceHandler { + return &handler{service: deps.Service} +} + +type handler struct { + service contentdomain.Service +} + +var errorMappings = map[error]connect.Code{ + contentdomain.ErrNotFound: connect.CodeNotFound, + contentdomain.ErrAlreadyExists: connect.CodeAlreadyExists, +} +``` + +### mapper.go + +Mapping functions between proto types (`gen/proto/`) and sqlc models (`gen/sqlc/`). For update params, sqlc generates `pgtype.Text` / `pgtype.Int4` for nullable fields (from `sqlc.narg()`), NOT pointer types. + +```go +package content + +// toProto maps a sqlc model to a proto response message +// fromProtoCreate maps a proto create request to sqlc create params +// fromProtoUpdate maps a proto update request to sqlc update params +// - uses pgtype.Text{String: val, Valid: true} for nullable string fields +// - uses pgtype.Int4{Int32: val, Valid: true} for nullable int fields +// - only sets Valid: true for fields in the update mask +``` + +### route_*.go — One file per RPC + +Each file contains a single method on the Handler struct: + +```go +// route_create_content.go +func (h *handler) CreateContent( + ctx context.Context, + req *connect.Request[contentv1.CreateContentRequest], +) (*connect.Response[contentv1.CreateContentResponse], error) { + result, err := h.service.Create(ctx, fromProtoCreate(req.Msg)) + if err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + return connect.NewResponse(&contentv1.CreateContentResponse{ + Content: toProto(result), + }), nil +} +``` + +Error mappings are defined as a package-level var in `handler.go` (see above). + +## internal/domain/ — Domain Layer + +### errors.go + +Sentinel errors used across the domain. + +```go +package content + +import "errors" + +var ( + ErrNotFound = errors.New("content not found") + ErrAlreadyExists = errors.New("content already exists") +) +``` + +### service.go + +Service interface is public; struct is private. No store abstraction — sqlc IS the store. + +```go +package content + +import ( + "context" + + "github.com/gofrs/uuid/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + sqlccontent "<module>/gen/sqlc/content" + "<module>/pkg/cache" + "<module>/pkg/outbox" +) + +// Service is the public interface for the content domain. +type Service interface { + Create(ctx context.Context, params sqlccontent.CreateContentParams) (*sqlccontent.Content, error) + Get(ctx context.Context, id uuid.UUID) (*sqlccontent.Content, error) + List(ctx context.Context, pageSize int32, pageToken string) ([]sqlccontent.Content, string, error) + Update(ctx context.Context, id uuid.UUID, params sqlccontent.UpdateContentParams) (*sqlccontent.Content, error) + Delete(ctx context.Context, id uuid.UUID) error +} + +// Dependencies defines the dependencies for the content domain service. +type Dependencies struct { + Pool *pgxpool.Pool + Queries *sqlccontent.Queries + Cache cache.Cache[uuid.UUID, *sqlccontent.Content] + Outbox outbox.Outbox[pgx.Tx] +} + +func New(deps Dependencies) Service { + return &service{ + pool: deps.Pool, + queries: deps.Queries, + cache: deps.Cache, + outbox: deps.Outbox, + } +} + +type service struct { + pool *pgxpool.Pool + queries *sqlccontent.Queries + cache cache.Cache[uuid.UUID, *sqlccontent.Content] + outbox outbox.Outbox[pgx.Tx] +} +``` + +### op_*.go — One file per operation + +Each operation runs within a DB transaction for transactional outbox: + +```go +// op_create.go +func (s *service) Create(ctx context.Context, params sqlccontent.CreateContentParams) (*sqlccontent.Content, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + item, err := s.queries.WithTx(tx).CreateContent(ctx, params) + if err != nil { + return nil, err + } + + if err := s.outbox.Emit(ctx, tx, outbox.Event{Type: "content.created", ID: item.ID.String(), Data: item}); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + s.cache.Set(item.ID, &item, 0) + return &item, nil +} +``` + +For reads, check cache first: + +```go +// op_get.go +func (s *service) Get(ctx context.Context, id uuid.UUID) (*sqlccontent.Content, error) { + if cached, ok := s.cache.Get(id); ok { + return cached, nil + } + item, err := s.queries.GetContent(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + s.cache.Set(id, &item, 0) + return &item, nil +} +``` + +## internal/outbox/ — Outbox Layer + +### river.go — River implementation of pkg/outbox.Outbox + +Maps domain events to river jobs. Each event type can fan out to multiple jobs (index, audit, analytics). + +```go +package outbox + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/riverqueue/river" + + contentevents "<module>/internal/outbox/content" + "<module>/pkg/outbox" +) + +func NewRiverOutbox(client *river.Client[pgx.Tx]) outbox.Outbox[pgx.Tx] { + return &riverOutbox{client: client} +} + +type riverOutbox struct { + client *river.Client[pgx.Tx] +} + +func (o *riverOutbox) Emit(ctx context.Context, tx pgx.Tx, events ...outbox.Event) error { + for _, event := range events { + jobs, err := o.mapEvent(event) + if err != nil { + return err + } + for _, args := range jobs { + if _, err := o.client.InsertTx(ctx, tx, args, nil); err != nil { + return err + } + } + } + return nil +} + +// mapEvent fans out a domain event into one or more river jobs. +func (o *riverOutbox) mapEvent(event outbox.Event) ([]river.JobArgs, error) { + switch event.Type { + case "content.created": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + contentevents.NewAuditArgs(event), + }, nil + case "content.updated": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + }, nil + case "content.deleted": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + contentevents.NewAuditArgs(event), + }, nil + default: + return nil, fmt.Errorf("unknown event type: %s", event.Type) + } +} +``` + +### event_*.go — One file per concern + +Each file contains river `JobArgs` + `Worker` for a specific concern (index, audit, analytics). + +```go +// event_index.go — indexing concern +package content + +import ( + "context" + + "github.com/riverqueue/river" + + "<module>/pkg/outbox" +) + +type IndexArgs struct { + ID string `json:"id"` + Type string `json:"type"` +} + +func (IndexArgs) Kind() string { return "content.index" } + +func NewIndexArgs(event outbox.Event) *IndexArgs { + return &IndexArgs{ID: event.ID, Type: event.Type} +} + +type IndexWorker struct { + river.WorkerDefaults[IndexArgs] +} + +func (w *IndexWorker) Work(ctx context.Context, job *river.Job[IndexArgs]) error { + // TODO: index/update/delete content in OpenSearch based on job.Args.Type + return nil +} +``` + +```go +// event_audit.go — auditing concern +package content + +import ( + "context" + + "github.com/riverqueue/river" + + "<module>/pkg/outbox" +) + +type AuditArgs struct { + ID string `json:"id"` + Action string `json:"action"` +} + +func (AuditArgs) Kind() string { return "content.audit" } + +func NewAuditArgs(event outbox.Event) *AuditArgs { + return &AuditArgs{ID: event.ID, Action: event.Type} +} + +type AuditWorker struct { + river.WorkerDefaults[AuditArgs] +} + +func (w *AuditWorker) Work(ctx context.Context, job *river.Job[AuditArgs]) error { + // TODO: write audit trail + return nil +} +``` + +## SQL — Migrations & Queries + +### sql/migrations/001_create_content.sql + +```sql +-- +goose Up +CREATE TABLE content ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + body TEXT NOT NULL, + status INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS content; +``` + +### sql/queries/content/content.sql + +sqlc annotated queries — one query per operation. + +```sql +-- name: GetContent :one +SELECT * FROM content WHERE id = sqlc.arg('id'); + +-- name: ListContent :many +SELECT * FROM content ORDER BY created_at LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); + +-- name: CountContent :one +SELECT count(*) FROM content; + +-- name: CreateContent :one +INSERT INTO content (title, body, status) +VALUES (sqlc.arg('title'), sqlc.arg('body'), sqlc.arg('status')) +RETURNING *; + +-- name: UpdateContent :one +UPDATE content +SET title = COALESCE(sqlc.narg('title'), title), + body = COALESCE(sqlc.narg('body'), body), + status = COALESCE(sqlc.narg('status'), status), + updated_at = now() +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteContent :execrows +DELETE FROM content WHERE id = sqlc.arg('id'); +``` + +### sqlc.yaml + +One `sql` block per domain. Generated code isolated under `gen/sqlc/<domain>/`. + +```yaml +version: "2" +sql: + - engine: "postgresql" + queries: "sql/queries/content/" + schema: "sql/migrations/" + gen: + go: + package: "content" + out: "gen/sqlc/content" + sql_package: "pgx/v5" + overrides: + - db_type: "uuid" + go_type: + import: "github.com/gofrs/uuid/v5" + type: "UUID" + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" +``` + +## buf.gen.yaml + +Uses buf v2 config with managed mode to rewrite `go_package` imports to match the Go module path. Without managed mode, generated connect code imports the raw proto `go_package` (e.g., `content/v1`) which won't resolve. + +```yaml +version: v2 +managed: + enabled: true + disable: + - file_option: go_package + module: buf.build/bufbuild/protovalidate + override: + - file_option: go_package_prefix + value: <module>/gen/proto +plugins: + - protoc_builtin: go + out: gen/proto + opt: paths=source_relative + - remote: buf.build/connectrpc/go + out: gen/proto + opt: paths=source_relative +``` + +Key points: +- `go_package_prefix` rewrites proto `go_package` to `<module>/gen/proto/<proto_path>` so Go imports resolve correctly +- `disable` for `buf.build/bufbuild/protovalidate` prevents rewriting third-party dep go_packages +- Replace `<module>` with the actual Go module name + +## cmd/server/ — App Wiring + +Split into focused setup files so bootstrapping is easy to reason about: + +``` +cmd/server/ +├── main.go +├── setup_connections.go +├── setup_domains.go +└── setup_gateway.go +``` + +### cmd/server/main.go + +```go +package main + +import ( + "context" + "os/signal" + "syscall" + + "github.com/rs/zerolog/log" + + "<module>/pkg/config" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg, err := config.Load() + if err != nil { + log.Fatal().Err(err).Msg("failed to load config") + } + + connections := setupConnections(ctx, cfg) + defer connections.Close(ctx) + + domains := setupDomains(connections) + application := setupGateway(cfg, domains) + + if err := application.Run(ctx); err != nil { + log.Fatal().Err(err).Msg("server error") + } +} +``` + +### cmd/server/setup_connections.go + +Establishes infrastructure connections: database pool, migrations, river client. + +```go +package main + +import ( + "context" + "database/sql" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + "github.com/rs/zerolog/log" + + _ "github.com/jackc/pgx/v5/stdlib" + + outboxcontent "<module>/internal/outbox/content" + "<module>/pkg/config" + "<module>/pkg/migrate" + migrations "<module>/sql/migrations" +) + +type Connections struct { + Pool *pgxpool.Pool + RiverClient *river.Client[pgx.Tx] +} + +func (c *Connections) Close(ctx context.Context) { + if err := c.RiverClient.Stop(ctx); err != nil { + log.Error().Err(err).Msg("failed to stop river client") + } + c.Pool.Close() +} + +func setupConnections(ctx context.Context, cfg *config.Config) *Connections { + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("failed to connect to database") + } + + // River migrations + riverMigrator, err := rivermigrate.New(riverpgxv5.New(pool), nil) + if err != nil { + log.Fatal().Err(err).Msg("failed to create river migrator") + } + if _, err := riverMigrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil { + log.Fatal().Err(err).Msg("failed to run river migrations") + } + + // Domain migrations (goose) + stdDB, err := sql.Open("pgx", cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("failed to open sql connection") + } + if err := migrate.Run(stdDB, migrations.FS, "."); err != nil { + log.Fatal().Err(err).Msg("failed to run domain migrations") + } + stdDB.Close() + + // River client + workers + workers := river.NewWorkers() + river.AddWorker(workers, &outboxcontent.IndexWorker{}) + river.AddWorker(workers, &outboxcontent.AuditWorker{}) + + riverClient, err := river.NewClient(riverpgxv5.New(pool), &river.Config{ + Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: 100}}, + Workers: workers, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create river client") + } + if err := riverClient.Start(ctx); err != nil { + log.Fatal().Err(err).Msg("failed to start river client") + } + + return &Connections{Pool: pool, RiverClient: riverClient} +} +``` + +### cmd/server/setup_domains.go + +Wires domain services with their dependencies: sqlc queries, cache, outbox. + +```go +package main + +import ( + "github.com/gofrs/uuid/v5" + + sqlccontent "<module>/gen/sqlc/content" + contentdomain "<module>/internal/domain/content" + internaloutbox "<module>/internal/outbox" + "<module>/pkg/cache" +) + +type Domains struct { + Content contentdomain.Service +} + +func setupDomains(conn *Connections) *Domains { + queries := sqlccontent.New(conn.Pool) + contentCache := cache.NewInMemory[uuid.UUID, *sqlccontent.Content]() + contentOutbox := internaloutbox.NewRiverOutbox(conn.RiverClient) + + contentService := contentdomain.New(contentdomain.Dependencies{ + Pool: conn.Pool, + Queries: queries, + Cache: contentCache, + Outbox: contentOutbox, + }) + + return &Domains{Content: contentService} +} +``` + +### cmd/server/setup_gateway.go + +Registers Connect RPC handlers with interceptors on the app. + +```go +package main + +import ( + "connectrpc.com/connect" + + contentv1connect "<module>/gen/proto/content/v1/contentv1connect" + contentapi "<module>/internal/api/content" + "<module>/pkg/config" + "<module>/pkg/connectapp" + "<module>/pkg/connectutil" +) + +func setupGateway(cfg *config.Config, domains *Domains) connectapp.App { + application := connectapp.New(connectapp.WithAddr(cfg.ServerAddr)) + interceptors := connectutil.NewInterceptors() + + contentHandler := contentapi.New(contentapi.Dependencies{Service: domains.Content}) + path, h := contentv1connect.NewContentServiceHandler( + contentHandler, + connect.WithInterceptors(interceptors...), + ) + application.Handle(path, h) + + return application +} +``` + +## Dockerfile + +Multi-stage build: generate (buf + sqlc) → build → runtime. + +```dockerfile +# Stage 1: Generate proto + sqlc code +FROM golang:1.25.6-alpine AS generate + +RUN apk add --no-cache git +RUN go install github.com/bufbuild/buf/cmd/buf@v1.66.0 +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 +RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.1 +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + +WORKDIR /build + +# buf generate +COPY <implementation>/buf.gen.yaml ./ +COPY _shared/protobuf/ proto/ +RUN buf dep update proto +RUN buf generate proto + +# sqlc generate +COPY <implementation>/sqlc.yaml ./ +COPY <implementation>/sql ./sql +RUN sqlc generate + +# Stage 2: Build +FROM golang:1.25.6-alpine AS builder + +WORKDIR /app +COPY <implementation>/ . +COPY --from=generate /build/gen ./gen +RUN go mod tidy +RUN CGO_ENABLED=0 go build -o /server ./cmd/server + +# Stage 3: Runtime +FROM alpine:3.20 + +RUN apk add --no-cache curl +COPY --from=builder /server /server + +EXPOSE 8080 +CMD ["/server"] +``` + +Replace `<implementation>` with the actual directory name (e.g., `connect-rpc`). + +## docker-compose.yml + +```yaml +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: benchmark + POSTGRES_USER: benchmark + POSTGRES_PASSWORD: benchmark + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U benchmark"] + interval: 2s + timeout: 2s + retries: 10 + + opensearch: + image: opensearchproject/opensearch:3.5.0 + environment: + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + ports: + - "9200:9200" + + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:3.5.0 + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + ports: + - "5601:5601" + depends_on: + - opensearch + + codegen: + build: + context: .. + dockerfile: <implementation>/Dockerfile + target: generate + volumes: + - ./gen:/out/gen + entrypoint: ["cp", "-r", "/build/gen/.", "/out/gen/"] + profiles: + - codegen + + api: + build: + context: .. + dockerfile: <implementation>/Dockerfile + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgres://benchmark:benchmark@postgres:5432/benchmark?sslmode=disable + - OPENSEARCH_URL=http://opensearch:9200 + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s +``` + +## Error Handling + +| Domain Error | Connect Code | HTTP | +|-------------------|--------------------------|------| +| ErrNotFound | CodeNotFound | 404 | +| ErrAlreadyExists | CodeAlreadyExists | 409 | +| ErrInvalidArgument| CodeInvalidArgument | 400 | +| ErrPrecondition | CodeFailedPrecondition | 412 | +| ErrPermission | CodePermissionDenied | 403 | +| ErrUnauthenticated| CodeUnauthenticated | 401 | +| (default) | CodeInternal | 500 | + +## Pinned Versions + +| Dependency | Version | +|---|---| +| Go | 1.25.6 | +| connectrpc.com/connect | v1.19.1 | +| connectrpc.com/validate | latest stable | +| google.golang.org/protobuf | v1.36.11 | +| github.com/jackc/pgx/v5 | latest stable | +| github.com/riverqueue/river | latest stable | +| github.com/pressly/goose/v3 | latest stable | +| github.com/rs/zerolog | latest stable | +| github.com/joho/godotenv | latest stable | +| github.com/gofrs/uuid/v5 | latest stable | +| golang.org/x/net | v0.25.0 | +| buf | v1.66.0 | +| protoc-gen-go | v1.36.11 | +| protoc-gen-connect-go | v1.19.1 | +| sqlc | v1.30.0 | + +## Makefile + +Generate a `Makefile` with these targets: + +```makefile +.PHONY: codegen tidy vet build test start stop clean + +# --- codegen --- + +codegen: + docker compose --profile codegen run --rm codegen + +tidy: codegen + go mod tidy + +# --- checks --- + +vet: tidy + go vet ./... + +build: vet + docker compose build api + +test: vet + go test ./... + +# --- run --- + +start: + docker compose up -d + @echo "waiting for api to be healthy..." + @until curl -sf http://localhost:8080/health > /dev/null 2>&1; do sleep 1; done + @echo "api is up" + +stop: + docker compose down + +# --- cleanup --- + +clean: + docker compose down -v + rm -rf gen/ +``` + +## Post-Generation Devloop + +After writing all source files, run through these steps in order. Fix any errors before proceeding to the next step. + +1. **`make vet`** — generates code (codegen), resolves dependencies (tidy), then runs `go vet ./...` to catch unused imports, type mismatches, and compilation errors. Fix all issues before continuing. +2. **`make build`** — builds the Docker image end-to-end (generate → compile → runtime). Confirms the full build pipeline works. +3. **`make start`** — starts all services (postgres, opensearch, api) and waits for the health check. Confirms the server boots and migrations run. +4. **`make stop`** — tears down services after verification. + +If `make vet` fails, read the errors carefully — common issues: +- Unused imports: remove them (only import packages directly referenced in the file) +- `pgtype.Text` / `pgtype.Int4`: sqlc generates these for `sqlc.narg()` nullable params, NOT `*string` / `*int32` +- Wrong return count: check the actual signature of third-party functions (e.g., `validate.NewInterceptor()` returns 1 value) +- Proto import paths: ensure buf.gen.yaml managed mode `go_package_prefix` is set to `<module>/gen/proto` + +## Checklist + +Before finishing generation, verify: +- [ ] All RPCs from the proto file are implemented as `route_*.go` files +- [ ] Each domain operation has its own `op_*.go` file +- [ ] Each outbox event has its own `event_*.go` file +- [ ] sqlc queries cover all CRUD operations +- [ ] Migration creates the correct table schema +- [ ] docker-compose includes postgres, opensearch, and the api service +- [ ] Single server on :8080 via h2c — `/health` (plain HTTP) and Connect RPC paths (with interceptors) +- [ ] All env vars consolidated in `pkg/config` with godotenv loading +- [ ] sqlc uses `sql_package: pgx/v5` and `gofrs/uuid/v5` override +- [ ] buf.gen.yaml uses managed mode with `go_package_prefix` and disables for deps +- [ ] Every package exposes interfaces; structs are private implementation details +- [ ] `make vet` passes with no errors +- [ ] `make build` succeeds +- [ ] `make start` boots and health check passes diff --git a/.claude/commands/define-api.md b/.claude/commands/define-api.md new file mode 100644 index 0000000..7a45678 --- /dev/null +++ b/.claude/commands/define-api.md @@ -0,0 +1,138 @@ +# Define a new API project + +Guide the user interactively through creating a new API project with shared specs and k6 load test scripts. + +## Step 1 — Name the project + +Ask the user for a project name in kebab-case (e.g., `content-api`, `user-api`, `order-api`). This becomes the directory under `projects/`. + +## Step 2 — Define the domain model + +Ask the user to describe the domain: +- The primary entity name (e.g., `content`, `user`, `order`) +- The fields on the entity, with types (e.g., `title: string`, `price: number`, `status: enum(draft, published, archived)`) +- Which fields are required on create vs optional on update +- Whether the entity uses UUID or auto-increment IDs (default: UUID) + +## Step 3 — Choose API styles + +Ask which API styles to generate specs for. The user can pick one or more: +- `openapi` — REST API with OpenAPI 3.1 spec +- `graphql` — GraphQL schema +- `protobuf` — Protocol Buffers / gRPC service definition + +All selected styles will define the same logical operations over the same domain model, so implementations across styles are directly comparable in benchmarks. + +## Step 4 — Generate the shared specs + +Create the `projects/<project>/_shared/` directory with a subdirectory per chosen style. + +Health checks (`GET /health` returning `{"status": "up"}`) are an implementation convention — they are NOT part of the API spec. Do not include health endpoints in any spec. + +Every style must define these standard operations: +1. **Create** — create a new entity (all required fields) +2. **Get** — retrieve a single entity by ID (not found if missing) +3. **List** — paginated list with `limit` and `offset` +4. **Update** — partial update by ID (not found if missing) +5. **Delete** — delete by ID (not found if missing) + +Use the existing `projects/content-api/_shared/` as the reference for conventions and structure. + +### openapi — `_shared/openapi/api-spec.yaml` + +Generate an OpenAPI 3.1.0 spec following the content-api conventions: +- `GET /api/v1/<entity>` with `limit` and `offset` query params, returns paginated list with `items`, `total`, `limit`, `offset` +- `POST /api/v1/<entity>` with request body, returns 201 +- `GET /api/v1/<entity>/{id}` returns single item, 404 if not found +- `PUT /api/v1/<entity>/{id}` partial update, 404 if not found +- `DELETE /api/v1/<entity>/{id}` returns 204, 404 if not found +- All entities include `id` (UUID), `createdAt`, `updatedAt` (ISO 8601 timestamps) +- Use camelCase for JSON fields + +### graphql — `_shared/graphql/schema.graphql` + +Generate a GraphQL schema following the content-api conventions: +- `Query.<entity>(id: ID!)` returning the entity type +- `Query.<entities>(limit: Int = 20, offset: Int = 0)` returning a list type with `items`, `total`, `limit`, `offset` +- `Mutation.create<Entity>(input: Create<Entity>Input!)` returning the entity +- `Mutation.update<Entity>(id: ID!, input: Update<Entity>Input!)` returning the entity +- `Mutation.delete<Entity>(id: ID!)` returning `Boolean` +- Enum types where applicable +- Timestamps as `DateTime` scalars (ISO 8601 strings) + +### protobuf — `_shared/protobuf/<entity>/v1/` + +The proto file path must match the package path. For example, package `content.v1` lives at `_shared/protobuf/content/v1/`. + +Split the proto definition into three files: + +- **`<entity>_refs.proto`** — Reference types that wrap an ID field with `(buf.validate.field).string.uuid = true`. Refs are for **cross-package** use — when another package needs to reference this entity (e.g., a `comment` service referencing `ContentRef`). Within the same package, request messages use plain `string id` fields directly. +- **`<entity>_model.proto`** — The entity model message and any enum types. This is the core domain type. +- **`<entity>_service.proto`** — The service definition with all RPCs, plus request/response messages. Imports `_model.proto`. Uses plain `string id` fields in requests within the same package. + +Also generate a `buf.yaml` at `_shared/protobuf/buf.yaml`: +```yaml +version: v2 +deps: + - buf.build/bufbuild/protovalidate +``` + +Proto conventions: +- Package: `<entity>.v1` +- Language options for multi-stack compatibility: + - `option go_package = "<entity>/v1;<entity>v1";` + - `option java_multiple_files = true;` + - `option java_package = "com.labset.benchmark.<entity>.v1";` + - `option csharp_namespace = "Labset.Benchmark.<Entity>.V1";` +- Service with RPCs: `Create<Entity>`, `Get<Entity>`, `List<Entity>`, `Update<Entity>`, `Delete<Entity>` +- Consistent naming: every RPC has a `<RpcName>Request` and `<RpcName>Response` message pair +- Response messages that return an entity wrap it in a named field (e.g., `CreateContentResponse { Content content = 1; }`) +- Within the same package, requests use plain `string id` fields (e.g., `GetContentRequest { string id = 1; }`) +- Ref types in `_refs.proto` are for cross-package references and validate the ID with `(buf.validate.field).string.uuid = true` +- Enum types with `UNSPECIFIED = 0` sentinel +- Update requests use `google.protobuf.FieldMask` for partial updates — send the entity and a mask of fields to update (import `google/protobuf/field_mask.proto`) +- List requests use `page_size` / `page_token` cursor-based pagination; responses return `repeated <Entity> items` and `next_page_token` +- List response items field is always named `items` for consistency across all entities +- Timestamps as `google.protobuf.Timestamp` (import `google/protobuf/timestamp.proto`) +- **buf validate annotations** on all request fields: + - ID fields: `(buf.validate.field).string.uuid = true` + - String fields: `(buf.validate.field).string = {min_len: N, max_len: N}` as appropriate + - Enum fields: `(buf.validate.field).enum = {defined_only: true, not_in: [0]}` to reject UNSPECIFIED + - Pagination: `page_size` with `{gte: 1, lte: 100}` + - Update: `content` and `update_mask` fields with `required = true` + - Import `buf/validate/validate.proto` in the service proto + +## Step 5 — Generate k6 load test scripts + +For each chosen style, generate a k6 script at `_shared/<style>/k6/<project>.js`. + +Use the existing content-api k6 scripts as the reference. Each script must: +- Follow the same group structure: create → get → list → update → delete +- Use `randomString()` from `k6-utils` for generating test data +- Extract the entity ID from the create response and use it in subsequent operations +- Include `check()` assertions for status codes and response shape +- Include `sleep(0.1)` between iterations +- Set appropriate thresholds + +### openapi k6 script +- Use `k6/http` module +- `BASE_URL` env var (default: `http://localhost:8080`) +- Thresholds: `http_req_duration p95 < 500ms`, `http_req_failed rate < 0.01` +- Validate HTTP status codes (201, 200, 204) + +### graphql k6 script +- Use `k6/http` module with POST to `${BASE_URL}/graphql` +- `BASE_URL` env var (default: `http://localhost:8080`) +- Thresholds: `http_req_duration p95 < 500ms`, `http_req_failed rate < 0.01` +- Send queries/mutations as JSON with variables + +### protobuf k6 script +- Use `k6/net/grpc` module +- `GRPC_HOST` env var (default: `localhost:8080`), `PROTO_DIR` env var pointing to the protobuf root (e.g., `./projects/<project>/_shared/protobuf`) +- Threshold: `grpc_req_duration p95 < 500ms` +- Connect with `plaintext: true` +- Validate gRPC `StatusOK` + +## Step 6 — Verify + +List the generated files and confirm with the user. The project is now ready for implementations to be scaffolded with `/scaffold-implementation`. diff --git a/.claude/commands/scaffold-implementation.md b/.claude/commands/scaffold-implementation.md index 131a507..010a29f 100644 --- a/.claude/commands/scaffold-implementation.md +++ b/.claude/commands/scaffold-implementation.md @@ -24,6 +24,14 @@ These are used in benchmark tags and guide code generation. ## Step 5 — Generate the implementation +### Agent delegation + +Check if a specialized agent prompt exists at `.claude/agents/<implementation>-<language>.md` (e.g., `.claude/agents/connect-rpc-go.md`). If found, read that file and follow its architecture guide to generate the implementation under `projects/<project>/<implementation>/`. Then skip directly to Step 6. + +If no agent prompt exists, fall through to the generic generation instructions below. + +### Generic generation (fallback) + Read the API spec and k6 script from `projects/<project>/_shared/<api-style>/` to understand the contract. Generate the following files under `projects/<project>/<implementation>/`: @@ -59,21 +67,15 @@ services: ### Application source code - Implement ALL endpoints defined in the API spec +- Every implementation must register `GET /health` returning `{"status": "up"}` — this is an implementation convention, not part of the API spec - For openapi style: - - `GET /health` returning `{"status": "up"}` - - `GET /api/v1/content` with pagination (limit/offset query params) - - `POST /api/v1/content` creating a new content item (return 201) - - `GET /api/v1/content/{id}` returning a single item (404 if not found) - - `PUT /api/v1/content/{id}` updating an item (404 if not found) - - `DELETE /api/v1/content/{id}` deleting an item (return 204, 404 if not found) + - Implement all paths from the OpenAPI spec - For graphql style: - Implement all queries and mutations defined in the schema - Serve at `/graphql` endpoint - - Also serve `GET /health` returning `{"status": "up"}` for the readiness probe - For protobuf style: - Implement all RPCs defined in the proto service - - Serve on port 50051 for gRPC - - Also serve `GET /health` on port 8080 for the readiness probe + - Serve on port 8080 using h2c (HTTP/2 cleartext) - Use in-memory storage (a simple map/list) unless the user specifically requests a database - Generate UUIDs for content IDs - Track createdAt and updatedAt timestamps @@ -121,7 +123,7 @@ Add a target entry to `benchmark.config.json`: For protobuf targets, adjust: - `protocol`: `"grpc"` -- `k6.env`: use `GRPC_HOST` and `PROTO_PATH` instead of `BASE_URL` +- `k6.env`: use `GRPC_HOST` (set to `localhost:8080`) and `PROTO_DIR` (e.g., `./projects/<project>/_shared/protobuf`) instead of `BASE_URL` ## Step 7 — Verify diff --git a/.github/workflows/content-api-connect-rpc.yml b/.github/workflows/content-api-connect-rpc.yml new file mode 100644 index 0000000..3c61e06 --- /dev/null +++ b/.github/workflows/content-api-connect-rpc.yml @@ -0,0 +1,23 @@ +name: content-api/connect-rpc + +on: + push: + branches: [main] + paths: + - "projects/content-api/connect-rpc/**" + - "projects/content-api/_shared/protobuf/**" + pull_request: + branches: [main] + paths: + - "projects/content-api/connect-rpc/**" + - "projects/content-api/_shared/protobuf/**" + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - name: Build Docker image + working-directory: projects/content-api + run: docker build -f connect-rpc/Dockerfile . diff --git a/README.md b/README.md index 294eef7..19d1939 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # benchmark -API benchmark platform for comparing backend service implementations. Measures build time, deploy time, and load test performance across different tech stacks, then publishes results to Grafana Cloud for comparison. +API benchmark platform for comparing backend service implementations. Define your APIs, generate implementations across different tech stacks using Claude Code, then measure and compare their performance. ## Prerequisites - [Node.js](https://nodejs.org/) >= 22 - [Docker](https://docs.docker.com/get-docker/) with Compose v2 - [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/) for load testing -- A [Grafana Cloud](https://grafana.com/products/cloud/) account (for publishing results) +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) for scaffolding implementations +- A [Grafana Cloud](https://grafana.com/products/cloud/) account (optional, for publishing results) ## Setup @@ -17,18 +18,70 @@ npm install ## Quick start +### 1. Define an API + +Use the `/define-api` slash command in Claude Code to create a new API project: + +``` +> /define-api + +# Claude will walk you through: +# 1. Name the project → user-api +# 2. Describe the domain → User entity with name, email, role +# 3. Pick API styles → openapi, protobuf, graphql +# 4. Generate specs + k6 → projects/user-api/_shared/ +``` + +This creates the shared API specs and k6 load test scripts under `projects/<project>/_shared/`: + +``` +projects/user-api/ + _shared/ + openapi/ + api-spec.yaml # OpenAPI 3.1 spec + k6/user-api.js # k6 load test script + protobuf/ + user.proto # Protocol Buffers service definition + k6/user-api.js # k6 load test script + graphql/ + schema.graphql # GraphQL schema + k6/user-api.js # k6 load test script +``` + +### 2. Generate an implementation with Claude Code + +Use the `/scaffold-implementation` slash command in Claude Code to generate a complete implementation for any tech stack: + +``` +> /scaffold-implementation + +# Claude will walk you through: +# 1. Pick a project → content-api +# 2. Pick an API style → protobuf +# 3. Name it → connect-rpc +# 4. Language + framework → go + connect-rpc +# 5. Generate everything → Dockerfile, docker-compose, source code, build files +# 6. Register the target → benchmark.config.json updated +``` + +Repeat for as many stacks as you want to compare (e.g., `spring-boot` in Java, `express` in TypeScript, `ktor` in Kotlin). + +### 3. Run benchmarks + ```bash -# list configured targets +# list all configured targets npm run benchmark -- list -# run a full benchmark (build + deploy + loadtest + collect) -npm run benchmark -- run my-api +# run a full benchmark (build → deploy → loadtest → collect → cleanup) +npm run benchmark -- run content-api/connect-rpc -# run and publish results to Grafana Cloud -npm run benchmark -- run my-api --publish +# benchmark multiple implementations and compare +npm run benchmark -- run content-api/connect-rpc +npm run benchmark -- run content-api/spring-boot +npm run benchmark -- compare results/content-api-connect-rpc-*.json results/content-api-spring-boot-*.json -# compare results from multiple runs -npm run benchmark -- compare results/go-api-2026-03-04T12-00-00-000Z.json results/java-api-2026-03-04T12-00-00-000Z.json +# publish results to Grafana Cloud +npm run benchmark -- run content-api/connect-rpc --publish ``` ## Configuration @@ -259,12 +312,55 @@ Custom scripts receive environment variables defined in `k6.env`. The bundled sc ## Adding a target project -1. Create your project under `examples/` (or anywhere) with a `docker-compose.yml` +The recommended way is to use Claude Code's `/scaffold-implementation` command, which handles everything end-to-end. + +To add one manually: + +1. Create your project under `projects/<project-name>/<implementation>/` with a `docker-compose.yml` 2. Make sure the service has a health endpoint 3. Add a target entry to `benchmark.config.json` 4. Run `npm run benchmark -- list` to verify 5. Run `npm run benchmark -- run <target-name>` +## Claude Code commands + +This repo ships with Claude Code slash commands and architecture agents for scaffolding benchmark implementations. + +### `/define-api` + +Interactive command that walks you through defining a new API project with shared specs and k6 load test scripts: + +1. Name the project in kebab-case (e.g., `user-api`, `order-api`) +2. Describe the domain model (entity, fields, types) +3. Choose API styles to generate (`openapi`, `graphql`, `protobuf` — pick one or more) +4. Generate specs and k6 scripts under `projects/<project>/_shared/` + +Once defined, use `/scaffold-implementation` to generate implementations for the API. + +### `/scaffold-implementation` + +Interactive command that walks you through creating a new benchmark implementation: + +1. Pick a project (e.g., `content-api`) +2. Pick an API style (`openapi`, `graphql`, `protobuf`) +3. Name the implementation in kebab-case (e.g., `connect-rpc`, `spring-boot`) +4. Specify language and framework +5. Generate the full implementation (Dockerfile, docker-compose, source code, build files) +6. Register the target in `benchmark.config.json` +7. Verify with `npm run benchmark -- list` + +If a specialized architecture agent exists at `.claude/agents/<implementation>-<language>.md`, the command delegates to it for code generation. Otherwise it falls back to generic scaffolding. + +### Architecture agents + +Architecture agents live under `.claude/agents/` and define opinionated, layered code generation guides for specific tech stacks. + +| Agent | Stack | Description | +| --- | --- | --- | +| `connect-rpc-go.md` | Go + Connect RPC | Layered architecture (APP/API/DOMAIN/OUTBOX) with sqlc, River queue, goose migrations, zerolog, godotenv, and buf validate | + +To add a new agent, create `.claude/agents/<implementation>-<language>.md` following the same conventions. The scaffold command will automatically delegate to it. + ## Project structure ``` @@ -279,7 +375,13 @@ toolkit/ # the benchmark CLI toolkit publish/ # OTLP formatter, Grafana Cloud client report/ # comparison logic, terminal table, JSON writer k6/scripts/ # bundled k6 test scripts -examples/ # target projects for benchmarking +projects/ # benchmark target projects + <project>/ + _shared/ # shared API specs and k6 scripts per style + <implementation>/ # individual implementations to benchmark +.claude/ + commands/ # Claude Code slash commands + agents/ # architecture agents for code generation results/ # benchmark output (gitignored) ``` diff --git a/_architecture/platform-backend.md b/_architecture/platform-backend.md new file mode 100644 index 0000000..efe93fa --- /dev/null +++ b/_architecture/platform-backend.md @@ -0,0 +1,194 @@ +# Platform Backend Architecture + +See `platform-backend.png` for the visual reference. + +## Codebase Layers + +Four layers with strict dependency direction — each layer only depends on layers below it. + +``` +APP/ entry point, application bootstrap + │ runs +API/ api definitions, maps to protobuf or GraphQL + │ registers handlers, invokes +DOMAIN/ service layer, business logic, transactions + │ triggers +OUTBOX/ async event processing, data projections +``` + +### APP/ + +**Purpose**: Application bootstrap and wiring. Creates infrastructure connections (database, queue, search), initialises domain services, registers API handlers, and manages server lifecycle. + +**Exposes**: The application entry point (server). + +**Consumes**: All other layers — wires them together. This is the only layer that knows about all dependencies. + +**Contains**: +- Server initialisation with graceful shutdown +- Connection setup (database pool, migrations, queue client) +- Domain service construction with dependency injection +- API handler registration on the server + +### API/ + +**Purpose**: API definitions. Translates between the external protocol (protobuf/GraphQL/REST) and the domain layer. Handles request validation, response mapping, and error translation. + +**Exposes**: Protocol-specific handlers that the APP layer registers on the server. One handler per domain, one file per RPC/endpoint (`route_<rpc_name>`). + +**Consumes**: +- `DOMAIN/` — invokes domain services +- Generated protocol code (proto stubs, GraphQL resolvers) +- Generated store models (for mapping between protocol and store types) +- Shared utilities (error mapping, interceptors) + +**Contains**: +- `handler` — handler struct, constructor, error mapping table +- `mapper` — bidirectional mapping between protocol types and store models +- `route_<rpc>` — one file per RPC, each a single method on the handler + +**Request lifecycle** (per the diagram): +1. Interceptors run first: validate context, validate request +2. Handler validates domain state +3. Handler invokes domain service +4. Handler maps response + +### DOMAIN/ + +**Purpose**: Service layer where business logic lives. Ensures operations happen in a transaction. Deals with simple canonical data from the store. Orchestrates cache, store, and outbox. + +**Exposes**: A service interface per domain. One file per operation (`op_<operation>`). + +**Consumes**: +- Generated store queries (the code-gen tool IS the store — no extra repository layer) +- Cache interface (get/set/delete with TTL) +- Outbox interface (emit events within a transaction) + +**Contains**: +- `errors` — sentinel domain errors (not found, already exists, etc.) +- `service` — interface definition, dependencies struct, constructor +- `op_<operation>` — one file per business operation (create, get, list, update, delete) + +**Operation patterns**: +- **Writes** (create/update/delete): begin transaction → execute store query via `WithTx(tx)` → emit outbox event within same transaction → commit → update cache post-commit +- **Reads** (get): check cache → fall back to store → populate cache on miss +- **List**: pass-through to store (no caching) + +### OUTBOX/ + +**Purpose**: Async event processing. Handles data projections for different event styles. Each worker represents a concern (audit, index, analytics, graph), not an event type. + +**Exposes**: A queue implementation of the outbox interface. Worker definitions that the APP layer registers with the queue client. + +**Consumes**: +- Outbox interface (from shared packages) +- Store models (for event payload typing) +- Queue library (for job insertion and worker definitions) + +**Contains**: +- Queue implementation — maps domain events to one or more jobs by concern (fan-out) +- `event_<concern>` — one file per concern (index, audit, analytics), each defining job args + worker + +**Event fan-out**: +- A single domain event (e.g., `content.created`) can trigger multiple workers (index + audit) +- Workers run asynchronously, polling the job table — either in-process or as a separate worker process +- Job insertion happens within the domain transaction (transactional outbox guarantee) + +## Shared Packages (pkg/) + +Reusable, framework-agnostic packages. Depend on nothing from the application — extractable as a shared module. + +| Package | Exposes | Purpose | +|---------|---------|---------| +| `config` | Typed config struct, `Load()` | Env-based configuration with defaults | +| `connectapp` | `App` interface with `Handle()`, `Run()` | Server lifecycle: h2c, health endpoint, graceful shutdown | +| `connectutil/errors` | `NewErrorFrom(err, mappings)` | Maps domain sentinel errors to protocol error codes | +| `connectutil/interceptors` | `NewInterceptors()` | Recovery (panic → internal error), logging, request validation | +| `cache` | `Cache[K,V]` interface | Generic cache with get/set/delete and TTL support | +| `outbox` | `Outbox[T]` interface, `Event` struct | Generic outbox for emitting domain events within a transaction | +| `migrate` | `Run(db, fs, dir)` | Migration runner wrapper (embeds SQL, runs on startup) | + +## Request Lifecycle + +From the diagram, a request flows through: + +``` +RPC(ctx, Request) → (Response, error) + │ + ├── interceptors + │ validate ctx (authentication, authorization) + │ validate request (field constraints, required fields) + │ + ├── handler + │ validate state (business preconditions) + │ invoke domain service + │ map response + │ + ├── service (synchronous) + │ cache → DB → Index → External + │ + └── workers (asynchronous, via outbox) + audit, index, analytics, graph +``` + +## Error Mapping + +Domain errors map to protocol-specific error codes. The mapping is defined per handler as a lookup table. + +| gRPC Error | HTTP Code | When | +|---|---|---| +| CANCELLED | 499 | Client cancelled | +| UNAUTHENTICATED | 401 Unauthorized | Missing/invalid credentials | +| INVALID ARGUMENT | 400 Bad Request | Request validation failure | +| PERMISSION DENIED | 403 Forbidden | Insufficient permissions | +| NOT FOUND | 404 Not Found | Entity does not exist | +| ALREADY EXISTS | 409 Conflict | Duplicate creation | +| PRECONDITION FAILED | 412 Precondition Failed | State constraint violated | +| INTERNAL | 500 Internal Server Error | Unexpected failure | +| UNAVAILABLE | 503 Service Unavailable | Downstream dependency down | +| DEADLINE EXCEEDED | 504 Gateway Timeout | Operation timed out | + +## Infrastructure + +### Code Generation + +Two code-gen tools run before compilation: +- **Protocol codegen** (buf) — generates typed stubs and service interfaces from proto/GraphQL definitions +- **Store codegen** (sqlc) — generates type-safe query functions from annotated SQL + +Generated code lives under `gen/` and is never manually edited. Each domain gets its own isolated output directory. + +### Migrations + +Two migration systems run on startup, in order: +1. **Queue migrations** — the queue library manages its own tables independently +2. **Domain migrations** — application schema (embedded SQL files, shared sequence across domains) + +### Docker + +- **Multi-stage build**: generate (codegen tools) → build (compile) → runtime (minimal image + curl for health checks) +- **Compose services**: database (primary store + queue tables), search engine (outbox indexing target), dashboards (search UI), codegen (copies generated code to host for IDE support), api (application) +- **Codegen profile**: runs the generate stage and copies output to host, enabling IDE navigation of generated code + +### Devloop + +``` +codegen → tidy → vet → build / test → start → stop +``` + +Each step depends on the previous. Fix errors at each stage before proceeding. + +## Design Invariants + +These hold across all stacks: + +1. **Layer separation** — dependencies flow downward only, never upward or sideways +2. **Interface-first** — packages expose interfaces, structs are private, constructors return the interface +3. **Dependencies struct** — each layer declares its dependencies as an exported struct, constructor takes it as a single parameter +4. **File-per-concern** — `route_<rpc>`, `op_<operation>`, `event_<concern>` — one responsibility per file +5. **No store abstraction** — the code-gen tool IS the store, no extra repository/DAO layer +6. **Transactional outbox** — store mutation and event emission are atomic (same transaction) +7. **Read-through cache** — cache check → store fallback → cache populate +8. **Sentinel errors** — domain defines errors, API layer maps them to protocol codes via a lookup table +9. **Single server** — one port serves health (no interceptors) and API (with interceptors) on different paths +10. **Embedded migrations** — SQL files embedded in binary, run on startup before listeners diff --git a/benchmark.config.json b/benchmark.config.json index b843820..99a6820 100644 --- a/benchmark.config.json +++ b/benchmark.config.json @@ -1,5 +1,35 @@ { - "targets": {}, + "targets": { + "content-api/connect-rpc": { + "path": "./projects/content-api/connect-rpc", + "composeFile": "docker-compose.yml", + "service": "api", + "port": 8080, + "protocol": "grpc", + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": 8080, + "expectedStatus": 200 + } + }, + "k6": { + "script": "./projects/content-api/_shared/protobuf/k6/content-api.js", + "vus": 50, + "duration": "30s", + "env": { + "GRPC_HOST": "localhost:8080", + "PROTO_DIR": "./projects/content-api/_shared/protobuf" + } + }, + "tags": { + "project": "content-api", + "language": "go", + "framework": "connect-rpc", + "api-style": "protobuf" + } + } + }, "defaults": { "k6": { "vus": 50, diff --git a/projects/content-api/_shared/graphql/k6/content-api.js b/projects/content-api/_shared/graphql/k6/content-api.js deleted file mode 100644 index e6255db..0000000 --- a/projects/content-api/_shared/graphql/k6/content-api.js +++ /dev/null @@ -1,139 +0,0 @@ -import http from 'k6/http'; -import { check, group, sleep } from 'k6'; -import { randomString } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; - -const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; -const GRAPHQL_URL = `${BASE_URL}/graphql`; - -export const options = { - thresholds: { - http_req_duration: ['p(95)<500'], - http_req_failed: ['rate<0.01'], - }, -}; - -const headers = { 'Content-Type': 'application/json' }; - -function graphql(query, variables = {}) { - return http.post(GRAPHQL_URL, JSON.stringify({ query, variables }), { headers }); -} - -export default function () { - let contentId; - - group('create content', () => { - const res = graphql( - `mutation CreateContent($input: CreateContentInput!) { - createContent(input: $input) { - id title body status createdAt updatedAt - } - }`, - { - input: { - title: `Benchmark ${randomString(8)}`, - body: `Load test content body ${randomString(32)}`, - status: 'DRAFT', - }, - } - ); - - check(res, { - 'create: status is 200': (r) => r.status === 200, - 'create: has id': (r) => { - const body = r.json(); - contentId = body.data && body.data.createContent && body.data.createContent.id; - return !!contentId; - }, - 'create: no errors': (r) => !r.json().errors, - }); - }); - - group('get content', () => { - if (!contentId) return; - - const res = graphql( - `query GetContent($id: ID!) { - content(id: $id) { - id title body status createdAt updatedAt - } - }`, - { id: contentId } - ); - - check(res, { - 'get: status is 200': (r) => r.status === 200, - 'get: correct id': (r) => { - const body = r.json(); - return body.data && body.data.content && body.data.content.id === contentId; - }, - }); - }); - - group('list content', () => { - const res = graphql( - `query ListContent($limit: Int, $offset: Int) { - contents(limit: $limit, offset: $offset) { - items { id title status } - total limit offset - } - }`, - { limit: 10, offset: 0 } - ); - - check(res, { - 'list: status is 200': (r) => r.status === 200, - 'list: has items': (r) => { - const body = r.json(); - return body.data && Array.isArray(body.data.contents.items); - }, - }); - }); - - group('update content', () => { - if (!contentId) return; - - const res = graphql( - `mutation UpdateContent($id: ID!, $input: UpdateContentInput!) { - updateContent(id: $id, input: $input) { - id title status updatedAt - } - }`, - { - id: contentId, - input: { - title: `Updated ${randomString(8)}`, - status: 'PUBLISHED', - }, - } - ); - - check(res, { - 'update: status is 200': (r) => r.status === 200, - 'update: status changed': (r) => { - const body = r.json(); - return body.data && body.data.updateContent && body.data.updateContent.status === 'PUBLISHED'; - }, - }); - }); - - group('delete content', () => { - if (!contentId) return; - - const res = graphql( - `mutation DeleteContent($id: ID!) { - deleteContent(id: $id) - }`, - { id: contentId } - ); - - check(res, { - 'delete: status is 200': (r) => r.status === 200, - 'delete: success': (r) => { - const body = r.json(); - return body.data && body.data.deleteContent === true; - }, - }); - }); - - sleep(0.1); -} diff --git a/projects/content-api/_shared/graphql/schema.graphql b/projects/content-api/_shared/graphql/schema.graphql deleted file mode 100644 index ec404c4..0000000 --- a/projects/content-api/_shared/graphql/schema.graphql +++ /dev/null @@ -1,49 +0,0 @@ -type Query { - health: HealthResponse! - content(id: ID!): Content - contents(limit: Int = 20, offset: Int = 0): ContentList! -} - -type Mutation { - createContent(input: CreateContentInput!): Content! - updateContent(id: ID!, input: UpdateContentInput!): Content - deleteContent(id: ID!): Boolean! -} - -type HealthResponse { - status: String! -} - -type Content { - id: ID! - title: String! - body: String! - status: ContentStatus! - createdAt: String! - updatedAt: String! -} - -type ContentList { - items: [Content!]! - total: Int! - limit: Int! - offset: Int! -} - -input CreateContentInput { - title: String! - body: String! - status: ContentStatus = DRAFT -} - -input UpdateContentInput { - title: String - body: String - status: ContentStatus -} - -enum ContentStatus { - DRAFT - PUBLISHED - ARCHIVED -} diff --git a/projects/content-api/_shared/openapi/api-spec.yaml b/projects/content-api/_shared/openapi/api-spec.yaml deleted file mode 100644 index 86b20bf..0000000 --- a/projects/content-api/_shared/openapi/api-spec.yaml +++ /dev/null @@ -1,200 +0,0 @@ -openapi: 3.1.0 -info: - title: Content API - description: > - Benchmark API contract for content management. - All implementations must conform to this specification. - version: 1.0.0 - -paths: - /health: - get: - operationId: healthCheck - summary: Health check endpoint - responses: - '200': - description: Service is healthy - content: - application/json: - schema: - $ref: '#/components/schemas/HealthResponse' - - /api/v1/content: - get: - operationId: listContent - summary: List all content items - parameters: - - name: limit - in: query - schema: - type: integer - default: 20 - maximum: 100 - - name: offset - in: query - schema: - type: integer - default: 0 - responses: - '200': - description: Paginated list of content items - content: - application/json: - schema: - $ref: '#/components/schemas/ContentList' - - post: - operationId: createContent - summary: Create a new content item - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateContentRequest' - responses: - '201': - description: Content item created - content: - application/json: - schema: - $ref: '#/components/schemas/Content' - '400': - description: Invalid request - - /api/v1/content/{id}: - get: - operationId: getContent - summary: Get a content item by ID - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Content item - content: - application/json: - schema: - $ref: '#/components/schemas/Content' - '404': - description: Not found - - put: - operationId: updateContent - summary: Update a content item - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateContentRequest' - responses: - '200': - description: Content item updated - content: - application/json: - schema: - $ref: '#/components/schemas/Content' - '404': - description: Not found - - delete: - operationId: deleteContent - summary: Delete a content item - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - responses: - '204': - description: Content item deleted - '404': - description: Not found - -components: - schemas: - HealthResponse: - type: object - required: [status] - properties: - status: - type: string - enum: [up] - - Content: - type: object - required: [id, title, body, status, createdAt, updatedAt] - properties: - id: - type: string - format: uuid - title: - type: string - body: - type: string - status: - type: string - enum: [draft, published, archived] - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - - ContentList: - type: object - required: [items, total, limit, offset] - properties: - items: - type: array - items: - $ref: '#/components/schemas/Content' - total: - type: integer - limit: - type: integer - offset: - type: integer - - CreateContentRequest: - type: object - required: [title, body] - properties: - title: - type: string - minLength: 1 - maxLength: 255 - body: - type: string - status: - type: string - enum: [draft, published] - default: draft - - UpdateContentRequest: - type: object - properties: - title: - type: string - minLength: 1 - maxLength: 255 - body: - type: string - status: - type: string - enum: [draft, published, archived] diff --git a/projects/content-api/_shared/openapi/k6/content-api.js b/projects/content-api/_shared/openapi/k6/content-api.js deleted file mode 100644 index a4edd8c..0000000 --- a/projects/content-api/_shared/openapi/k6/content-api.js +++ /dev/null @@ -1,85 +0,0 @@ -import http from 'k6/http'; -import { check, group, sleep } from 'k6'; -import { randomString } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; - -const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; - -export const options = { - thresholds: { - http_req_duration: ['p(95)<500'], - http_req_failed: ['rate<0.01'], - }, -}; - -const headers = { 'Content-Type': 'application/json' }; - -export default function () { - let contentId; - - group('create content', () => { - const payload = JSON.stringify({ - title: `Benchmark ${randomString(8)}`, - body: `Load test content body ${randomString(32)}`, - status: 'draft', - }); - - const res = http.post(`${BASE_URL}/api/v1/content`, payload, { headers }); - - check(res, { - 'create: status is 201': (r) => r.status === 201, - 'create: has id': (r) => { - const body = r.json(); - contentId = body.id; - return !!contentId; - }, - }); - }); - - group('get content', () => { - if (!contentId) return; - - const res = http.get(`${BASE_URL}/api/v1/content/${contentId}`); - - check(res, { - 'get: status is 200': (r) => r.status === 200, - 'get: correct id': (r) => r.json().id === contentId, - }); - }); - - group('list content', () => { - const res = http.get(`${BASE_URL}/api/v1/content?limit=10&offset=0`); - - check(res, { - 'list: status is 200': (r) => r.status === 200, - 'list: has items array': (r) => Array.isArray(r.json().items), - }); - }); - - group('update content', () => { - if (!contentId) return; - - const payload = JSON.stringify({ - title: `Updated ${randomString(8)}`, - status: 'published', - }); - - const res = http.put(`${BASE_URL}/api/v1/content/${contentId}`, payload, { headers }); - - check(res, { - 'update: status is 200': (r) => r.status === 200, - 'update: status changed': (r) => r.json().status === 'published', - }); - }); - - group('delete content', () => { - if (!contentId) return; - - const res = http.del(`${BASE_URL}/api/v1/content/${contentId}`); - - check(res, { - 'delete: status is 204': (r) => r.status === 204, - }); - }); - - sleep(0.1); -} diff --git a/projects/content-api/_shared/protobuf/buf.yaml b/projects/content-api/_shared/protobuf/buf.yaml new file mode 100644 index 0000000..291ef16 --- /dev/null +++ b/projects/content-api/_shared/protobuf/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +deps: + - buf.build/bufbuild/protovalidate diff --git a/projects/content-api/_shared/protobuf/content.proto b/projects/content-api/_shared/protobuf/content.proto deleted file mode 100644 index 7bb91f5..0000000 --- a/projects/content-api/_shared/protobuf/content.proto +++ /dev/null @@ -1,74 +0,0 @@ -syntax = "proto3"; - -package content.v1; - -option java_multiple_files = true; -option java_package = "com.labset.benchmark.content.v1"; - -service ContentService { - rpc CheckHealth(HealthRequest) returns (HealthResponse); - rpc ListContent(ListContentRequest) returns (ListContentResponse); - rpc GetContent(GetContentRequest) returns (Content); - rpc CreateContent(CreateContentRequest) returns (Content); - rpc UpdateContent(UpdateContentRequest) returns (Content); - rpc DeleteContent(DeleteContentRequest) returns (DeleteContentResponse); -} - -message HealthRequest {} - -message HealthResponse { - string status = 1; -} - -enum ContentStatus { - CONTENT_STATUS_UNSPECIFIED = 0; - CONTENT_STATUS_DRAFT = 1; - CONTENT_STATUS_PUBLISHED = 2; - CONTENT_STATUS_ARCHIVED = 3; -} - -message Content { - string id = 1; - string title = 2; - string body = 3; - ContentStatus status = 4; - string created_at = 5; - string updated_at = 6; -} - -message ListContentRequest { - int32 limit = 1; - int32 offset = 2; -} - -message ListContentResponse { - repeated Content items = 1; - int32 total = 2; - int32 limit = 3; - int32 offset = 4; -} - -message GetContentRequest { - string id = 1; -} - -message CreateContentRequest { - string title = 1; - string body = 2; - ContentStatus status = 3; -} - -message UpdateContentRequest { - string id = 1; - optional string title = 2; - optional string body = 3; - optional ContentStatus status = 4; -} - -message DeleteContentRequest { - string id = 1; -} - -message DeleteContentResponse { - bool success = 1; -} diff --git a/projects/content-api/_shared/protobuf/content/v1/content_model.proto b/projects/content-api/_shared/protobuf/content/v1/content_model.proto new file mode 100644 index 0000000..ca7270c --- /dev/null +++ b/projects/content-api/_shared/protobuf/content/v1/content_model.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package content.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "content/v1;contentv1"; +option java_multiple_files = true; +option java_package = "com.labset.benchmark.content.v1"; +option csharp_namespace = "Labset.Benchmark.Content.V1"; + +enum ContentStatus { + CONTENT_STATUS_UNSPECIFIED = 0; + CONTENT_STATUS_DRAFT = 1; + CONTENT_STATUS_PUBLISHED = 2; + CONTENT_STATUS_ARCHIVED = 3; +} + +message Content { + string id = 1; + string title = 2; + string body = 3; + ContentStatus status = 4; + repeated string tags = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} diff --git a/projects/content-api/_shared/protobuf/content/v1/content_refs.proto b/projects/content-api/_shared/protobuf/content/v1/content_refs.proto new file mode 100644 index 0000000..a0eb593 --- /dev/null +++ b/projects/content-api/_shared/protobuf/content/v1/content_refs.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package content.v1; + +import "buf/validate/validate.proto"; + +option go_package = "content/v1;contentv1"; +option java_multiple_files = true; +option java_package = "com.labset.benchmark.content.v1"; +option csharp_namespace = "Labset.Benchmark.Content.V1"; + +message ContentRef { + string id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/projects/content-api/_shared/protobuf/content/v1/content_service.proto b/projects/content-api/_shared/protobuf/content/v1/content_service.proto new file mode 100644 index 0000000..a6a5d0d --- /dev/null +++ b/projects/content-api/_shared/protobuf/content/v1/content_service.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; + +package content.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/field_mask.proto"; +import "content/v1/content_model.proto"; + +option go_package = "content/v1;contentv1"; +option java_multiple_files = true; +option java_package = "com.labset.benchmark.content.v1"; +option csharp_namespace = "Labset.Benchmark.Content.V1"; + +service ContentService { + rpc CreateContent(CreateContentRequest) returns (CreateContentResponse); + rpc GetContent(GetContentRequest) returns (GetContentResponse); + rpc ListContent(ListContentRequest) returns (ListContentResponse); + rpc UpdateContent(UpdateContentRequest) returns (UpdateContentResponse); + rpc DeleteContent(DeleteContentRequest) returns (DeleteContentResponse); +} + +// Create + +message CreateContentRequest { + string title = 1 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; + string body = 2 [(buf.validate.field).string.min_len = 1]; + ContentStatus status = 3 [(buf.validate.field).enum = {defined_only: true, not_in: [0]}]; + repeated string tags = 4; +} + +message CreateContentResponse { + Content content = 1; +} + +// Get + +message GetContentRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} + +message GetContentResponse { + Content content = 1; +} + +// List + +message ListContentRequest { + int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}]; + string page_token = 2; +} + +message ListContentResponse { + repeated Content items = 1; + string next_page_token = 2; +} + +// Update + +message UpdateContentRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; + Content content = 2 [(buf.validate.field).required = true]; + google.protobuf.FieldMask update_mask = 3 [(buf.validate.field).required = true]; +} + +message UpdateContentResponse { + Content content = 1; +} + +// Delete + +message DeleteContentRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} + +message DeleteContentResponse { + bool success = 1; +} diff --git a/projects/content-api/_shared/protobuf/k6/content-api.js b/projects/content-api/_shared/protobuf/k6/content-api.js index 25fbeb4..9ff1a9d 100644 --- a/projects/content-api/_shared/protobuf/k6/content-api.js +++ b/projects/content-api/_shared/protobuf/k6/content-api.js @@ -1,12 +1,19 @@ import grpc from 'k6/net/grpc'; import { check, group, sleep } from 'k6'; -import { randomString } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; -const GRPC_HOST = __ENV.GRPC_HOST || 'localhost:50051'; -const PROTO_PATH = __ENV.PROTO_PATH || ''; +const GRPC_HOST = __ENV.GRPC_HOST || 'localhost:8080'; +const PROTO_DIR = __ENV.PROTO_DIR || ''; const client = new grpc.Client(); +if (PROTO_DIR) { + client.load( + [PROTO_DIR], + 'content/v1/content_service.proto', + 'content/v1/content_model.proto', + ); +} + export const options = { thresholds: { grpc_req_duration: ['p(95)<500'], @@ -14,25 +21,22 @@ export const options = { }; export default function () { - if (PROTO_PATH) { - client.load([], PROTO_PATH); - } - client.connect(GRPC_HOST, { plaintext: true }); let contentId; group('create content', () => { const res = client.invoke('content.v1.ContentService/CreateContent', { - title: `Benchmark ${randomString(8)}`, - body: `Load test content body ${randomString(32)}`, + title: `Benchmark ${crypto.randomUUID()}`, + body: `Load test content body ${crypto.randomUUID()}`, status: 'CONTENT_STATUS_DRAFT', + tags: ['benchmark', 'k6'], }); check(res, { 'create: status is OK': (r) => r && r.status === grpc.StatusOK, 'create: has id': (r) => { - contentId = r && r.message && r.message.id; + contentId = r && r.message && r.message.content && r.message.content.id; return !!contentId; }, }); @@ -47,14 +51,14 @@ export default function () { check(res, { 'get: status is OK': (r) => r && r.status === grpc.StatusOK, - 'get: correct id': (r) => r && r.message && r.message.id === contentId, + 'get: correct id': (r) => + r && r.message && r.message.content && r.message.content.id === contentId, }); }); group('list content', () => { const res = client.invoke('content.v1.ContentService/ListContent', { - limit: 10, - offset: 0, + pageSize: 10, }); check(res, { @@ -68,14 +72,20 @@ export default function () { const res = client.invoke('content.v1.ContentService/UpdateContent', { id: contentId, - title: `Updated ${randomString(8)}`, - status: 'CONTENT_STATUS_PUBLISHED', + content: { + title: `Updated ${crypto.randomUUID()}`, + status: 'CONTENT_STATUS_PUBLISHED', + }, + updateMask: { paths: ['title', 'status'] }, }); check(res, { 'update: status is OK': (r) => r && r.status === grpc.StatusOK, 'update: status changed': (r) => - r && r.message && r.message.status === 'CONTENT_STATUS_PUBLISHED', + r && + r.message && + r.message.content && + r.message.content.status === 'CONTENT_STATUS_PUBLISHED', }); }); diff --git a/projects/content-api/connect-rpc/.gitignore b/projects/content-api/connect-rpc/.gitignore new file mode 100644 index 0000000..e8e450b --- /dev/null +++ b/projects/content-api/connect-rpc/.gitignore @@ -0,0 +1 @@ +gen/ diff --git a/projects/content-api/connect-rpc/Dockerfile b/projects/content-api/connect-rpc/Dockerfile new file mode 100644 index 0000000..2507cf7 --- /dev/null +++ b/projects/content-api/connect-rpc/Dockerfile @@ -0,0 +1,39 @@ +# Stage 1: Generate proto + sqlc code +FROM golang:1.25.6-alpine AS generate + +RUN apk add --no-cache git +RUN go install github.com/bufbuild/buf/cmd/buf@v1.66.0 +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + +WORKDIR /build + +# buf generate +COPY connect-rpc/buf.gen.yaml ./ +COPY _shared/protobuf/ proto/ +RUN buf dep update proto +RUN buf generate proto + +# sqlc generate +COPY connect-rpc/sqlc.yaml ./ +COPY connect-rpc/sql ./sql +RUN sqlc generate + +# Stage 2: Build +FROM golang:1.25.6-alpine AS builder + +WORKDIR /app +COPY connect-rpc/ . +COPY --from=generate /build/gen ./gen +RUN go mod tidy +RUN CGO_ENABLED=0 go build -o /server ./cmd/server + +# Stage 3: Runtime +FROM alpine:3.20 + +RUN apk add --no-cache curl +COPY --from=builder /server /server + +EXPOSE 8080 +CMD ["/server"] diff --git a/projects/content-api/connect-rpc/Makefile b/projects/content-api/connect-rpc/Makefile new file mode 100644 index 0000000..a8c3fd0 --- /dev/null +++ b/projects/content-api/connect-rpc/Makefile @@ -0,0 +1,37 @@ +.PHONY: codegen tidy vet build test start stop clean + +# --- codegen --- + +codegen: + docker compose --profile codegen run --rm codegen + +tidy: codegen + go mod tidy + +# --- checks --- + +vet: tidy + go vet ./... + +build: vet + docker compose build api + +test: vet + go test ./... + +# --- run --- + +start: + docker compose up -d + @echo "waiting for api to be healthy..." + @until curl -sf http://localhost:8080/health > /dev/null 2>&1; do sleep 1; done + @echo "api is up" + +stop: + docker compose down + +# --- cleanup --- + +clean: + docker compose down -v + rm -rf gen/ diff --git a/projects/content-api/connect-rpc/buf.gen.yaml b/projects/content-api/connect-rpc/buf.gen.yaml new file mode 100644 index 0000000..74f9611 --- /dev/null +++ b/projects/content-api/connect-rpc/buf.gen.yaml @@ -0,0 +1,16 @@ +version: v2 +managed: + enabled: true + disable: + - file_option: go_package + module: buf.build/bufbuild/protovalidate + override: + - file_option: go_package_prefix + value: content-api-connect-rpc/gen/proto +plugins: + - protoc_builtin: go + out: gen/proto + opt: paths=source_relative + - remote: buf.build/connectrpc/go + out: gen/proto + opt: paths=source_relative diff --git a/projects/content-api/connect-rpc/cmd/server/main.go b/projects/content-api/connect-rpc/cmd/server/main.go new file mode 100644 index 0000000..592df09 --- /dev/null +++ b/projects/content-api/connect-rpc/cmd/server/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "os/signal" + "syscall" + + "github.com/rs/zerolog/log" + + "content-api-connect-rpc/pkg/config" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg, err := config.Load() + if err != nil { + log.Fatal().Err(err).Msg("failed to load config") + } + + connections := setupConnections(ctx, cfg) + defer connections.Close(ctx) + + domains := setupDomains(connections) + application := setupGateway(cfg, domains) + + if err := application.Run(ctx); err != nil { + log.Fatal().Err(err).Msg("server error") + } +} diff --git a/projects/content-api/connect-rpc/cmd/server/setup_connections.go b/projects/content-api/connect-rpc/cmd/server/setup_connections.go new file mode 100644 index 0000000..186e5fd --- /dev/null +++ b/projects/content-api/connect-rpc/cmd/server/setup_connections.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "database/sql" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + "github.com/rs/zerolog/log" + + _ "github.com/jackc/pgx/v5/stdlib" + + outboxcontent "content-api-connect-rpc/internal/outbox/content" + "content-api-connect-rpc/pkg/config" + "content-api-connect-rpc/pkg/migrate" + migrations "content-api-connect-rpc/sql/migrations" +) + +type Connections struct { + Pool *pgxpool.Pool + RiverClient *river.Client[pgx.Tx] +} + +func (c *Connections) Close(ctx context.Context) { + if err := c.RiverClient.Stop(ctx); err != nil { + log.Error().Err(err).Msg("failed to stop river client") + } + c.Pool.Close() +} + +func setupConnections(ctx context.Context, cfg *config.Config) *Connections { + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("failed to connect to database") + } + + // River migrations + riverMigrator, err := rivermigrate.New(riverpgxv5.New(pool), nil) + if err != nil { + log.Fatal().Err(err).Msg("failed to create river migrator") + } + if _, err := riverMigrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil { + log.Fatal().Err(err).Msg("failed to run river migrations") + } + + // Domain migrations (goose) + stdDB, err := sql.Open("pgx", cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("failed to open sql connection") + } + if err := migrate.Run(stdDB, migrations.FS, "."); err != nil { + log.Fatal().Err(err).Msg("failed to run domain migrations") + } + stdDB.Close() + + // River client + workers + workers := river.NewWorkers() + river.AddWorker(workers, &outboxcontent.IndexWorker{}) + river.AddWorker(workers, &outboxcontent.AuditWorker{}) + + riverClient, err := river.NewClient(riverpgxv5.New(pool), &river.Config{ + Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: 100}}, + Workers: workers, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create river client") + } + if err := riverClient.Start(ctx); err != nil { + log.Fatal().Err(err).Msg("failed to start river client") + } + + return &Connections{Pool: pool, RiverClient: riverClient} +} diff --git a/projects/content-api/connect-rpc/cmd/server/setup_domains.go b/projects/content-api/connect-rpc/cmd/server/setup_domains.go new file mode 100644 index 0000000..7776824 --- /dev/null +++ b/projects/content-api/connect-rpc/cmd/server/setup_domains.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/gofrs/uuid/v5" + + sqlccontent "content-api-connect-rpc/gen/sqlc/content" + contentdomain "content-api-connect-rpc/internal/domain/content" + internaloutbox "content-api-connect-rpc/internal/outbox" + "content-api-connect-rpc/pkg/cache" +) + +type Domains struct { + Content contentdomain.Service +} + +func setupDomains(conn *Connections) *Domains { + queries := sqlccontent.New(conn.Pool) + contentCache := cache.NewInMemory[uuid.UUID, *sqlccontent.Content]() + contentOutbox := internaloutbox.NewRiverOutbox(conn.RiverClient) + + contentService := contentdomain.New(contentdomain.Dependencies{ + Pool: conn.Pool, + Queries: queries, + Cache: contentCache, + Outbox: contentOutbox, + }) + + return &Domains{Content: contentService} +} diff --git a/projects/content-api/connect-rpc/cmd/server/setup_gateway.go b/projects/content-api/connect-rpc/cmd/server/setup_gateway.go new file mode 100644 index 0000000..960d227 --- /dev/null +++ b/projects/content-api/connect-rpc/cmd/server/setup_gateway.go @@ -0,0 +1,25 @@ +package main + +import ( + "connectrpc.com/connect" + + contentv1connect "content-api-connect-rpc/gen/proto/content/v1/contentv1connect" + contentapi "content-api-connect-rpc/internal/api/content" + "content-api-connect-rpc/pkg/config" + "content-api-connect-rpc/pkg/connectapp" + "content-api-connect-rpc/pkg/connectutil" +) + +func setupGateway(cfg *config.Config, domains *Domains) connectapp.App { + application := connectapp.New(connectapp.WithAddr(cfg.ServerAddr)) + interceptors := connectutil.NewInterceptors() + + contentHandler := contentapi.New(contentapi.Dependencies{Service: domains.Content}) + path, h := contentv1connect.NewContentServiceHandler( + contentHandler, + connect.WithInterceptors(interceptors...), + ) + application.Handle(path, h) + + return application +} diff --git a/projects/content-api/connect-rpc/docker-compose.yml b/projects/content-api/connect-rpc/docker-compose.yml new file mode 100644 index 0000000..4b7d83c --- /dev/null +++ b/projects/content-api/connect-rpc/docker-compose.yml @@ -0,0 +1,62 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: benchmark + POSTGRES_USER: benchmark + POSTGRES_PASSWORD: benchmark + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U benchmark"] + interval: 2s + timeout: 2s + retries: 10 + + opensearch: + image: opensearchproject/opensearch:3.5.0 + environment: + - discovery.type=single-node + - DISABLE_SECURITY_PLUGIN=true + ports: + - "9200:9200" + + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:3.5.0 + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + ports: + - "5601:5601" + depends_on: + - opensearch + + codegen: + build: + context: .. + dockerfile: connect-rpc/Dockerfile + target: generate + volumes: + - ./gen:/out/gen + entrypoint: ["cp", "-r", "/build/gen/.", "/out/gen/"] + profiles: + - codegen + + api: + build: + context: .. + dockerfile: connect-rpc/Dockerfile + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgres://benchmark:benchmark@postgres:5432/benchmark?sslmode=disable + - OPENSEARCH_URL=http://opensearch:9200 + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s diff --git a/projects/content-api/connect-rpc/go.mod b/projects/content-api/connect-rpc/go.mod new file mode 100644 index 0000000..f903c8c --- /dev/null +++ b/projects/content-api/connect-rpc/go.mod @@ -0,0 +1,52 @@ +module content-api-connect-rpc + +go 1.25.0 + +require ( + connectrpc.com/connect v1.19.1 + connectrpc.com/validate v0.6.0 + github.com/gofrs/uuid/v5 v5.4.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 + github.com/pressly/goose/v3 v3.27.0 + github.com/riverqueue/river v0.31.0 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.31.0 + github.com/rs/zerolog v1.34.0 + golang.org/x/net v0.51.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 // indirect + buf.build/go/protovalidate v1.0.0 // indirect + cel.dev/expr v0.24.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/cel-go v0.26.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/riverqueue/river/riverdriver v0.31.0 // indirect + github.com/riverqueue/river/rivershared v0.31.0 // indirect + github.com/riverqueue/river/rivertype v0.31.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.uber.org/goleak v1.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/projects/content-api/connect-rpc/go.sum b/projects/content-api/connect-rpc/go.sum new file mode 100644 index 0000000..ab1322c --- /dev/null +++ b/projects/content-api/connect-rpc/go.sum @@ -0,0 +1,139 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= +buf.build/go/protovalidate v1.0.0 h1:IAG1etULddAy93fiBsFVhpj7es5zL53AfB/79CVGtyY= +buf.build/go/protovalidate v1.0.0/go.mod h1:KQmEUrcQuC99hAw+juzOEAmILScQiKBP1Oc36vvCLW8= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= +connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= +github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/riverqueue/river v0.31.0 h1:BERwce/WS4Guter0/A3GyTDP+1rxl6vFHyBQv+U/5tM= +github.com/riverqueue/river v0.31.0/go.mod h1:Aqbb/jBrFMvh6rbe6SDC6XVZnS0v1W+QQPjejRvyHzk= +github.com/riverqueue/river/riverdriver v0.31.0 h1:XwDa8DqkRxkqMqfdLOYTgSykiTHNSRcWG1LcCg/g0ys= +github.com/riverqueue/river/riverdriver v0.31.0/go.mod h1:Vl6XPbWtjqP+rqEa/HxcEeXeZL/KPCwqjRlqj+wWsq8= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.31.0 h1:Zii6/VNqasBuPvFIA98xgjz3MRy2EvMm6lMyh1RtWBw= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.31.0/go.mod h1:z859lpsOraO3IYWjY9w8RZec5I0BAcas9rjZkwxAijU= +github.com/riverqueue/river/rivershared v0.31.0 h1:KVEp+13jnK9YOlMUKnR0eUyJaK+P/APcheoSGMfZArA= +github.com/riverqueue/river/rivershared v0.31.0/go.mod h1:Wvf489bvAiZsJm7mln8YAPZbK7pVfuK7bYfsBt5Nzbw= +github.com/riverqueue/river/rivertype v0.31.0 h1:O6vaJ72SffgF1nxzCrDKd4M+eMZFRlJpycnOcUIGLD8= +github.com/riverqueue/river/rivertype v0.31.0/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 h1:jm6v6kMRpTYKxBRrDkYAitNJegUeO1Mf3Kt80obv0gg= +google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9/go.mod h1:LmwNphe5Afor5V3R5BppOULHOnt2mCIf+NxMd4XiygE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/projects/content-api/connect-rpc/internal/api/content/handler.go b/projects/content-api/connect-rpc/internal/api/content/handler.go new file mode 100644 index 0000000..2ab5781 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/handler.go @@ -0,0 +1,25 @@ +package content + +import ( + "connectrpc.com/connect" + + contentv1connect "content-api-connect-rpc/gen/proto/content/v1/contentv1connect" + contentdomain "content-api-connect-rpc/internal/domain/content" +) + +type Dependencies struct { + Service contentdomain.Service +} + +func New(deps Dependencies) contentv1connect.ContentServiceHandler { + return &handler{service: deps.Service} +} + +type handler struct { + service contentdomain.Service +} + +var errorMappings = map[error]connect.Code{ + contentdomain.ErrNotFound: connect.CodeNotFound, + contentdomain.ErrAlreadyExists: connect.CodeAlreadyExists, +} diff --git a/projects/content-api/connect-rpc/internal/api/content/mapper.go b/projects/content-api/connect-rpc/internal/api/content/mapper.go new file mode 100644 index 0000000..e115a59 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/mapper.go @@ -0,0 +1,50 @@ +package content + +import ( + "github.com/jackc/pgx/v5/pgtype" + "google.golang.org/protobuf/types/known/timestamppb" + + contentv1 "content-api-connect-rpc/gen/proto/content/v1" + sqlccontent "content-api-connect-rpc/gen/sqlc/content" +) + +func toProto(item *sqlccontent.Content) *contentv1.Content { + return &contentv1.Content{ + Id: item.ID.String(), + Title: item.Title, + Body: item.Body, + Status: contentv1.ContentStatus(item.Status), + Tags: item.Tags, + CreatedAt: timestamppb.New(item.CreatedAt), + UpdatedAt: timestamppb.New(item.UpdatedAt), + } +} + +func fromProtoCreate(msg *contentv1.CreateContentRequest) sqlccontent.CreateContentParams { + return sqlccontent.CreateContentParams{ + Title: msg.Title, + Body: msg.Body, + Status: int32(msg.Status), + Tags: msg.Tags, + } +} + +func fromProtoUpdate(msg *contentv1.UpdateContentRequest) sqlccontent.UpdateContentParams { + params := sqlccontent.UpdateContentParams{} + if msg.UpdateMask == nil { + return params + } + for _, path := range msg.UpdateMask.Paths { + switch path { + case "title": + params.Title = pgtype.Text{String: msg.Content.Title, Valid: true} + case "body": + params.Body = pgtype.Text{String: msg.Content.Body, Valid: true} + case "status": + params.Status = pgtype.Int4{Int32: int32(msg.Content.Status), Valid: true} + case "tags": + params.Tags = msg.Content.Tags + } + } + return params +} diff --git a/projects/content-api/connect-rpc/internal/api/content/route_create_content.go b/projects/content-api/connect-rpc/internal/api/content/route_create_content.go new file mode 100644 index 0000000..4db8e02 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/route_create_content.go @@ -0,0 +1,23 @@ +package content + +import ( + "context" + + "connectrpc.com/connect" + + contentv1 "content-api-connect-rpc/gen/proto/content/v1" + "content-api-connect-rpc/pkg/connectutil" +) + +func (h *handler) CreateContent( + ctx context.Context, + req *connect.Request[contentv1.CreateContentRequest], +) (*connect.Response[contentv1.CreateContentResponse], error) { + result, err := h.service.Create(ctx, fromProtoCreate(req.Msg)) + if err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + return connect.NewResponse(&contentv1.CreateContentResponse{ + Content: toProto(result), + }), nil +} diff --git a/projects/content-api/connect-rpc/internal/api/content/route_delete_content.go b/projects/content-api/connect-rpc/internal/api/content/route_delete_content.go new file mode 100644 index 0000000..dd4af8e --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/route_delete_content.go @@ -0,0 +1,27 @@ +package content + +import ( + "context" + + "connectrpc.com/connect" + "github.com/gofrs/uuid/v5" + + contentv1 "content-api-connect-rpc/gen/proto/content/v1" + "content-api-connect-rpc/pkg/connectutil" +) + +func (h *handler) DeleteContent( + ctx context.Context, + req *connect.Request[contentv1.DeleteContentRequest], +) (*connect.Response[contentv1.DeleteContentResponse], error) { + id, err := uuid.FromString(req.Msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + if err := h.service.Delete(ctx, id); err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + return connect.NewResponse(&contentv1.DeleteContentResponse{ + Success: true, + }), nil +} diff --git a/projects/content-api/connect-rpc/internal/api/content/route_get_content.go b/projects/content-api/connect-rpc/internal/api/content/route_get_content.go new file mode 100644 index 0000000..d9f71b3 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/route_get_content.go @@ -0,0 +1,28 @@ +package content + +import ( + "context" + + "connectrpc.com/connect" + "github.com/gofrs/uuid/v5" + + contentv1 "content-api-connect-rpc/gen/proto/content/v1" + "content-api-connect-rpc/pkg/connectutil" +) + +func (h *handler) GetContent( + ctx context.Context, + req *connect.Request[contentv1.GetContentRequest], +) (*connect.Response[contentv1.GetContentResponse], error) { + id, err := uuid.FromString(req.Msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + result, err := h.service.Get(ctx, id) + if err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + return connect.NewResponse(&contentv1.GetContentResponse{ + Content: toProto(result), + }), nil +} diff --git a/projects/content-api/connect-rpc/internal/api/content/route_list_content.go b/projects/content-api/connect-rpc/internal/api/content/route_list_content.go new file mode 100644 index 0000000..882236d --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/route_list_content.go @@ -0,0 +1,28 @@ +package content + +import ( + "context" + + "connectrpc.com/connect" + + contentv1 "content-api-connect-rpc/gen/proto/content/v1" + "content-api-connect-rpc/pkg/connectutil" +) + +func (h *handler) ListContent( + ctx context.Context, + req *connect.Request[contentv1.ListContentRequest], +) (*connect.Response[contentv1.ListContentResponse], error) { + items, nextToken, err := h.service.List(ctx, req.Msg.PageSize, req.Msg.PageToken) + if err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + protoItems := make([]*contentv1.Content, len(items)) + for i := range items { + protoItems[i] = toProto(&items[i]) + } + return connect.NewResponse(&contentv1.ListContentResponse{ + Items: protoItems, + NextPageToken: nextToken, + }), nil +} diff --git a/projects/content-api/connect-rpc/internal/api/content/route_update_content.go b/projects/content-api/connect-rpc/internal/api/content/route_update_content.go new file mode 100644 index 0000000..8477d50 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/api/content/route_update_content.go @@ -0,0 +1,29 @@ +package content + +import ( + "context" + + "connectrpc.com/connect" + "github.com/gofrs/uuid/v5" + + contentv1 "content-api-connect-rpc/gen/proto/content/v1" + "content-api-connect-rpc/pkg/connectutil" +) + +func (h *handler) UpdateContent( + ctx context.Context, + req *connect.Request[contentv1.UpdateContentRequest], +) (*connect.Response[contentv1.UpdateContentResponse], error) { + id, err := uuid.FromString(req.Msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + params := fromProtoUpdate(req.Msg) + result, err := h.service.Update(ctx, id, params) + if err != nil { + return nil, connectutil.NewErrorFrom(err, errorMappings) + } + return connect.NewResponse(&contentv1.UpdateContentResponse{ + Content: toProto(result), + }), nil +} diff --git a/projects/content-api/connect-rpc/internal/domain/content/errors.go b/projects/content-api/connect-rpc/internal/domain/content/errors.go new file mode 100644 index 0000000..329517c --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/errors.go @@ -0,0 +1,8 @@ +package content + +import "errors" + +var ( + ErrNotFound = errors.New("content not found") + ErrAlreadyExists = errors.New("content already exists") +) diff --git a/projects/content-api/connect-rpc/internal/domain/content/op_create.go b/projects/content-api/connect-rpc/internal/domain/content/op_create.go new file mode 100644 index 0000000..b403457 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/op_create.go @@ -0,0 +1,32 @@ +package content + +import ( + "context" + + sqlccontent "content-api-connect-rpc/gen/sqlc/content" + "content-api-connect-rpc/pkg/outbox" +) + +func (s *service) Create(ctx context.Context, params sqlccontent.CreateContentParams) (*sqlccontent.Content, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + item, err := s.queries.WithTx(tx).CreateContent(ctx, params) + if err != nil { + return nil, err + } + + if err := s.outbox.Emit(ctx, tx, outbox.Event{Type: "content.created", ID: item.ID.String(), Data: item}); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + s.cache.Set(item.ID, &item, 0) + return &item, nil +} diff --git a/projects/content-api/connect-rpc/internal/domain/content/op_delete.go b/projects/content-api/connect-rpc/internal/domain/content/op_delete.go new file mode 100644 index 0000000..4a2fb22 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/op_delete.go @@ -0,0 +1,36 @@ +package content + +import ( + "context" + + "github.com/gofrs/uuid/v5" + + "content-api-connect-rpc/pkg/outbox" +) + +func (s *service) Delete(ctx context.Context, id uuid.UUID) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + rows, err := s.queries.WithTx(tx).DeleteContent(ctx, id) + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + + if err := s.outbox.Emit(ctx, tx, outbox.Event{Type: "content.deleted", ID: id.String()}); err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return err + } + + s.cache.Delete(id) + return nil +} diff --git a/projects/content-api/connect-rpc/internal/domain/content/op_get.go b/projects/content-api/connect-rpc/internal/domain/content/op_get.go new file mode 100644 index 0000000..bdf0b0f --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/op_get.go @@ -0,0 +1,26 @@ +package content + +import ( + "context" + "errors" + + "github.com/gofrs/uuid/v5" + "github.com/jackc/pgx/v5" + + sqlccontent "content-api-connect-rpc/gen/sqlc/content" +) + +func (s *service) Get(ctx context.Context, id uuid.UUID) (*sqlccontent.Content, error) { + if cached, ok := s.cache.Get(id); ok { + return cached, nil + } + item, err := s.queries.GetContent(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + s.cache.Set(id, &item, 0) + return &item, nil +} diff --git a/projects/content-api/connect-rpc/internal/domain/content/op_list.go b/projects/content-api/connect-rpc/internal/domain/content/op_list.go new file mode 100644 index 0000000..6fd2901 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/op_list.go @@ -0,0 +1,34 @@ +package content + +import ( + "context" + "strconv" + + sqlccontent "content-api-connect-rpc/gen/sqlc/content" +) + +func (s *service) List(ctx context.Context, pageSize int32, pageToken string) ([]sqlccontent.Content, string, error) { + offset := int32(0) + if pageToken != "" { + parsed, err := strconv.Atoi(pageToken) + if err != nil { + return nil, "", err + } + offset = int32(parsed) + } + + items, err := s.queries.ListContent(ctx, sqlccontent.ListContentParams{ + Limit: pageSize, + Offset: offset, + }) + if err != nil { + return nil, "", err + } + + var nextToken string + if int32(len(items)) == pageSize { + nextToken = strconv.Itoa(int(offset + pageSize)) + } + + return items, nextToken, nil +} diff --git a/projects/content-api/connect-rpc/internal/domain/content/op_update.go b/projects/content-api/connect-rpc/internal/domain/content/op_update.go new file mode 100644 index 0000000..653c9bc --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/op_update.go @@ -0,0 +1,40 @@ +package content + +import ( + "context" + "errors" + + "github.com/gofrs/uuid/v5" + "github.com/jackc/pgx/v5" + + sqlccontent "content-api-connect-rpc/gen/sqlc/content" + "content-api-connect-rpc/pkg/outbox" +) + +func (s *service) Update(ctx context.Context, id uuid.UUID, params sqlccontent.UpdateContentParams) (*sqlccontent.Content, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + params.ID = id + item, err := s.queries.WithTx(tx).UpdateContent(ctx, params) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if err := s.outbox.Emit(ctx, tx, outbox.Event{Type: "content.updated", ID: item.ID.String(), Data: item}); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + s.cache.Set(item.ID, &item, 0) + return &item, nil +} diff --git a/projects/content-api/connect-rpc/internal/domain/content/service.go b/projects/content-api/connect-rpc/internal/domain/content/service.go new file mode 100644 index 0000000..164823c --- /dev/null +++ b/projects/content-api/connect-rpc/internal/domain/content/service.go @@ -0,0 +1,44 @@ +package content + +import ( + "context" + + "github.com/gofrs/uuid/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + sqlccontent "content-api-connect-rpc/gen/sqlc/content" + "content-api-connect-rpc/pkg/cache" + "content-api-connect-rpc/pkg/outbox" +) + +type Service interface { + Create(ctx context.Context, params sqlccontent.CreateContentParams) (*sqlccontent.Content, error) + Get(ctx context.Context, id uuid.UUID) (*sqlccontent.Content, error) + List(ctx context.Context, pageSize int32, pageToken string) ([]sqlccontent.Content, string, error) + Update(ctx context.Context, id uuid.UUID, params sqlccontent.UpdateContentParams) (*sqlccontent.Content, error) + Delete(ctx context.Context, id uuid.UUID) error +} + +type Dependencies struct { + Pool *pgxpool.Pool + Queries *sqlccontent.Queries + Cache cache.Cache[uuid.UUID, *sqlccontent.Content] + Outbox outbox.Outbox[pgx.Tx] +} + +func New(deps Dependencies) Service { + return &service{ + pool: deps.Pool, + queries: deps.Queries, + cache: deps.Cache, + outbox: deps.Outbox, + } +} + +type service struct { + pool *pgxpool.Pool + queries *sqlccontent.Queries + cache cache.Cache[uuid.UUID, *sqlccontent.Content] + outbox outbox.Outbox[pgx.Tx] +} diff --git a/projects/content-api/connect-rpc/internal/outbox/content/event_audit.go b/projects/content-api/connect-rpc/internal/outbox/content/event_audit.go new file mode 100644 index 0000000..2105914 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/outbox/content/event_audit.go @@ -0,0 +1,29 @@ +package content + +import ( + "context" + + "github.com/riverqueue/river" + + "content-api-connect-rpc/pkg/outbox" +) + +type AuditArgs struct { + ID string `json:"id"` + Action string `json:"action"` +} + +func (AuditArgs) Kind() string { return "content.audit" } + +func NewAuditArgs(event outbox.Event) *AuditArgs { + return &AuditArgs{ID: event.ID, Action: event.Type} +} + +type AuditWorker struct { + river.WorkerDefaults[AuditArgs] +} + +func (w *AuditWorker) Work(ctx context.Context, job *river.Job[AuditArgs]) error { + // TODO: write audit trail + return nil +} diff --git a/projects/content-api/connect-rpc/internal/outbox/content/event_index.go b/projects/content-api/connect-rpc/internal/outbox/content/event_index.go new file mode 100644 index 0000000..802ce26 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/outbox/content/event_index.go @@ -0,0 +1,29 @@ +package content + +import ( + "context" + + "github.com/riverqueue/river" + + "content-api-connect-rpc/pkg/outbox" +) + +type IndexArgs struct { + ID string `json:"id"` + Type string `json:"type"` +} + +func (IndexArgs) Kind() string { return "content.index" } + +func NewIndexArgs(event outbox.Event) *IndexArgs { + return &IndexArgs{ID: event.ID, Type: event.Type} +} + +type IndexWorker struct { + river.WorkerDefaults[IndexArgs] +} + +func (w *IndexWorker) Work(ctx context.Context, job *river.Job[IndexArgs]) error { + // TODO: index/update/delete content in OpenSearch based on job.Args.Type + return nil +} diff --git a/projects/content-api/connect-rpc/internal/outbox/river.go b/projects/content-api/connect-rpc/internal/outbox/river.go new file mode 100644 index 0000000..9f230f2 --- /dev/null +++ b/projects/content-api/connect-rpc/internal/outbox/river.go @@ -0,0 +1,56 @@ +package outbox + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/riverqueue/river" + + contentevents "content-api-connect-rpc/internal/outbox/content" + "content-api-connect-rpc/pkg/outbox" +) + +func NewRiverOutbox(client *river.Client[pgx.Tx]) outbox.Outbox[pgx.Tx] { + return &riverOutbox{client: client} +} + +type riverOutbox struct { + client *river.Client[pgx.Tx] +} + +func (o *riverOutbox) Emit(ctx context.Context, tx pgx.Tx, events ...outbox.Event) error { + for _, event := range events { + jobs, err := o.mapEvent(event) + if err != nil { + return err + } + for _, args := range jobs { + if _, err := o.client.InsertTx(ctx, tx, args, nil); err != nil { + return err + } + } + } + return nil +} + +func (o *riverOutbox) mapEvent(event outbox.Event) ([]river.JobArgs, error) { + switch event.Type { + case "content.created": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + contentevents.NewAuditArgs(event), + }, nil + case "content.updated": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + }, nil + case "content.deleted": + return []river.JobArgs{ + contentevents.NewIndexArgs(event), + contentevents.NewAuditArgs(event), + }, nil + default: + return nil, fmt.Errorf("unknown event type: %s", event.Type) + } +} diff --git a/projects/content-api/connect-rpc/pkg/cache/cache.go b/projects/content-api/connect-rpc/pkg/cache/cache.go new file mode 100644 index 0000000..42699f0 --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/cache/cache.go @@ -0,0 +1,53 @@ +package cache + +import ( + "sync" + "time" +) + +type Cache[K comparable, V any] interface { + Get(key K) (V, bool) + Set(key K, value V, ttl time.Duration) + Delete(key K) +} + +func NewInMemory[K comparable, V any]() Cache[K, V] { + return &inMemory[K, V]{items: make(map[K]entry[V])} +} + +type inMemory[K comparable, V any] struct { + mu sync.RWMutex + items map[K]entry[V] +} + +type entry[V any] struct { + value V + expiresAt time.Time +} + +func (c *inMemory[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.items[key] + if !ok || (!e.expiresAt.IsZero() && time.Now().After(e.expiresAt)) { + var zero V + return zero, false + } + return e.value, true +} + +func (c *inMemory[K, V]) Set(key K, value V, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + var exp time.Time + if ttl > 0 { + exp = time.Now().Add(ttl) + } + c.items[key] = entry[V]{value: value, expiresAt: exp} +} + +func (c *inMemory[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.items, key) +} diff --git a/projects/content-api/connect-rpc/pkg/config/config.go b/projects/content-api/connect-rpc/pkg/config/config.go new file mode 100644 index 0000000..514aa70 --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + DatabaseURL string + OpenSearchURL string + ServerAddr string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + return &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://benchmark:benchmark@localhost:5432/benchmark?sslmode=disable"), + OpenSearchURL: getEnv("OPENSEARCH_URL", "http://localhost:9200"), + ServerAddr: getEnv("SERVER_ADDR", ":8080"), + }, nil +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/projects/content-api/connect-rpc/pkg/connectapp/app.go b/projects/content-api/connect-rpc/pkg/connectapp/app.go new file mode 100644 index 0000000..ef30681 --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/connectapp/app.go @@ -0,0 +1,59 @@ +package connectapp + +import ( + "context" + "net/http" + + "github.com/rs/zerolog/log" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +type App interface { + Handle(path string, handler http.Handler) + Run(ctx context.Context) error +} + +type Option func(*app) + +func WithAddr(addr string) Option { return func(a *app) { a.addr = addr } } + +func New(opts ...Option) App { + a := &app{addr: ":8080", mux: http.NewServeMux()} + for _, o := range opts { + o(a) + } + a.mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"up"}`)) + }) + return a +} + +type app struct { + addr string + mux *http.ServeMux +} + +func (a *app) Handle(path string, handler http.Handler) { + a.mux.Handle(path, handler) +} + +func (a *app) Run(ctx context.Context) error { + server := &http.Server{ + Addr: a.addr, + Handler: h2c.NewHandler(a.mux, &http2.Server{}), + } + + log.Info().Str("addr", a.addr).Msg("server started") + + errCh := make(chan error, 1) + go func() { errCh <- server.ListenAndServe() }() + + select { + case <-ctx.Done(): + return server.Close() + case err := <-errCh: + return err + } +} diff --git a/projects/content-api/connect-rpc/pkg/connectutil/errors.go b/projects/content-api/connect-rpc/pkg/connectutil/errors.go new file mode 100644 index 0000000..ba14a0d --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/connectutil/errors.go @@ -0,0 +1,16 @@ +package connectutil + +import ( + "errors" + + "connectrpc.com/connect" +) + +func NewErrorFrom(err error, mappings map[error]connect.Code) *connect.Error { + for sentinel, code := range mappings { + if errors.Is(err, sentinel) { + return connect.NewError(code, err) + } + } + return connect.NewError(connect.CodeInternal, err) +} diff --git a/projects/content-api/connect-rpc/pkg/connectutil/interceptors.go b/projects/content-api/connect-rpc/pkg/connectutil/interceptors.go new file mode 100644 index 0000000..05a4c73 --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/connectutil/interceptors.go @@ -0,0 +1,51 @@ +package connectutil + +import ( + "context" + "fmt" + "time" + + "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/rs/zerolog/log" +) + +func NewInterceptors() []connect.Interceptor { + validateInterceptor := validate.NewInterceptor() + return []connect.Interceptor{ + NewRecoveryInterceptor(), + NewLoggingInterceptor(), + validateInterceptor, + } +} + +func NewRecoveryInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) { + defer func() { + if r := recover(); r != nil { + err = connect.NewError(connect.CodeInternal, fmt.Errorf("panic: %v", r)) + } + }() + return next(ctx, req) + } + } +} + +func NewLoggingInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + start := time.Now() + resp, err := next(ctx, req) + evt := log.Info() + if err != nil { + evt = log.Error().Err(err) + } + evt. + Str("procedure", req.Spec().Procedure). + Dur("duration", time.Since(start)). + Msg("rpc") + return resp, err + } + } +} diff --git a/projects/content-api/connect-rpc/pkg/migrate/migrate.go b/projects/content-api/connect-rpc/pkg/migrate/migrate.go new file mode 100644 index 0000000..168000e --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/migrate/migrate.go @@ -0,0 +1,16 @@ +package migrate + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" +) + +func Run(db *sql.DB, migrations embed.FS, dir string) error { + goose.SetBaseFS(migrations) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.Up(db, dir) +} diff --git a/projects/content-api/connect-rpc/pkg/outbox/outbox.go b/projects/content-api/connect-rpc/pkg/outbox/outbox.go new file mode 100644 index 0000000..e1d3f14 --- /dev/null +++ b/projects/content-api/connect-rpc/pkg/outbox/outbox.go @@ -0,0 +1,13 @@ +package outbox + +import "context" + +type Event struct { + Type string + ID string + Data any +} + +type Outbox[T any] interface { + Emit(ctx context.Context, tx T, events ...Event) error +} diff --git a/projects/content-api/connect-rpc/sql/migrations/001_create_content.sql b/projects/content-api/connect-rpc/sql/migrations/001_create_content.sql new file mode 100644 index 0000000..8ba1bf7 --- /dev/null +++ b/projects/content-api/connect-rpc/sql/migrations/001_create_content.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE content ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + body TEXT NOT NULL, + status INT NOT NULL DEFAULT 1, + tags TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS content; diff --git a/projects/content-api/connect-rpc/sql/migrations/migrations.go b/projects/content-api/connect-rpc/sql/migrations/migrations.go new file mode 100644 index 0000000..91cca1c --- /dev/null +++ b/projects/content-api/connect-rpc/sql/migrations/migrations.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/projects/content-api/connect-rpc/sql/queries/content/content.sql b/projects/content-api/connect-rpc/sql/queries/content/content.sql new file mode 100644 index 0000000..b3c2d62 --- /dev/null +++ b/projects/content-api/connect-rpc/sql/queries/content/content.sql @@ -0,0 +1,26 @@ +-- name: GetContent :one +SELECT * FROM content WHERE id = sqlc.arg('id'); + +-- name: ListContent :many +SELECT * FROM content ORDER BY created_at LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); + +-- name: CountContent :one +SELECT count(*) FROM content; + +-- name: CreateContent :one +INSERT INTO content (title, body, status, tags) +VALUES (sqlc.arg('title'), sqlc.arg('body'), sqlc.arg('status'), sqlc.arg('tags')) +RETURNING *; + +-- name: UpdateContent :one +UPDATE content +SET title = COALESCE(sqlc.narg('title'), title), + body = COALESCE(sqlc.narg('body'), body), + status = COALESCE(sqlc.narg('status'), status), + tags = COALESCE(sqlc.narg('tags'), tags), + updated_at = now() +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteContent :execrows +DELETE FROM content WHERE id = sqlc.arg('id'); diff --git a/projects/content-api/connect-rpc/sqlc.yaml b/projects/content-api/connect-rpc/sqlc.yaml new file mode 100644 index 0000000..65e24d8 --- /dev/null +++ b/projects/content-api/connect-rpc/sqlc.yaml @@ -0,0 +1,19 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "sql/queries/content/" + schema: "sql/migrations/" + gen: + go: + package: "content" + out: "gen/sqlc/content" + sql_package: "pgx/v5" + overrides: + - db_type: "uuid" + go_type: + import: "github.com/gofrs/uuid/v5" + type: "UUID" + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" diff --git a/toolkit/k6/scripts/default-grpc.js b/toolkit/k6/scripts/default-grpc.js index 6773ac6..75522f9 100644 --- a/toolkit/k6/scripts/default-grpc.js +++ b/toolkit/k6/scripts/default-grpc.js @@ -1,13 +1,18 @@ import grpc from 'k6/net/grpc'; import { check, sleep } from 'k6'; -const GRPC_HOST = __ENV.GRPC_HOST || 'localhost:50051'; +const GRPC_HOST = __ENV.GRPC_HOST || 'localhost:8080'; const GRPC_SERVICE = __ENV.GRPC_SERVICE || ''; const GRPC_METHOD = __ENV.GRPC_METHOD || ''; -const PROTO_PATH = __ENV.PROTO_PATH || ''; +const PROTO_DIR = __ENV.PROTO_DIR || ''; +const PROTO_FILES = (__ENV.PROTO_FILES || '').split(',').filter(Boolean); const client = new grpc.Client(); +if (PROTO_DIR && PROTO_FILES.length > 0) { + client.load([PROTO_DIR], ...PROTO_FILES); +} + export const options = { thresholds: { grpc_req_duration: ['p(95)<500'], @@ -15,10 +20,6 @@ export const options = { }; export default function () { - if (PROTO_PATH) { - client.load([], PROTO_PATH); - } - client.connect(GRPC_HOST, { plaintext: true }); const response = client.invoke(`${GRPC_SERVICE}/${GRPC_METHOD}`, {});