instructions
How to Write GraphQL Mutations
This document describes the best practices and step-by-step process for implementing GraphQL mutations in this project.
Project Overview
This is a Go-based web application with the following technology stack:
- Language: Go 1.21+
- Database: SQLite with WAL mode
- Query Builder: sqlc for type-safe SQL
- GraphQL: gqlgen for GraphQL server
- HTTP Server: fasthttp for high performance
- Validation: ozzo-validation for input validation
- Authentication: JWT tokens with custom user token management
- Database Migrations: dbmate for schema migrations
- Task Queue: backlite for background jobs
- File Storage: MinIO S3-compatible object storage
- Frontend: Server-side rendered HTML with TypeScript components
- CSS: Tailwind CSS for styling
- Build Tools:
make sqlc- Generate database code from SQLmake gqlgen- Generate GraphQL resolvers and types
Project Structure
├── cmd/server/ # Main application entry point
├── internal/
│ ├── case/ # Business logic resolvers (mutations/queries)
│ │ ├── admin/ # Admin-only operations
│ │ └── ... # Other business cases
│ ├── db/ # Generated database code (sqlc)
│ ├── graph/ # GraphQL schema and resolvers (gqlgen)
│ └── ... # Other internal packages
├── db/migrations/ # Database schema migrations
├── queries.sql # SQL queries for sqlc generation
├── assets/ # Frontend assets and UI components
└── docs/ # Documentation
Overview
Mutations in this project follow a structured pattern that ensures consistency, testability, and maintainability. Each mutation is implemented as a resolver function in a dedicated package under internal/case/.
Step-by-Step Process
1. Write Database Queries
First, add your SQL queries to queries.sql:
-- name: InsertApiKey :one
insert into api_keys (value, created_by, description)
values (?, ?, ?)
returning *;
-- name: DisableApiKey :exec
update api_keys
set disabled_by = ?, disabled_at = datetime('now')
where id = ?;
Then regenerate the database code:
make sqlc
2. Define GraphQL Schema
Add your mutation types to internal/graph/schema.graphqls:
input CreateAPIKeyInput {
description: String!
}
type CreateAPIKeyPayload {
apiKey: AdminApiKey
value: String!
}
union CreateAPIKeyOrErrorPayload = CreateAPIKeyPayload | ErrorPayload
extend type AdminMutation {
createApiKey(input: CreateAPIKeyInput!): CreateAPIKeyOrErrorPayload!
disableApiKey(input: DeleteAPIKeyInput!): DeleteAPIKeyOrErrorPayload!
}
Then regenerate the GraphQL code:
make gqlgen
3. Implement the Resolver Package
Create a new package under internal/case/ (e.g., internal/case/admin/createapikey/) with a resolve.go file:
package createapikey
import (
"context"
"trip2g/internal/appreq"
"trip2g/internal/db"
"trip2g/internal/graph/model"
)
// Env interface describes all IO dependencies
// This allows writing tests with mocked implementations
type Env interface {
GenerateApiKey() string
InsertApiKey(ctx context.Context, params db.InsertApiKeyParams) (db.ApiKey, error)
}
func Resolve(ctx context.Context, env Env, input model.CreateAPIKeyInput) (model.CreateAPIKeyOrErrorPayload, error) {
// Extract user token from context
req, err := appreq.FromCtx(ctx)
if err != nil {
return nil, err
}
token, err := req.UserToken()
if err != nil {
return nil, err
}
// Check authorization
if !token.IsAdmin() {
return &model.ErrorPayload{Message: "Unauthorized"}, nil
}
// Business logic
apiKey := env.GenerateApiKey()
params := db.InsertApiKeyParams{
Value: apiKey,
CreatedBy: int64(token.ID),
Description: input.Description,
}
createdKey, err := env.InsertApiKey(ctx, params)
if err != nil {
return nil, err
}
// Return success payload
response := model.CreateAPIKeyPayload{
APIKey: &createdKey,
Value: apiKey,
}
return &response, nil
}
4. Update GraphQL Resolvers
Update internal/graph/schema.resolvers.go:
- Add the import for your new package:
import (
// ... other imports
"trip2g/internal/case/admin/createapikey"
)
- Update the mutation resolver:
func (r *adminMutationResolver) CreateAPIKey(ctx context.Context, obj *appmodel.AdminMutation, input model.CreateAPIKeyInput) (model.CreateAPIKeyOrErrorPayload, error) {
return createapikey.Resolve(ctx, r.env(ctx), input)
}
5. Add Env Interface to Main Resolver
Update internal/graph/resolver.go to include your new Env interface:
- Add the import:
import (
// ... other imports
"trip2g/internal/case/admin/createapikey"
)
- Add to the main Env interface:
type Env interface {
// ... other interfaces
createapikey.Env
}
Best Practices
Authorization
- Always extract and validate user tokens from context using
appreq.FromCtx(ctx) - Return
ErrorPayloadfor authorization failures rather than throwing errors - Use
token.IsAdmin()for admin-only operations
Error Handling
- Return
ErrorPayloadfor business logic errors (validation, authorization) - Return actual Go errors for unexpected system errors (database failures, etc.)
- Use descriptive error messages in
ErrorPayload
Transaction Management
- All mutations are automatically wrapped in database transactions
- Transactions are committed on success and rolled back on errors
- You don't need to manage transactions manually
Env Interface Pattern
- Define an
Envinterface in each resolver package - Include only the dependencies that specific resolver needs
- This enables easy testing with mocked implementations
- Keeps resolvers focused and dependencies explicit
Code Organization
- One package per major mutation or related group of mutations
- Keep resolver logic focused on orchestration, not business rules
- Extract complex business logic into separate functions or packages
Input Validation
- Use GraphQL schema validation for basic type checking
- Use
github.com/go-ozzo/ozzo-validationfor input validation - Return
ErrorPayloadwith clear validation messages - Follow the normalize → validate → process pattern
Naming Conventions
- Package names should be descriptive (e.g.,
createapikey,updateuser) - Resolver function is always named
Resolve - Input types follow pattern:
{Operation}{Entity}Input - Payload types follow pattern:
{Operation}{Entity}Payload - Union types follow pattern:
{Operation}{Entity}OrErrorPayload
Testing
Overview
This project follows comprehensive testing practices with table-driven tests, mock generation, and specific testing libraries. All tests should be thorough, maintainable, and follow established patterns.
Required Testing Libraries
import (
"github.com/kr/pretty" // For detailed diff output
"github.com/matryer/moq" // For mock generation
"github.com/stretchr/testify/require" // For assertions
)
Error Handling Pattern in Tests
Follow the project's two-line error handling standard in tests:
// CORRECT: Two-line pattern
result, err := someFunction()
require.NoError(t, err)
// INCORRECT: Single-line pattern
require.NoError(t, someFunction())
Mock Generation with Moq
Generate mocks for interfaces using moq:
# Generate mocks for testing
//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env
Example mock generation comment:
//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env
type Env interface {
UserByEmail(ctx context.Context, email string) (db.User, error)
SendSignInCode(ctx context.Context, params bqtask.SendSignInCodeParams) error
}
Table-Driven Test Pattern
Use table-driven tests for comprehensive coverage:
func TestResolve(t *testing.T) {
tests := []struct {
name string
input model.SignInByEmailInput
mockSetup func(*EnvMock)
expectedResult func() model.SignInOrErrorPayload
expectedError string
}{
{
name: "success",
input: model.SignInByEmailInput{
Email: "test@example.com",
Code: "123456",
},
mockSetup: func(env *EnvMock) {
env.UserByEmailFunc = func(ctx context.Context, email string) (db.User, error) {
return db.User{ID: 1, Email: email}, nil
}
env.SendSignInCodeFunc = func(ctx context.Context, params bqtask.SendSignInCodeParams) error {
return nil
}
},
expectedResult: func() model.SignInOrErrorPayload {
return &model.SignInPayload{
User: &db.User{ID: 1, Email: "test@example.com"},
}
},
},
{
name: "validation_error_missing_email",
input: model.SignInByEmailInput{
Code: "123456",
},
mockSetup: func(env *EnvMock) {},
expectedResult: func() model.SignInOrErrorPayload {
return &model.ErrorPayload{Message: "Email: cannot be blank."}
},
},
{
name: "user_not_found",
input: model.SignInByEmailInput{
Email: "notfound@example.com",
Code: "123456",
},
mockSetup: func(env *EnvMock) {
env.UserByEmailFunc = func(ctx context.Context, email string) (db.User, error) {
return db.User{}, db.ErrNoRows
}
},
expectedResult: func() model.SignInOrErrorPayload {
return &model.ErrorPayload{Message: "Invalid email or code"}
},
},
{
name: "database_error",
input: model.SignInByEmailInput{
Email: "test@example.com",
Code: "123456",
},
mockSetup: func(env *EnvMock) {
env.UserByEmailFunc = func(ctx context.Context, email string) (db.User, error) {
return db.User{}, errors.New("database connection failed")
}
},
expectedError: "failed to get user: database connection failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := &EnvMock{}
tt.mockSetup(env)
result, err := signinbyemail.Resolve(context.Background(), env, tt.input)
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
return
}
require.NoError(t, err)
expected := tt.expectedResult()
if diff := pretty.Diff(expected, result); len(diff) > 0 {
t.Errorf("Unexpected result (-expected +actual):\n%s", strings.Join(diff, "\n"))
}
})
}
}
Testing Cache Behavior
For packages with caching (like userbans), test cache behavior explicitly:
func TestUserBanByUserID_Cache(t *testing.T) {
tests := []struct {
name string
userID int64
dbCalls int
setupMock func(*DBMock)
expectedBan *db.UserBan
expectedError string
}{
{
name: "cache_miss_then_hit",
userID: 1,
dbCalls: 1, // Should only call DB once
setupMock: func(db *DBMock) {
db.UserBanByUserIDFunc = func(ctx context.Context, userID int64) (db.UserBan, error) {
return db.UserBan{ID: 1, UserID: userID}, nil
}
},
expectedBan: &db.UserBan{ID: 1, UserID: 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := &DBMock{}
tt.setupMock(db)
ub := &UserBans{db: db}
// First call - cache miss
result1, err := ub.UserBanByUserID(context.Background(), tt.userID)
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
return
}
require.NoError(t, err)
// Second call - cache hit
result2, err := ub.UserBanByUserID(context.Background(), tt.userID)
require.NoError(t, err)
// Verify results are identical
if diff := pretty.Diff(result1, result2); len(diff) > 0 {
t.Errorf("Cache results differ (-first +second):\n%s", strings.Join(diff, "\n"))
}
// Verify DB was called correct number of times
require.Len(t, db.UserBanByUserIDCalls(), tt.dbCalls)
})
}
}
Testing Configuration and Validation
For configuration packages, test validation comprehensively:
func TestConfig_Validate(t *testing.T) {
tests := []struct {
name string
config Config
expectedError string
}{
{
name: "valid_config",
config: Config{
PublicURL: "https://example.com",
Port: "8080",
},
},
{
name: "invalid_public_url",
config: Config{
PublicURL: "not-a-url",
Port: "8080",
},
expectedError: "PublicURL: must be a valid URL",
},
{
name: "missing_port",
config: Config{
PublicURL: "https://example.com",
},
expectedError: "Port: cannot be blank",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
})
}
}
Best Practices
- Test Structure: Use descriptive test names that explain the scenario
- Mock Setup: Keep mock setup functions focused and reusable
- Error Testing: Test both success and error paths thoroughly
- Cache Testing: Verify cache behavior when applicable
- Validation Testing: Test all validation rules and edge cases
- Pretty Diff: Use
pretty.Difffor detailed comparison output - Require: Use
requirefor assertions that should stop test execution - Context: Always pass proper context to functions under test
Test Organization
func TestPackageName(t *testing.T) {
// Setup common to all tests
t.Run("SubTestGroup", func(t *testing.T) {
// Grouped related tests
t.Run("specific_scenario", func(t *testing.T) {
// Individual test case
})
})
}
Env Interface Testing Pattern
The Env interface pattern makes testing straightforward with mocks:
//go:generate go tool github.com/matryer/moq -out mocks_test.go . Env
type Env interface {
GenerateApiKey() string
InsertApiKey(ctx context.Context, params db.InsertApiKeyParams) (db.ApiKey, error)
}
func TestResolve(t *testing.T) {
env := &EnvMock{
GenerateApiKeyFunc: func() string {
return "test-api-key"
},
InsertApiKeyFunc: func(ctx context.Context, params db.InsertApiKeyParams) (db.ApiKey, error) {
return db.ApiKey{ID: 1, Value: params.Value}, nil
},
}
input := model.CreateAPIKeyInput{Description: "test"}
result, err := createapikey.Resolve(context.Background(), env, input)
require.NoError(t, err)
// Use pretty.Diff for detailed comparison
expected := &model.CreateAPIKeyPayload{
APIKey: &db.ApiKey{ID: 1, Value: "test-api-key"},
Value: "test-api-key",
}
if diff := pretty.Diff(expected, result); len(diff) > 0 {
t.Errorf("Unexpected result (-expected +actual):\n%s", strings.Join(diff, "\n"))
}
}
Input Validation with Ozzo Validation
This project uses github.com/go-ozzo/ozzo-validation for robust input validation. The validation follows a three-step pattern: normalize, validate, process.
Validation Pattern
import (
ozzo "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
gmodel "trip2g/internal/graph/model"
)
func normalizeRequest(r *gmodel.SignInByEmailInput) {
r.Email = strings.TrimSpace(strings.ToLower(r.Email))
}
func validateRequest(r *gmodel.SignInByEmailInput) *gmodel.ErrorPayload {
return gmodel.NewOzzoError(ozzo.ValidateStruct(r,
ozzo.Field(&r.Email, ozzo.Required, is.Email),
ozzo.Field(&r.Code, ozzo.Required, ozzo.Length(6, 6)),
))
}
func Resolve(ctx context.Context, env Env, req gmodel.SignInByEmailInput) (gmodel.SignInOrErrorPayload, error) {
// Step 1: Normalize input
normalizeRequest(&req)
// Step 2: Validate input
errorPayload := validateRequest(&req)
if errorPayload != nil {
return errorPayload, nil
}
// Step 3: Process business logic
// ... rest of the resolver logic
}
Helper Functions
The project provides two helper functions for creating error responses:
gmodel.NewOzzoError(err error)- Converts ozzo validation errors toErrorPayloadgmodel.NewFieldError(field, message string)- Creates single field error
Common Validation Rules
// Required field
ozzo.Field(&input.Name, ozzo.Required)
// Email validation
ozzo.Field(&input.Email, ozzo.Required, is.Email)
// Length validation
ozzo.Field(&input.Code, ozzo.Required, ozzo.Length(6, 6))
ozzo.Field(&input.Description, ozzo.Length(1, 500))
// Custom validation
ozzo.Field(&input.Status, ozzo.Required, ozzo.In("active", "inactive"))
// Conditional validation
ozzo.Field(&input.Password, ozzo.When(input.RequirePassword, ozzo.Required, ozzo.Length(8, 50)))
Business Logic Validation
For validation that requires database access or complex business rules, use manual validation after the initial ozzo validation:
// After ozzo validation passes
if input.UserID != 0 {
user, err := env.UserByID(ctx, input.UserID)
if err != nil {
if db.IsNoFound(err) {
return gmodel.NewFieldError("userID", "user_not_found"), nil
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
}
Common Patterns
User ID Extraction
req, err := appreq.FromCtx(ctx)
if err != nil {
return nil, err
}
token, err := req.UserToken()
if err != nil {
return nil, err
}
userID := int64(token.ID)
Admin Authorization
if !token.IsAdmin() {
return &model.ErrorPayload{Message: "Unauthorized"}, nil
}
Error Response
return &model.ErrorPayload{Message: "Validation failed"}, nil
Success Response
response := model.CreateEntityPayload{
Entity: &createdEntity,
}
return &response, nil