This document contains patterns, conventions, and workflows for implementing new endpoints and features in the Nhost Hasura Auth Go codebase.
- Always write tests for endpoints and new use cases in existing endpoints
- Follow existing patterns - use other implementations as examples
- Security first - never expose secrets, always validate inputs, follow auth patterns
- Database safety - use transactions where appropriate, handle errors gracefully
go/
├── api/ # OpenAPI specs and generated types
│ ├── server.cfg.yaml # oapi-codegen server generation config
│ ├── types.cfg.yaml # oapi-codegen types generation config
│ ├── server.gen.go # Generated server code
│ └── types.gen.go # Generated types
├── controller/ # HTTP handlers and business logic
│ ├── sign_in_*.go # Endpoint handlers (following naming pattern)
│ ├── *_test.go # Tests
│ ├── workflows.go # Business logic workflows
│ ├── errors.go # Error definitions and handling
│ ├── controller.go # Main controller and interfaces
│ └── mock/ # Generated mocks
├── sql/ # Database layer
│ ├── query.sql # SQL queries
│ ├── query.sql.go # Generated Go code
│ ├── sqlc.yaml # sqlc configuration
│ └── models.go # Database models
└── middleware/ # HTTP middleware
Add endpoint definition to docs/openapi.yaml:
paths:
/your-endpoint:
post:
summary: Description of what this does
tags:
- authentication # Use existing tags: authentication, security, session, user, system, verification
security:
- BearerAuth: [] # For authenticated endpoints
- {} # For optional auth
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/YourRequest'
required: true
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/YourResponse'
description: Success description
'401':
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: Error description
components:
schemas:
YourRequest:
type: object
additionalProperties: false
properties:
field:
type: string
description: Field description
required:
- fieldImportant:
- Use
additionalProperties: falsefor strict validation - Add proper descriptions and examples
- Define all possible response codes
- Use existing error response schemas
Add database queries to go/sql/query.sql:
-- name: YourQueryName :one/:many/:exec
SELECT/UPDATE/DELETE/INSERT ...
WHERE condition = $1;Query naming conventions:
:one- returns single row:many- returns multiple rows:exec- no return value (INSERT/UPDATE/DELETE)- Use descriptive names like
GetUserByRefreshTokenHash
Create go/controller/your_endpoint.go:
package controller
import (
"context"
"github.com/nhost/hasura-auth/go/api"
"github.com/nhost/hasura-auth/go/middleware"
)
func (ctrl *Controller) YourEndpoint( //nolint:ireturn
ctx context.Context, request api.YourEndpointRequestObject,
) (api.YourEndpointResponseObject, error) {
logger := middleware.LoggerFromContext(ctx)
// Validate inputs
if apiErr := ctrl.wf.ValidateInput(request.Body.Field, logger); apiErr != nil {
return ctrl.respondWithError(apiErr), nil
}
// Get authenticated user if needed
user, apiErr := ctrl.wf.GetUserFromJWTInContext(ctx, logger)
if apiErr != nil {
return ctrl.sendError(ErrUnauthenticatedUser), nil
}
// Business logic
result, apiErr := ctrl.wf.DoSomething(ctx, user.ID, request.Body.Field, logger)
if apiErr != nil {
return ctrl.respondWithError(apiErr), nil
}
return api.YourEndpoint200JSONResponse(*result), nil
}Controller patterns:
- Always get logger from context:
middleware.LoggerFromContext(ctx) - Use workflows for business logic, not direct DB calls
- Handle errors with
ctrl.respondWithError(apiErr)orctrl.sendError(ErrType) - Return appropriate HTTP status codes via generated response types
Add business logic to go/controller/workflows.go:
func (wf *Workflows) DoSomething(
ctx context.Context,
userID uuid.UUID,
input string,
logger *slog.Logger,
) (*Result, *APIError) {
// Validate business rules
if !wf.ValidateBusinessRule(input) {
logger.WarnContext(ctx, "business rule validation failed")
return nil, ErrInvalidRequest
}
// Database operations
result, err := wf.db.YourQuery(ctx, sql.YourQueryParams{
UserID: userID,
Input: pgtype.Text{String: input, Valid: true},
})
if errors.Is(err, pgx.ErrNoRows) {
logger.WarnContext(ctx, "resource not found")
return nil, ErrNotFound
}
if err != nil {
logger.ErrorContext(ctx, "database error", logError(err))
return nil, ErrInternalServerError
}
return &result, nil
}Workflow patterns:
- Take context, relevant IDs, inputs, and logger as parameters
- Return result and
*APIError(not Go error) - Log warnings for user errors, errors for system errors
- Use
pgtype.Text{String: value, Valid: true}for nullable text fields orsql.Text(value)helper - Handle
pgx.ErrNoRowsspecifically for not found cases
Add errors to go/controller/errors.go if needed:
var (
ErrYourSpecificError = &APIError{api.YourErrorType}
)Error patterns:
- Use existing error types when possible (
ErrInvalidRequest,ErrInternalServerError, etc.) - Map to appropriate HTTP status codes
- Provide meaningful error messages
- Never expose internal system details
If adding new database methods, update go/controller/controller.go:
type DBClient interface {
// ... existing composed interfaces
YourNewMethod(ctx context.Context, params YourParams) (YourResult, error)
}Create go/controller/your_endpoint_test.go:
package controller_test
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/nhost/hasura-auth/go/api"
"github.com/nhost/hasura-auth/go/controller"
"github.com/nhost/hasura-auth/go/controller/mock"
"go.uber.org/mock/gomock"
)
func TestYourEndpoint(t *testing.T) {
t.Parallel()
userID := uuid.MustParse("db477732-48fa-4289-b694-2886a646b6eb")
cases := []testRequest[api.YourEndpointRequestObject, api.YourEndpointResponseObject]{
{
name: "success case",
config: getConfig,
db: func(ctrl *gomock.Controller) controller.DBClient {
mock := mock.NewMockDBClient(ctrl)
mock.EXPECT().YourQuery(
gomock.Any(),
gomock.Any(),
).Return(expectedResult, nil)
return mock
},
request: api.YourEndpointRequestObject{
Body: &api.YourRequest{
Field: "test-value",
},
},
expectedResponse: api.YourEndpoint200JSONResponse(expectedResponse),
expectedJWT: nil,
jwtTokenFn: nil,
getControllerOpts: []getControllerOptsFunc{},
},
// Add error cases, edge cases, etc.
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c, _ := getController(t, ctrl, tc.config, tc.db, tc.getControllerOpts...)
resp, err := c.YourEndpoint(context.Background(), tc.request)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff := cmp.Diff(tc.expectedResponse, resp); diff != "" {
t.Errorf("unexpected response (-want +got):\n%s", diff)
}
})
}
}Testing patterns:
- Use
testRequeststruct for consistent test structure - Test success cases, error cases, edge cases, and validation
- Use
gomock.NewController(t)anddefer ctrl.Finish() - Use
cmp.Difffor response comparison - Use
getSigninUser(userID)helper for authenticated user scenarios
After making changes, always run:
go generate ./...This generates:
- API server code from OpenAPI spec (
api/server.gen.go,api/types.gen.go) - SQL client code from queries (
sql/query.sql.go) - Mocks from interfaces (
controller/mock/)
Generation directives:
- OpenAPI:
//go:generate oapi-codegen -config go/api/server.cfg.yaml docs/openapi.yaml - Mocks:
//go:generate mockgen -package mock -destination mock/controller.go --source=controller.go
// Get authenticated user
user, apiErr := ctrl.wf.GetUserFromJWTInContext(ctx, logger)
if apiErr != nil {
return ctrl.sendError(ErrUnauthenticatedUser), nil
}// Optional auth - proceed with or without user
user, _ := ctrl.wf.GetUserFromJWTInContext(ctx, logger)
// user will be empty if not authenticated// Hash refresh tokens before database operations
hashedToken := hashRefreshToken([]byte(refreshToken))
// Use proper pgtype for database parameters
pgtype.Text{String: hashedToken, Valid: true}
// Or use helper
sql.Text(hashedToken)- Input Validation: Always validate inputs at the workflow level
- Authorization: Check user permissions before operations
- Token Handling: Always hash tokens before storage/comparison
- Error Messages: Don't expose sensitive information in errors
- Logging: Log security events appropriately (warn for user errors, error for system issues)
- Use Transactions: For multi-step operations
- Handle NULL: Use
pgtype.Textfor nullable fields orsql.Text()helper - Error Handling: Always check for
pgx.ErrNoRows - Parameterized Queries: Never build SQL strings dynamically
- Naming: Use descriptive query names following existing patterns
// Single token
wf.db.DeleteRefreshToken(ctx, pgtype.Text{String: hashedToken, Valid: true})
// All user tokens
wf.db.DeleteRefreshTokens(ctx, userID)if apiErr := wf.ValidateUser(user, logger); apiErr != nil {
return user, apiErr
}// For client errors (400-level)
return ctrl.sendError(ErrInvalidRequest), nil
// For server errors (500-level)
return ctrl.respondWithError(apiErr), nil- Design: Plan the endpoint, request/response, and database changes
- OpenAPI: Define the API specification
- SQL: Add required database queries
- Generate: Run
go generate ./... - Implement: Write controller and workflow code
- Test: Write comprehensive tests
- Format: Run
golines -w --base-formatter=gofumpt . - Lint: Run
golangci-lint run --fix - Test: Run
go test -v ./...
- Formatter:
golines -w --base-formatter=gofumpt . - Linter:
golangci-lint run --fix - Tests:
go test -v ./... - Coverage:
go test -v -cover ./...
When implementing new features:
- Review existing similar implementations for business logic patterns
- Identify required database operations and add appropriate queries
- Follow established validation patterns in workflows
- Ensure error codes match existing API conventions
- Test authentication flows thoroughly
- Verify response formats match API specifications